diff --git a/.dockerignore b/.dockerignore index 74bedb17..042dcf2f 100644 --- a/.dockerignore +++ b/.dockerignore @@ -27,3 +27,4 @@ bruno/ LICENSE CONTRIBUTING.md dist +.git diff --git a/.gitignore b/.gitignore index 04c4b7ef..c700a478 100644 --- a/.gitignore +++ b/.gitignore @@ -34,3 +34,4 @@ bin test_event.json .idea/ server/db/index.ts +build.ts \ No newline at end of file diff --git a/bruno/Clients/createClient.bru b/bruno/Clients/createClient.bru new file mode 100644 index 00000000..7577bb28 --- /dev/null +++ b/bruno/Clients/createClient.bru @@ -0,0 +1,22 @@ +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 new file mode 100644 index 00000000..61509c11 --- /dev/null +++ b/bruno/Clients/pickClientDefaults.bru @@ -0,0 +1,11 @@ +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/docker-compose.dev.yml b/docker-compose.dev.yml new file mode 100644 index 00000000..8ca8fac0 --- /dev/null +++ b/docker-compose.dev.yml @@ -0,0 +1,19 @@ +services: + # PostgreSQL Service + db: + image: postgres:17 # Use the PostgreSQL 17 image + container_name: dev_postgres # Name your PostgreSQL container + environment: + POSTGRES_DB: postgres # Default database name + POSTGRES_USER: postgres # Default user + POSTGRES_PASSWORD: password # Default password (change for production!) + ports: + - "5432:5432" # Map host port 5432 to container port 5432 + restart: no + + redis: + image: redis:latest # Use the latest Redis image + container_name: dev_redis # Name your Redis container + ports: + - "6379:6379" # Map host port 6379 to container port 6379 + restart: no diff --git a/drizzle.pg.config.ts b/drizzle.pg.config.ts index 14aeba5b..4d1f1e43 100644 --- a/drizzle.pg.config.ts +++ b/drizzle.pg.config.ts @@ -3,7 +3,7 @@ import path from "path"; export default defineConfig({ dialect: "postgresql", - schema: path.join("server", "db", "pg", "schema.ts"), + schema: [path.join("server", "db", "pg", "schema.ts")], out: path.join("server", "migrations"), verbose: true, dbCredentials: { diff --git a/install/config/config.yml b/install/config/config.yml index db6b2b87..74b3d009 100644 --- a/install/config/config.yml +++ b/install/config/config.yml @@ -22,6 +22,10 @@ gerbil: start_port: 51820 base_endpoint: "{{.DashboardDomain}}" +orgs: + block_size: 24 + subnet_group: 100.89.138.0/20 + {{if .EnableEmail}} email: smtp_host: "{{.EmailSMTPHost}}" diff --git a/install/config/crowdsec/profiles.yaml b/install/config/crowdsec/profiles.yaml index 3796b47f..0632f51d 100644 --- a/install/config/crowdsec/profiles.yaml +++ b/install/config/crowdsec/profiles.yaml @@ -1,4 +1,4 @@ -name: captcha_remediation +iame: captcha_remediation filters: - Alert.Remediation == true && Alert.GetScope() == "Ip" && Alert.GetScenario() contains "http" decisions: diff --git a/messages/en-US.json b/messages/en-US.json index 4814388f..48103b04 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -10,7 +10,8 @@ "setupErrorIdentifier": "Organization ID is already taken. Please choose a different one.", "componentsErrorNoMemberCreate": "You are not currently a member of any organizations. Create an organization to get started.", "componentsErrorNoMember": "You are not currently a member of any organizations.", - "welcome": "Welcome to Pangolin", + "welcome": "Welcome!", + "welcomeTo": "Welcome to", "componentsCreateOrg": "Create an Organization", "componentsMember": "You're a member of {count, plural, =0 {no organization} one {one organization} other {# organizations}}.", "componentsInvalidKey": "Invalid or expired license keys detected. Follow license terms to continue using all features.", @@ -206,6 +207,7 @@ "orgGeneralSettings": "Organization Settings", "orgGeneralSettingsDescription": "Manage your organization details and configuration", "saveGeneralSettings": "Save General Settings", + "saveSettings": "Save Settings", "orgDangerZone": "Danger Zone", "orgDangerZoneDescription": "Once you delete this org, there is no going back. Please be certain.", "orgDelete": "Delete Organization", @@ -1092,6 +1094,8 @@ "sidebarAllUsers": "All Users", "sidebarIdentityProviders": "Identity Providers", "sidebarLicense": "License", + "sidebarClients": "Clients", + "sidebarDomains": "Domains", "enableDockerSocket": "Enable Docker Socket", "enableDockerSocketDescription": "Enable Docker Socket discovery for populating container information. Socket path must be provided to Newt.", "enableDockerSocketLink": "Learn More", @@ -1131,10 +1135,88 @@ "dark": "dark", "system": "system", "theme": "Theme", + "subnetRequired": "Subnet is required", "initialSetupTitle": "Initial Server Setup", "initialSetupDescription": "Create the intial server admin account. Only one server admin can exist. You can always change these credentials later.", "createAdminAccount": "Create Admin Account", "setupErrorCreateAdmin": "An error occurred while creating the server admin account.", + "certificateStatus": "Certificate Status", + "loading": "Loading", + "restart": "Restart", + "domains": "Domains", + "domainsDescription": "Manage domains for your organization", + "domainsSearch": "Search domains...", + "domainAdd": "Add Domain", + "domainAddDescription": "Register a new domain with your organization", + "domainCreate": "Create Domain", + "domainCreatedDescription": "Domain created successfully", + "domainDeletedDescription": "Domain deleted successfully", + "domainQuestionRemove": "Are you sure you want to remove the domain {domain} from your account?", + "domainMessageRemove": "Once removed, the domain will no longer be associated with your account.", + "domainMessageConfirm": "To confirm, please type the domain name below.", + "domainConfirmDelete": "Confirm Delete Domain", + "domainDelete": "Delete Domain", + "domain": "Domain", + "selectDomainTypeNsName": "Domain Delegation (NS)", + "selectDomainTypeNsDescription": "This domain and all its subdomains. Use this when you want to control an entire domain zone.", + "selectDomainTypeCnameName": "Single Domain (CNAME)", + "selectDomainTypeCnameDescription": "Just this specific domain. Use this for individual subdomains or specific domain entries.", + "selectDomainTypeWildcardName": "Wildcard Domain (CNAME)", + "selectDomainTypeWildcardDescription": "This domain and its first level of subdomains.", + "domainDelegation": "Single Domain", + "selectType": "Select a type", + "actions": "Actions", + "refresh": "Refresh", + "refreshError": "Failed to refresh data", + "verified": "Verified", + "pending": "Pending", + "sidebarBilling": "Billing", + "billing": "Billing", + "orgBillingDescription": "Manage your billing information and subscriptions", + "github": "GitHub", + "pangolinHosted": "Pangolin Hosted", + "fossorial": "Fossorial", + "completeAccountSetup": "Complete Account Setup", + "completeAccountSetupDescription": "Set your password to get started", + "accountSetupSent": "We'll send an account setup code to this email address.", + "accountSetupCode": "Setup Code", + "accountSetupCodeDescription": "Check your email for the setup code.", + "passwordCreate": "Create Password", + "passwordCreateConfirm": "Confirm Password", + "accountSetupSubmit": "Send Setup Code", + "completeSetup": "Complete Setup", + "accountSetupSuccess": "Account setup completed! Welcome to Pangolin!", + "documentation": "Documentation", + "saveAllSettings": "Save All Settings", + "settingsUpdated": "Settings updated", + "settingsUpdatedDescription": "All settings have been updated successfully", + "settingsErrorUpdate": "Failed to update settings", + "settingsErrorUpdateDescription": "An error occurred while updating settings", + "sidebarCollapse": "Collapse", + "sidebarExpand": "Expand", + "newtUpdateAvailable": "Update Available", + "newtUpdateAvailableInfo": "A new version of Newt is available. Please update to the latest version for the best experience.", + "domainPickerEnterDomain": "Enter your domain", + "domainPickerPlaceholder": "myapp.example.com, api.v1.mydomain.com, or just myapp", + "domainPickerDescription": "Enter a full domain, subdomain, or just a name to see available options", + "domainPickerTabAll": "All", + "domainPickerTabOrganization": "Organization", + "domainPickerTabProvided": "Provided", + "domainPickerSortAsc": "A-Z", + "domainPickerSortDesc": "Z-A", + "domainPickerCheckingAvailability": "Checking availability...", + "domainPickerNoMatchingDomains": "No matching domains found for \"{userInput}\". Try a different domain or check your organization's domain settings.", + "domainPickerOrganizationDomains": "Organization Domains", + "domainPickerProvidedDomains": "Provided Domains", + "domainPickerSubdomain": "Subdomain: {subdomain}", + "domainPickerNamespace": "Namespace: {namespace}", + "domainPickerShowMore": "Show More", + "domainNotFound": "Domain Not Found", + "domainNotFoundDescription": "This resource is disabled because the domain no longer exists our system. Please set a new domain for this resource.", + "failed": "Failed", + "createNewOrgDescription": "Create a new organization", + "organization": "Organization", + "port": "Port", "securityKeyManage": "Manage Security Keys", "securityKeyDescription": "Add or remove security keys for passwordless authentication", "securityKeyRegister": "Register New Security Key", diff --git a/package-lock.json b/package-lock.json index cb44266b..3a6f0f12 100644 --- a/package-lock.json +++ b/package-lock.json @@ -31,6 +31,7 @@ "@radix-ui/react-switch": "1.2.5", "@radix-ui/react-tabs": "1.1.12", "@radix-ui/react-toast": "1.2.14", + "@radix-ui/react-tooltip": "^1.2.7", "@react-email/components": "0.3.1", "@react-email/render": "^1.1.2", "@react-email/tailwind": "1.2.1", @@ -60,6 +61,7 @@ "http-errors": "2.0.0", "i": "^0.3.7", "input-otp": "1.4.2", + "ioredis": "^5.6.1", "jmespath": "^0.16.0", "js-yaml": "4.1.0", "jsonwebtoken": "^9.0.2", @@ -75,6 +77,7 @@ "oslo": "1.2.1", "pg": "^8.16.2", "qrcode.react": "4.2.0", + "rate-limit-redis": "^4.2.1", "react": "19.1.0", "react-dom": "19.1.0", "react-easy-sort": "^1.6.0", @@ -109,6 +112,7 @@ "@types/jsonwebtoken": "^9.0.10", "@types/node": "^24", "@types/nodemailer": "6.4.17", + "@types/pg": "8.15.4", "@types/react": "19.1.8", "@types/react-dom": "19.1.6", "@types/semver": "^7.7.0", @@ -182,16 +186,16 @@ } }, "node_modules/@babel/generator": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.0.tgz", - "integrity": "sha512-lJjzvrbEeWrhB4P3QBsH7tey117PjLZnDbLiQEKjQ/fNJTjuq4HSqgFA+UNSwZT8D7dxxbnuSBMsa1lrWzKlQg==", + "version": "7.27.5", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.27.5.tgz", + "integrity": "sha512-ZGhA37l0e/g2s1Cnzdix0O3aLYm66eF8aufiVteOgnwxgnRP8GoyMj7VWsgWnQbVKXyge7hqrFh2K2TQM6t1Hw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/parser": "^7.28.0", - "@babel/types": "^7.28.0", - "@jridgewell/gen-mapping": "^0.3.12", - "@jridgewell/trace-mapping": "^0.3.28", + "@babel/parser": "^7.27.5", + "@babel/types": "^7.27.3", + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.25", "jsesc": "^3.0.2" }, "engines": { @@ -219,13 +223,13 @@ } }, "node_modules/@babel/parser": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.0.tgz", - "integrity": "sha512-jVZGvOxOuNSsuQuLRTh13nU0AogFlw32w/MT+LV6D3sP5WdbW61E77RnkbaO2dUvmPAYrBDJXGn5gGS6tH4j8g==", + "version": "7.27.5", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.27.5.tgz", + "integrity": "sha512-OsQd175SxWkGlzbny8J3K8TnnDD0N3lrIUtB92xwyRpzaenGZhxDvxN/JgU00U3CDZNj9tPuDJ5H0WS4Nt3vKg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/types": "^7.28.0" + "@babel/types": "^7.27.3" }, "bin": { "parser": "bin/babel-parser.js" @@ -250,17 +254,17 @@ } }, "node_modules/@babel/traverse": { - "version": "7.27.7", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.27.7.tgz", - "integrity": "sha512-X6ZlfR/O/s5EQ/SnUSLzr+6kGnkg8HXGMzpgsMsrJVcfDtH1vIp6ctCN4eZ1LS5c0+te5Cb6Y514fASjMRJ1nw==", + "version": "7.27.4", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.27.4.tgz", + "integrity": "sha512-oNcu2QbHqts9BtOWJosOVJapWjBDSxGCpFvikNR5TGDYDQf3JwpIoMzIKrvfoti93cLfPJEG4tH9SPVeyCGgdA==", "dev": true, "license": "MIT", "dependencies": { "@babel/code-frame": "^7.27.1", - "@babel/generator": "^7.27.5", - "@babel/parser": "^7.27.7", + "@babel/generator": "^7.27.3", + "@babel/parser": "^7.27.4", "@babel/template": "^7.27.2", - "@babel/types": "^7.27.7", + "@babel/types": "^7.27.3", "debug": "^4.3.1", "globals": "^11.1.0" }, @@ -279,9 +283,9 @@ } }, "node_modules/@babel/types": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.0.tgz", - "integrity": "sha512-jYnje+JyZG5YThjHiF28oT4SIZLnYOcSBb6+SDaFIyzDVSkXQmQQYclJ2R+YxcdmK0AX6x1E5OQNtuh3jHDrUg==", + "version": "7.27.6", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.27.6.tgz", + "integrity": "sha512-ETyHEk2VHHvl9b9jZP5IHPavHYk57EhanlRRuae9XCpb/j5bDCbPPMOBfCWhnl/7EDJz0jEMCi/RhccCE8r1+Q==", "dev": true, "license": "MIT", "dependencies": { @@ -358,37 +362,6 @@ "@noble/ciphers": "^1.0.0" } }, - "node_modules/@emnapi/core": { - "version": "1.4.3", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.4.3.tgz", - "integrity": "sha512-4m62DuCE07lw01soJwPiBGC0nAww0Q+RY70VZ+n49yDIO13yyinhbWCeNnaob0lakDtWQzSdtNWzJeOJt2ma+g==", - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/wasi-threads": "1.0.2", - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/runtime": { - "version": "1.4.3", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.4.3.tgz", - "integrity": "sha512-pBPWdu6MLKROBX05wSNKcNb++m5Er+KQ9QkB+WVM+pW2Kx9hoSrVTnu3BdkI5eBLZoKu/J6mW/B6i6bJB2ytXQ==", - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/wasi-threads": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.0.2.tgz", - "integrity": "sha512-5n3nTJblwRi8LlXkJ9eBzu+kZR8Yxcc7ubakyQTFzPMtIhFpUBRbsnc2Dv88IZDIbCDlBiWrknhB4Lsz7mg6BA==", - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, "node_modules/@esbuild-kit/core-utils": { "version": "3.3.2", "resolved": "https://registry.npmjs.org/@esbuild-kit/core-utils/-/core-utils-3.3.2.tgz", @@ -1402,44 +1375,56 @@ } }, "node_modules/@eslint/plugin-kit": { - "version": "0.3.3", - "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.3.3.tgz", - "integrity": "sha512-1+WqvgNMhmlAambTvT3KPtCl/Ibr68VldY2XY40SL1CE0ZXiakFR/cbTspaF5HsnpDMvcYYoJHfl4980NBjGag==", + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.3.2.tgz", + "integrity": "sha512-4SaFZCNfJqvk/kenHpI8xvN42DMaoycy4PzKc5otHxRswww1kAt82OlBuwRVLofCACCTZEcla2Ydxv8scMXaTg==", "license": "Apache-2.0", "dependencies": { - "@eslint/core": "^0.15.1", + "@eslint/core": "^0.15.0", "levn": "^0.4.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@eslint/plugin-kit/node_modules/@eslint/core": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.15.0.tgz", + "integrity": "sha512-b7ePw78tEWWkpgZCDYkbqDOP8dmM6qe+AOC6iuJqlq1R/0ahMAeH3qynpnqKFGkMltrp44ohV4ubGyvLX28tzw==", + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, "node_modules/@floating-ui/core": { - "version": "1.7.2", - "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.2.tgz", - "integrity": "sha512-wNB5ooIKHQc+Kui96jE/n69rHFWAVoxn5CAzL1Xdd8FG03cgY3MLO+GF9U3W737fYDSgPWA6MReKhBQBop6Pcw==", + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.1.tgz", + "integrity": "sha512-azI0DrjMMfIug/ExbBaeDVJXcY0a7EPvPjb2xAJPa4HeimBX+Z18HK8QQR3jb6356SnDDdxx+hinMLcJEDdOjw==", "license": "MIT", "dependencies": { - "@floating-ui/utils": "^0.2.10" + "@floating-ui/utils": "^0.2.9" } }, "node_modules/@floating-ui/dom": { - "version": "1.7.2", - "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.2.tgz", - "integrity": "sha512-7cfaOQuCS27HD7DX+6ib2OrnW+b4ZBwDNnCcT0uTyidcmyWb03FnQqJybDBoCnpdxwBSfA94UAYlRCt7mV+TbA==", + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.1.tgz", + "integrity": "sha512-cwsmW/zyw5ltYTUeeYJ60CnQuPqmGwuGVhG9w0PRaRKkAyi38BT5CKrpIbb+jtahSwUl04cWzSx9ZOIxeS6RsQ==", "license": "MIT", "dependencies": { - "@floating-ui/core": "^1.7.2", - "@floating-ui/utils": "^0.2.10" + "@floating-ui/core": "^1.7.1", + "@floating-ui/utils": "^0.2.9" } }, "node_modules/@floating-ui/react-dom": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.4.tgz", - "integrity": "sha512-JbbpPhp38UmXDDAu60RJmbeme37Jbgsm7NrHGgzYYFKmblzRUh6Pa641dII6LsjwF4XlScDrde2UAzDo/b9KPw==", + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.3.tgz", + "integrity": "sha512-huMBfiU9UnQ2oBwIhgzyIiSpVgvlDstU8CX0AF+wS+KzmYMs0J2a3GwuFHV1Lz+jlrQGeC1fF+Nv0QoumyV0bA==", "license": "MIT", "dependencies": { - "@floating-ui/dom": "^1.7.2" + "@floating-ui/dom": "^1.0.0" }, "peerDependencies": { "react": ">=16.8.0", @@ -1447,9 +1432,9 @@ } }, "node_modules/@floating-ui/utils": { - "version": "0.2.10", - "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.10.tgz", - "integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==", + "version": "0.2.9", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.9.tgz", + "integrity": "sha512-MDWhGtE+eHw5JW7lq4qhc5yRLS11ERl1c7Z6Xd0a58DozHES6EnNNwUWbMiG4J9Cgj053Bhk8zvlhFYKVhULwg==", "license": "MIT" }, "node_modules/@formatjs/ecma402-abstract": { @@ -1588,146 +1573,6 @@ "url": "https://github.com/sponsors/nzakas" } }, - "node_modules/@img/sharp-darwin-arm64": { - "version": "0.34.2", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.2.tgz", - "integrity": "sha512-OfXHZPppddivUJnqyKoi5YVeHRkkNE2zUFT2gbpKxp/JZCFYEYubnMg+gOp6lWfasPrTS+KPosKqdI+ELYVDtg==", - "cpu": [ - "arm64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-darwin-arm64": "1.1.0" - } - }, - "node_modules/@img/sharp-darwin-x64": { - "version": "0.34.2", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.2.tgz", - "integrity": "sha512-dYvWqmjU9VxqXmjEtjmvHnGqF8GrVjM2Epj9rJ6BUIXvk8slvNDJbhGFvIoXzkDhrJC2jUxNLz/GUjjvSzfw+g==", - "cpu": [ - "x64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-darwin-x64": "1.1.0" - } - }, - "node_modules/@img/sharp-libvips-darwin-arm64": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.1.0.tgz", - "integrity": "sha512-HZ/JUmPwrJSoM4DIQPv/BfNh9yrOA8tlBbqbLz4JZ5uew2+o22Ik+tHQJcih7QJuSa0zo5coHTfD5J8inqj9DA==", - "cpu": [ - "arm64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "darwin" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-darwin-x64": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.1.0.tgz", - "integrity": "sha512-Xzc2ToEmHN+hfvsl9wja0RlnXEgpKNmftriQp6XzY/RaSfwD9th+MSh0WQKzUreLKKINb3afirxW7A0fz2YWuQ==", - "cpu": [ - "x64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "darwin" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-arm": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.1.0.tgz", - "integrity": "sha512-s8BAd0lwUIvYCJyRdFqvsj+BJIpDBSxs6ivrOPm/R7piTs5UIwY5OjXrP2bqXC9/moGsyRa37eYWYCOGVXxVrA==", - "cpu": [ - "arm" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-arm64": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.1.0.tgz", - "integrity": "sha512-IVfGJa7gjChDET1dK9SekxFFdflarnUB8PwW8aGwEoF3oAsSDuNUTYS+SKDOyOJxQyDC1aPFMuRYLoDInyV9Ew==", - "cpu": [ - "arm64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-ppc64": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.1.0.tgz", - "integrity": "sha512-tiXxFZFbhnkWE2LA8oQj7KYR+bWBkiV2nilRldT7bqoEZ4HiDOcePr9wVDAZPi/Id5fT1oY9iGnDq20cwUz8lQ==", - "cpu": [ - "ppc64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-s390x": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.1.0.tgz", - "integrity": "sha512-xukSwvhguw7COyzvmjydRb3x/09+21HykyapcZchiCUkTThEQEOMtBj9UhkaBRLuBrgLFzQ2wbxdeCCJW/jgJA==", - "cpu": [ - "s390x" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, "node_modules/@img/sharp-libvips-linux-x64": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.1.0.tgz", @@ -1744,22 +1589,6 @@ "url": "https://opencollective.com/libvips" } }, - "node_modules/@img/sharp-libvips-linuxmusl-arm64": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.1.0.tgz", - "integrity": "sha512-jYZdG+whg0MDK+q2COKbYidaqW/WTz0cc1E+tMAusiDygrM4ypmSCjOJPmFTvHHJ8j/6cAGyeDWZOsK06tP33w==", - "cpu": [ - "arm64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, "node_modules/@img/sharp-libvips-linuxmusl-x64": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.1.0.tgz", @@ -1776,72 +1605,6 @@ "url": "https://opencollective.com/libvips" } }, - "node_modules/@img/sharp-linux-arm": { - "version": "0.34.2", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.2.tgz", - "integrity": "sha512-0DZzkvuEOqQUP9mo2kjjKNok5AmnOr1jB2XYjkaoNRwpAYMDzRmAqUIa1nRi58S2WswqSfPOWLNOr0FDT3H5RQ==", - "cpu": [ - "arm" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-arm": "1.1.0" - } - }, - "node_modules/@img/sharp-linux-arm64": { - "version": "0.34.2", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.2.tgz", - "integrity": "sha512-D8n8wgWmPDakc83LORcfJepdOSN6MvWNzzz2ux0MnIbOqdieRZwVYY32zxVx+IFUT8er5KPcyU3XXsn+GzG/0Q==", - "cpu": [ - "arm64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-arm64": "1.1.0" - } - }, - "node_modules/@img/sharp-linux-s390x": { - "version": "0.34.2", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.2.tgz", - "integrity": "sha512-EGZ1xwhBI7dNISwxjChqBGELCWMGDvmxZXKjQRuqMrakhO8QoMgqCrdjnAqJq/CScxfRn+Bb7suXBElKQpPDiw==", - "cpu": [ - "s390x" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-s390x": "1.1.0" - } - }, "node_modules/@img/sharp-linux-x64": { "version": "0.34.2", "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.2.tgz", @@ -1864,28 +1627,6 @@ "@img/sharp-libvips-linux-x64": "1.1.0" } }, - "node_modules/@img/sharp-linuxmusl-arm64": { - "version": "0.34.2", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.2.tgz", - "integrity": "sha512-NEE2vQ6wcxYav1/A22OOxoSOGiKnNmDzCYFOZ949xFmrWZOVII1Bp3NqVVpvj+3UeHMFyN5eP/V5hzViQ5CZNA==", - "cpu": [ - "arm64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-arm64": "1.1.0" - } - }, "node_modules/@img/sharp-linuxmusl-x64": { "version": "0.34.2", "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.2.tgz", @@ -1908,81 +1649,11 @@ "@img/sharp-libvips-linuxmusl-x64": "1.1.0" } }, - "node_modules/@img/sharp-wasm32": { - "version": "0.34.2", - "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.2.tgz", - "integrity": "sha512-/VI4mdlJ9zkaq53MbIG6rZY+QRN3MLbR6usYlgITEzi4Rpx5S6LFKsycOQjkOGmqTNmkIdLjEvooFKwww6OpdQ==", - "cpu": [ - "wasm32" - ], - "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", - "optional": true, - "dependencies": { - "@emnapi/runtime": "^1.4.3" - }, - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-win32-arm64": { - "version": "0.34.2", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.2.tgz", - "integrity": "sha512-cfP/r9FdS63VA5k0xiqaNaEoGxBg9k7uE+RQGzuK9fHt7jib4zAVVseR9LsE4gJcNWgT6APKMNnCcnyOtmSEUQ==", - "cpu": [ - "arm64" - ], - "license": "Apache-2.0 AND LGPL-3.0-or-later", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-win32-ia32": { - "version": "0.34.2", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.2.tgz", - "integrity": "sha512-QLjGGvAbj0X/FXl8n1WbtQ6iVBpWU7JO94u/P2M4a8CFYsvQi4GW2mRy/JqkRx0qpBzaOdKJKw8uc930EX2AHw==", - "cpu": [ - "ia32" - ], - "license": "Apache-2.0 AND LGPL-3.0-or-later", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-win32-x64": { - "version": "0.34.2", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.2.tgz", - "integrity": "sha512-aUdT6zEYtDKCaxkofmmJDJYGCf0+pJg3eU9/oBuqvEeoB9dKI6ZLc/1iLJCTuJQDO4ptntAlkUmHgGjyuobZbw==", - "cpu": [ - "x64" - ], - "license": "Apache-2.0 AND LGPL-3.0-or-later", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - } + "node_modules/@ioredis/commands": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.2.0.tgz", + "integrity": "sha512-Sx1pU8EM64o2BrqNpEO1CNLtKQwyhuXuqyfH7oGKCk+1a33d2r5saW8zNwm3j6BTExtjrv2BxTgzzkMwts6vGg==", + "license": "MIT" }, "node_modules/@isaacs/balanced-match": { "version": "4.0.1", @@ -2036,14 +1707,18 @@ } }, "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.12", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.12.tgz", - "integrity": "sha512-OuLGC46TjB5BbN1dH8JULVVZY4WTdkF7tV9Ys6wLL1rubZnCMstOhNHueU5bLCrnRuDhKPDM4g6sw4Bel5Gzqg==", + "version": "0.3.8", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.8.tgz", + "integrity": "sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA==", "dev": true, "license": "MIT", "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/set-array": "^1.2.1", + "@jridgewell/sourcemap-codec": "^1.4.10", "@jridgewell/trace-mapping": "^0.3.24" + }, + "engines": { + "node": ">=6.0.0" } }, "node_modules/@jridgewell/resolve-uri": { @@ -2056,17 +1731,27 @@ "node": ">=6.0.0" } }, + "node_modules/@jridgewell/set-array": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.2.1.tgz", + "integrity": "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.3", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.3.tgz", - "integrity": "sha512-AiR5uKpFxP3PjO4R19kQGIMwxyRyPuXmKEEy301V1C0+1rVjS94EZQXf1QKZYN8Q0YM+estSPhmx5JwNftv6nw==", + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz", + "integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==", "dev": true, "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.28", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.28.tgz", - "integrity": "sha512-KNNHHwW3EIp4EDYOvYFGyIFfx36R2dNJYH4knnZlF8T5jdbD5Wx8xmSaQ2gP9URkJ04LGEtlcCtwArKcmFcwKw==", + "version": "0.3.25", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz", + "integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==", "dev": true, "license": "MIT", "dependencies": { @@ -2080,18 +1765,6 @@ "integrity": "sha512-llBRm4dT4Z89aRsm6u2oEZ8tfwL/2l6BwpZ7JcyieouniDECM5AqNgr/y08zalEIvW3RSK4upYyybDcmjXqAow==", "license": "MIT" }, - "node_modules/@napi-rs/wasm-runtime": { - "version": "0.2.11", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.11.tgz", - "integrity": "sha512-9DPkXtvHydrcOsopiYpUgPHpmj0HWZKMUnL2dZqpvC42lsratuBG06V5ipyno0fUek5VlFsNQ+AcFATSrJXgMA==", - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/core": "^1.4.3", - "@emnapi/runtime": "^1.4.3", - "@tybys/wasm-util": "^0.9.0" - } - }, "node_modules/@next/env": { "version": "15.3.5", "resolved": "https://registry.npmjs.org/@next/env/-/env-15.3.5.tgz", @@ -2302,134 +1975,6 @@ "@node-rs/argon2-win32-x64-msvc": "2.0.2" } }, - "node_modules/@node-rs/argon2-android-arm-eabi": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/@node-rs/argon2-android-arm-eabi/-/argon2-android-arm-eabi-2.0.2.tgz", - "integrity": "sha512-DV/H8p/jt40lrao5z5g6nM9dPNPGEHL+aK6Iy/og+dbL503Uj0AHLqj1Hk9aVUSCNnsDdUEKp4TVMi0YakDYKw==", - "cpu": [ - "arm" - ], - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@node-rs/argon2-android-arm64": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/@node-rs/argon2-android-arm64/-/argon2-android-arm64-2.0.2.tgz", - "integrity": "sha512-1LKwskau+8O1ktKx7TbK7jx1oMOMt4YEXZOdSNIar1TQKxm6isZ0cRXgHLibPHEcNHgYRsJWDE9zvDGBB17QDg==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@node-rs/argon2-darwin-arm64": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/@node-rs/argon2-darwin-arm64/-/argon2-darwin-arm64-2.0.2.tgz", - "integrity": "sha512-3TTNL/7wbcpNju5YcqUrCgXnXUSbD7ogeAKatzBVHsbpjZQbNb1NDxDjqqrWoTt6XL3z9mJUMGwbAk7zQltHtA==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@node-rs/argon2-darwin-x64": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/@node-rs/argon2-darwin-x64/-/argon2-darwin-x64-2.0.2.tgz", - "integrity": "sha512-vNPfkLj5Ij5111UTiYuwgxMqE7DRbOS2y58O2DIySzSHbcnu+nipmRKg+P0doRq6eKIJStyBK8dQi5Ic8pFyDw==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@node-rs/argon2-freebsd-x64": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/@node-rs/argon2-freebsd-x64/-/argon2-freebsd-x64-2.0.2.tgz", - "integrity": "sha512-M8vQZk01qojQfCqQU0/O1j1a4zPPrz93zc9fSINY7Q/6RhQRBCYwDw7ltDCZXg5JRGlSaeS8cUXWyhPGar3cGg==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@node-rs/argon2-linux-arm-gnueabihf": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/@node-rs/argon2-linux-arm-gnueabihf/-/argon2-linux-arm-gnueabihf-2.0.2.tgz", - "integrity": "sha512-7EmmEPHLzcu0G2GDh30L6G48CH38roFC2dqlQJmtRCxs6no3tTE/pvgBGatTp/o2n2oyOJcfmgndVFcUpwMnww==", - "cpu": [ - "arm" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@node-rs/argon2-linux-arm64-gnu": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/@node-rs/argon2-linux-arm64-gnu/-/argon2-linux-arm64-gnu-2.0.2.tgz", - "integrity": "sha512-6lsYh3Ftbk+HAIZ7wNuRF4SZDtxtFTfK+HYFAQQyW7Ig3LHqasqwfUKRXVSV5tJ+xTnxjqgKzvZSUJCAyIfHew==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@node-rs/argon2-linux-arm64-musl": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/@node-rs/argon2-linux-arm64-musl/-/argon2-linux-arm64-musl-2.0.2.tgz", - "integrity": "sha512-p3YqVMNT/4DNR67tIHTYGbedYmXxW9QlFmF39SkXyEbGQwpgSf6pH457/fyXBIYznTU/smnG9EH+C1uzT5j4hA==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, "node_modules/@node-rs/argon2-linux-x64-gnu": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/@node-rs/argon2-linux-x64-gnu/-/argon2-linux-x64-gnu-2.0.2.tgz", @@ -2462,70 +2007,6 @@ "node": ">= 10" } }, - "node_modules/@node-rs/argon2-wasm32-wasi": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/@node-rs/argon2-wasm32-wasi/-/argon2-wasm32-wasi-2.0.2.tgz", - "integrity": "sha512-U3PzLYKSQYzTERstgtHLd4ZTkOF9co57zTXT77r0cVUsleGZOrd6ut7rHzeWwoJSiHOVxxa0OhG1JVQeB7lLoQ==", - "cpu": [ - "wasm32" - ], - "license": "MIT", - "optional": true, - "dependencies": { - "@napi-rs/wasm-runtime": "^0.2.5" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@node-rs/argon2-win32-arm64-msvc": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/@node-rs/argon2-win32-arm64-msvc/-/argon2-win32-arm64-msvc-2.0.2.tgz", - "integrity": "sha512-Eisd7/NM0m23ijrGr6xI2iMocdOuyl6gO27gfMfya4C5BODbUSP7ljKJ7LrA0teqZMdYHesRDzx36Js++/vhiQ==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@node-rs/argon2-win32-ia32-msvc": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/@node-rs/argon2-win32-ia32-msvc/-/argon2-win32-ia32-msvc-2.0.2.tgz", - "integrity": "sha512-GsE2ezwAYwh72f9gIjbGTZOf4HxEksb5M2eCaj+Y5rGYVwAdt7C12Q2e9H5LRYxWcFvLH4m4jiSZpQQ4upnPAQ==", - "cpu": [ - "ia32" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@node-rs/argon2-win32-x64-msvc": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/@node-rs/argon2-win32-x64-msvc/-/argon2-win32-x64-msvc-2.0.2.tgz", - "integrity": "sha512-cJxWXanH4Ew9CfuZ4IAEiafpOBCe97bzoKowHCGk5lG/7kR4WF/eknnBlHW9m8q7t10mKq75kruPLtbSDqgRTw==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - } - }, "node_modules/@node-rs/bcrypt": { "version": "1.9.0", "resolved": "https://registry.npmjs.org/@node-rs/bcrypt/-/bcrypt-1.9.0.tgz", @@ -2555,134 +2036,6 @@ "@node-rs/bcrypt-win32-x64-msvc": "1.9.0" } }, - "node_modules/@node-rs/bcrypt-android-arm-eabi": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/@node-rs/bcrypt-android-arm-eabi/-/bcrypt-android-arm-eabi-1.9.0.tgz", - "integrity": "sha512-nOCFISGtnodGHNiLrG0WYLWr81qQzZKYfmwHc7muUeq+KY0sQXyHOwZk9OuNQAWv/lnntmtbwkwT0QNEmOyLvA==", - "cpu": [ - "arm" - ], - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@node-rs/bcrypt-android-arm64": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/@node-rs/bcrypt-android-arm64/-/bcrypt-android-arm64-1.9.0.tgz", - "integrity": "sha512-+ZrIAtigVmjYkqZQTThHVlz0+TG6D+GDHWhVKvR2DifjtqJ0i+mb9gjo++hN+fWEQdWNGxKCiBBjwgT4EcXd6A==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@node-rs/bcrypt-darwin-arm64": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/@node-rs/bcrypt-darwin-arm64/-/bcrypt-darwin-arm64-1.9.0.tgz", - "integrity": "sha512-CQiS+F9Pa0XozvkXR1g7uXE9QvBOPOplDg0iCCPRYTN9PqA5qYxhwe48G3o+v2UeQceNRrbnEtWuANm7JRqIhw==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@node-rs/bcrypt-darwin-x64": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/@node-rs/bcrypt-darwin-x64/-/bcrypt-darwin-x64-1.9.0.tgz", - "integrity": "sha512-4pTKGawYd7sNEjdJ7R/R67uwQH1VvwPZ0SSUMmeNHbxD5QlwAPXdDH11q22uzVXsvNFZ6nGQBg8No5OUGpx6Ug==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@node-rs/bcrypt-freebsd-x64": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/@node-rs/bcrypt-freebsd-x64/-/bcrypt-freebsd-x64-1.9.0.tgz", - "integrity": "sha512-UmWzySX4BJhT/B8xmTru6iFif3h0Rpx3TqxRLCcbgmH43r7k5/9QuhpiyzpvKGpKHJCFNm4F3rC2wghvw5FCIg==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@node-rs/bcrypt-linux-arm-gnueabihf": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/@node-rs/bcrypt-linux-arm-gnueabihf/-/bcrypt-linux-arm-gnueabihf-1.9.0.tgz", - "integrity": "sha512-8qoX4PgBND2cVwsbajoAWo3NwdfJPEXgpCsZQZURz42oMjbGyhhSYbovBCskGU3EBLoC8RA2B1jFWooeYVn5BA==", - "cpu": [ - "arm" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@node-rs/bcrypt-linux-arm64-gnu": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/@node-rs/bcrypt-linux-arm64-gnu/-/bcrypt-linux-arm64-gnu-1.9.0.tgz", - "integrity": "sha512-TuAC6kx0SbcIA4mSEWPi+OCcDjTQUMl213v5gMNlttF+D4ieIZx6pPDGTaMO6M2PDHTeCG0CBzZl0Lu+9b0c7Q==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@node-rs/bcrypt-linux-arm64-musl": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/@node-rs/bcrypt-linux-arm64-musl/-/bcrypt-linux-arm64-musl-1.9.0.tgz", - "integrity": "sha512-/sIvKDABOI8QOEnLD7hIj02BVaNOuCIWBKvxcJOt8+TuwJ6zmY1UI5kSv9d99WbiHjTp97wtAUbZQwauU4b9ew==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, "node_modules/@node-rs/bcrypt-linux-x64-gnu": { "version": "1.9.0", "resolved": "https://registry.npmjs.org/@node-rs/bcrypt-linux-x64-gnu/-/bcrypt-linux-x64-gnu-1.9.0.tgz", @@ -2715,103 +2068,6 @@ "node": ">= 10" } }, - "node_modules/@node-rs/bcrypt-wasm32-wasi": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/@node-rs/bcrypt-wasm32-wasi/-/bcrypt-wasm32-wasi-1.9.0.tgz", - "integrity": "sha512-ylaGmn9Wjwv/D5lxtawttx3H6Uu2WTTR7lWlRHGT6Ga/MB1Vj4OjSGUW8G8zIVnKuXpGbZ92pgHlt4HUpSLctw==", - "cpu": [ - "wasm32" - ], - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/core": "^0.45.0", - "@emnapi/runtime": "^0.45.0", - "@tybys/wasm-util": "^0.8.1", - "memfs-browser": "^3.4.13000" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@node-rs/bcrypt-wasm32-wasi/node_modules/@emnapi/core": { - "version": "0.45.0", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-0.45.0.tgz", - "integrity": "sha512-DPWjcUDQkCeEM4VnljEOEcXdAD7pp8zSZsgOujk/LGIwCXWbXJngin+MO4zbH429lzeC3WbYLGjE2MaUOwzpyw==", - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@node-rs/bcrypt-wasm32-wasi/node_modules/@emnapi/runtime": { - "version": "0.45.0", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-0.45.0.tgz", - "integrity": "sha512-Txumi3td7J4A/xTTwlssKieHKTGl3j4A1tglBx72auZ49YK7ePY6XZricgIg9mnZT4xPfA+UPCUdnhRuEFDL+w==", - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@node-rs/bcrypt-wasm32-wasi/node_modules/@tybys/wasm-util": { - "version": "0.8.3", - "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.8.3.tgz", - "integrity": "sha512-Z96T/L6dUFFxgFJ+pQtkPpne9q7i6kIPYCFnQBHSgSPV9idTsKfIhCss0h5iM9irweZCatkrdeP8yi5uM1eX6Q==", - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@node-rs/bcrypt-win32-arm64-msvc": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/@node-rs/bcrypt-win32-arm64-msvc/-/bcrypt-win32-arm64-msvc-1.9.0.tgz", - "integrity": "sha512-2h86gF7QFyEzODuDFml/Dp1MSJoZjxJ4yyT2Erf4NkwsiA5MqowUhUsorRwZhX6+2CtlGa7orbwi13AKMsYndw==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@node-rs/bcrypt-win32-ia32-msvc": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/@node-rs/bcrypt-win32-ia32-msvc/-/bcrypt-win32-ia32-msvc-1.9.0.tgz", - "integrity": "sha512-kqxalCvhs4FkN0+gWWfa4Bdy2NQAkfiqq/CEf6mNXC13RSV673Ev9V8sRlQyNpCHCNkeXfOT9pgoBdJmMs9muA==", - "cpu": [ - "ia32" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@node-rs/bcrypt-win32-x64-msvc": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/@node-rs/bcrypt-win32-x64-msvc/-/bcrypt-win32-x64-msvc-1.9.0.tgz", - "integrity": "sha512-2y0Tuo6ZAT2Cz8V7DHulSlv1Bip3zbzeXyeur+uR25IRNYXKvI/P99Zl85Fbuu/zzYAZRLLlGTRe6/9IHofe/w==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - } - }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -3810,6 +3066,40 @@ } } }, + "node_modules/@radix-ui/react-tooltip": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.2.7.tgz", + "integrity": "sha512-Ap+fNYwKTYJ9pzqW+Xe2HtMRbQ/EeWkj2qykZ6SuEV4iS/o1bZI5ssJbk4D2r8XuDuOBVz/tIx2JObtuqU+5Zw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.2", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.10", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-popper": "1.2.7", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.4", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-visually-hidden": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-use-callback-ref": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz", @@ -4280,9 +3570,9 @@ "license": "MIT" }, "node_modules/@rushstack/eslint-patch": { - "version": "1.12.0", - "resolved": "https://registry.npmjs.org/@rushstack/eslint-patch/-/eslint-patch-1.12.0.tgz", - "integrity": "sha512-5EwMtOqvJMMa3HbmxLlF74e+3/HhwBTMcvt3nqVJgGCozO6hzIPOBlwm8mGVNR9SN2IJpxSnlxczyDjcn7qIyw==", + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/@rushstack/eslint-patch/-/eslint-patch-1.11.0.tgz", + "integrity": "sha512-zxnHvoMQVqewTJr/W4pKjF0bMGiKJv1WX7bSrkl46Hg0QjESbzBROWK0Wg4RphzSOS5Jiy7eFimmM3UgMrMZbQ==", "license": "MIT" }, "node_modules/@scarf/scarf": { @@ -4312,9 +3602,9 @@ } }, "node_modules/@simplewebauthn/browser": { - "version": "13.1.0", - "resolved": "https://registry.npmjs.org/@simplewebauthn/browser/-/browser-13.1.0.tgz", - "integrity": "sha512-WuHZ/PYvyPJ9nxSzgHtOEjogBhwJfC8xzYkPC+rR/+8chl/ft4ngjiK8kSU5HtRJfczupyOh33b25TjYbvwAcg==", + "version": "13.1.2", + "resolved": "https://registry.npmjs.org/@simplewebauthn/browser/-/browser-13.1.2.tgz", + "integrity": "sha512-aZnW0KawAM83fSBUgglP5WofbrLbLyr7CoPqYr66Eppm7zO86YX6rrCjRB3hQKPrL7ATvY4FVXlykZ6w6FwYYw==", "license": "MIT" }, "node_modules/@simplewebauthn/server": { @@ -4378,9 +3668,9 @@ } }, "node_modules/@tailwindcss/node": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.11.tgz", - "integrity": "sha512-yzhzuGRmv5QyU9qLNg4GTlYI6STedBWRE7NjxP45CsFYYq9taI0zJXZBMqIC/c8fViNLhmrbpSFS57EoxUmD6Q==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.10.tgz", + "integrity": "sha512-2ACf1znY5fpRBwRhMgj9ZXvb2XZW8qs+oTfotJ2C5xR0/WNL7UHZ7zXl6s+rUqedL1mNi+0O+WQr5awGowS3PQ==", "dev": true, "license": "MIT", "dependencies": { @@ -4390,13 +3680,13 @@ "lightningcss": "1.30.1", "magic-string": "^0.30.17", "source-map-js": "^1.2.1", - "tailwindcss": "4.1.11" + "tailwindcss": "4.1.10" } }, "node_modules/@tailwindcss/oxide": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.11.tgz", - "integrity": "sha512-Q69XzrtAhuyfHo+5/HMgr1lAiPP/G40OMFAnws7xcFEYqcypZmdW8eGXaOUIeOl1dzPJBPENXgbjsOyhg2nkrg==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.10.tgz", + "integrity": "sha512-v0C43s7Pjw+B9w21htrQwuFObSkio2aV/qPx/mhrRldbqxbWJK6KizM+q7BF1/1CmuLqZqX3CeYF7s7P9fbA8Q==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -4408,143 +3698,24 @@ "node": ">= 10" }, "optionalDependencies": { - "@tailwindcss/oxide-android-arm64": "4.1.11", - "@tailwindcss/oxide-darwin-arm64": "4.1.11", - "@tailwindcss/oxide-darwin-x64": "4.1.11", - "@tailwindcss/oxide-freebsd-x64": "4.1.11", - "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.11", - "@tailwindcss/oxide-linux-arm64-gnu": "4.1.11", - "@tailwindcss/oxide-linux-arm64-musl": "4.1.11", - "@tailwindcss/oxide-linux-x64-gnu": "4.1.11", - "@tailwindcss/oxide-linux-x64-musl": "4.1.11", - "@tailwindcss/oxide-wasm32-wasi": "4.1.11", - "@tailwindcss/oxide-win32-arm64-msvc": "4.1.11", - "@tailwindcss/oxide-win32-x64-msvc": "4.1.11" - } - }, - "node_modules/@tailwindcss/oxide-android-arm64": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.11.tgz", - "integrity": "sha512-3IfFuATVRUMZZprEIx9OGDjG3Ou3jG4xQzNTvjDoKmU9JdmoCohQJ83MYd0GPnQIu89YoJqvMM0G3uqLRFtetg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tailwindcss/oxide-darwin-arm64": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.11.tgz", - "integrity": "sha512-ESgStEOEsyg8J5YcMb1xl8WFOXfeBmrhAwGsFxxB2CxY9evy63+AtpbDLAyRkJnxLy2WsD1qF13E97uQyP1lfQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tailwindcss/oxide-darwin-x64": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.11.tgz", - "integrity": "sha512-EgnK8kRchgmgzG6jE10UQNaH9Mwi2n+yw1jWmof9Vyg2lpKNX2ioe7CJdf9M5f8V9uaQxInenZkOxnTVL3fhAw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tailwindcss/oxide-freebsd-x64": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.11.tgz", - "integrity": "sha512-xdqKtbpHs7pQhIKmqVpxStnY1skuNh4CtbcyOHeX1YBE0hArj2romsFGb6yUmzkq/6M24nkxDqU8GYrKrz+UcA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.11.tgz", - "integrity": "sha512-ryHQK2eyDYYMwB5wZL46uoxz2zzDZsFBwfjssgB7pzytAeCCa6glsiJGjhTEddq/4OsIjsLNMAiMlHNYnkEEeg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.11.tgz", - "integrity": "sha512-mYwqheq4BXF83j/w75ewkPJmPZIqqP1nhoghS9D57CLjsh3Nfq0m4ftTotRYtGnZd3eCztgbSPJ9QhfC91gDZQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tailwindcss/oxide-linux-arm64-musl": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.11.tgz", - "integrity": "sha512-m/NVRFNGlEHJrNVk3O6I9ggVuNjXHIPoD6bqay/pubtYC9QIdAMpS+cswZQPBLvVvEF6GtSNONbDkZrjWZXYNQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" + "@tailwindcss/oxide-android-arm64": "4.1.10", + "@tailwindcss/oxide-darwin-arm64": "4.1.10", + "@tailwindcss/oxide-darwin-x64": "4.1.10", + "@tailwindcss/oxide-freebsd-x64": "4.1.10", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.10", + "@tailwindcss/oxide-linux-arm64-gnu": "4.1.10", + "@tailwindcss/oxide-linux-arm64-musl": "4.1.10", + "@tailwindcss/oxide-linux-x64-gnu": "4.1.10", + "@tailwindcss/oxide-linux-x64-musl": "4.1.10", + "@tailwindcss/oxide-wasm32-wasi": "4.1.10", + "@tailwindcss/oxide-win32-arm64-msvc": "4.1.10", + "@tailwindcss/oxide-win32-x64-msvc": "4.1.10" } }, "node_modules/@tailwindcss/oxide-linux-x64-gnu": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.11.tgz", - "integrity": "sha512-YW6sblI7xukSD2TdbbaeQVDysIm/UPJtObHJHKxDEcW2exAtY47j52f8jZXkqE1krdnkhCMGqP3dbniu1Te2Fg==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.10.tgz", + "integrity": "sha512-/G3ao/ybV9YEEgAXeEg28dyH6gs1QG8tvdN9c2MNZdUXYBaIY/Gx0N6RlJzfLy/7Nkdok4kaxKPHKJUlAaoTdA==", "cpu": [ "x64" ], @@ -4559,9 +3730,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-x64-musl": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.11.tgz", - "integrity": "sha512-e3C/RRhGunWYNC3aSF7exsQkdXzQ/M+aYuZHKnw4U7KQwTJotnWsGOIVih0s2qQzmEzOFIJ3+xt7iq67K/p56Q==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.10.tgz", + "integrity": "sha512-LNr7X8fTiKGRtQGOerSayc2pWJp/9ptRYAa4G+U+cjw9kJZvkopav1AQc5HHD+U364f71tZv6XamaHKgrIoVzA==", "cpu": [ "x64" ], @@ -4575,82 +3746,18 @@ "node": ">= 10" } }, - "node_modules/@tailwindcss/oxide-wasm32-wasi": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.11.tgz", - "integrity": "sha512-Xo1+/GU0JEN/C/dvcammKHzeM6NqKovG+6921MR6oadee5XPBaKOumrJCXvopJ/Qb5TH7LX/UAywbqrP4lax0g==", - "bundleDependencies": [ - "@napi-rs/wasm-runtime", - "@emnapi/core", - "@emnapi/runtime", - "@tybys/wasm-util", - "@emnapi/wasi-threads", - "tslib" - ], - "cpu": [ - "wasm32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/core": "^1.4.3", - "@emnapi/runtime": "^1.4.3", - "@emnapi/wasi-threads": "^1.0.2", - "@napi-rs/wasm-runtime": "^0.2.11", - "@tybys/wasm-util": "^0.9.0", - "tslib": "^2.8.0" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.11.tgz", - "integrity": "sha512-UgKYx5PwEKrac3GPNPf6HVMNhUIGuUh4wlDFR2jYYdkX6pL/rn73zTq/4pzUm8fOjAn5L8zDeHp9iXmUGOXZ+w==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tailwindcss/oxide-win32-x64-msvc": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.11.tgz", - "integrity": "sha512-YfHoggn1j0LK7wR82TOucWc5LDCguHnoS879idHekmmiR7g9HUtMw9MI0NHatS28u/Xlkfi9w5RJWgz2Dl+5Qg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - } - }, "node_modules/@tailwindcss/postcss": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.1.11.tgz", - "integrity": "sha512-q/EAIIpF6WpLhKEuQSEVMZNMIY8KhWoAemZ9eylNAih9jxMGAYPPWBn3I9QL/2jZ+e7OEz/tZkX5HwbBR4HohA==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.1.10.tgz", + "integrity": "sha512-B+7r7ABZbkXJwpvt2VMnS6ujcDoR2OOcFaqrLIo1xbcdxje4Vf+VgJdBzNNbrAjBj/rLZ66/tlQ1knIGNLKOBQ==", "dev": true, "license": "MIT", "dependencies": { "@alloc/quick-lru": "^5.2.0", - "@tailwindcss/node": "4.1.11", - "@tailwindcss/oxide": "4.1.11", + "@tailwindcss/node": "4.1.10", + "@tailwindcss/oxide": "4.1.10", "postcss": "^8.4.41", - "tailwindcss": "4.1.11" + "tailwindcss": "4.1.10" } }, "node_modules/@tanstack/react-table": { @@ -4686,16 +3793,6 @@ "url": "https://github.com/sponsors/tannerlinsley" } }, - "node_modules/@tybys/wasm-util": { - "version": "0.9.0", - "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.9.0.tgz", - "integrity": "sha512-6+7nlbMVX/PVDCwaIQ8nTOPveOcFLSt8GcXdx8hD0bt39uWxYT88uXzqTd4fTvqta7oeUJqudepapKNt2DYJFw==", - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, "node_modules/@types/better-sqlite3": { "version": "7.6.12", "resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.12.tgz", @@ -4855,9 +3952,9 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "24.0.12", - "resolved": "https://registry.npmjs.org/@types/node/-/node-24.0.12.tgz", - "integrity": "sha512-LtOrbvDf5ndC9Xi+4QZjVL0woFymF/xSTKZKPgrrl7H7XoeDvnD+E2IclKVDyaK9UM756W/3BXqSU+JEHopA9g==", + "version": "24.0.13", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.0.13.tgz", + "integrity": "sha512-Qm9OYVOFHFYg3wJoTSrz80hoec5Lia/dPp84do3X7dZvLikQvM1YpmvTBEdIr/e+U8HTkFjLHLnl78K/qjf+jQ==", "devOptional": true, "license": "MIT", "dependencies": { @@ -4874,6 +3971,18 @@ "@types/node": "*" } }, + "node_modules/@types/pg": { + "version": "8.15.4", + "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.15.4.tgz", + "integrity": "sha512-I6UNVBAoYbvuWkkU3oosC8yxqH21f4/Jc4DK71JLG3dT2mdlGe1z+ep/LQGXaKaOgcvUrsQoPRqfgtMcvZiJhg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "pg-protocol": "*", + "pg-types": "^2.2.0" + } + }, "node_modules/@types/qs": { "version": "6.14.0", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz", @@ -4983,16 +4092,16 @@ "license": "MIT" }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.36.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.36.0.tgz", - "integrity": "sha512-lZNihHUVB6ZZiPBNgOQGSxUASI7UJWhT8nHyUGCnaQ28XFCw98IfrMCG3rUl1uwUWoAvodJQby2KTs79UTcrAg==", + "version": "8.37.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.37.0.tgz", + "integrity": "sha512-jsuVWeIkb6ggzB+wPCsR4e6loj+rM72ohW6IBn2C+5NCvfUVY8s33iFPySSVXqtm5Hu29Ne/9bnA0JmyLmgenA==", "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.10.0", - "@typescript-eslint/scope-manager": "8.36.0", - "@typescript-eslint/type-utils": "8.36.0", - "@typescript-eslint/utils": "8.36.0", - "@typescript-eslint/visitor-keys": "8.36.0", + "@typescript-eslint/scope-manager": "8.37.0", + "@typescript-eslint/type-utils": "8.37.0", + "@typescript-eslint/utils": "8.37.0", + "@typescript-eslint/visitor-keys": "8.37.0", "graphemer": "^1.4.0", "ignore": "^7.0.0", "natural-compare": "^1.4.0", @@ -5006,7 +4115,7 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.36.0", + "@typescript-eslint/parser": "^8.37.0", "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <5.9.0" } @@ -5021,15 +4130,15 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.36.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.36.0.tgz", - "integrity": "sha512-FuYgkHwZLuPbZjQHzJXrtXreJdFMKl16BFYyRrLxDhWr6Qr7Kbcu2s1Yhu8tsiMXw1S0W1pjfFfYEt+R604s+Q==", + "version": "8.37.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.37.0.tgz", + "integrity": "sha512-kVIaQE9vrN9RLCQMQ3iyRlVJpTiDUY6woHGb30JDkfJErqrQEmtdWH3gV0PBAfGZgQXoqzXOO0T3K6ioApbbAA==", "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.36.0", - "@typescript-eslint/types": "8.36.0", - "@typescript-eslint/typescript-estree": "8.36.0", - "@typescript-eslint/visitor-keys": "8.36.0", + "@typescript-eslint/scope-manager": "8.37.0", + "@typescript-eslint/types": "8.37.0", + "@typescript-eslint/typescript-estree": "8.37.0", + "@typescript-eslint/visitor-keys": "8.37.0", "debug": "^4.3.4" }, "engines": { @@ -5045,13 +4154,13 @@ } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.36.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.36.0.tgz", - "integrity": "sha512-JAhQFIABkWccQYeLMrHadu/fhpzmSQ1F1KXkpzqiVxA/iYI6UnRt2trqXHt1sYEcw1mxLnB9rKMsOxXPxowN/g==", + "version": "8.37.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.37.0.tgz", + "integrity": "sha512-BIUXYsbkl5A1aJDdYJCBAo8rCEbAvdquQ8AnLb6z5Lp1u3x5PNgSSx9A/zqYc++Xnr/0DVpls8iQ2cJs/izTXA==", "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.36.0", - "@typescript-eslint/types": "^8.36.0", + "@typescript-eslint/tsconfig-utils": "^8.37.0", + "@typescript-eslint/types": "^8.37.0", "debug": "^4.3.4" }, "engines": { @@ -5066,13 +4175,13 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.36.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.36.0.tgz", - "integrity": "sha512-wCnapIKnDkN62fYtTGv2+RY8FlnBYA3tNm0fm91kc2BjPhV2vIjwwozJ7LToaLAyb1ca8BxrS7vT+Pvvf7RvqA==", + "version": "8.37.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.37.0.tgz", + "integrity": "sha512-0vGq0yiU1gbjKob2q691ybTg9JX6ShiVXAAfm2jGf3q0hdP6/BruaFjL/ManAR/lj05AvYCH+5bbVo0VtzmjOA==", "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.36.0", - "@typescript-eslint/visitor-keys": "8.36.0" + "@typescript-eslint/types": "8.37.0", + "@typescript-eslint/visitor-keys": "8.37.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -5083,9 +4192,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.36.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.36.0.tgz", - "integrity": "sha512-Nhh3TIEgN18mNbdXpd5Q8mSCBnrZQeY9V7Ca3dqYvNDStNIGRmJA6dmrIPMJ0kow3C7gcQbpsG2rPzy1Ks/AnA==", + "version": "8.37.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.37.0.tgz", + "integrity": "sha512-1/YHvAVTimMM9mmlPvTec9NP4bobA1RkDbMydxG8omqwJJLEW/Iy2C4adsAESIXU3WGLXFHSZUU+C9EoFWl4Zg==", "license": "MIT", "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -5099,13 +4208,14 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.36.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.36.0.tgz", - "integrity": "sha512-5aaGYG8cVDd6cxfk/ynpYzxBRZJk7w/ymto6uiyUFtdCozQIsQWh7M28/6r57Fwkbweng8qAzoMCPwSJfWlmsg==", + "version": "8.37.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.37.0.tgz", + "integrity": "sha512-SPkXWIkVZxhgwSwVq9rqj/4VFo7MnWwVaRNznfQDc/xPYHjXnPfLWn+4L6FF1cAz6e7dsqBeMawgl7QjUMj4Ow==", "license": "MIT", "dependencies": { - "@typescript-eslint/typescript-estree": "8.36.0", - "@typescript-eslint/utils": "8.36.0", + "@typescript-eslint/types": "8.37.0", + "@typescript-eslint/typescript-estree": "8.37.0", + "@typescript-eslint/utils": "8.37.0", "debug": "^4.3.4", "ts-api-utils": "^2.1.0" }, @@ -5122,9 +4232,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.36.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.36.0.tgz", - "integrity": "sha512-xGms6l5cTJKQPZOKM75Dl9yBfNdGeLRsIyufewnxT4vZTrjC0ImQT4fj8QmtJK84F58uSh5HVBSANwcfiXxABQ==", + "version": "8.37.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.37.0.tgz", + "integrity": "sha512-ax0nv7PUF9NOVPs+lmQ7yIE7IQmAf8LGcXbMvHX5Gm+YJUYNAl340XkGnrimxZ0elXyoQJuN5sbg6C4evKA4SQ==", "license": "MIT", "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -5135,15 +4245,15 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.36.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.36.0.tgz", - "integrity": "sha512-JaS8bDVrfVJX4av0jLpe4ye0BpAaUW7+tnS4Y4ETa3q7NoZgzYbN9zDQTJ8kPb5fQ4n0hliAt9tA4Pfs2zA2Hg==", + "version": "8.37.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.37.0.tgz", + "integrity": "sha512-zuWDMDuzMRbQOM+bHyU4/slw27bAUEcKSKKs3hcv2aNnc/tvE/h7w60dwVw8vnal2Pub6RT1T7BI8tFZ1fE+yg==", "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.36.0", - "@typescript-eslint/tsconfig-utils": "8.36.0", - "@typescript-eslint/types": "8.36.0", - "@typescript-eslint/visitor-keys": "8.36.0", + "@typescript-eslint/project-service": "8.37.0", + "@typescript-eslint/tsconfig-utils": "8.37.0", + "@typescript-eslint/types": "8.37.0", + "@typescript-eslint/visitor-keys": "8.37.0", "debug": "^4.3.4", "fast-glob": "^3.3.2", "is-glob": "^4.0.3", @@ -5215,15 +4325,15 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.36.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.36.0.tgz", - "integrity": "sha512-VOqmHu42aEMT+P2qYjylw6zP/3E/HvptRwdn/PZxyV27KhZg2IOszXod4NcXisWzPAGSS4trE/g4moNj6XmH2g==", + "version": "8.37.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.37.0.tgz", + "integrity": "sha512-TSFvkIW6gGjN2p6zbXo20FzCABbyUAuq6tBvNRGsKdsSQ6a7rnV6ADfZ7f4iI3lIiXc4F4WWvtUfDw9CJ9pO5A==", "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.7.0", - "@typescript-eslint/scope-manager": "8.36.0", - "@typescript-eslint/types": "8.36.0", - "@typescript-eslint/typescript-estree": "8.36.0" + "@typescript-eslint/scope-manager": "8.37.0", + "@typescript-eslint/types": "8.37.0", + "@typescript-eslint/typescript-estree": "8.37.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -5238,12 +4348,12 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.36.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.36.0.tgz", - "integrity": "sha512-vZrhV2lRPWDuGoxcmrzRZyxAggPL+qp3WzUrlZD+slFueDiYHxeBa34dUXPuC0RmGKzl4lS5kFJYvKCq9cnNDA==", + "version": "8.37.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.37.0.tgz", + "integrity": "sha512-YzfhzcTnZVPiLfP/oeKtDp2evwvHLMe0LOy7oe+hb9KKIumLNohYS9Hgp1ifwpu42YWxhZE8yieggz6JpqO/1w==", "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.36.0", + "@typescript-eslint/types": "8.37.0", "eslint-visitor-keys": "^4.2.1" }, "engines": { @@ -5254,175 +4364,6 @@ "url": "https://opencollective.com/typescript-eslint" } }, - "node_modules/@unrs/resolver-binding-android-arm-eabi": { - "version": "1.9.2", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm-eabi/-/resolver-binding-android-arm-eabi-1.9.2.tgz", - "integrity": "sha512-tS+lqTU3N0kkthU+rYp0spAYq15DU8ld9kXkaKg9sbQqJNF+WPMuNHZQGCgdxrUOEO0j22RKMwRVhF1HTl+X8A==", - "cpu": [ - "arm" - ], - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@unrs/resolver-binding-android-arm64": { - "version": "1.9.2", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm64/-/resolver-binding-android-arm64-1.9.2.tgz", - "integrity": "sha512-MffGiZULa/KmkNjHeuuflLVqfhqLv1vZLm8lWIyeADvlElJ/GLSOkoUX+5jf4/EGtfwrNFcEaB8BRas03KT0/Q==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@unrs/resolver-binding-darwin-arm64": { - "version": "1.9.2", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-arm64/-/resolver-binding-darwin-arm64-1.9.2.tgz", - "integrity": "sha512-dzJYK5rohS1sYl1DHdJ3mwfwClJj5BClQnQSyAgEfggbUwA9RlROQSSbKBLqrGfsiC/VyrDPtbO8hh56fnkbsQ==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@unrs/resolver-binding-darwin-x64": { - "version": "1.9.2", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-x64/-/resolver-binding-darwin-x64-1.9.2.tgz", - "integrity": "sha512-gaIMWK+CWtXcg9gUyznkdV54LzQ90S3X3dn8zlh+QR5Xy7Y+Efqw4Rs4im61K1juy4YNb67vmJsCDAGOnIeffQ==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@unrs/resolver-binding-freebsd-x64": { - "version": "1.9.2", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-freebsd-x64/-/resolver-binding-freebsd-x64-1.9.2.tgz", - "integrity": "sha512-S7QpkMbVoVJb0xwHFwujnwCAEDe/596xqY603rpi/ioTn9VDgBHnCCxh+UFrr5yxuMH+dliHfjwCZJXOPJGPnw==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@unrs/resolver-binding-linux-arm-gnueabihf": { - "version": "1.9.2", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-gnueabihf/-/resolver-binding-linux-arm-gnueabihf-1.9.2.tgz", - "integrity": "sha512-+XPUMCuCCI80I46nCDFbGum0ZODP5NWGiwS3Pj8fOgsG5/ctz+/zzuBlq/WmGa+EjWZdue6CF0aWWNv84sE1uw==", - "cpu": [ - "arm" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@unrs/resolver-binding-linux-arm-musleabihf": { - "version": "1.9.2", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-musleabihf/-/resolver-binding-linux-arm-musleabihf-1.9.2.tgz", - "integrity": "sha512-sqvUyAd1JUpwbz33Ce2tuTLJKM+ucSsYpPGl2vuFwZnEIg0CmdxiZ01MHQ3j6ExuRqEDUCy8yvkDKvjYFPb8Zg==", - "cpu": [ - "arm" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@unrs/resolver-binding-linux-arm64-gnu": { - "version": "1.9.2", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-gnu/-/resolver-binding-linux-arm64-gnu-1.9.2.tgz", - "integrity": "sha512-UYA0MA8ajkEDCFRQdng/FVx3F6szBvk3EPnkTTQuuO9lV1kPGuTB+V9TmbDxy5ikaEgyWKxa4CI3ySjklZ9lFA==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@unrs/resolver-binding-linux-arm64-musl": { - "version": "1.9.2", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-musl/-/resolver-binding-linux-arm64-musl-1.9.2.tgz", - "integrity": "sha512-P/CO3ODU9YJIHFqAkHbquKtFst0COxdphc8TKGL5yCX75GOiVpGqd1d15ahpqu8xXVsqP4MGFP2C3LRZnnL5MA==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@unrs/resolver-binding-linux-ppc64-gnu": { - "version": "1.9.2", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-ppc64-gnu/-/resolver-binding-linux-ppc64-gnu-1.9.2.tgz", - "integrity": "sha512-uKStFlOELBxBum2s1hODPtgJhY4NxYJE9pAeyBgNEzHgTqTiVBPjfTlPFJkfxyTjQEuxZbbJlJnMCrRgD7ubzw==", - "cpu": [ - "ppc64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@unrs/resolver-binding-linux-riscv64-gnu": { - "version": "1.9.2", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-gnu/-/resolver-binding-linux-riscv64-gnu-1.9.2.tgz", - "integrity": "sha512-LkbNnZlhINfY9gK30AHs26IIVEZ9PEl9qOScYdmY2o81imJYI4IMnJiW0vJVtXaDHvBvxeAgEy5CflwJFIl3tQ==", - "cpu": [ - "riscv64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@unrs/resolver-binding-linux-riscv64-musl": { - "version": "1.9.2", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-musl/-/resolver-binding-linux-riscv64-musl-1.9.2.tgz", - "integrity": "sha512-vI+e6FzLyZHSLFNomPi+nT+qUWN4YSj8pFtQZSFTtmgFoxqB6NyjxSjAxEC1m93qn6hUXhIsh8WMp+fGgxCoRg==", - "cpu": [ - "riscv64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@unrs/resolver-binding-linux-s390x-gnu": { - "version": "1.9.2", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-s390x-gnu/-/resolver-binding-linux-s390x-gnu-1.9.2.tgz", - "integrity": "sha512-sSO4AlAYhSM2RAzBsRpahcJB1msc6uYLAtP6pesPbZtptF8OU/CbCPhSRW6cnYOGuVmEmWVW5xVboAqCnWTeHQ==", - "cpu": [ - "s390x" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, "node_modules/@unrs/resolver-binding-linux-x64-gnu": { "version": "1.9.2", "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-gnu/-/resolver-binding-linux-x64-gnu-1.9.2.tgz", @@ -5449,61 +4390,6 @@ "linux" ] }, - "node_modules/@unrs/resolver-binding-wasm32-wasi": { - "version": "1.9.2", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-wasm32-wasi/-/resolver-binding-wasm32-wasi-1.9.2.tgz", - "integrity": "sha512-EpBGwkcjDicjR/ybC0g8wO5adPNdVuMrNalVgYcWi+gYtC1XYNuxe3rufcO7dA76OHGeVabcO6cSkPJKVcbCXQ==", - "cpu": [ - "wasm32" - ], - "license": "MIT", - "optional": true, - "dependencies": { - "@napi-rs/wasm-runtime": "^0.2.11" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@unrs/resolver-binding-win32-arm64-msvc": { - "version": "1.9.2", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-arm64-msvc/-/resolver-binding-win32-arm64-msvc-1.9.2.tgz", - "integrity": "sha512-EdFbGn7o1SxGmN6aZw9wAkehZJetFPao0VGZ9OMBwKx6TkvDuj6cNeLimF/Psi6ts9lMOe+Dt6z19fZQ9Ye2fw==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@unrs/resolver-binding-win32-ia32-msvc": { - "version": "1.9.2", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-ia32-msvc/-/resolver-binding-win32-ia32-msvc-1.9.2.tgz", - "integrity": "sha512-JY9hi1p7AG+5c/dMU8o2kWemM8I6VZxfGwn1GCtf3c5i+IKcMo2NQ8OjZ4Z3/itvY/Si3K10jOBQn7qsD/whUA==", - "cpu": [ - "ia32" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@unrs/resolver-binding-win32-x64-msvc": { - "version": "1.9.2", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-x64-msvc/-/resolver-binding-win32-x64-msvc-1.9.2.tgz", - "integrity": "sha512-ryoo+EB19lMxAd80ln9BVf8pdOAxLb97amrQ3SFN9OCRn/5M5wvwDgAe4i8ZjhpbiHoDeP8yavcTEnpKBo7lZg==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, "node_modules/accepts": { "version": "1.3.8", "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", @@ -6166,9 +5052,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001726", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001726.tgz", - "integrity": "sha512-VQAUIUzBiZ/UnlM28fSp2CRF3ivUn1BWEvxMcVTNwpw91Py1pGbPIyIKtd+tzct9C3ouceCVdGAXxZOpZAsgdw==", + "version": "1.0.30001724", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001724.tgz", + "integrity": "sha512-WqJo7p0TbHDOythNTqYujmaJTvtYRZrjpP8TCvH6Vb9CYJerJNKamKzIWOM4BkQatWj9H2lYulpdAQNBe7QhNA==", "funding": [ { "type": "opencollective", @@ -6378,6 +5264,15 @@ "node": ">=6" } }, + "node_modules/cluster-key-slot": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz", + "integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/cmdk": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/cmdk/-/cmdk-1.1.1.tgz", @@ -6861,6 +5756,15 @@ "node": ">=0.4.0" } }, + "node_modules/denque": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", + "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10" + } + }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -6976,9 +5880,9 @@ } }, "node_modules/dotenv": { - "version": "16.6.1", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", - "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "version": "16.5.0", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.5.0.tgz", + "integrity": "sha512-m/C+AwOAr9/W1UOIZUo232ejMNnJAJtYQjUbHoNTBNTJSvqzzDh7vnrei3o3r3m9blf6ZoDkvcw0VmozNRFJxg==", "dev": true, "license": "BSD-2-Clause", "engines": { @@ -8450,13 +7354,6 @@ "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", "license": "MIT" }, - "node_modules/fs-monkey": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/fs-monkey/-/fs-monkey-1.0.6.tgz", - "integrity": "sha512-b1FMfwetIKymC0eioW7mTywihSQE4oLzQn1dB6rZB5fx/3NpNEdAWeCSMB+60/AeT0TCXsxzAlcYVEFCTAksWg==", - "license": "Unlicense", - "optional": true - }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -9028,6 +7925,30 @@ "tslib": "^2.8.0" } }, + "node_modules/ioredis": { + "version": "5.6.1", + "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.6.1.tgz", + "integrity": "sha512-UxC0Yv1Y4WRJiGQxQkP0hfdL0/5/6YvdfOOClRgJ0qppSarkhneSa6UvkMkms0AkdGimSH3Ikqm+6mkMmX7vGA==", + "license": "MIT", + "dependencies": { + "@ioredis/commands": "^1.1.1", + "cluster-key-slot": "^1.1.0", + "debug": "^4.3.4", + "denque": "^2.1.0", + "lodash.defaults": "^4.2.0", + "lodash.isarguments": "^3.1.0", + "redis-errors": "^1.2.0", + "redis-parser": "^3.0.0", + "standard-as-callback": "^2.1.0" + }, + "engines": { + "node": ">=12.22.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/ioredis" + } + }, "node_modules/ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", @@ -9770,132 +8691,6 @@ "lightningcss-win32-x64-msvc": "1.30.1" } }, - "node_modules/lightningcss-darwin-arm64": { - "version": "1.30.1", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.30.1.tgz", - "integrity": "sha512-c8JK7hyE65X1MHMN+Viq9n11RRC7hgin3HhYKhrMyaXflk5GVplZ60IxyoVtzILeKr+xAJwg6zK6sjTBJ0FKYQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-darwin-x64": { - "version": "1.30.1", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.30.1.tgz", - "integrity": "sha512-k1EvjakfumAQoTfcXUcHQZhSpLlkAuEkdMBsI/ivWw9hL+7FtilQc0Cy3hrx0AAQrVtQAbMI7YjCgYgvn37PzA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-freebsd-x64": { - "version": "1.30.1", - "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.30.1.tgz", - "integrity": "sha512-kmW6UGCGg2PcyUE59K5r0kWfKPAVy4SltVeut+umLCFoJ53RdCUWxcRDzO1eTaxf/7Q2H7LTquFHPL5R+Gjyig==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm-gnueabihf": { - "version": "1.30.1", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.30.1.tgz", - "integrity": "sha512-MjxUShl1v8pit+6D/zSPq9S9dQ2NPFSQwGvxBCYaBYLPlCWuPh9/t1MRS8iUaR8i+a6w7aps+B4N0S1TYP/R+Q==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm64-gnu": { - "version": "1.30.1", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.30.1.tgz", - "integrity": "sha512-gB72maP8rmrKsnKYy8XUuXi/4OctJiuQjcuqWNlJQ6jZiWqtPvqFziskH3hnajfvKB27ynbVCucKSm2rkQp4Bw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm64-musl": { - "version": "1.30.1", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.30.1.tgz", - "integrity": "sha512-jmUQVx4331m6LIX+0wUhBbmMX7TCfjF5FoOH6SD1CttzuYlGNVpA7QnrmLxrsub43ClTINfGSYyHe2HWeLl5CQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, "node_modules/lightningcss-linux-x64-gnu": { "version": "1.30.1", "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.30.1.tgz", @@ -9938,48 +8733,6 @@ "url": "https://opencollective.com/parcel" } }, - "node_modules/lightningcss-win32-arm64-msvc": { - "version": "1.30.1", - "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.30.1.tgz", - "integrity": "sha512-mSL4rqPi4iXq5YVqzSsJgMVFENoa4nGTT/GjO2c0Yl9OuQfPsIfncvLrEW6RbbB24WtZ3xP/2CCmI3tNkNV4oA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-win32-x64-msvc": { - "version": "1.30.1", - "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.30.1.tgz", - "integrity": "sha512-PVqXh48wh4T53F/1CCu8PIPCxLzWyCnn/9T5W1Jpmdy5h9Cwd+0YQS6/LwhHXSafuc61/xg9Lv5OrCby6a++jg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -9995,12 +8748,24 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/lodash.defaults": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz", + "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==", + "license": "MIT" + }, "node_modules/lodash.includes": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", "license": "MIT" }, + "node_modules/lodash.isarguments": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz", + "integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==", + "license": "MIT" + }, "node_modules/lodash.isboolean": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", @@ -10159,29 +8924,6 @@ "node": ">= 0.6" } }, - "node_modules/memfs": { - "version": "3.5.3", - "resolved": "https://registry.npmjs.org/memfs/-/memfs-3.5.3.tgz", - "integrity": "sha512-UERzLsxzllchadvbPs5aolHh65ISpKpM+ccLbOJ8/vvpBKmAWf+la7dXFy7Mr0ySHbdHrFv5kGFCUHHe6GFEmw==", - "license": "Unlicense", - "optional": true, - "dependencies": { - "fs-monkey": "^1.0.4" - }, - "engines": { - "node": ">= 4.0.0" - } - }, - "node_modules/memfs-browser": { - "version": "3.5.10302", - "resolved": "https://registry.npmjs.org/memfs-browser/-/memfs-browser-3.5.10302.tgz", - "integrity": "sha512-JJTc/nh3ig05O0gBBGZjTCPOyydaTxNF0uHYBrcc1gHNnO+KIHIvo0Y1FKCJsaei6FCl8C6xfQomXqu+cuzkIw==", - "license": "Unlicense", - "optional": true, - "dependencies": { - "memfs": "3.5.3" - } - }, "node_modules/merge-descriptors": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", @@ -10437,9 +9179,9 @@ "license": "MIT" }, "node_modules/napi-postinstall": { - "version": "0.2.5", - "resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.2.5.tgz", - "integrity": "sha512-kmsgUvCRIJohHjbZ3V8avP0I1Pekw329MVAMDzVxsrkjgdnqiwvMX5XwR+hWV66vsAtZ+iM+fVnq8RTQawUmCQ==", + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.2.4.tgz", + "integrity": "sha512-ZEzHJwBhZ8qQSbknHqYcdtQVr8zUgGyM/q6h6qAyhtyVMNrSgDhrC4disf03dYW0e+czXyLnZINnCTEkWy0eJg==", "license": "MIT", "bin": { "napi-postinstall": "lib/cli.js" @@ -13490,26 +12232,6 @@ "@node-rs/bcrypt": "1.9.0" } }, - "node_modules/oslo/node_modules/@emnapi/core": { - "version": "0.45.0", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-0.45.0.tgz", - "integrity": "sha512-DPWjcUDQkCeEM4VnljEOEcXdAD7pp8zSZsgOujk/LGIwCXWbXJngin+MO4zbH429lzeC3WbYLGjE2MaUOwzpyw==", - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/oslo/node_modules/@emnapi/runtime": { - "version": "0.45.0", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-0.45.0.tgz", - "integrity": "sha512-Txumi3td7J4A/xTTwlssKieHKTGl3j4A1tglBx72auZ49YK7ePY6XZricgIg9mnZT4xPfA+UPCUdnhRuEFDL+w==", - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, "node_modules/oslo/node_modules/@node-rs/argon2": { "version": "1.7.0", "resolved": "https://registry.npmjs.org/@node-rs/argon2/-/argon2-1.7.0.tgz", @@ -13535,134 +12257,6 @@ "@node-rs/argon2-win32-x64-msvc": "1.7.0" } }, - "node_modules/oslo/node_modules/@node-rs/argon2-android-arm-eabi": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/@node-rs/argon2-android-arm-eabi/-/argon2-android-arm-eabi-1.7.0.tgz", - "integrity": "sha512-udDqkr5P9E+wYX1SZwAVPdyfYvaF4ry9Tm+R9LkfSHbzWH0uhU6zjIwNRp7m+n4gx691rk+lqqDAIP8RLKwbhg==", - "cpu": [ - "arm" - ], - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/oslo/node_modules/@node-rs/argon2-android-arm64": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/@node-rs/argon2-android-arm64/-/argon2-android-arm64-1.7.0.tgz", - "integrity": "sha512-s9j/G30xKUx8WU50WIhF0fIl1EdhBGq0RQ06lEhZ0Gi0ap8lhqbE2Bn5h3/G2D1k0Dx+yjeVVNmt/xOQIRG38A==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/oslo/node_modules/@node-rs/argon2-darwin-arm64": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/@node-rs/argon2-darwin-arm64/-/argon2-darwin-arm64-1.7.0.tgz", - "integrity": "sha512-ZIz4L6HGOB9U1kW23g+m7anGNuTZ0RuTw0vNp3o+2DWpb8u8rODq6A8tH4JRL79S+Co/Nq608m9uackN2pe0Rw==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/oslo/node_modules/@node-rs/argon2-darwin-x64": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/@node-rs/argon2-darwin-x64/-/argon2-darwin-x64-1.7.0.tgz", - "integrity": "sha512-5oi/pxqVhODW/pj1+3zElMTn/YukQeywPHHYDbcAW3KsojFjKySfhcJMd1DjKTc+CHQI+4lOxZzSUzK7mI14Hw==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/oslo/node_modules/@node-rs/argon2-freebsd-x64": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/@node-rs/argon2-freebsd-x64/-/argon2-freebsd-x64-1.7.0.tgz", - "integrity": "sha512-Ify08683hA4QVXYoIm5SUWOY5DPIT/CMB0CQT+IdxQAg/F+qp342+lUkeAtD5bvStQuCx/dFO3bnnzoe2clMhA==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/oslo/node_modules/@node-rs/argon2-linux-arm-gnueabihf": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/@node-rs/argon2-linux-arm-gnueabihf/-/argon2-linux-arm-gnueabihf-1.7.0.tgz", - "integrity": "sha512-7DjDZ1h5AUHAtRNjD19RnQatbhL+uuxBASuuXIBu4/w6Dx8n7YPxwTP4MXfsvuRgKuMWiOb/Ub/HJ3kXVCXRkg==", - "cpu": [ - "arm" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/oslo/node_modules/@node-rs/argon2-linux-arm64-gnu": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/@node-rs/argon2-linux-arm64-gnu/-/argon2-linux-arm64-gnu-1.7.0.tgz", - "integrity": "sha512-nJDoMP4Y3YcqGswE4DvP080w6O24RmnFEDnL0emdI8Nou17kNYBzP2546Nasx9GCyLzRcYQwZOUjrtUuQ+od2g==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/oslo/node_modules/@node-rs/argon2-linux-arm64-musl": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/@node-rs/argon2-linux-arm64-musl/-/argon2-linux-arm64-musl-1.7.0.tgz", - "integrity": "sha512-BKWS8iVconhE3jrb9mj6t1J9vwUqQPpzCbUKxfTGJfc+kNL58F1SXHBoe2cDYGnHrFEHTY0YochzXoAfm4Dm/A==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, "node_modules/oslo/node_modules/@node-rs/argon2-linux-x64-gnu": { "version": "1.7.0", "resolved": "https://registry.npmjs.org/@node-rs/argon2-linux-x64-gnu/-/argon2-linux-x64-gnu-1.7.0.tgz", @@ -13695,83 +12289,6 @@ "node": ">= 10" } }, - "node_modules/oslo/node_modules/@node-rs/argon2-wasm32-wasi": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/@node-rs/argon2-wasm32-wasi/-/argon2-wasm32-wasi-1.7.0.tgz", - "integrity": "sha512-Evmk9VcxqnuwQftfAfYEr6YZYSPLzmKUsbFIMep5nTt9PT4XYRFAERj7wNYp+rOcBenF3X4xoB+LhwcOMTNE5w==", - "cpu": [ - "wasm32" - ], - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/core": "^0.45.0", - "@emnapi/runtime": "^0.45.0", - "@tybys/wasm-util": "^0.8.1", - "memfs-browser": "^3.4.13000" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/oslo/node_modules/@node-rs/argon2-win32-arm64-msvc": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/@node-rs/argon2-win32-arm64-msvc/-/argon2-win32-arm64-msvc-1.7.0.tgz", - "integrity": "sha512-qgsU7T004COWWpSA0tppDqDxbPLgg8FaU09krIJ7FBl71Sz8SFO40h7fDIjfbTT5w7u6mcaINMQ5bSHu75PCaA==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/oslo/node_modules/@node-rs/argon2-win32-ia32-msvc": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/@node-rs/argon2-win32-ia32-msvc/-/argon2-win32-ia32-msvc-1.7.0.tgz", - "integrity": "sha512-JGafwWYQ/HpZ3XSwP4adQ6W41pRvhcdXvpzIWtKvX+17+xEXAe2nmGWM6s27pVkg1iV2ZtoYLRDkOUoGqZkCcg==", - "cpu": [ - "ia32" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/oslo/node_modules/@node-rs/argon2-win32-x64-msvc": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/@node-rs/argon2-win32-x64-msvc/-/argon2-win32-x64-msvc-1.7.0.tgz", - "integrity": "sha512-9oq4ShyFakw8AG3mRls0AoCpxBFcimYx7+jvXeAf2OqKNO+mSA6eZ9z7KQeVCi0+SOEUYxMGf5UiGiDb9R6+9Q==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/oslo/node_modules/@tybys/wasm-util": { - "version": "0.8.3", - "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.8.3.tgz", - "integrity": "sha512-Z96T/L6dUFFxgFJ+pQtkPpne9q7i6kIPYCFnQBHSgSPV9idTsKfIhCss0h5iM9irweZCatkrdeP8yi5uM1eX6Q==", - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, "node_modules/own-keys": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz", @@ -13932,14 +12449,14 @@ } }, "node_modules/pg": { - "version": "8.16.3", - "resolved": "https://registry.npmjs.org/pg/-/pg-8.16.3.tgz", - "integrity": "sha512-enxc1h0jA/aq5oSDMvqyW3q89ra6XIIDZgCX9vkMrnz5DFTw/Ny3Li2lFQ+pt3L6MCgm/5o2o8HW9hiJji+xvw==", + "version": "8.16.2", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.16.2.tgz", + "integrity": "sha512-OtLWF0mKLmpxelOt9BqVq83QV6bTfsS0XLegIeAKqKjurRnRKie1Dc1iL89MugmSLhftxw6NNCyZhm1yQFLMEQ==", "license": "MIT", "dependencies": { "pg-connection-string": "^2.9.1", "pg-pool": "^3.10.1", - "pg-protocol": "^1.10.3", + "pg-protocol": "^1.10.2", "pg-types": "2.2.0", "pgpass": "1.0.5" }, @@ -13947,7 +12464,7 @@ "node": ">= 16.0.0" }, "optionalDependencies": { - "pg-cloudflare": "^1.2.7" + "pg-cloudflare": "^1.2.6" }, "peerDependencies": { "pg-native": ">=3.0.1" @@ -13959,9 +12476,9 @@ } }, "node_modules/pg-cloudflare": { - "version": "1.2.7", - "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.2.7.tgz", - "integrity": "sha512-YgCtzMH0ptvZJslLM1ffsY4EuGaU0cx4XSdXLRFae8bPP4dS5xL1tNB3k2o/N64cHJpwU7dxKli/nZ2lUa5fLg==", + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.2.6.tgz", + "integrity": "sha512-uxmJAnmIgmYgnSFzgOf2cqGQBzwnRYcrEgXuFjJNEkpedEIPBSEzxY7ph4uA9k1mI+l/GR0HjPNS6FKNZe8SBQ==", "license": "MIT", "optional": true }, @@ -13990,9 +12507,9 @@ } }, "node_modules/pg-protocol": { - "version": "1.10.3", - "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.10.3.tgz", - "integrity": "sha512-6DIBgBQaTKDJyxnXaLiLR8wBpQQcGWuAESkRBX/t6OwA8YsqP+iVSiond2EDy6Y/dsGk8rh/jtax3js5NeV7JQ==", + "version": "1.10.2", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.10.2.tgz", + "integrity": "sha512-Ci7jy8PbaWxfsck2dwZdERcDG2A0MG8JoQILs+uZNjABFuBuItAZCWUNz8sXRDMoui24rJw7WlXqgpMdBSN/vQ==", "license": "MIT" }, "node_modules/pg-types": { @@ -14176,9 +12693,9 @@ } }, "node_modules/prettier": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.6.2.tgz", - "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==", + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.6.0.tgz", + "integrity": "sha512-ujSB9uXHJKzM/2GBuE0hBOUgC77CN3Bnpqa+g80bkv3T3A93wL/xlzDATHhnhkzifz/UE2SNOvmbTz5hSkDlHw==", "license": "MIT", "bin": { "prettier": "bin/prettier.cjs" @@ -14343,6 +12860,18 @@ "node": ">= 0.6" } }, + "node_modules/rate-limit-redis": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/rate-limit-redis/-/rate-limit-redis-4.2.1.tgz", + "integrity": "sha512-JsUsVmRVI6G/XrlYtfGV1NMCbGS/CVYayHkxD5Ism5FaL8qpFHCXbFkUeIi5WJ/onJOKWCgtB/xtCLa6qSXb4g==", + "license": "MIT", + "engines": { + "node": ">= 16" + }, + "peerDependencies": { + "express-rate-limit": ">= 6" + } + }, "node_modules/raw-body": { "version": "2.5.2", "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.2.tgz", @@ -14689,6 +13218,27 @@ "node": ">=0.8.8" } }, + "node_modules/redis-errors": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz", + "integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/redis-parser": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz", + "integrity": "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==", + "license": "MIT", + "dependencies": { + "redis-errors": "^1.0.0" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/reflect.getprototypeof": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", @@ -15467,6 +14017,12 @@ "node": "*" } }, + "node_modules/standard-as-callback": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz", + "integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==", + "license": "MIT" + }, "node_modules/statuses": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", @@ -15801,9 +14357,9 @@ } }, "node_modules/swagger-ui-dist": { - "version": "5.25.3", - "resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.25.3.tgz", - "integrity": "sha512-mqWJAhfl8mhVKJezwszUqRJAlrvKG/22am5xRUWzr7ya0MFaFBAAd7Nm+tD4BdKnVx7KRWkWYJMYRkFm5a8iTg==", + "version": "5.26.2", + "resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.26.2.tgz", + "integrity": "sha512-WmMS9iMlHQejNm/Uw5ZTo4e3M2QMmEavRz7WLWVsq7Mlx4PSHJbY+VCrLsAz9wLxyHVgrJdt7N8+SdQLa52Ykg==", "license": "Apache-2.0", "dependencies": { "@scarf/scarf": "=1.4.0" @@ -15835,9 +14391,9 @@ } }, "node_modules/tailwindcss": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.11.tgz", - "integrity": "sha512-2E9TBm6MDD/xKYe+dvJZAmg3yxIEDNRc0jwlNyDg/4Fil2QcSLjFKGVff0lAf1jjeaArlG/M75Ey/EYr/OJtBA==", + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.10.tgz", + "integrity": "sha512-P3nr6WkvKV/ONsTzj6Gb57sWPMX29EPNPopo7+FcpkQaNsrNpZ1pv8QmrYI2RqEKD7mlGqLnGovlcYnBK0IqUA==", "license": "MIT" }, "node_modules/tapable": { @@ -16256,15 +14812,16 @@ } }, "node_modules/typescript-eslint": { - "version": "8.36.0", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.36.0.tgz", - "integrity": "sha512-fTCqxthY+h9QbEgSIBfL9iV6CvKDFuoxg6bHPNpJ9HIUzS+jy2lCEyCmGyZRWEBSaykqcDPf1SJ+BfCI8DRopA==", + "version": "8.37.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.37.0.tgz", + "integrity": "sha512-TnbEjzkE9EmcO0Q2zM+GE8NQLItNAJpMmED1BdgoBMYNdqMhzlbqfdSwiRlAzEK2pA9UzVW0gzaaIzXWg2BjfA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/eslint-plugin": "8.36.0", - "@typescript-eslint/parser": "8.36.0", - "@typescript-eslint/utils": "8.36.0" + "@typescript-eslint/eslint-plugin": "8.37.0", + "@typescript-eslint/parser": "8.37.0", + "@typescript-eslint/typescript-estree": "8.37.0", + "@typescript-eslint/utils": "8.37.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" diff --git a/package.json b/package.json index 04851288..c8c4111f 100644 --- a/package.json +++ b/package.json @@ -49,6 +49,7 @@ "@radix-ui/react-switch": "1.2.5", "@radix-ui/react-tabs": "1.1.12", "@radix-ui/react-toast": "1.2.14", + "@radix-ui/react-tooltip": "^1.2.7", "@react-email/components": "0.3.1", "@react-email/render": "^1.1.2", "@simplewebauthn/browser": "^13.1.0", @@ -78,6 +79,7 @@ "http-errors": "2.0.0", "i": "^0.3.7", "input-otp": "1.4.2", + "ioredis": "^5.6.1", "jmespath": "^0.16.0", "js-yaml": "4.1.0", "jsonwebtoken": "^9.0.2", @@ -93,6 +95,7 @@ "oslo": "1.2.1", "pg": "^8.16.2", "qrcode.react": "4.2.0", + "rate-limit-redis": "^4.2.1", "react": "19.1.0", "react-dom": "19.1.0", "react-easy-sort": "^1.6.0", @@ -127,6 +130,7 @@ "@types/jsonwebtoken": "^9.0.10", "@types/node": "^24", "@types/nodemailer": "6.4.17", + "@types/pg": "8.15.4", "@types/react": "19.1.8", "@types/react-dom": "19.1.6", "@types/semver": "^7.7.0", diff --git a/server/apiServer.ts b/server/apiServer.ts index 75b23ea9..ebc4b74e 100644 --- a/server/apiServer.ts +++ b/server/apiServer.ts @@ -5,7 +5,7 @@ import config from "@server/lib/config"; import logger from "@server/logger"; import { errorHandlerMiddleware, - notFoundMiddleware, + notFoundMiddleware } from "@server/middlewares"; import { authenticated, unauthenticated } from "@server/routers/external"; import { router as wsRouter, handleWSUpgrade } from "@server/routers/ws"; @@ -15,12 +15,14 @@ import helmet from "helmet"; import rateLimit from "express-rate-limit"; import createHttpError from "http-errors"; import HttpCode from "./types/HttpCode"; +import requestTimeoutMiddleware from "./middlewares/requestTimeout"; const dev = config.isDev; const externalPort = config.getRawConfig().server.external_port; export function createApiServer() { const apiServer = express(); + const prefix = `/api/v1`; const trustProxy = config.getRawConfig().server.trust_proxy; if (trustProxy) { @@ -56,6 +58,9 @@ export function createApiServer() { apiServer.use(cookieParser()); apiServer.use(express.json()); + // Add request timeout middleware + apiServer.use(requestTimeoutMiddleware(60000)); // 60 second timeout + if (!dev) { apiServer.use( rateLimit({ @@ -76,7 +81,6 @@ export function createApiServer() { } // API routes - const prefix = `/api/v1`; apiServer.use(logIncomingMiddleware); apiServer.use(prefix, unauthenticated); apiServer.use(prefix, authenticated); diff --git a/server/auth/actions.ts b/server/auth/actions.ts index f68202da..ee2c5dac 100644 --- a/server/auth/actions.ts +++ b/server/auth/actions.ts @@ -69,6 +69,11 @@ export enum ActionsEnum { deleteResourceRule = "deleteResourceRule", listResourceRules = "listResourceRules", updateResourceRule = "updateResourceRule", + createClient = "createClient", + deleteClient = "deleteClient", + updateClient = "updateClient", + listClients = "listClients", + getClient = "getClient", listOrgDomains = "listOrgDomains", createNewt = "createNewt", createIdp = "createIdp", @@ -87,7 +92,10 @@ export enum ActionsEnum { setApiKeyOrgs = "setApiKeyOrgs", listApiKeyActions = "listApiKeyActions", listApiKeys = "listApiKeys", - getApiKey = "getApiKey" + getApiKey = "getApiKey", + createOrgDomain = "createOrgDomain", + deleteOrgDomain = "deleteOrgDomain", + restartOrgDomain = "restartOrgDomain" } export async function checkUserActionPermission( diff --git a/server/auth/limits.ts b/server/auth/limits.ts deleted file mode 100644 index 5d0b14e4..00000000 --- a/server/auth/limits.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { db } from '@server/db'; -import { limitsTable } from '@server/db'; -import { and, eq } from 'drizzle-orm'; -import createHttpError from 'http-errors'; -import HttpCode from '@server/types/HttpCode'; - -interface CheckLimitOptions { - orgId: string; - limitName: string; - currentValue: number; - increment?: number; -} - -export async function checkOrgLimit({ orgId, limitName, currentValue, increment = 0 }: CheckLimitOptions): Promise { - try { - const limit = await db.select() - .from(limitsTable) - .where( - and( - eq(limitsTable.orgId, orgId), - eq(limitsTable.name, limitName) - ) - ) - .limit(1); - - if (limit.length === 0) { - throw createHttpError(HttpCode.NOT_FOUND, `Limit "${limitName}" not found for organization`); - } - - const limitValue = limit[0].value; - - // Check if the current value plus the increment is within the limit - return (currentValue + increment) <= limitValue; - } catch (error) { - if (error instanceof Error) { - throw createHttpError(HttpCode.INTERNAL_SERVER_ERROR, `Error checking limit: ${error.message}`); - } - throw createHttpError(HttpCode.INTERNAL_SERVER_ERROR, 'Unknown error occurred while checking limit'); - } -} diff --git a/server/auth/sessions/olm.ts b/server/auth/sessions/olm.ts new file mode 100644 index 00000000..89a0e81e --- /dev/null +++ b/server/auth/sessions/olm.ts @@ -0,0 +1,72 @@ +import { + encodeHexLowerCase, +} from "@oslojs/encoding"; +import { sha256 } from "@oslojs/crypto/sha2"; +import { Olm, olms, olmSessions, OlmSession } from "@server/db"; +import { db } from "@server/db"; +import { eq } from "drizzle-orm"; + +export const EXPIRES = 1000 * 60 * 60 * 24 * 30; + +export async function createOlmSession( + token: string, + olmId: string, +): Promise { + const sessionId = encodeHexLowerCase( + sha256(new TextEncoder().encode(token)), + ); + const session: OlmSession = { + sessionId: sessionId, + olmId, + expiresAt: new Date(Date.now() + EXPIRES).getTime(), + }; + await db.insert(olmSessions).values(session); + return session; +} + +export async function validateOlmSessionToken( + token: string, +): Promise { + const sessionId = encodeHexLowerCase( + sha256(new TextEncoder().encode(token)), + ); + const result = await db + .select({ olm: olms, session: olmSessions }) + .from(olmSessions) + .innerJoin(olms, eq(olmSessions.olmId, olms.olmId)) + .where(eq(olmSessions.sessionId, sessionId)); + if (result.length < 1) { + return { session: null, olm: null }; + } + const { olm, session } = result[0]; + if (Date.now() >= session.expiresAt) { + await db + .delete(olmSessions) + .where(eq(olmSessions.sessionId, session.sessionId)); + return { session: null, olm: null }; + } + if (Date.now() >= session.expiresAt - (EXPIRES / 2)) { + session.expiresAt = new Date( + Date.now() + EXPIRES, + ).getTime(); + await db + .update(olmSessions) + .set({ + expiresAt: session.expiresAt, + }) + .where(eq(olmSessions.sessionId, session.sessionId)); + } + return { session, olm }; +} + +export async function invalidateOlmSession(sessionId: string): Promise { + await db.delete(olmSessions).where(eq(olmSessions.sessionId, sessionId)); +} + +export async function invalidateAllOlmSessions(olmId: string): Promise { + await db.delete(olmSessions).where(eq(olmSessions.olmId, olmId)); +} + +export type SessionValidationResult = + | { session: OlmSession; olm: Olm } + | { session: null; olm: null }; diff --git a/server/db/names.ts b/server/db/names.ts index 56d62373..41f4c170 100644 --- a/server/db/names.ts +++ b/server/db/names.ts @@ -59,7 +59,7 @@ export async function getUniqueExitNodeEndpointName(): Promise { export function generateName(): string { - return ( + const name = ( names.descriptors[ Math.floor(Math.random() * names.descriptors.length) ] + @@ -68,4 +68,7 @@ export function generateName(): string { ) .toLowerCase() .replace(/\s/g, "-"); + + // clean out any non-alphanumeric characters except for dashes + return name.replace(/[^a-z0-9-]/g, ""); } diff --git a/server/db/pg/driver.ts b/server/db/pg/driver.ts index 116e4610..9625867d 100644 --- a/server/db/pg/driver.ts +++ b/server/db/pg/driver.ts @@ -1,4 +1,5 @@ import { drizzle as DrizzlePostgres } from "drizzle-orm/node-postgres"; +import { Pool } from "pg"; import { readConfigFile } from "@server/lib/readConfigFile"; import { withReplicas } from "drizzle-orm/pg-core"; @@ -20,19 +21,31 @@ function createDb() { ); } - const primary = DrizzlePostgres(connectionString); + // Create connection pools instead of individual connections + const primaryPool = new Pool({ + connectionString, + max: 20, + idleTimeoutMillis: 30000, + connectionTimeoutMillis: 2000, + }); + const replicas = []; if (!replicaConnections.length) { - replicas.push(primary); + replicas.push(DrizzlePostgres(primaryPool)); } else { for (const conn of replicaConnections) { - const replica = DrizzlePostgres(conn.connection_string); - replicas.push(replica); + const replicaPool = new Pool({ + connectionString: conn.connection_string, + max: 10, + idleTimeoutMillis: 30000, + connectionTimeoutMillis: 2000, + }); + replicas.push(DrizzlePostgres(replicaPool)); } } - return withReplicas(primary, replicas as any); + return withReplicas(DrizzlePostgres(primaryPool), replicas as any); } export const db = createDb(); diff --git a/server/db/pg/index.ts b/server/db/pg/index.ts index 9ad4678c..4829c04c 100644 --- a/server/db/pg/index.ts +++ b/server/db/pg/index.ts @@ -1,2 +1,2 @@ export * from "./driver"; -export * from "./schema"; +export * from "./schema"; \ No newline at end of file diff --git a/server/db/pg/migrate.ts b/server/db/pg/migrate.ts index b9463dd4..70b2ef54 100644 --- a/server/db/pg/migrate.ts +++ b/server/db/pg/migrate.ts @@ -1,5 +1,5 @@ import { migrate } from "drizzle-orm/node-postgres/migrator"; -import db from "./driver"; +import { db } from "./driver"; import path from "path"; const migrationsFolder = path.join("server/migrations"); diff --git a/server/db/pg/schema.ts b/server/db/pg/schema.ts index 0834d98b..1a2175c4 100644 --- a/server/db/pg/schema.ts +++ b/server/db/pg/schema.ts @@ -12,12 +12,17 @@ import { InferSelectModel } from "drizzle-orm"; export const domains = pgTable("domains", { domainId: varchar("domainId").primaryKey(), baseDomain: varchar("baseDomain").notNull(), - configManaged: boolean("configManaged").notNull().default(false) + configManaged: boolean("configManaged").notNull().default(false), + type: varchar("type"), // "ns", "cname", "wildcard" + verified: boolean("verified").notNull().default(false), + failed: boolean("failed").notNull().default(false), + tries: integer("tries").notNull().default(0) }); export const orgs = pgTable("orgs", { orgId: varchar("orgId").primaryKey(), - name: varchar("name").notNull() + name: varchar("name").notNull(), + subnet: varchar("subnet").notNull() }); export const orgDomains = pgTable("orgDomains", { @@ -42,12 +47,17 @@ export const sites = pgTable("sites", { }), name: varchar("name").notNull(), pubKey: varchar("pubKey"), - subnet: varchar("subnet").notNull(), - megabytesIn: real("bytesIn"), - megabytesOut: real("bytesOut"), + subnet: varchar("subnet"), + megabytesIn: real("bytesIn").default(0), + megabytesOut: real("bytesOut").default(0), lastBandwidthUpdate: varchar("lastBandwidthUpdate"), type: varchar("type").notNull(), // "newt" or "wireguard" online: boolean("online").notNull().default(false), + address: varchar("address"), + endpoint: varchar("endpoint"), + publicKey: varchar("publicKey"), + lastHolePunch: bigint("lastHolePunch", { mode: "number" }), + listenPort: integer("listenPort"), dockerSocketEnabled: boolean("dockerSocketEnabled").notNull().default(true) }); @@ -107,7 +117,8 @@ export const exitNodes = pgTable("exitNodes", { endpoint: varchar("endpoint").notNull(), publicKey: varchar("publicKey").notNull(), listenPort: integer("listenPort").notNull(), - reachableAt: varchar("reachableAt") + reachableAt: varchar("reachableAt"), + maxConnections: integer("maxConnections") }); export const users = pgTable("user", { @@ -132,6 +143,7 @@ export const newts = pgTable("newt", { newtId: varchar("id").primaryKey(), secretHash: varchar("secretHash").notNull(), dateCreated: varchar("dateCreated").notNull(), + version: varchar("version"), siteId: integer("siteId").references(() => sites.siteId, { onDelete: "cascade" }) @@ -274,18 +286,6 @@ export const userResources = pgTable("userResources", { .references(() => resources.resourceId, { onDelete: "cascade" }) }); -export const limitsTable = pgTable("limits", { - limitId: serial("limitId").primaryKey(), - orgId: varchar("orgId") - .references(() => orgs.orgId, { - onDelete: "cascade" - }) - .notNull(), - name: varchar("name").notNull(), - value: bigint("value", { mode: "number" }).notNull(), - description: varchar("description") -}); - export const userInvites = pgTable("userInvites", { inviteId: varchar("inviteId").primaryKey(), orgId: varchar("orgId") @@ -492,6 +492,75 @@ export const idpOrg = pgTable("idpOrg", { orgMapping: varchar("orgMapping") }); +export const clients = pgTable("clients", { + clientId: serial("id").primaryKey(), + orgId: varchar("orgId") + .references(() => orgs.orgId, { + onDelete: "cascade" + }) + .notNull(), + exitNodeId: integer("exitNode").references(() => exitNodes.exitNodeId, { + onDelete: "set null" + }), + name: varchar("name").notNull(), + pubKey: varchar("pubKey"), + subnet: varchar("subnet").notNull(), + megabytesIn: integer("bytesIn"), + megabytesOut: integer("bytesOut"), + lastBandwidthUpdate: varchar("lastBandwidthUpdate"), + lastPing: varchar("lastPing"), + type: varchar("type").notNull(), // "olm" + online: boolean("online").notNull().default(false), + endpoint: varchar("endpoint"), + lastHolePunch: integer("lastHolePunch"), + maxConnections: integer("maxConnections") +}); + +export const clientSites = pgTable("clientSites", { + clientId: integer("clientId") + .notNull() + .references(() => clients.clientId, { onDelete: "cascade" }), + siteId: integer("siteId") + .notNull() + .references(() => sites.siteId, { onDelete: "cascade" }), + isRelayed: boolean("isRelayed").notNull().default(false) +}); + +export const olms = pgTable("olms", { + olmId: varchar("id").primaryKey(), + secretHash: varchar("secretHash").notNull(), + dateCreated: varchar("dateCreated").notNull(), + clientId: integer("clientId").references(() => clients.clientId, { + onDelete: "cascade" + }) +}); + +export const olmSessions = pgTable("clientSession", { + sessionId: varchar("id").primaryKey(), + olmId: varchar("olmId") + .notNull() + .references(() => olms.olmId, { onDelete: "cascade" }), + expiresAt: integer("expiresAt").notNull() +}); + +export const userClients = pgTable("userClients", { + userId: varchar("userId") + .notNull() + .references(() => users.userId, { onDelete: "cascade" }), + clientId: integer("clientId") + .notNull() + .references(() => clients.clientId, { onDelete: "cascade" }) +}); + +export const roleClients = pgTable("roleClients", { + roleId: integer("roleId") + .notNull() + .references(() => roles.roleId, { onDelete: "cascade" }), + clientId: integer("clientId") + .notNull() + .references(() => clients.clientId, { onDelete: "cascade" }) +}); + export const securityKeys = pgTable("webauthnCredentials", { credentialId: varchar("credentialId").primaryKey(), userId: varchar("userId").notNull().references(() => users.userId, { @@ -538,7 +607,6 @@ export type RoleSite = InferSelectModel; export type UserSite = InferSelectModel; export type RoleResource = InferSelectModel; export type UserResource = InferSelectModel; -export type Limit = InferSelectModel; export type UserInvite = InferSelectModel; export type UserOrg = InferSelectModel; export type ResourceSession = InferSelectModel; @@ -555,3 +623,10 @@ export type Idp = InferSelectModel; export type ApiKey = InferSelectModel; export type ApiKeyAction = InferSelectModel; export type ApiKeyOrg = InferSelectModel; +export type Client = InferSelectModel; +export type ClientSite = InferSelectModel; +export type Olm = InferSelectModel; +export type OlmSession = InferSelectModel; +export type UserClient = InferSelectModel; +export type RoleClient = InferSelectModel; +export type OrgDomains = InferSelectModel; \ No newline at end of file diff --git a/server/db/redis.ts b/server/db/redis.ts new file mode 100644 index 00000000..c57b447c --- /dev/null +++ b/server/db/redis.ts @@ -0,0 +1,442 @@ +import Redis, { RedisOptions } from "ioredis"; +import logger from "@server/logger"; +import config from "@server/lib/config"; +import { build } from "@server/build"; + +class RedisManager { + public client: Redis | null = null; + private subscriber: Redis | null = null; + private publisher: Redis | null = null; + private isEnabled: boolean = false; + private isHealthy: boolean = true; + private lastHealthCheck: number = 0; + private healthCheckInterval: number = 30000; // 30 seconds + private subscribers: Map< + string, + Set<(channel: string, message: string) => void> + > = new Map(); + + constructor() { + if (build == "oss") { + this.isEnabled = false; + } else { + this.isEnabled = config.getRawConfig().flags?.enable_redis || false; + } + if (this.isEnabled) { + this.initializeClients(); + } + } + + private getRedisConfig(): RedisOptions { + const redisConfig = config.getRawConfig().redis!; + const opts: RedisOptions = { + host: redisConfig.host!, + port: redisConfig.port!, + password: redisConfig.password, + db: redisConfig.db, + // tls: { + // rejectUnauthorized: + // redisConfig.tls?.reject_unauthorized || false + // } + }; + return opts; + } + + // Add reconnection logic in initializeClients + private initializeClients(): void { + const config = this.getRedisConfig(); + + try { + this.client = new Redis({ + ...config, + enableReadyCheck: false, + maxRetriesPerRequest: 3, + keepAlive: 30000, + connectTimeout: 10000, // 10 seconds + commandTimeout: 5000, // 5 seconds + }); + + this.publisher = new Redis({ + ...config, + enableReadyCheck: false, + maxRetriesPerRequest: 3, + keepAlive: 30000, + connectTimeout: 10000, // 10 seconds + commandTimeout: 5000, // 5 seconds + }); + + this.subscriber = new Redis({ + ...config, + enableReadyCheck: false, + maxRetriesPerRequest: 3, + keepAlive: 30000, + connectTimeout: 10000, // 10 seconds + commandTimeout: 5000, // 5 seconds + }); + + // Add reconnection handlers + this.client.on("error", (err) => { + logger.error("Redis client error:", err); + this.isHealthy = false; + }); + + this.client.on("reconnecting", () => { + logger.info("Redis client reconnecting..."); + this.isHealthy = false; + }); + + this.client.on("ready", () => { + logger.info("Redis client ready"); + this.isHealthy = true; + }); + + this.publisher.on("error", (err) => { + logger.error("Redis publisher error:", err); + this.isHealthy = false; + }); + + this.publisher.on("ready", () => { + logger.info("Redis publisher ready"); + }); + + this.subscriber.on("error", (err) => { + logger.error("Redis subscriber error:", err); + this.isHealthy = false; + }); + + this.subscriber.on("ready", () => { + logger.info("Redis subscriber ready"); + }); + + // Set up connection handlers + this.client.on("connect", () => { + logger.info("Redis client connected"); + }); + + this.publisher.on("connect", () => { + logger.info("Redis publisher connected"); + }); + + this.subscriber.on("connect", () => { + logger.info("Redis subscriber connected"); + }); + + // Set up message handler for subscriber + this.subscriber.on( + "message", + (channel: string, message: string) => { + const channelSubscribers = this.subscribers.get(channel); + if (channelSubscribers) { + channelSubscribers.forEach((callback) => { + try { + callback(channel, message); + } catch (error) { + logger.error( + `Error in subscriber callback for channel ${channel}:`, + error + ); + } + }); + } + } + ); + + logger.info("Redis clients initialized successfully"); + + // Start periodic health monitoring + this.startHealthMonitoring(); + } catch (error) { + logger.error("Failed to initialize Redis clients:", error); + this.isEnabled = false; + } + } + + private startHealthMonitoring(): void { + if (!this.isEnabled) return; + + // Check health every 30 seconds + setInterval(async () => { + try { + await this.checkRedisHealth(); + } catch (error) { + logger.error("Error during Redis health monitoring:", error); + } + }, this.healthCheckInterval); + } + + public isRedisEnabled(): boolean { + return this.isEnabled && this.client !== null && this.isHealthy; + } + + private async checkRedisHealth(): Promise { + const now = Date.now(); + + // Only check health every 30 seconds + if (now - this.lastHealthCheck < this.healthCheckInterval) { + return this.isHealthy; + } + + this.lastHealthCheck = now; + + if (!this.client) { + this.isHealthy = false; + return false; + } + + try { + await Promise.race([ + this.client.ping(), + new Promise((_, reject) => + setTimeout(() => reject(new Error('Health check timeout')), 2000) + ) + ]); + this.isHealthy = true; + return true; + } catch (error) { + logger.error("Redis health check failed:", error); + this.isHealthy = false; + return false; + } + } + + public getClient(): Redis { + return this.client!; + } + + public async set( + key: string, + value: string, + ttl?: number + ): Promise { + if (!this.isRedisEnabled() || !this.client) return false; + + try { + if (ttl) { + await this.client.setex(key, ttl, value); + } else { + await this.client.set(key, value); + } + return true; + } catch (error) { + logger.error("Redis SET error:", error); + return false; + } + } + + public async get(key: string): Promise { + if (!this.isRedisEnabled() || !this.client) return null; + + try { + return await this.client.get(key); + } catch (error) { + logger.error("Redis GET error:", error); + return null; + } + } + + public async del(key: string): Promise { + if (!this.isRedisEnabled() || !this.client) return false; + + try { + await this.client.del(key); + return true; + } catch (error) { + logger.error("Redis DEL error:", error); + return false; + } + } + + public async sadd(key: string, member: string): Promise { + if (!this.isRedisEnabled() || !this.client) return false; + + try { + await this.client.sadd(key, member); + return true; + } catch (error) { + logger.error("Redis SADD error:", error); + return false; + } + } + + public async srem(key: string, member: string): Promise { + if (!this.isRedisEnabled() || !this.client) return false; + + try { + await this.client.srem(key, member); + return true; + } catch (error) { + logger.error("Redis SREM error:", error); + return false; + } + } + + public async smembers(key: string): Promise { + if (!this.isRedisEnabled() || !this.client) return []; + + try { + return await this.client.smembers(key); + } catch (error) { + logger.error("Redis SMEMBERS error:", error); + return []; + } + } + + public async hset( + key: string, + field: string, + value: string + ): Promise { + if (!this.isRedisEnabled() || !this.client) return false; + + try { + await this.client.hset(key, field, value); + return true; + } catch (error) { + logger.error("Redis HSET error:", error); + return false; + } + } + + public async hget(key: string, field: string): Promise { + if (!this.isRedisEnabled() || !this.client) return null; + + try { + return await this.client.hget(key, field); + } catch (error) { + logger.error("Redis HGET error:", error); + return null; + } + } + + public async hdel(key: string, field: string): Promise { + if (!this.isRedisEnabled() || !this.client) return false; + + try { + await this.client.hdel(key, field); + return true; + } catch (error) { + logger.error("Redis HDEL error:", error); + return false; + } + } + + public async hgetall(key: string): Promise> { + if (!this.isRedisEnabled() || !this.client) return {}; + + try { + return await this.client.hgetall(key); + } catch (error) { + logger.error("Redis HGETALL error:", error); + return {}; + } + } + + public async publish(channel: string, message: string): Promise { + if (!this.isRedisEnabled() || !this.publisher) return false; + + // Quick health check before attempting to publish + const isHealthy = await this.checkRedisHealth(); + if (!isHealthy) { + logger.warn("Skipping Redis publish due to unhealthy connection"); + return false; + } + + try { + // Add timeout to prevent hanging + await Promise.race([ + this.publisher.publish(channel, message), + new Promise((_, reject) => + setTimeout(() => reject(new Error('Redis publish timeout')), 3000) + ) + ]); + return true; + } catch (error) { + logger.error("Redis PUBLISH error:", error); + this.isHealthy = false; // Mark as unhealthy on error + return false; + } + } + + public async subscribe( + channel: string, + callback: (channel: string, message: string) => void + ): Promise { + if (!this.isRedisEnabled() || !this.subscriber) return false; + + try { + // Add callback to subscribers map + if (!this.subscribers.has(channel)) { + this.subscribers.set(channel, new Set()); + // Only subscribe to the channel if it's the first subscriber + await Promise.race([ + this.subscriber.subscribe(channel), + new Promise((_, reject) => + setTimeout(() => reject(new Error('Redis subscribe timeout')), 5000) + ) + ]); + } + + this.subscribers.get(channel)!.add(callback); + return true; + } catch (error) { + logger.error("Redis SUBSCRIBE error:", error); + this.isHealthy = false; + return false; + } + } + + public async unsubscribe( + channel: string, + callback?: (channel: string, message: string) => void + ): Promise { + if (!this.isRedisEnabled() || !this.subscriber) return false; + + try { + const channelSubscribers = this.subscribers.get(channel); + if (!channelSubscribers) return true; + + if (callback) { + // Remove specific callback + channelSubscribers.delete(callback); + if (channelSubscribers.size === 0) { + this.subscribers.delete(channel); + await this.subscriber.unsubscribe(channel); + } + } else { + // Remove all callbacks for this channel + this.subscribers.delete(channel); + await this.subscriber.unsubscribe(channel); + } + + return true; + } catch (error) { + logger.error("Redis UNSUBSCRIBE error:", error); + return false; + } + } + + public async disconnect(): Promise { + try { + if (this.client) { + await this.client.quit(); + this.client = null; + } + if (this.publisher) { + await this.publisher.quit(); + this.publisher = null; + } + if (this.subscriber) { + await this.subscriber.quit(); + this.subscriber = null; + } + this.subscribers.clear(); + logger.info("Redis clients disconnected"); + } catch (error) { + logger.error("Error disconnecting Redis clients:", error); + } + } +} + +export const redisManager = new RedisManager(); +export const redis = redisManager.getClient(); +export default redisManager; diff --git a/server/db/sqlite/migrate.ts b/server/db/sqlite/migrate.ts index 20b9043f..e4a730d0 100644 --- a/server/db/sqlite/migrate.ts +++ b/server/db/sqlite/migrate.ts @@ -1,5 +1,5 @@ import { migrate } from "drizzle-orm/better-sqlite3/migrator"; -import db from "./driver"; +import { db } from "./driver"; import path from "path"; const migrationsFolder = path.join("server/migrations"); diff --git a/server/db/sqlite/schema.ts b/server/db/sqlite/schema.ts index 5bf04223..c4191c34 100644 --- a/server/db/sqlite/schema.ts +++ b/server/db/sqlite/schema.ts @@ -6,12 +6,26 @@ export const domains = sqliteTable("domains", { baseDomain: text("baseDomain").notNull(), configManaged: integer("configManaged", { mode: "boolean" }) .notNull() - .default(false) + .default(false), + type: text("type"), // "ns", "cname", "wildcard" + verified: integer("verified", { mode: "boolean" }).notNull().default(false), + failed: integer("failed", { mode: "boolean" }).notNull().default(false), + tries: integer("tries").notNull().default(0) }); export const orgs = sqliteTable("orgs", { orgId: text("orgId").primaryKey(), - name: text("name").notNull() + name: text("name").notNull(), + subnet: text("subnet").notNull(), +}); + +export const userDomains = sqliteTable("userDomains", { + userId: text("userId") + .notNull() + .references(() => users.userId, { onDelete: "cascade" }), + domainId: text("domainId") + .notNull() + .references(() => domains.domainId, { onDelete: "cascade" }) }); export const orgDomains = sqliteTable("orgDomains", { @@ -36,12 +50,19 @@ export const sites = sqliteTable("sites", { }), name: text("name").notNull(), pubKey: text("pubKey"), - subnet: text("subnet").notNull(), - megabytesIn: integer("bytesIn"), - megabytesOut: integer("bytesOut"), + subnet: text("subnet"), + megabytesIn: integer("bytesIn").default(0), + megabytesOut: integer("bytesOut").default(0), lastBandwidthUpdate: text("lastBandwidthUpdate"), type: text("type").notNull(), // "newt" or "wireguard" online: integer("online", { mode: "boolean" }).notNull().default(false), + + // exit node stuff that is how to connect to the site when it has a wg server + address: text("address"), // this is the address of the wireguard interface in newt + endpoint: text("endpoint"), // this is how to reach gerbil externally - gets put into the wireguard config + publicKey: text("publicKey"), // TODO: Fix typo in publicKey + lastHolePunch: integer("lastHolePunch"), + listenPort: integer("listenPort"), dockerSocketEnabled: integer("dockerSocketEnabled", { mode: "boolean" }) .notNull() .default(true) @@ -109,7 +130,8 @@ export const exitNodes = sqliteTable("exitNodes", { endpoint: text("endpoint").notNull(), // this is how to reach gerbil externally - gets put into the wireguard config publicKey: text("publicKey").notNull(), listenPort: integer("listenPort").notNull(), - reachableAt: text("reachableAt") // this is the internal address of the gerbil http server for command control + reachableAt: text("reachableAt"), // this is the internal address of the gerbil http server for command control + maxConnections: integer("maxConnections") }); export const users = sqliteTable("user", { @@ -165,11 +187,54 @@ export const newts = sqliteTable("newt", { newtId: text("id").primaryKey(), secretHash: text("secretHash").notNull(), dateCreated: text("dateCreated").notNull(), + version: text("version"), siteId: integer("siteId").references(() => sites.siteId, { onDelete: "cascade" }) }); +export const clients = sqliteTable("clients", { + clientId: integer("id").primaryKey({ autoIncrement: true }), + orgId: text("orgId") + .references(() => orgs.orgId, { + onDelete: "cascade" + }) + .notNull(), + exitNodeId: integer("exitNode").references(() => exitNodes.exitNodeId, { + onDelete: "set null" + }), + name: text("name").notNull(), + pubKey: text("pubKey"), + subnet: text("subnet").notNull(), + megabytesIn: integer("bytesIn"), + megabytesOut: integer("bytesOut"), + lastBandwidthUpdate: text("lastBandwidthUpdate"), + lastPing: text("lastPing"), + type: text("type").notNull(), // "olm" + online: integer("online", { mode: "boolean" }).notNull().default(false), + endpoint: text("endpoint"), + lastHolePunch: integer("lastHolePunch") +}); + +export const clientSites = sqliteTable("clientSites", { + clientId: integer("clientId") + .notNull() + .references(() => clients.clientId, { onDelete: "cascade" }), + siteId: integer("siteId") + .notNull() + .references(() => sites.siteId, { onDelete: "cascade" }), + isRelayed: integer("isRelayed", { mode: "boolean" }).notNull().default(false) +}); + +export const olms = sqliteTable("olms", { + olmId: text("id").primaryKey(), + secretHash: text("secretHash").notNull(), + dateCreated: text("dateCreated").notNull(), + clientId: integer("clientId").references(() => clients.clientId, { + onDelete: "cascade" + }) +}); + export const twoFactorBackupCodes = sqliteTable("twoFactorBackupCodes", { codeId: integer("id").primaryKey({ autoIncrement: true }), userId: text("userId") @@ -194,6 +259,14 @@ export const newtSessions = sqliteTable("newtSession", { expiresAt: integer("expiresAt").notNull() }); +export const olmSessions = sqliteTable("clientSession", { + sessionId: text("id").primaryKey(), + olmId: text("olmId") + .notNull() + .references(() => olms.olmId, { onDelete: "cascade" }), + expiresAt: integer("expiresAt").notNull() +}); + export const userOrgs = sqliteTable("userOrgs", { userId: text("userId") .notNull() @@ -289,6 +362,24 @@ export const userSites = sqliteTable("userSites", { .references(() => sites.siteId, { onDelete: "cascade" }) }); +export const userClients = sqliteTable("userClients", { + userId: text("userId") + .notNull() + .references(() => users.userId, { onDelete: "cascade" }), + clientId: integer("clientId") + .notNull() + .references(() => clients.clientId, { onDelete: "cascade" }) +}); + +export const roleClients = sqliteTable("roleClients", { + roleId: integer("roleId") + .notNull() + .references(() => roles.roleId, { onDelete: "cascade" }), + clientId: integer("clientId") + .notNull() + .references(() => clients.clientId, { onDelete: "cascade" }) +}); + export const roleResources = sqliteTable("roleResources", { roleId: integer("roleId") .notNull() @@ -547,6 +638,8 @@ export type Target = InferSelectModel; export type Session = InferSelectModel; export type Newt = InferSelectModel; export type NewtSession = InferSelectModel; +export type Olm = InferSelectModel; +export type OlmSession = InferSelectModel; export type EmailVerificationCode = InferSelectModel< typeof emailVerificationCodes >; @@ -572,8 +665,13 @@ export type ResourceWhitelist = InferSelectModel; export type VersionMigration = InferSelectModel; export type ResourceRule = InferSelectModel; export type Domain = InferSelectModel; +export type Client = InferSelectModel; +export type ClientSite = InferSelectModel; +export type RoleClient = InferSelectModel; +export type UserClient = InferSelectModel; export type SupporterKey = InferSelectModel; export type Idp = InferSelectModel; export type ApiKey = InferSelectModel; export type ApiKeyAction = InferSelectModel; export type ApiKeyOrg = InferSelectModel; +export type OrgDomains = InferSelectModel; \ No newline at end of file diff --git a/server/emails/sendEmail.ts b/server/emails/sendEmail.ts index d7a59608..4c5586f1 100644 --- a/server/emails/sendEmail.ts +++ b/server/emails/sendEmail.ts @@ -2,6 +2,7 @@ import { render } from "@react-email/render"; import { ReactElement } from "react"; import emailClient from "@server/emails"; import logger from "@server/logger"; +import config from "@server/lib/config"; export async function sendEmail( template: ReactElement, @@ -24,9 +25,11 @@ export async function sendEmail( const emailHtml = await render(template); + const appName = "Fossorial - Pangolin"; + await emailClient.sendMail({ from: { - name: opts.name || "Pangolin", + name: opts.name || appName, address: opts.from, }, to: opts.to, diff --git a/server/emails/templates/NotifyResetPassword.tsx b/server/emails/templates/NotifyResetPassword.tsx index aaa1cbdd..66ea2430 100644 --- a/server/emails/templates/NotifyResetPassword.tsx +++ b/server/emails/templates/NotifyResetPassword.tsx @@ -1,11 +1,5 @@ -import { - Body, - Head, - Html, - Preview, - Tailwind -} from "@react-email/components"; -import * as React from "react"; +import React from "react"; +import { Body, Head, Html, Preview, Tailwind } from "@react-email/components"; import { themeColors } from "./lib/theme"; import { EmailContainer, @@ -22,29 +16,29 @@ interface Props { } export const ConfirmPasswordReset = ({ email }: Props) => { - const previewText = `Your password has been reset`; + const previewText = `Your password has been successfully reset.`; return ( {previewText} - + - Password Reset Confirmation + {/* Password Successfully Reset */} - Hi {email || "there"}, + Hi there, - This email confirms that your password has just been - reset. If you made this change, no further action is - required. + Your password has been successfully reset. You can + now sign in to your account using your new password. - Thank you for keeping your account secure. + If you didn't make this change, please contact our + support team immediately to secure your account. diff --git a/server/emails/templates/ResetPasswordCode.tsx b/server/emails/templates/ResetPasswordCode.tsx index 1a79527b..df14b8be 100644 --- a/server/emails/templates/ResetPasswordCode.tsx +++ b/server/emails/templates/ResetPasswordCode.tsx @@ -1,11 +1,5 @@ -import { - Body, - Head, - Html, - Preview, - Tailwind -} from "@react-email/components"; -import * as React from "react"; +import React from "react"; +import { Body, Head, Html, Preview, Tailwind } from "@react-email/components"; import { themeColors } from "./lib/theme"; import { EmailContainer, @@ -18,6 +12,7 @@ import { EmailText } from "./components/Email"; import CopyCodeBox from "./components/CopyCodeBox"; +import ButtonLink from "./components/ButtonLink"; interface Props { email: string; @@ -26,37 +21,39 @@ interface Props { } export const ResetPasswordCode = ({ email, code, link }: Props) => { - const previewText = `Your password reset code is ${code}`; + const previewText = `Reset your password with code: ${code}`; return ( {previewText} - + - Password Reset Request + {/* Reset Your Password */} - Hi {email || "there"}, + Hi there, - You’ve requested to reset your password. Please{" "} - - click here - {" "} - and follow the instructions to reset your password, - or manually enter the following code: + You've requested to reset your password. Click the + button below to reset your password, or use the + verification code provided if prompted. + + Reset Password + + - If you didn’t request this, you can safely ignore - this email. + This reset code will expire in 2 hours. If you + didn't request a password reset, you can safely + ignore this email. diff --git a/server/emails/templates/ResourceOTPCode.tsx b/server/emails/templates/ResourceOTPCode.tsx index 086dc444..4f68d9df 100644 --- a/server/emails/templates/ResourceOTPCode.tsx +++ b/server/emails/templates/ResourceOTPCode.tsx @@ -1,11 +1,5 @@ -import { - Body, - Head, - Html, - Preview, - Tailwind -} from "@react-email/components"; -import * as React from "react"; +import React from "react"; +import { Body, Head, Html, Preview, Tailwind } from "@react-email/components"; import { EmailContainer, EmailLetterHead, @@ -32,34 +26,40 @@ export const ResourceOTPCode = ({ orgName: organizationName, otp }: ResourceOTPCodeProps) => { - const previewText = `Your one-time password for ${resourceName} is ${otp}`; + const previewText = `Your access code for ${resourceName}: ${otp}`; return ( {previewText} - + - - Your One-Time Code for {resourceName} - + {/* */} + {/* Access Code for {resourceName} */} + {/* */} - Hi {email || "there"}, + Hi there, - You’ve requested a one-time password to access{" "} + You've requested access to{" "} {resourceName} in{" "} - {organizationName}. Use the code - below to complete your authentication: + {organizationName}. Use the + verification code below to complete your + authentication. + + This code will expire in 15 minutes. If you didn't + request this code, please ignore this email. + + diff --git a/server/emails/templates/SendInviteLink.tsx b/server/emails/templates/SendInviteLink.tsx index ed3c7b53..c859d3d7 100644 --- a/server/emails/templates/SendInviteLink.tsx +++ b/server/emails/templates/SendInviteLink.tsx @@ -1,11 +1,5 @@ -import { - Body, - Head, - Html, - Preview, - Tailwind, -} from "@react-email/components"; -import * as React from "react"; +import React from "react"; +import { Body, Head, Html, Preview, Tailwind } from "@react-email/components"; import { themeColors } from "./lib/theme"; import { EmailContainer, @@ -41,35 +35,44 @@ export const SendInviteLink = ({ {previewText} - + - Invited to Join {orgName} + {/* */} + {/* You're Invited to Join {orgName} */} + {/* */} - Hi {email || "there"}, + Hi there, - You’ve been invited to join the organization{" "} + You've been invited to join{" "} {orgName} - {inviterName ? ` by ${inviterName}.` : "."} Please - access the link below to accept the invite. - - - - This invite will expire in{" "} - - {expiresInDays}{" "} - {expiresInDays === "1" ? "day" : "days"}. - + {inviterName ? ` by ${inviterName}` : ""}. Click the + button below to accept your invitation and get + started. - Accept Invite to {orgName} + Accept Invitation + {/* */} + {/* If you're having trouble clicking the button, copy */} + {/* and paste the URL below into your web browser: */} + {/*
*/} + {/* {inviteLink} */} + {/*
*/} + + + This invite expires in {expiresInDays}{" "} + {expiresInDays === "1" ? "day" : "days"}. If the + link has expired, please contact the owner of the + organization to request a new invitation. + + diff --git a/server/emails/templates/TwoFactorAuthNotification.tsx b/server/emails/templates/TwoFactorAuthNotification.tsx index 8993a3bd..3261023e 100644 --- a/server/emails/templates/TwoFactorAuthNotification.tsx +++ b/server/emails/templates/TwoFactorAuthNotification.tsx @@ -1,11 +1,5 @@ -import { - Body, - Head, - Html, - Preview, - Tailwind -} from "@react-email/components"; -import * as React from "react"; +import React from "react"; +import { Body, Head, Html, Preview, Tailwind } from "@react-email/components"; import { themeColors } from "./lib/theme"; import { EmailContainer, @@ -23,44 +17,52 @@ interface Props { } export const TwoFactorAuthNotification = ({ email, enabled }: Props) => { - const previewText = `Two-Factor Authentication has been ${enabled ? "enabled" : "disabled"}`; + const previewText = `Two-Factor Authentication ${enabled ? "enabled" : "disabled"} for your account`; return ( {previewText} - + - - Two-Factor Authentication{" "} - {enabled ? "Enabled" : "Disabled"} - + {/* */} + {/* Security Update: 2FA{" "} */} + {/* {enabled ? "Enabled" : "Disabled"} */} + {/* */} - Hi {email || "there"}, + Hi there, - This email confirms that Two-Factor Authentication - has been successfully{" "} - {enabled ? "enabled" : "disabled"} on your account. + Two-factor authentication has been successfully{" "} + {enabled ? "enabled" : "disabled"}{" "} + on your account. {enabled ? ( - - With Two-Factor Authentication enabled, your - account is now more secure. Please ensure you - keep your authentication method safe. - + <> + + Your account is now protected with an + additional layer of security. Keep your + authentication method safe and accessible. + + ) : ( - - With Two-Factor Authentication disabled, your - account may be less secure. We recommend - enabling it to protect your account. - + <> + + We recommend re-enabling two-factor + authentication to keep your account secure. + + )} + + If you didn't make this change, please contact our + support team immediately. + + diff --git a/server/emails/templates/VerifyEmailCode.tsx b/server/emails/templates/VerifyEmailCode.tsx index ad0ef053..6a361648 100644 --- a/server/emails/templates/VerifyEmailCode.tsx +++ b/server/emails/templates/VerifyEmailCode.tsx @@ -1,5 +1,5 @@ +import React from "react"; import { Body, Head, Html, Preview, Tailwind } from "@react-email/components"; -import * as React from "react"; import { themeColors } from "./lib/theme"; import { EmailContainer, @@ -24,25 +24,24 @@ export const VerifyEmail = ({ verificationCode, verifyLink }: VerifyEmailProps) => { - const previewText = `Your verification code is ${verificationCode}`; + const previewText = `Verify your email with code: ${verificationCode}`; return ( {previewText} - + - Please Verify Your Email + {/* Verify Your Email Address */} - Hi {username || "there"}, + Hi there, - You’ve requested to verify your email. Please use - the code below to complete the verification process - upon logging in. + Welcome! To complete your account setup, please + verify your email address using the code below. @@ -50,7 +49,8 @@ export const VerifyEmail = ({ - If you didn’t request this, you can safely ignore + This verification code will expire in 15 minutes. If + you didn't create an account, you can safely ignore this email. diff --git a/server/emails/templates/WelcomeQuickStart.tsx b/server/emails/templates/WelcomeQuickStart.tsx new file mode 100644 index 00000000..caebff06 --- /dev/null +++ b/server/emails/templates/WelcomeQuickStart.tsx @@ -0,0 +1,131 @@ +import React from "react"; +import { Body, Head, Html, Preview, Tailwind } from "@react-email/components"; +import { themeColors } from "./lib/theme"; +import { + EmailContainer, + EmailFooter, + EmailGreeting, + EmailHeading, + EmailLetterHead, + EmailSection, + EmailSignature, + EmailText, + EmailInfoSection +} from "./components/Email"; +import ButtonLink from "./components/ButtonLink"; +import CopyCodeBox from "./components/CopyCodeBox"; + +interface WelcomeQuickStartProps { + username?: string; + link: string; + fallbackLink: string; + resourceMethod: string; + resourceHostname: string; + resourcePort: string | number; + resourceUrl: string; + cliCommand: string; +} + +export const WelcomeQuickStart = ({ + username, + link, + fallbackLink, + resourceMethod, + resourceHostname, + resourcePort, + resourceUrl, + cliCommand +}: WelcomeQuickStartProps) => { + const previewText = "Welcome! Here's what to do next"; + + return ( + + + {previewText} + + + + + + Hi there, + + + Thank you for trying out Pangolin! We're excited to + have you on board. + + + + To continue to configure your site, resources, and + other features, complete your account setup to + access the full dashboard. + + + + + View Your Dashboard + + {/*

*/} + {/* If the button above doesn't work, you can also */} + {/* use this{" "} */} + {/* */} + {/* link */} + {/* */} + {/* . */} + {/*

*/} +
+ + +
+ Connect your site using Newt +
+
+
+ + {cliCommand} + +
+

+ To learn how to use Newt, including more + installation methods, visit the{" "} + + docs + + . +

+
+
+ + + {resourceUrl} + + ) + } + ]} + /> + + + + +
+ +
+ + ); +}; + +export default WelcomeQuickStart; diff --git a/server/emails/templates/components/ButtonLink.tsx b/server/emails/templates/components/ButtonLink.tsx index e32e1810..9086bd47 100644 --- a/server/emails/templates/components/ButtonLink.tsx +++ b/server/emails/templates/components/ButtonLink.tsx @@ -12,7 +12,11 @@ export default function ButtonLink({ return ( {children} diff --git a/server/emails/templates/components/CopyCodeBox.tsx b/server/emails/templates/components/CopyCodeBox.tsx index ef48b383..3e4d1d08 100644 --- a/server/emails/templates/components/CopyCodeBox.tsx +++ b/server/emails/templates/components/CopyCodeBox.tsx @@ -2,10 +2,15 @@ import React from "react"; export default function CopyCodeBox({ text }: { text: string }) { return ( -
- - {text} - +
+
+ + {text} + +
+

+ Copy and paste this code when prompted +

); } diff --git a/server/emails/templates/components/Email.tsx b/server/emails/templates/components/Email.tsx index c73e4c85..05912622 100644 --- a/server/emails/templates/components/Email.tsx +++ b/server/emails/templates/components/Email.tsx @@ -1,47 +1,26 @@ -import { Container } from "@react-email/components"; import React from "react"; +import { Container, Img } from "@react-email/components"; // EmailContainer: Wraps the entire email layout export function EmailContainer({ children }: { children: React.ReactNode }) { return ( - + {children} ); } -// EmailLetterHead: For branding or logo at the top +// EmailLetterHead: For branding with logo on dark background export function EmailLetterHead() { return ( -
- - - - - -
- Pangolin - - {new Date().getFullYear()} -
+
+ Fossorial
); } @@ -49,14 +28,22 @@ export function EmailLetterHead() { // EmailHeading: For the primary message or headline export function EmailHeading({ children }: { children: React.ReactNode }) { return ( -

- {children} -

+
+

+ {children} +

+
); } export function EmailGreeting({ children }: { children: React.ReactNode }) { - return

{children}

; + return ( +
+

+ {children} +

+
+ ); } // EmailText: For general text content @@ -68,9 +55,13 @@ export function EmailText({ className?: string; }) { return ( -

- {children} -

+
+

+ {children} +

+
); } @@ -82,20 +73,70 @@ export function EmailSection({ children: React.ReactNode; className?: string; }) { - return
{children}
; + return ( +
{children}
+ ); } // EmailFooter: For closing or signature export function EmailFooter({ children }: { children: React.ReactNode }) { - return
{children}
; + return ( +
+ {children} +

+ For any questions or support, please contact us at: +
+ support@fossorial.io +

+

+ © {new Date().getFullYear()} Fossorial, Inc. All rights + reserved. +

+
+ ); } export function EmailSignature() { return ( -

- Best regards, -
- Fossorial -

+
+

+ Best regards, +
+ The Fossorial Team +

+
+ ); +} + +// EmailInfoSection: For structured key-value info (like resource details) +export function EmailInfoSection({ + title, + items +}: { + title?: string; + items: { label: string; value: React.ReactNode }[]; +}) { + return ( +
+ {title && ( +
+ {title} +
+ )} + + + {items.map((item, idx) => ( + + + + + ))} + +
+ {item.label} + + {item.value} +
+
); } diff --git a/server/emails/templates/lib/theme.ts b/server/emails/templates/lib/theme.ts index ada77fd2..a10ff77a 100644 --- a/server/emails/templates/lib/theme.ts +++ b/server/emails/templates/lib/theme.ts @@ -1,3 +1,5 @@ +import React from "react"; + export const themeColors = { theme: { extend: { diff --git a/server/index.ts b/server/index.ts index a07bbc93..55b34543 100644 --- a/server/index.ts +++ b/server/index.ts @@ -9,6 +9,7 @@ import { createIntegrationApiServer } from "./integrationApiServer"; import config from "@server/lib/config"; async function startServers() { + await config.initServer(); await runSetupFunctions(); // Start all servers diff --git a/server/integrationApiServer.ts b/server/integrationApiServer.ts index f3dfbbef..eefaacd8 100644 --- a/server/integrationApiServer.ts +++ b/server/integrationApiServer.ts @@ -20,8 +20,9 @@ const externalPort = config.getRawConfig().server.integration_port; export function createIntegrationApiServer() { const apiServer = express(); - if (config.getRawConfig().server.trust_proxy) { - apiServer.set("trust proxy", 1); + const trustProxy = config.getRawConfig().server.trust_proxy; + if (trustProxy) { + apiServer.set("trust proxy", trustProxy); } apiServer.use(cors()); diff --git a/server/lib/config.ts b/server/lib/config.ts index f6cd240f..8e23ae0f 100644 --- a/server/lib/config.ts +++ b/server/lib/config.ts @@ -17,10 +17,6 @@ export class Config { isDev: boolean = process.env.ENVIRONMENT !== "prod"; constructor() { - this.load(); - } - - public load() { const environment = readConfigFile(); const { @@ -90,17 +86,36 @@ export class Config { ? "true" : "false"; process.env.DASHBOARD_URL = parsedConfig.app.dashboard_url; + process.env.FLAGS_DISABLE_LOCAL_SITES = parsedConfig.flags + ?.disable_local_sites + ? "true" + : "false"; + process.env.FLAGS_DISABLE_BASIC_WIREGUARD_SITES = parsedConfig.flags + ?.disable_basic_wireguard_sites + ? "true" + : "false"; - license.setServerSecret(parsedConfig.server.secret); - - this.checkKeyStatus(); + process.env.FLAGS_ENABLE_CLIENTS = parsedConfig.flags?.enable_clients + ? "true" + : "false"; this.rawConfig = parsedConfig; } + public async initServer() { + if (!this.rawConfig) { + throw new Error("Config not loaded. Call load() first."); + } + license.setServerSecret(this.rawConfig.server.secret); + + await this.checkKeyStatus(); + } + private async checkKeyStatus() { const licenseStatus = await license.check(); - if (!licenseStatus.isHostLicensed) { + if ( + !licenseStatus.isHostLicensed + ) { this.checkSupporterKey(); } } @@ -116,6 +131,9 @@ export class Config { } public getDomain(domainId: string) { + if (!this.rawConfig.domains || !this.rawConfig.domains[domainId]) { + return null; + } return this.rawConfig.domains[domainId]; } diff --git a/server/lib/ip.test.ts b/server/lib/ip.test.ts index 2c2dd057..67a2faaa 100644 --- a/server/lib/ip.test.ts +++ b/server/lib/ip.test.ts @@ -4,7 +4,14 @@ import { assertEquals } from "@test/assert"; // Test cases function testFindNextAvailableCidr() { console.log("Running findNextAvailableCidr tests..."); - + + // Test 0: Basic IPv4 allocation with a subnet in the wrong range + { + const existing = ["100.90.130.1/30", "100.90.128.4/30"]; + const result = findNextAvailableCidr(existing, 30, "100.90.130.1/24"); + assertEquals(result, "100.90.130.4/30", "Basic IPv4 allocation failed"); + } + // Test 1: Basic IPv4 allocation { const existing = ["10.0.0.0/16", "10.1.0.0/16"]; @@ -26,6 +33,12 @@ function testFindNextAvailableCidr() { assertEquals(result, null, "No available space test failed"); } + // Test 4: Empty existing + { + const existing: string[] = []; + const result = findNextAvailableCidr(existing, 30, "10.0.0.0/8"); + assertEquals(result, "10.0.0.0/30", "Empty existing test failed"); + } // // Test 4: IPv6 allocation // { // const existing = ["2001:db8::/32", "2001:db8:1::/32"]; diff --git a/server/lib/ip.ts b/server/lib/ip.ts index fd6f07ab..cf886fad 100644 --- a/server/lib/ip.ts +++ b/server/lib/ip.ts @@ -1,3 +1,8 @@ +import { db } from "@server/db"; +import { clients, orgs, sites } from "@server/db"; +import { and, eq, isNotNull } from "drizzle-orm"; +import config from "@server/lib/config"; + interface IPRange { start: bigint; end: bigint; @@ -9,7 +14,7 @@ type IPVersion = 4 | 6; * Detects IP version from address string */ function detectIpVersion(ip: string): IPVersion { - return ip.includes(':') ? 6 : 4; + return ip.includes(":") ? 6 : 4; } /** @@ -19,34 +24,34 @@ function ipToBigInt(ip: string): bigint { const version = detectIpVersion(ip); if (version === 4) { - return ip.split('.') - .reduce((acc, octet) => { - const num = parseInt(octet); - if (isNaN(num) || num < 0 || num > 255) { - throw new Error(`Invalid IPv4 octet: ${octet}`); - } - return BigInt.asUintN(64, (acc << BigInt(8)) + BigInt(num)); - }, BigInt(0)); + return ip.split(".").reduce((acc, octet) => { + const num = parseInt(octet); + if (isNaN(num) || num < 0 || num > 255) { + throw new Error(`Invalid IPv4 octet: ${octet}`); + } + return BigInt.asUintN(64, (acc << BigInt(8)) + BigInt(num)); + }, BigInt(0)); } else { // Handle IPv6 // Expand :: notation let fullAddress = ip; - if (ip.includes('::')) { - const parts = ip.split('::'); - if (parts.length > 2) throw new Error('Invalid IPv6 address: multiple :: found'); - const missing = 8 - (parts[0].split(':').length + parts[1].split(':').length); - const padding = Array(missing).fill('0').join(':'); + if (ip.includes("::")) { + const parts = ip.split("::"); + if (parts.length > 2) + throw new Error("Invalid IPv6 address: multiple :: found"); + const missing = + 8 - (parts[0].split(":").length + parts[1].split(":").length); + const padding = Array(missing).fill("0").join(":"); fullAddress = `${parts[0]}:${padding}:${parts[1]}`; } - return fullAddress.split(':') - .reduce((acc, hextet) => { - const num = parseInt(hextet || '0', 16); - if (isNaN(num) || num < 0 || num > 65535) { - throw new Error(`Invalid IPv6 hextet: ${hextet}`); - } - return BigInt.asUintN(128, (acc << BigInt(16)) + BigInt(num)); - }, BigInt(0)); + return fullAddress.split(":").reduce((acc, hextet) => { + const num = parseInt(hextet || "0", 16); + if (isNaN(num) || num < 0 || num > 65535) { + throw new Error(`Invalid IPv6 hextet: ${hextet}`); + } + return BigInt.asUintN(128, (acc << BigInt(16)) + BigInt(num)); + }, BigInt(0)); } } @@ -60,11 +65,15 @@ function bigIntToIp(num: bigint, version: IPVersion): string { octets.unshift(Number(num & BigInt(255))); num = num >> BigInt(8); } - return octets.join('.'); + return octets.join("."); } else { const hextets: string[] = []; for (let i = 0; i < 8; i++) { - hextets.unshift(Number(num & BigInt(65535)).toString(16).padStart(4, '0')); + hextets.unshift( + Number(num & BigInt(65535)) + .toString(16) + .padStart(4, "0") + ); num = num >> BigInt(16); } // Compress zero sequences @@ -74,7 +83,7 @@ function bigIntToIp(num: bigint, version: IPVersion): string { let currentZeroLength = 0; for (let i = 0; i < hextets.length; i++) { - if (hextets[i] === '0000') { + if (hextets[i] === "0000") { if (currentZeroStart === -1) currentZeroStart = i; currentZeroLength++; if (currentZeroLength > maxZeroLength) { @@ -88,12 +97,14 @@ function bigIntToIp(num: bigint, version: IPVersion): string { } if (maxZeroLength > 1) { - hextets.splice(maxZeroStart, maxZeroLength, ''); - if (maxZeroStart === 0) hextets.unshift(''); - if (maxZeroStart + maxZeroLength === 8) hextets.push(''); + hextets.splice(maxZeroStart, maxZeroLength, ""); + if (maxZeroStart === 0) hextets.unshift(""); + if (maxZeroStart + maxZeroLength === 8) hextets.push(""); } - return hextets.map(h => h === '0000' ? '0' : h.replace(/^0+/, '')).join(':'); + return hextets + .map((h) => (h === "0000" ? "0" : h.replace(/^0+/, ""))) + .join(":"); } } @@ -101,7 +112,7 @@ function bigIntToIp(num: bigint, version: IPVersion): string { * Converts CIDR to IP range */ export function cidrToRange(cidr: string): IPRange { - const [ip, prefix] = cidr.split('/'); + const [ip, prefix] = cidr.split("/"); const version = detectIpVersion(ip); const prefixBits = parseInt(prefix); const ipBigInt = ipToBigInt(ip); @@ -113,7 +124,10 @@ export function cidrToRange(cidr: string): IPRange { } const shiftBits = BigInt(maxPrefix - prefixBits); - const mask = BigInt.asUintN(version === 4 ? 64 : 128, (BigInt(1) << shiftBits) - BigInt(1)); + const mask = BigInt.asUintN( + version === 4 ? 64 : 128, + (BigInt(1) << shiftBits) - BigInt(1) + ); const start = ipBigInt & ~mask; const end = start | mask; @@ -132,28 +146,32 @@ export function findNextAvailableCidr( blockSize: number, startCidr?: string ): string | null { - if (!startCidr && existingCidrs.length === 0) { return null; } // If no existing CIDRs, use the IP version from startCidr - const version = startCidr - ? detectIpVersion(startCidr.split('/')[0]) - : 4; // Default to IPv4 if no startCidr provided + const version = startCidr ? detectIpVersion(startCidr.split("/")[0]) : 4; // Default to IPv4 if no startCidr provided // Use appropriate default startCidr if none provided startCidr = startCidr || (version === 4 ? "0.0.0.0/0" : "::/0"); // If there are existing CIDRs, ensure all are same version - if (existingCidrs.length > 0 && - existingCidrs.some(cidr => detectIpVersion(cidr.split('/')[0]) !== version)) { - throw new Error('All CIDRs must be of the same IP version'); + if ( + existingCidrs.length > 0 && + existingCidrs.some( + (cidr) => detectIpVersion(cidr.split("/")[0]) !== version + ) + ) { + throw new Error("All CIDRs must be of the same IP version"); } + // Extract the network part from startCidr to ensure we stay in the right subnet + const startCidrRange = cidrToRange(startCidr); + // Convert existing CIDRs to ranges and sort them const existingRanges = existingCidrs - .map(cidr => cidrToRange(cidr)) + .map((cidr) => cidrToRange(cidr)) .sort((a, b) => (a.start < b.start ? -1 : 1)); // Calculate block size @@ -161,14 +179,17 @@ export function findNextAvailableCidr( const blockSizeBigInt = BigInt(1) << BigInt(maxPrefix - blockSize); // Start from the beginning of the given CIDR - let current = cidrToRange(startCidr).start; - const maxIp = cidrToRange(startCidr).end; + let current = startCidrRange.start; + const maxIp = startCidrRange.end; // Iterate through existing ranges for (let i = 0; i <= existingRanges.length; i++) { const nextRange = existingRanges[i]; + // Align current to block size - const alignedCurrent = current + ((blockSizeBigInt - (current % blockSizeBigInt)) % blockSizeBigInt); + const alignedCurrent = + current + + ((blockSizeBigInt - (current % blockSizeBigInt)) % blockSizeBigInt); // Check if we've gone beyond the maximum allowed IP if (alignedCurrent + blockSizeBigInt - BigInt(1) > maxIp) { @@ -176,12 +197,18 @@ export function findNextAvailableCidr( } // If we're at the end of existing ranges or found a gap - if (!nextRange || alignedCurrent + blockSizeBigInt - BigInt(1) < nextRange.start) { + if ( + !nextRange || + alignedCurrent + blockSizeBigInt - BigInt(1) < nextRange.start + ) { return `${bigIntToIp(alignedCurrent, version)}/${blockSize}`; } - // Move current pointer to after the current range - current = nextRange.end + BigInt(1); + // If next range overlaps with our search space, move past it + if (nextRange.end >= startCidrRange.start && nextRange.start <= maxIp) { + // Move current pointer to after the current range + current = nextRange.end + BigInt(1); + } } return null; @@ -195,7 +222,7 @@ export function findNextAvailableCidr( */ export function isIpInCidr(ip: string, cidr: string): boolean { const ipVersion = detectIpVersion(ip); - const cidrVersion = detectIpVersion(cidr.split('/')[0]); + const cidrVersion = detectIpVersion(cidr.split("/")[0]); // If IP versions don't match, the IP cannot be in the CIDR range if (ipVersion !== cidrVersion) { @@ -207,3 +234,61 @@ export function isIpInCidr(ip: string, cidr: string): boolean { const range = cidrToRange(cidr); return ipBigInt >= range.start && ipBigInt <= range.end; } + +export async function getNextAvailableClientSubnet( + orgId: string +): Promise { + const [org] = await db.select().from(orgs).where(eq(orgs.orgId, orgId)); + + const existingAddressesSites = await db + .select({ + address: sites.address + }) + .from(sites) + .where(and(isNotNull(sites.address), eq(sites.orgId, orgId))); + + const existingAddressesClients = await db + .select({ + address: clients.subnet + }) + .from(clients) + .where(and(isNotNull(clients.subnet), eq(clients.orgId, orgId))); + + const addresses = [ + ...existingAddressesSites.map( + (site) => `${site.address?.split("/")[0]}/32` + ), // we are overriding the 32 so that we pick individual addresses in the subnet of the org for the site and the client even though they are stored with the /block_size of the org + ...existingAddressesClients.map( + (client) => `${client.address.split("/")}/32` + ) + ].filter((address) => address !== null) as string[]; + + let subnet = findNextAvailableCidr(addresses, 32, org.subnet); // pick the sites address in the org + if (!subnet) { + throw new Error("No available subnets remaining in space"); + } + + return subnet; +} + +export async function getNextAvailableOrgSubnet(): Promise { + const existingAddresses = await db + .select({ + subnet: orgs.subnet + }) + .from(orgs) + .where(isNotNull(orgs.subnet)); + + const addresses = existingAddresses.map((org) => org.subnet); + + let subnet = findNextAvailableCidr( + addresses, + config.getRawConfig().orgs.block_size, + config.getRawConfig().orgs.subnet_group + ); + if (!subnet) { + throw new Error("No available subnets remaining in space"); + } + + return subnet; +} diff --git a/server/lib/rateLimitStore.ts b/server/lib/rateLimitStore.ts new file mode 100644 index 00000000..78e1a9fc --- /dev/null +++ b/server/lib/rateLimitStore.ts @@ -0,0 +1,16 @@ +import { MemoryStore, Store } from "express-rate-limit"; +import config from "./config"; +import redisManager from "@server/db/redis"; +import { RedisStore } from "rate-limit-redis"; + +export function createStore(): Store { + let rateLimitStore: Store = new MemoryStore(); + if (config.getRawConfig().flags?.enable_redis) { + const client = redisManager.client!; + rateLimitStore = new RedisStore({ + sendCommand: async (command: string, ...args: string[]) => + (await client.call(command, args)) as any + }); + } + return rateLimitStore; +} diff --git a/server/lib/readConfigFile.ts b/server/lib/readConfigFile.ts index e98695c5..220348fc 100644 --- a/server/lib/readConfigFile.ts +++ b/server/lib/readConfigFile.ts @@ -3,6 +3,7 @@ import yaml from "js-yaml"; import { configFilePath1, configFilePath2 } from "./consts"; import { z } from "zod"; import stoi from "./stoi"; +import { build } from "@server/build"; const portSchema = z.number().positive().gt(0).lte(65535); @@ -10,214 +11,279 @@ const getEnvOrYaml = (envVar: string) => (valFromYaml: any) => { return process.env[envVar] ?? valFromYaml; }; -export const configSchema = z.object({ - app: z.object({ - dashboard_url: z - .string() - .url() - .optional() - .pipe(z.string().url()) - .transform((url) => url.toLowerCase()), - log_level: z - .enum(["debug", "info", "warn", "error"]) - .optional() - .default("info"), - save_logs: z.boolean().optional().default(false), - log_failed_attempts: z.boolean().optional().default(false) - }), - domains: z - .record( - z.string(), - z.object({ - base_domain: z - .string() - .nonempty("base_domain must not be empty") - .transform((url) => url.toLowerCase()), - cert_resolver: z.string().optional().default("letsencrypt"), - prefer_wildcard_cert: z.boolean().optional().default(false) - }) - ) - .refine( - (domains) => { - const keys = Object.keys(domains); - - if (keys.length === 0) { - return false; - } - - return true; - }, - { - message: "At least one domain must be defined" - } - ), - server: z.object({ - integration_port: portSchema - .optional() - .default(3003) - .transform(stoi) - .pipe(portSchema.optional()), - external_port: portSchema - .optional() - .default(3000) - .transform(stoi) - .pipe(portSchema), - internal_port: portSchema - .optional() - .default(3001) - .transform(stoi) - .pipe(portSchema), - next_port: portSchema - .optional() - .default(3002) - .transform(stoi) - .pipe(portSchema), - internal_hostname: z - .string() - .optional() - .default("pangolin") - .transform((url) => url.toLowerCase()), - session_cookie_name: z.string().optional().default("p_session_token"), - resource_access_token_param: z.string().optional().default("p_token"), - resource_access_token_headers: z +export const configSchema = z + .object({ + app: z.object({ + dashboard_url: z + .string() + .url() + .optional() + .pipe(z.string().url()) + .transform((url) => url.toLowerCase()), + log_level: z + .enum(["debug", "info", "warn", "error"]) + .optional() + .default("info"), + save_logs: z.boolean().optional().default(false), + log_failed_attempts: z.boolean().optional().default(false) + }), + domains: z + .record( + z.string(), + z.object({ + base_domain: z + .string() + .nonempty("base_domain must not be empty") + .transform((url) => url.toLowerCase()), + cert_resolver: z.string().optional().default("letsencrypt"), + prefer_wildcard_cert: z.boolean().optional().default(false) + }) + ) + .optional(), + server: z.object({ + integration_port: portSchema + .optional() + .default(3003) + .transform(stoi) + .pipe(portSchema.optional()), + external_port: portSchema + .optional() + .default(3000) + .transform(stoi) + .pipe(portSchema), + internal_port: portSchema + .optional() + .default(3001) + .transform(stoi) + .pipe(portSchema), + next_port: portSchema + .optional() + .default(3002) + .transform(stoi) + .pipe(portSchema), + internal_hostname: z + .string() + .optional() + .default("pangolin") + .transform((url) => url.toLowerCase()), + session_cookie_name: z + .string() + .optional() + .default("p_session_token"), + resource_access_token_param: z + .string() + .optional() + .default("p_token"), + resource_access_token_headers: z + .object({ + id: z.string().optional().default("P-Access-Token-Id"), + token: z.string().optional().default("P-Access-Token") + }) + .optional() + .default({}), + resource_session_request_param: z + .string() + .optional() + .default("resource_session_request_param"), + dashboard_session_length_hours: z + .number() + .positive() + .gt(0) + .optional() + .default(720), + resource_session_length_hours: z + .number() + .positive() + .gt(0) + .optional() + .default(720), + cors: z + .object({ + origins: z.array(z.string()).optional(), + methods: z.array(z.string()).optional(), + allowed_headers: z.array(z.string()).optional(), + credentials: z.boolean().optional() + }) + .optional(), + trust_proxy: z.number().int().gte(0).optional().default(1), + secret: z + .string() + .optional() + .transform(getEnvOrYaml("SERVER_SECRET")) + .pipe(z.string().min(8)) + }), + postgres: z .object({ - id: z.string().optional().default("P-Access-Token-Id"), - token: z.string().optional().default("P-Access-Token") + connection_string: z.string(), + replicas: z + .array( + z.object({ + connection_string: z.string() + }) + ) + .optional() + }) + .optional(), + redis: z + .object({ + host: z.string(), + port: portSchema, + password: z.string().optional(), + db: z.number().int().nonnegative().optional().default(0), + tls: z + .object({ + reject_unauthorized: z + .boolean() + .optional() + .default(true) + }) + .optional() + }) + .optional(), + traefik: z + .object({ + http_entrypoint: z.string().optional().default("web"), + https_entrypoint: z.string().optional().default("websecure"), + additional_middlewares: z.array(z.string()).optional() }) .optional() .default({}), - resource_session_request_param: z - .string() - .optional() - .default("resource_session_request_param"), - dashboard_session_length_hours: z - .number() - .positive() - .gt(0) - .optional() - .default(720), - resource_session_length_hours: z - .number() - .positive() - .gt(0) - .optional() - .default(720), - cors: z + gerbil: z .object({ - origins: z.array(z.string()).optional(), - methods: z.array(z.string()).optional(), - allowed_headers: z.array(z.string()).optional(), - credentials: z.boolean().optional() + exit_node_name: z.string().optional(), + start_port: portSchema + .optional() + .default(51820) + .transform(stoi) + .pipe(portSchema), + base_endpoint: z + .string() + .optional() + .pipe(z.string()) + .transform((url) => url.toLowerCase()), + use_subdomain: z.boolean().optional().default(false), + subnet_group: z.string().optional().default("100.89.137.0/20"), + block_size: z.number().positive().gt(0).optional().default(24), + site_block_size: z + .number() + .positive() + .gt(0) + .optional() + .default(30) + }) + .optional() + .default({}), + orgs: z.object({ + block_size: z.number().positive().gt(0), + subnet_group: z.string() + }), + rate_limits: z + .object({ + global: z + .object({ + window_minutes: z + .number() + .positive() + .gt(0) + .optional() + .default(1), + max_requests: z + .number() + .positive() + .gt(0) + .optional() + .default(500) + }) + .optional() + .default({}), + auth: z + .object({ + window_minutes: z + .number() + .positive() + .gt(0) + .optional() + .default(1), + max_requests: z + .number() + .positive() + .gt(0) + .optional() + .default(500) + }) + .optional() + .default({}) + }) + .optional() + .default({}), + email: z + .object({ + smtp_host: z.string().optional(), + smtp_port: portSchema.optional(), + smtp_user: z.string().optional(), + smtp_pass: z.string().optional(), + smtp_secure: z.boolean().optional(), + smtp_tls_reject_unauthorized: z.boolean().optional(), + no_reply: z.string().email().optional() }) .optional(), - trust_proxy: z.number().int().gte(0).optional().default(1), - secret: z - .string() + flags: z + .object({ + require_email_verification: z.boolean().optional(), + disable_signup_without_invite: z.boolean().optional(), + disable_user_create_org: z.boolean().optional(), + allow_raw_resources: z.boolean().optional(), + allow_base_domain_resources: z.boolean().optional(), + enable_integration_api: z.boolean().optional(), + enable_redis: z.boolean().optional(), + disable_local_sites: z.boolean().optional(), + disable_basic_wireguard_sites: z.boolean().optional(), + disable_config_managed_domains: z.boolean().optional(), + enable_clients: z.boolean().optional() + }) .optional() - .transform(getEnvOrYaml("SERVER_SECRET")) - .pipe(z.string().min(8)) - }), - postgres: z - .object({ - connection_string: z.string(), - replicas: z - .array( - z.object({ - connection_string: z.string() - }) - ) - .optional() - }) - .optional(), - traefik: z - .object({ - http_entrypoint: z.string().optional().default("web"), - https_entrypoint: z.string().optional().default("websecure"), - additional_middlewares: z.array(z.string()).optional() - }) - .optional() - .default({}), - gerbil: z - .object({ - start_port: portSchema - .optional() - .default(51820) - .transform(stoi) - .pipe(portSchema), - base_endpoint: z - .string() - .optional() - .pipe(z.string()) - .transform((url) => url.toLowerCase()), - use_subdomain: z.boolean().optional().default(false), - subnet_group: z.string().optional().default("100.89.137.0/20"), - block_size: z.number().positive().gt(0).optional().default(24), - site_block_size: z.number().positive().gt(0).optional().default(30) - }) - .optional() - .default({}), - rate_limits: z - .object({ - global: z - .object({ - window_minutes: z - .number() - .positive() - .gt(0) - .optional() - .default(1), - max_requests: z - .number() - .positive() - .gt(0) - .optional() - .default(500) - }) - .optional() - .default({}), - auth: z - .object({ - window_minutes: z - .number() - .positive() - .gt(0) - .optional() - .default(1), - max_requests: z - .number() - .positive() - .gt(0) - .optional() - .default(500) - }) - .optional() - .default({}), - }) - .optional() - .default({}), - email: z - .object({ - smtp_host: z.string().optional(), - smtp_port: portSchema.optional(), - smtp_user: z.string().optional(), - smtp_pass: z.string().optional(), - smtp_secure: z.boolean().optional(), - smtp_tls_reject_unauthorized: z.boolean().optional(), - no_reply: z.string().email().optional() - }) - .optional(), - flags: z - .object({ - require_email_verification: z.boolean().optional(), - disable_signup_without_invite: z.boolean().optional(), - disable_user_create_org: z.boolean().optional(), - allow_raw_resources: z.boolean().optional(), - allow_base_domain_resources: z.boolean().optional(), - allow_local_sites: z.boolean().optional(), - enable_integration_api: z.boolean().optional() - }) - .optional() -}); + }) + .refine( + (data) => { + if (data.flags?.enable_redis) { + return data?.redis !== undefined; + } + return true; + }, + { + message: + "If Redis is enabled, configuration details must be provided" + } + ) + .refine( + (data) => { + const keys = Object.keys(data.domains || {}); + if (data.flags?.disable_config_managed_domains) { + return true; + } + if (keys.length === 0) { + return false; + } + return true; + }, + { + message: "At least one domain must be defined" + } + ) + .refine( + (data) => { + if (build == "oss" && data.redis) { + return false; + } + if (build == "oss" && data.flags?.enable_redis) { + return false; + } + return true; + }, + { + message: "Redis" + } + ); export function readConfigFile() { const loadConfig = (configPath: string) => { diff --git a/server/lib/validators.ts b/server/lib/validators.ts index 50ff5674..6c581e47 100644 --- a/server/lib/validators.ts +++ b/server/lib/validators.ts @@ -93,3 +93,1482 @@ export function isTargetValid(value: string | undefined) { return DOMAIN_REGEX.test(value); } + +export function isValidDomain(domain: string): boolean { + // Check overall length + if (domain.length > 253) return false; + + // Check for invalid characters or patterns + if ( + domain.startsWith(".") || + domain.endsWith(".") || + domain.includes("..") + ) { + return false; + } + + const labels = domain.split("."); + + // Must have at least 2 labels (domain + TLD) + if (labels.length < 2) return false; + + // Validate each label + for (const label of labels) { + if (label.length === 0 || label.length > 63) return false; + if (label.startsWith("-") || label.endsWith("-")) return false; + if (!/^[a-zA-Z0-9-]+$/.test(label)) return false; + } + + // TLD should be at least 2 characters and contain only letters + const tld = labels[labels.length - 1]; + if (tld.length < 2 || !/^[a-zA-Z]+$/.test(tld)) return false; + + // Check if TLD is in the list of valid TLDs + if (!validTlds.includes(tld.toUpperCase())) return false; + + return true; +} + +const validTlds = [ + "AAA", + "AARP", + "ABB", + "ABBOTT", + "ABBVIE", + "ABC", + "ABLE", + "ABOGADO", + "ABUDHABI", + "AC", + "ACADEMY", + "ACCENTURE", + "ACCOUNTANT", + "ACCOUNTANTS", + "ACO", + "ACTOR", + "AD", + "ADS", + "ADULT", + "AE", + "AEG", + "AERO", + "AETNA", + "AF", + "AFL", + "AFRICA", + "AG", + "AGAKHAN", + "AGENCY", + "AI", + "AIG", + "AIRBUS", + "AIRFORCE", + "AIRTEL", + "AKDN", + "AL", + "ALIBABA", + "ALIPAY", + "ALLFINANZ", + "ALLSTATE", + "ALLY", + "ALSACE", + "ALSTOM", + "AM", + "AMAZON", + "AMERICANEXPRESS", + "AMERICANFAMILY", + "AMEX", + "AMFAM", + "AMICA", + "AMSTERDAM", + "ANALYTICS", + "ANDROID", + "ANQUAN", + "ANZ", + "AO", + "AOL", + "APARTMENTS", + "APP", + "APPLE", + "AQ", + "AQUARELLE", + "AR", + "ARAB", + "ARAMCO", + "ARCHI", + "ARMY", + "ARPA", + "ART", + "ARTE", + "AS", + "ASDA", + "ASIA", + "ASSOCIATES", + "AT", + "ATHLETA", + "ATTORNEY", + "AU", + "AUCTION", + "AUDI", + "AUDIBLE", + "AUDIO", + "AUSPOST", + "AUTHOR", + "AUTO", + "AUTOS", + "AW", + "AWS", + "AX", + "AXA", + "AZ", + "AZURE", + "BA", + "BABY", + "BAIDU", + "BANAMEX", + "BAND", + "BANK", + "BAR", + "BARCELONA", + "BARCLAYCARD", + "BARCLAYS", + "BAREFOOT", + "BARGAINS", + "BASEBALL", + "BASKETBALL", + "BAUHAUS", + "BAYERN", + "BB", + "BBC", + "BBT", + "BBVA", + "BCG", + "BCN", + "BD", + "BE", + "BEATS", + "BEAUTY", + "BEER", + "BERLIN", + "BEST", + "BESTBUY", + "BET", + "BF", + "BG", + "BH", + "BHARTI", + "BI", + "BIBLE", + "BID", + "BIKE", + "BING", + "BINGO", + "BIO", + "BIZ", + "BJ", + "BLACK", + "BLACKFRIDAY", + "BLOCKBUSTER", + "BLOG", + "BLOOMBERG", + "BLUE", + "BM", + "BMS", + "BMW", + "BN", + "BNPPARIBAS", + "BO", + "BOATS", + "BOEHRINGER", + "BOFA", + "BOM", + "BOND", + "BOO", + "BOOK", + "BOOKING", + "BOSCH", + "BOSTIK", + "BOSTON", + "BOT", + "BOUTIQUE", + "BOX", + "BR", + "BRADESCO", + "BRIDGESTONE", + "BROADWAY", + "BROKER", + "BROTHER", + "BRUSSELS", + "BS", + "BT", + "BUILD", + "BUILDERS", + "BUSINESS", + "BUY", + "BUZZ", + "BV", + "BW", + "BY", + "BZ", + "BZH", + "CA", + "CAB", + "CAFE", + "CAL", + "CALL", + "CALVINKLEIN", + "CAM", + "CAMERA", + "CAMP", + "CANON", + "CAPETOWN", + "CAPITAL", + "CAPITALONE", + "CAR", + "CARAVAN", + "CARDS", + "CARE", + "CAREER", + "CAREERS", + "CARS", + "CASA", + "CASE", + "CASH", + "CASINO", + "CAT", + "CATERING", + "CATHOLIC", + "CBA", + "CBN", + "CBRE", + "CC", + "CD", + "CENTER", + "CEO", + "CERN", + "CF", + "CFA", + "CFD", + "CG", + "CH", + "CHANEL", + "CHANNEL", + "CHARITY", + "CHASE", + "CHAT", + "CHEAP", + "CHINTAI", + "CHRISTMAS", + "CHROME", + "CHURCH", + "CI", + "CIPRIANI", + "CIRCLE", + "CISCO", + "CITADEL", + "CITI", + "CITIC", + "CITY", + "CK", + "CL", + "CLAIMS", + "CLEANING", + "CLICK", + "CLINIC", + "CLINIQUE", + "CLOTHING", + "CLOUD", + "CLUB", + "CLUBMED", + "CM", + "CN", + "CO", + "COACH", + "CODES", + "COFFEE", + "COLLEGE", + "COLOGNE", + "COM", + "COMMBANK", + "COMMUNITY", + "COMPANY", + "COMPARE", + "COMPUTER", + "COMSEC", + "CONDOS", + "CONSTRUCTION", + "CONSULTING", + "CONTACT", + "CONTRACTORS", + "COOKING", + "COOL", + "COOP", + "CORSICA", + "COUNTRY", + "COUPON", + "COUPONS", + "COURSES", + "CPA", + "CR", + "CREDIT", + "CREDITCARD", + "CREDITUNION", + "CRICKET", + "CROWN", + "CRS", + "CRUISE", + "CRUISES", + "CU", + "CUISINELLA", + "CV", + "CW", + "CX", + "CY", + "CYMRU", + "CYOU", + "CZ", + "DAD", + "DANCE", + "DATA", + "DATE", + "DATING", + "DATSUN", + "DAY", + "DCLK", + "DDS", + "DE", + "DEAL", + "DEALER", + "DEALS", + "DEGREE", + "DELIVERY", + "DELL", + "DELOITTE", + "DELTA", + "DEMOCRAT", + "DENTAL", + "DENTIST", + "DESI", + "DESIGN", + "DEV", + "DHL", + "DIAMONDS", + "DIET", + "DIGITAL", + "DIRECT", + "DIRECTORY", + "DISCOUNT", + "DISCOVER", + "DISH", + "DIY", + "DJ", + "DK", + "DM", + "DNP", + "DO", + "DOCS", + "DOCTOR", + "DOG", + "DOMAINS", + "DOT", + "DOWNLOAD", + "DRIVE", + "DTV", + "DUBAI", + "DUNLOP", + "DUPONT", + "DURBAN", + "DVAG", + "DVR", + "DZ", + "EARTH", + "EAT", + "EC", + "ECO", + "EDEKA", + "EDU", + "EDUCATION", + "EE", + "EG", + "EMAIL", + "EMERCK", + "ENERGY", + "ENGINEER", + "ENGINEERING", + "ENTERPRISES", + "EPSON", + "EQUIPMENT", + "ER", + "ERICSSON", + "ERNI", + "ES", + "ESQ", + "ESTATE", + "ET", + "EU", + "EUROVISION", + "EUS", + "EVENTS", + "EXCHANGE", + "EXPERT", + "EXPOSED", + "EXPRESS", + "EXTRASPACE", + "FAGE", + "FAIL", + "FAIRWINDS", + "FAITH", + "FAMILY", + "FAN", + "FANS", + "FARM", + "FARMERS", + "FASHION", + "FAST", + "FEDEX", + "FEEDBACK", + "FERRARI", + "FERRERO", + "FI", + "FIDELITY", + "FIDO", + "FILM", + "FINAL", + "FINANCE", + "FINANCIAL", + "FIRE", + "FIRESTONE", + "FIRMDALE", + "FISH", + "FISHING", + "FIT", + "FITNESS", + "FJ", + "FK", + "FLICKR", + "FLIGHTS", + "FLIR", + "FLORIST", + "FLOWERS", + "FLY", + "FM", + "FO", + "FOO", + "FOOD", + "FOOTBALL", + "FORD", + "FOREX", + "FORSALE", + "FORUM", + "FOUNDATION", + "FOX", + "FR", + "FREE", + "FRESENIUS", + "FRL", + "FROGANS", + "FRONTIER", + "FTR", + "FUJITSU", + "FUN", + "FUND", + "FURNITURE", + "FUTBOL", + "FYI", + "GA", + "GAL", + "GALLERY", + "GALLO", + "GALLUP", + "GAME", + "GAMES", + "GAP", + "GARDEN", + "GAY", + "GB", + "GBIZ", + "GD", + "GDN", + "GE", + "GEA", + "GENT", + "GENTING", + "GEORGE", + "GF", + "GG", + "GGEE", + "GH", + "GI", + "GIFT", + "GIFTS", + "GIVES", + "GIVING", + "GL", + "GLASS", + "GLE", + "GLOBAL", + "GLOBO", + "GM", + "GMAIL", + "GMBH", + "GMO", + "GMX", + "GN", + "GODADDY", + "GOLD", + "GOLDPOINT", + "GOLF", + "GOO", + "GOODYEAR", + "GOOG", + "GOOGLE", + "GOP", + "GOT", + "GOV", + "GP", + "GQ", + "GR", + "GRAINGER", + "GRAPHICS", + "GRATIS", + "GREEN", + "GRIPE", + "GROCERY", + "GROUP", + "GS", + "GT", + "GU", + "GUCCI", + "GUGE", + "GUIDE", + "GUITARS", + "GURU", + "GW", + "GY", + "HAIR", + "HAMBURG", + "HANGOUT", + "HAUS", + "HBO", + "HDFC", + "HDFCBANK", + "HEALTH", + "HEALTHCARE", + "HELP", + "HELSINKI", + "HERE", + "HERMES", + "HIPHOP", + "HISAMITSU", + "HITACHI", + "HIV", + "HK", + "HKT", + "HM", + "HN", + "HOCKEY", + "HOLDINGS", + "HOLIDAY", + "HOMEDEPOT", + "HOMEGOODS", + "HOMES", + "HOMESENSE", + "HONDA", + "HORSE", + "HOSPITAL", + "HOST", + "HOSTING", + "HOT", + "HOTELS", + "HOTMAIL", + "HOUSE", + "HOW", + "HR", + "HSBC", + "HT", + "HU", + "HUGHES", + "HYATT", + "HYUNDAI", + "IBM", + "ICBC", + "ICE", + "ICU", + "ID", + "IE", + "IEEE", + "IFM", + "IKANO", + "IL", + "IM", + "IMAMAT", + "IMDB", + "IMMO", + "IMMOBILIEN", + "IN", + "INC", + "INDUSTRIES", + "INFINITI", + "INFO", + "ING", + "INK", + "INSTITUTE", + "INSURANCE", + "INSURE", + "INT", + "INTERNATIONAL", + "INTUIT", + "INVESTMENTS", + "IO", + "IPIRANGA", + "IQ", + "IR", + "IRISH", + "IS", + "ISMAILI", + "IST", + "ISTANBUL", + "IT", + "ITAU", + "ITV", + "JAGUAR", + "JAVA", + "JCB", + "JE", + "JEEP", + "JETZT", + "JEWELRY", + "JIO", + "JLL", + "JM", + "JMP", + "JNJ", + "JO", + "JOBS", + "JOBURG", + "JOT", + "JOY", + "JP", + "JPMORGAN", + "JPRS", + "JUEGOS", + "JUNIPER", + "KAUFEN", + "KDDI", + "KE", + "KERRYHOTELS", + "KERRYPROPERTIES", + "KFH", + "KG", + "KH", + "KI", + "KIA", + "KIDS", + "KIM", + "KINDLE", + "KITCHEN", + "KIWI", + "KM", + "KN", + "KOELN", + "KOMATSU", + "KOSHER", + "KP", + "KPMG", + "KPN", + "KR", + "KRD", + "KRED", + "KUOKGROUP", + "KW", + "KY", + "KYOTO", + "KZ", + "LA", + "LACAIXA", + "LAMBORGHINI", + "LAMER", + "LAND", + "LANDROVER", + "LANXESS", + "LASALLE", + "LAT", + "LATINO", + "LATROBE", + "LAW", + "LAWYER", + "LB", + "LC", + "LDS", + "LEASE", + "LECLERC", + "LEFRAK", + "LEGAL", + "LEGO", + "LEXUS", + "LGBT", + "LI", + "LIDL", + "LIFE", + "LIFEINSURANCE", + "LIFESTYLE", + "LIGHTING", + "LIKE", + "LILLY", + "LIMITED", + "LIMO", + "LINCOLN", + "LINK", + "LIVE", + "LIVING", + "LK", + "LLC", + "LLP", + "LOAN", + "LOANS", + "LOCKER", + "LOCUS", + "LOL", + "LONDON", + "LOTTE", + "LOTTO", + "LOVE", + "LPL", + "LPLFINANCIAL", + "LR", + "LS", + "LT", + "LTD", + "LTDA", + "LU", + "LUNDBECK", + "LUXE", + "LUXURY", + "LV", + "LY", + "MA", + "MADRID", + "MAIF", + "MAISON", + "MAKEUP", + "MAN", + "MANAGEMENT", + "MANGO", + "MAP", + "MARKET", + "MARKETING", + "MARKETS", + "MARRIOTT", + "MARSHALLS", + "MATTEL", + "MBA", + "MC", + "MCKINSEY", + "MD", + "ME", + "MED", + "MEDIA", + "MEET", + "MELBOURNE", + "MEME", + "MEMORIAL", + "MEN", + "MENU", + "MERCKMSD", + "MG", + "MH", + "MIAMI", + "MICROSOFT", + "MIL", + "MINI", + "MINT", + "MIT", + "MITSUBISHI", + "MK", + "ML", + "MLB", + "MLS", + "MM", + "MMA", + "MN", + "MO", + "MOBI", + "MOBILE", + "MODA", + "MOE", + "MOI", + "MOM", + "MONASH", + "MONEY", + "MONSTER", + "MORMON", + "MORTGAGE", + "MOSCOW", + "MOTO", + "MOTORCYCLES", + "MOV", + "MOVIE", + "MP", + "MQ", + "MR", + "MS", + "MSD", + "MT", + "MTN", + "MTR", + "MU", + "MUSEUM", + "MUSIC", + "MV", + "MW", + "MX", + "MY", + "MZ", + "NA", + "NAB", + "NAGOYA", + "NAME", + "NAVY", + "NBA", + "NC", + "NE", + "NEC", + "NET", + "NETBANK", + "NETFLIX", + "NETWORK", + "NEUSTAR", + "NEW", + "NEWS", + "NEXT", + "NEXTDIRECT", + "NEXUS", + "NF", + "NFL", + "NG", + "NGO", + "NHK", + "NI", + "NICO", + "NIKE", + "NIKON", + "NINJA", + "NISSAN", + "NISSAY", + "NL", + "NO", + "NOKIA", + "NORTON", + "NOW", + "NOWRUZ", + "NOWTV", + "NP", + "NR", + "NRA", + "NRW", + "NTT", + "NU", + "NYC", + "NZ", + "OBI", + "OBSERVER", + "OFFICE", + "OKINAWA", + "OLAYAN", + "OLAYANGROUP", + "OLLO", + "OM", + "OMEGA", + "ONE", + "ONG", + "ONL", + "ONLINE", + "OOO", + "OPEN", + "ORACLE", + "ORANGE", + "ORG", + "ORGANIC", + "ORIGINS", + "OSAKA", + "OTSUKA", + "OTT", + "OVH", + "PA", + "PAGE", + "PANASONIC", + "PARIS", + "PARS", + "PARTNERS", + "PARTS", + "PARTY", + "PAY", + "PCCW", + "PE", + "PET", + "PF", + "PFIZER", + "PG", + "PH", + "PHARMACY", + "PHD", + "PHILIPS", + "PHONE", + "PHOTO", + "PHOTOGRAPHY", + "PHOTOS", + "PHYSIO", + "PICS", + "PICTET", + "PICTURES", + "PID", + "PIN", + "PING", + "PINK", + "PIONEER", + "PIZZA", + "PK", + "PL", + "PLACE", + "PLAY", + "PLAYSTATION", + "PLUMBING", + "PLUS", + "PM", + "PN", + "PNC", + "POHL", + "POKER", + "POLITIE", + "PORN", + "POST", + "PR", + "PRAXI", + "PRESS", + "PRIME", + "PRO", + "PROD", + "PRODUCTIONS", + "PROF", + "PROGRESSIVE", + "PROMO", + "PROPERTIES", + "PROPERTY", + "PROTECTION", + "PRU", + "PRUDENTIAL", + "PS", + "PT", + "PUB", + "PW", + "PWC", + "PY", + "QA", + "QPON", + "QUEBEC", + "QUEST", + "RACING", + "RADIO", + "RE", + "READ", + "REALESTATE", + "REALTOR", + "REALTY", + "RECIPES", + "RED", + "REDSTONE", + "REDUMBRELLA", + "REHAB", + "REISE", + "REISEN", + "REIT", + "RELIANCE", + "REN", + "RENT", + "RENTALS", + "REPAIR", + "REPORT", + "REPUBLICAN", + "REST", + "RESTAURANT", + "REVIEW", + "REVIEWS", + "REXROTH", + "RICH", + "RICHARDLI", + "RICOH", + "RIL", + "RIO", + "RIP", + "RO", + "ROCKS", + "RODEO", + "ROGERS", + "ROOM", + "RS", + "RSVP", + "RU", + "RUGBY", + "RUHR", + "RUN", + "RW", + "RWE", + "RYUKYU", + "SA", + "SAARLAND", + "SAFE", + "SAFETY", + "SAKURA", + "SALE", + "SALON", + "SAMSCLUB", + "SAMSUNG", + "SANDVIK", + "SANDVIKCOROMANT", + "SANOFI", + "SAP", + "SARL", + "SAS", + "SAVE", + "SAXO", + "SB", + "SBI", + "SBS", + "SC", + "SCB", + "SCHAEFFLER", + "SCHMIDT", + "SCHOLARSHIPS", + "SCHOOL", + "SCHULE", + "SCHWARZ", + "SCIENCE", + "SCOT", + "SD", + "SE", + "SEARCH", + "SEAT", + "SECURE", + "SECURITY", + "SEEK", + "SELECT", + "SENER", + "SERVICES", + "SEVEN", + "SEW", + "SEX", + "SEXY", + "SFR", + "SG", + "SH", + "SHANGRILA", + "SHARP", + "SHELL", + "SHIA", + "SHIKSHA", + "SHOES", + "SHOP", + "SHOPPING", + "SHOUJI", + "SHOW", + "SI", + "SILK", + "SINA", + "SINGLES", + "SITE", + "SJ", + "SK", + "SKI", + "SKIN", + "SKY", + "SKYPE", + "SL", + "SLING", + "SM", + "SMART", + "SMILE", + "SN", + "SNCF", + "SO", + "SOCCER", + "SOCIAL", + "SOFTBANK", + "SOFTWARE", + "SOHU", + "SOLAR", + "SOLUTIONS", + "SONG", + "SONY", + "SOY", + "SPA", + "SPACE", + "SPORT", + "SPOT", + "SR", + "SRL", + "SS", + "ST", + "STADA", + "STAPLES", + "STAR", + "STATEBANK", + "STATEFARM", + "STC", + "STCGROUP", + "STOCKHOLM", + "STORAGE", + "STORE", + "STREAM", + "STUDIO", + "STUDY", + "STYLE", + "SU", + "SUCKS", + "SUPPLIES", + "SUPPLY", + "SUPPORT", + "SURF", + "SURGERY", + "SUZUKI", + "SV", + "SWATCH", + "SWISS", + "SX", + "SY", + "SYDNEY", + "SYSTEMS", + "SZ", + "TAB", + "TAIPEI", + "TALK", + "TAOBAO", + "TARGET", + "TATAMOTORS", + "TATAR", + "TATTOO", + "TAX", + "TAXI", + "TC", + "TCI", + "TD", + "TDK", + "TEAM", + "TECH", + "TECHNOLOGY", + "TEL", + "TEMASEK", + "TENNIS", + "TEVA", + "TF", + "TG", + "TH", + "THD", + "THEATER", + "THEATRE", + "TIAA", + "TICKETS", + "TIENDA", + "TIPS", + "TIRES", + "TIROL", + "TJ", + "TJMAXX", + "TJX", + "TK", + "TKMAXX", + "TL", + "TM", + "TMALL", + "TN", + "TO", + "TODAY", + "TOKYO", + "TOOLS", + "TOP", + "TORAY", + "TOSHIBA", + "TOTAL", + "TOURS", + "TOWN", + "TOYOTA", + "TOYS", + "TR", + "TRADE", + "TRADING", + "TRAINING", + "TRAVEL", + "TRAVELERS", + "TRAVELERSINSURANCE", + "TRUST", + "TRV", + "TT", + "TUBE", + "TUI", + "TUNES", + "TUSHU", + "TV", + "TVS", + "TW", + "TZ", + "UA", + "UBANK", + "UBS", + "UG", + "UK", + "UNICOM", + "UNIVERSITY", + "UNO", + "UOL", + "UPS", + "US", + "UY", + "UZ", + "VA", + "VACATIONS", + "VANA", + "VANGUARD", + "VC", + "VE", + "VEGAS", + "VENTURES", + "VERISIGN", + "VERSICHERUNG", + "VET", + "VG", + "VI", + "VIAJES", + "VIDEO", + "VIG", + "VIKING", + "VILLAS", + "VIN", + "VIP", + "VIRGIN", + "VISA", + "VISION", + "VIVA", + "VIVO", + "VLAANDEREN", + "VN", + "VODKA", + "VOLVO", + "VOTE", + "VOTING", + "VOTO", + "VOYAGE", + "VU", + "WALES", + "WALMART", + "WALTER", + "WANG", + "WANGGOU", + "WATCH", + "WATCHES", + "WEATHER", + "WEATHERCHANNEL", + "WEBCAM", + "WEBER", + "WEBSITE", + "WED", + "WEDDING", + "WEIBO", + "WEIR", + "WF", + "WHOSWHO", + "WIEN", + "WIKI", + "WILLIAMHILL", + "WIN", + "WINDOWS", + "WINE", + "WINNERS", + "WME", + "WOLTERSKLUWER", + "WOODSIDE", + "WORK", + "WORKS", + "WORLD", + "WOW", + "WS", + "WTC", + "WTF", + "XBOX", + "XEROX", + "XIHUAN", + "XIN", + "XN--11B4C3D", + "XN--1CK2E1B", + "XN--1QQW23A", + "XN--2SCRJ9C", + "XN--30RR7Y", + "XN--3BST00M", + "XN--3DS443G", + "XN--3E0B707E", + "XN--3HCRJ9C", + "XN--3PXU8K", + "XN--42C2D9A", + "XN--45BR5CYL", + "XN--45BRJ9C", + "XN--45Q11C", + "XN--4DBRK0CE", + "XN--4GBRIM", + "XN--54B7FTA0CC", + "XN--55QW42G", + "XN--55QX5D", + "XN--5SU34J936BGSG", + "XN--5TZM5G", + "XN--6FRZ82G", + "XN--6QQ986B3XL", + "XN--80ADXHKS", + "XN--80AO21A", + "XN--80AQECDR1A", + "XN--80ASEHDB", + "XN--80ASWG", + "XN--8Y0A063A", + "XN--90A3AC", + "XN--90AE", + "XN--90AIS", + "XN--9DBQ2A", + "XN--9ET52U", + "XN--9KRT00A", + "XN--B4W605FERD", + "XN--BCK1B9A5DRE4C", + "XN--C1AVG", + "XN--C2BR7G", + "XN--CCK2B3B", + "XN--CCKWCXETD", + "XN--CG4BKI", + "XN--CLCHC0EA0B2G2A9GCD", + "XN--CZR694B", + "XN--CZRS0T", + "XN--CZRU2D", + "XN--D1ACJ3B", + "XN--D1ALF", + "XN--E1A4C", + "XN--ECKVDTC9D", + "XN--EFVY88H", + "XN--FCT429K", + "XN--FHBEI", + "XN--FIQ228C5HS", + "XN--FIQ64B", + "XN--FIQS8S", + "XN--FIQZ9S", + "XN--FJQ720A", + "XN--FLW351E", + "XN--FPCRJ9C3D", + "XN--FZC2C9E2C", + "XN--FZYS8D69UVGM", + "XN--G2XX48C", + "XN--GCKR3F0F", + "XN--GECRJ9C", + "XN--GK3AT1E", + "XN--H2BREG3EVE", + "XN--H2BRJ9C", + "XN--H2BRJ9C8C", + "XN--HXT814E", + "XN--I1B6B1A6A2E", + "XN--IMR513N", + "XN--IO0A7I", + "XN--J1AEF", + "XN--J1AMH", + "XN--J6W193G", + "XN--JLQ480N2RG", + "XN--JVR189M", + "XN--KCRX77D1X4A", + "XN--KPRW13D", + "XN--KPRY57D", + "XN--KPUT3I", + "XN--L1ACC", + "XN--LGBBAT1AD8J", + "XN--MGB9AWBF", + "XN--MGBA3A3EJT", + "XN--MGBA3A4F16A", + "XN--MGBA7C0BBN0A", + "XN--MGBAAM7A8H", + "XN--MGBAB2BD", + "XN--MGBAH1A3HJKRD", + "XN--MGBAI9AZGQP6J", + "XN--MGBAYH7GPA", + "XN--MGBBH1A", + "XN--MGBBH1A71E", + "XN--MGBC0A9AZCG", + "XN--MGBCA7DZDO", + "XN--MGBCPQ6GPA1A", + "XN--MGBERP4A5D4AR", + "XN--MGBGU82A", + "XN--MGBI4ECEXP", + "XN--MGBPL2FH", + "XN--MGBT3DHD", + "XN--MGBTX2B", + "XN--MGBX4CD0AB", + "XN--MIX891F", + "XN--MK1BU44C", + "XN--MXTQ1M", + "XN--NGBC5AZD", + "XN--NGBE9E0A", + "XN--NGBRX", + "XN--NODE", + "XN--NQV7F", + "XN--NQV7FS00EMA", + "XN--NYQY26A", + "XN--O3CW4H", + "XN--OGBPF8FL", + "XN--OTU796D", + "XN--P1ACF", + "XN--P1AI", + "XN--PGBS0DH", + "XN--PSSY2U", + "XN--Q7CE6A", + "XN--Q9JYB4C", + "XN--QCKA1PMC", + "XN--QXA6A", + "XN--QXAM", + "XN--RHQV96G", + "XN--ROVU88B", + "XN--RVC1E0AM3E", + "XN--S9BRJ9C", + "XN--SES554G", + "XN--T60B56A", + "XN--TCKWE", + "XN--TIQ49XQYJ", + "XN--UNUP4Y", + "XN--VERMGENSBERATER-CTB", + "XN--VERMGENSBERATUNG-PWB", + "XN--VHQUV", + "XN--VUQ861B", + "XN--W4R85EL8FHU5DNRA", + "XN--W4RS40L", + "XN--WGBH1C", + "XN--WGBL6A", + "XN--XHQ521B", + "XN--XKC2AL3HYE2A", + "XN--XKC2DL3A5EE0H", + "XN--Y9A3AQ", + "XN--YFRO4I67O", + "XN--YGBI2AMMX", + "XN--ZFR164B", + "XXX", + "XYZ", + "YACHTS", + "YAHOO", + "YAMAXUN", + "YANDEX", + "YE", + "YODOBASHI", + "YOGA", + "YOKOHAMA", + "YOU", + "YOUTUBE", + "YT", + "YUN", + "ZA", + "ZAPPOS", + "ZARA", + "ZERO", + "ZIP", + "ZM", + "ZONE", + "ZUERICH", + "ZW", + "" +]; diff --git a/server/middlewares/index.ts b/server/middlewares/index.ts index 01355f97..b1180995 100644 --- a/server/middlewares/index.ts +++ b/server/middlewares/index.ts @@ -13,9 +13,17 @@ export * from "./verifyAdmin"; export * from "./verifySetResourceUsers"; export * from "./verifyUserInRole"; export * from "./verifyAccessTokenAccess"; +export * from "./requestTimeout"; +export * from "./verifyClientAccess"; +export * from "./verifyUserHasAction"; export * from "./verifyUserIsServerAdmin"; export * from "./verifyIsLoggedInUser"; +export * from "./verifyIsLoggedInUser"; +export * from "./verifyClientAccess"; export * from "./integration"; export * from "./verifyValidLicense"; export * from "./verifyUserHasAction"; export * from "./verifyApiKeyAccess"; +export * from "./verifyDomainAccess"; +export * from "./verifyClientsEnabled"; +export * from "./verifyUserIsOrgOwner"; diff --git a/server/middlewares/requestTimeout.ts b/server/middlewares/requestTimeout.ts new file mode 100644 index 00000000..8b5852b7 --- /dev/null +++ b/server/middlewares/requestTimeout.ts @@ -0,0 +1,35 @@ +import { Request, Response, NextFunction } from 'express'; +import logger from '@server/logger'; +import createHttpError from 'http-errors'; +import HttpCode from '@server/types/HttpCode'; + +export function requestTimeoutMiddleware(timeoutMs: number = 30000) { + return (req: Request, res: Response, next: NextFunction) => { + // Set a timeout for the request + const timeout = setTimeout(() => { + if (!res.headersSent) { + logger.error(`Request timeout: ${req.method} ${req.url} from ${req.ip}`); + return next( + createHttpError( + HttpCode.REQUEST_TIMEOUT, + 'Request timeout - operation took too long to complete' + ) + ); + } + }, timeoutMs); + + // Clear timeout when response finishes + res.on('finish', () => { + clearTimeout(timeout); + }); + + // Clear timeout when response closes + res.on('close', () => { + clearTimeout(timeout); + }); + + next(); + }; +} + +export default requestTimeoutMiddleware; diff --git a/server/middlewares/verifyClientAccess.ts b/server/middlewares/verifyClientAccess.ts new file mode 100644 index 00000000..df45b541 --- /dev/null +++ b/server/middlewares/verifyClientAccess.ts @@ -0,0 +1,131 @@ +import { Request, Response, NextFunction } from "express"; +import { db } from "@server/db"; +import { userOrgs, clients, roleClients, userClients } from "@server/db"; +import { and, eq } from "drizzle-orm"; +import createHttpError from "http-errors"; +import HttpCode from "@server/types/HttpCode"; + +export async function verifyClientAccess( + req: Request, + res: Response, + next: NextFunction +) { + const userId = req.user!.userId; // Assuming you have user information in the request + const clientId = parseInt( + req.params.clientId || req.body.clientId || req.query.clientId + ); + + if (!userId) { + return next( + createHttpError(HttpCode.UNAUTHORIZED, "User not authenticated") + ); + } + + if (isNaN(clientId)) { + return next(createHttpError(HttpCode.BAD_REQUEST, "Invalid client ID")); + } + + try { + // Get the client + const [client] = await db + .select() + .from(clients) + .where(eq(clients.clientId, clientId)) + .limit(1); + + if (!client) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `Client with ID ${clientId} not found` + ) + ); + } + + if (!client.orgId) { + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + `Client with ID ${clientId} does not have an organization ID` + ) + ); + } + + if (!req.userOrg) { + // Get user's role ID in the organization + const userOrgRole = await db + .select() + .from(userOrgs) + .where( + and( + eq(userOrgs.userId, userId), + eq(userOrgs.orgId, client.orgId) + ) + ) + .limit(1); + req.userOrg = userOrgRole[0]; + } + + if (!req.userOrg) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "User does not have access to this organization" + ) + ); + } + + const userOrgRoleId = req.userOrg.roleId; + req.userOrgRoleId = userOrgRoleId; + req.userOrgId = client.orgId; + + // Check role-based site access first + const [roleClientAccess] = await db + .select() + .from(roleClients) + .where( + and( + eq(roleClients.clientId, clientId), + eq(roleClients.roleId, userOrgRoleId) + ) + ) + .limit(1); + + if (roleClientAccess) { + // User has access to the site through their role + return next(); + } + + // If role doesn't have access, check user-specific site access + const [userClientAccess] = await db + .select() + .from(userClients) + .where( + and( + eq(userClients.userId, userId), + eq(userClients.clientId, clientId) + ) + ) + .limit(1); + + if (userClientAccess) { + // User has direct access to the site + return next(); + } + + // If we reach here, the user doesn't have access to the site + return next( + createHttpError( + HttpCode.FORBIDDEN, + "User does not have access to this client" + ) + ); + } catch (error) { + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Error verifying site access" + ) + ); + } +} diff --git a/server/middlewares/verifyClientsEnabled.ts b/server/middlewares/verifyClientsEnabled.ts new file mode 100644 index 00000000..6e8070da --- /dev/null +++ b/server/middlewares/verifyClientsEnabled.ts @@ -0,0 +1,29 @@ +import { Request, Response, NextFunction } from "express"; +import createHttpError from "http-errors"; +import HttpCode from "@server/types/HttpCode"; +import config from "@server/lib/config"; + +export async function verifyClientsEnabled( + req: Request, + res: Response, + next: NextFunction +) { + try { + if (!config.getRawConfig().flags?.enable_clients) { + return next( + createHttpError( + HttpCode.NOT_IMPLEMENTED, + "Clients are not enabled on this server." + ) + ); + } + return next(); + } catch (error) { + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Failed to check if clients are enabled" + ) + ); + } +} diff --git a/server/middlewares/verifyDomainAccess.ts b/server/middlewares/verifyDomainAccess.ts new file mode 100644 index 00000000..a6daf451 --- /dev/null +++ b/server/middlewares/verifyDomainAccess.ts @@ -0,0 +1,93 @@ +import { Request, Response, NextFunction } from "express"; +import { db, domains, orgDomains } from "@server/db"; +import { userOrgs, apiKeyOrg } from "@server/db"; +import { and, eq } from "drizzle-orm"; +import createHttpError from "http-errors"; +import HttpCode from "@server/types/HttpCode"; + +export async function verifyDomainAccess( + req: Request, + res: Response, + next: NextFunction +) { + try { + const userId = req.user!.userId; + const domainId = + req.params.domainId || req.body.apiKeyId || req.query.apiKeyId; + const orgId = req.params.orgId; + + if (!userId) { + return next( + createHttpError(HttpCode.UNAUTHORIZED, "User not authenticated") + ); + } + + if (!orgId) { + return next( + createHttpError(HttpCode.BAD_REQUEST, "Invalid organization ID") + ); + } + + if (!domainId) { + return next( + createHttpError(HttpCode.BAD_REQUEST, "Invalid domain ID") + ); + } + + const [domain] = await db + .select() + .from(domains) + .innerJoin(orgDomains, eq(orgDomains.domainId, domains.domainId)) + .where( + and( + eq(orgDomains.domainId, domainId), + eq(orgDomains.orgId, orgId) + ) + ) + .limit(1); + + if (!domain.orgDomains) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `Domain with ID ${domainId} not found` + ) + ); + } + + if (!req.userOrg) { + const userOrgRole = await db + .select() + .from(userOrgs) + .where( + and( + eq(userOrgs.userId, userId), + eq(userOrgs.orgId, apiKeyOrg.orgId) + ) + ) + .limit(1); + req.userOrg = userOrgRole[0]; + } + + if (!req.userOrg) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "User does not have access to this organization" + ) + ); + } + + const userOrgRoleId = req.userOrg.roleId; + req.userOrgRoleId = userOrgRoleId; + + return next(); + } catch (error) { + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Error verifying domain access" + ) + ); + } +} diff --git a/server/openApi.ts b/server/openApi.ts index 4df6cbdd..32cdb67b 100644 --- a/server/openApi.ts +++ b/server/openApi.ts @@ -14,5 +14,6 @@ export enum OpenAPITags { AccessToken = "Access Token", Idp = "Identity Provider", Client = "Client", - ApiKey = "API Key" + ApiKey = "API Key", + Domain = "Domain" } diff --git a/server/routers/auth/requestTotpSecret.ts b/server/routers/auth/requestTotpSecret.ts index 475e2dac..753867b6 100644 --- a/server/routers/auth/requestTotpSecret.ts +++ b/server/routers/auth/requestTotpSecret.ts @@ -112,7 +112,11 @@ export async function requestTotpSecret( const hex = crypto.getRandomValues(new Uint8Array(20)); const secret = encodeHex(hex); - const uri = createTOTPKeyURI("Pangolin", user.email!, hex); + const uri = createTOTPKeyURI( + "Pangolin", + user.email!, + hex + ); await db .update(users) diff --git a/server/routers/auth/signup.ts b/server/routers/auth/signup.ts index 0c7e926e..2508ecfe 100644 --- a/server/routers/auth/signup.ts +++ b/server/routers/auth/signup.ts @@ -1,8 +1,7 @@ import { NextFunction, Request, Response } from "express"; -import { db } from "@server/db"; +import { db, users } from "@server/db"; import HttpCode from "@server/types/HttpCode"; import { z } from "zod"; -import { users } from "@server/db"; import { fromError } from "zod-validation-error"; import createHttpError from "http-errors"; import response from "@server/lib/response"; @@ -57,8 +56,6 @@ export async function signup( const { email, password, inviteToken, inviteId } = parsedBody.data; - logger.debug("signup", { email, password, inviteToken, inviteId }); - const passwordHash = await hashPassword(password); const userId = generateId(15); @@ -143,15 +140,21 @@ export async function signup( if (diff < 2) { // If the user was created less than 2 hours ago, we don't want to create a new user - return response(res, { - data: { - emailVerificationRequired: true - }, - success: true, - error: false, - message: `A user with that email address already exists. We sent an email to ${email} with a verification code.`, - status: HttpCode.OK - }); + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "A user with that email address already exists" + ) + ); + // return response(res, { + // data: { + // emailVerificationRequired: true + // }, + // success: true, + // error: false, + // message: `A user with that email address already exists. We sent an email to ${email} with a verification code.`, + // status: HttpCode.OK + // }); } else { // If the user was created more than 2 hours ago, we want to delete the old user and create a new one await db.delete(users).where(eq(users.userId, user.userId)); diff --git a/server/routers/auth/verifyEmail.ts b/server/routers/auth/verifyEmail.ts index f707de22..97ab540b 100644 --- a/server/routers/auth/verifyEmail.ts +++ b/server/routers/auth/verifyEmail.ts @@ -4,7 +4,7 @@ import { z } from "zod"; import { fromError } from "zod-validation-error"; import HttpCode from "@server/types/HttpCode"; import { response } from "@server/lib"; -import { db } from "@server/db"; +import { db, userOrgs } from "@server/db"; import { User, emailVerificationCodes, users } from "@server/db"; import { eq } from "drizzle-orm"; import { isWithinExpirationDate } from "oslo"; diff --git a/server/routers/client/createClient.ts b/server/routers/client/createClient.ts new file mode 100644 index 00000000..f5a67e68 --- /dev/null +++ b/server/routers/client/createClient.ts @@ -0,0 +1,252 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db } from "@server/db"; +import { + roles, + Client, + clients, + roleClients, + userClients, + olms, + clientSites, + exitNodes, + orgs, + 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 { eq, and } from "drizzle-orm"; +import { fromError } from "zod-validation-error"; +import moment from "moment"; +import { hashPassword } from "@server/auth/password"; +import { isValidCIDR, isValidIP } from "@server/lib/validators"; +import { isIpInCidr } from "@server/lib/ip"; +import { OpenAPITags, registry } from "@server/openApi"; + +const createClientParamsSchema = z + .object({ + orgId: z.string() + }) + .strict(); + +const createClientSchema = z + .object({ + name: z.string().min(1).max(255), + siteIds: z.array(z.number().int().positive()), + olmId: z.string(), + secret: z.string(), + subnet: z.string(), + type: z.enum(["olm"]) + }) + .strict(); + +export type CreateClientBody = z.infer; + +export type CreateClientResponse = Client; + +registry.registerPath({ + method: "put", + path: "/org/{orgId}/client", + description: "Create a new client.", + tags: [OpenAPITags.Client, OpenAPITags.Org], + request: { + params: createClientParamsSchema, + body: { + content: { + "application/json": { + schema: createClientSchema + } + } + } + }, + responses: {} +}); + +export async function createClient( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedBody = createClientSchema.safeParse(req.body); + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + + const { name, type, siteIds, olmId, secret, subnet } = parsedBody.data; + + const parsedParams = createClientParamsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { orgId } = parsedParams.data; + + if (req.user && !req.userOrgRoleId) { + return next( + createHttpError(HttpCode.FORBIDDEN, "User does not have a role") + ); + } + + if (!isValidIP(subnet)) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Invalid subnet format. Please provide a valid CIDR notation." + ) + ); + } + + const [org] = await db.select().from(orgs).where(eq(orgs.orgId, orgId)); + + if (!org) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `Organization with ID ${orgId} not found` + ) + ); + } + + if (!isIpInCidr(subnet, org.subnet)) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "IP is not in the CIDR range of the subnet." + ) + ); + } + + const updatedSubnet = `${subnet}/${org.subnet.split("/")[1]}`; // we want the block size of the whole org + + // make sure the subnet is unique + const subnetExistsClients = await db + .select() + .from(clients) + .where(eq(clients.subnet, updatedSubnet)) + .limit(1); + + if (subnetExistsClients.length > 0) { + return next( + createHttpError( + HttpCode.CONFLICT, + `Subnet ${subnet} already exists` + ) + ); + } + + const subnetExistsSites = await db + .select() + .from(sites) + .where(eq(sites.address, updatedSubnet)) + .limit(1); + + if (subnetExistsSites.length > 0) { + return next( + createHttpError( + HttpCode.CONFLICT, + `Subnet ${subnet} already exists` + ) + ); + } + + await db.transaction(async (trx) => { + // TODO: more intelligent way to pick the exit node + + // make sure there is an exit node by counting the exit nodes table + const nodes = await db.select().from(exitNodes); + if (nodes.length === 0) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + "No exit nodes available" + ) + ); + } + + // get the first exit node + const exitNode = nodes[0]; + + const adminRole = await trx + .select() + .from(roles) + .where(and(eq(roles.isAdmin, true), eq(roles.orgId, orgId))) + .limit(1); + + if (adminRole.length === 0) { + trx.rollback(); + return next( + createHttpError(HttpCode.NOT_FOUND, `Admin role not found`) + ); + } + + const [newClient] = await trx + .insert(clients) + .values({ + exitNodeId: exitNode.exitNodeId, + orgId, + name, + subnet: updatedSubnet, + type + }) + .returning(); + + await trx.insert(roleClients).values({ + roleId: adminRole[0].roleId, + clientId: newClient.clientId + }); + + if (req.user && req.userOrgRoleId != adminRole[0].roleId) { + // make sure the user can access the site + trx.insert(userClients).values({ + userId: req.user?.userId!, + clientId: newClient.clientId + }); + } + + // Create site to client associations + if (siteIds && siteIds.length > 0) { + await trx.insert(clientSites).values( + siteIds.map((siteId) => ({ + clientId: newClient.clientId, + siteId + })) + ); + } + + const secretHash = await hashPassword(secret); + + await trx.insert(olms).values({ + olmId, + secretHash, + clientId: newClient.clientId, + dateCreated: moment().toISOString() + }); + + return response(res, { + data: newClient, + success: true, + error: false, + message: "Site created successfully", + status: HttpCode.CREATED + }); + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/client/deleteClient.ts b/server/routers/client/deleteClient.ts new file mode 100644 index 00000000..a7512574 --- /dev/null +++ b/server/routers/client/deleteClient.ts @@ -0,0 +1,88 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db } from "@server/db"; +import { clients, clientSites } 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"; + +const deleteClientSchema = z + .object({ + clientId: z.string().transform(Number).pipe(z.number().int().positive()) + }) + .strict(); + +registry.registerPath({ + method: "delete", + path: "/client/{clientId}", + description: "Delete a client by its client ID.", + tags: [OpenAPITags.Client], + request: { + params: deleteClientSchema + }, + responses: {} +}); + +export async function deleteClient( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = deleteClientSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { clientId } = parsedParams.data; + + const [client] = await db + .select() + .from(clients) + .where(eq(clients.clientId, clientId)) + .limit(1); + + if (!client) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `Client with ID ${clientId} not found` + ) + ); + } + + await db.transaction(async (trx) => { + // Delete the client-site associations first + await trx + .delete(clientSites) + .where(eq(clientSites.clientId, clientId)); + + // Then delete the client itself + await trx + .delete(clients) + .where(eq(clients.clientId, clientId)); + }); + + return response(res, { + data: null, + success: true, + error: false, + message: "Client deleted successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/client/getClient.ts b/server/routers/client/getClient.ts new file mode 100644 index 00000000..46f31b8c --- /dev/null +++ b/server/routers/client/getClient.ts @@ -0,0 +1,101 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db } from "@server/db"; +import { clients, clientSites } from "@server/db"; +import { eq, and } 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 stoi from "@server/lib/stoi"; +import { fromError } from "zod-validation-error"; +import { OpenAPITags, registry } from "@server/openApi"; + +const getClientSchema = z + .object({ + clientId: z.string().transform(stoi).pipe(z.number().int().positive()), + orgId: z.string().optional() + }) + .strict(); + +async function query(clientId: number) { + // Get the client + const [client] = await db + .select() + .from(clients) + .where(eq(clients.clientId, clientId)) + .limit(1); + + if (!client) { + return null; + } + + // Get the siteIds associated with this client + const sites = await db + .select({ siteId: clientSites.siteId }) + .from(clientSites) + .where(eq(clientSites.clientId, clientId)); + + // Add the siteIds to the client object + return { + ...client, + siteIds: sites.map(site => site.siteId) + }; +} + +export type GetClientResponse = NonNullable>>; + +registry.registerPath({ + method: "get", + path: "/org/{orgId}/client/{clientId}", + description: "Get a client by its client ID.", + tags: [OpenAPITags.Client, OpenAPITags.Org], + request: { + params: getClientSchema + }, + responses: {} +}); + +export async function getClient( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = getClientSchema.safeParse(req.params); + if (!parsedParams.success) { + logger.error( + `Error parsing params: ${fromError(parsedParams.error).toString()}` + ); + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { clientId } = parsedParams.data; + + const client = await query(clientId); + + if (!client) { + return next( + createHttpError(HttpCode.NOT_FOUND, "Client not found") + ); + } + + return response(res, { + data: client, + success: true, + error: false, + message: "Client retrieved 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/routers/client/index.ts b/server/routers/client/index.ts new file mode 100644 index 00000000..385c7bed --- /dev/null +++ b/server/routers/client/index.ts @@ -0,0 +1,6 @@ +export * from "./pickClientDefaults"; +export * from "./createClient"; +export * from "./deleteClient"; +export * from "./listClients"; +export * from "./updateClient"; +export * from "./getClient"; \ No newline at end of file diff --git a/server/routers/client/listClients.ts b/server/routers/client/listClients.ts new file mode 100644 index 00000000..ff03b2e0 --- /dev/null +++ b/server/routers/client/listClients.ts @@ -0,0 +1,229 @@ +import { db } from "@server/db"; +import { + clients, + orgs, + roleClients, + sites, + userClients, + clientSites +} from "@server/db"; +import logger from "@server/logger"; +import HttpCode from "@server/types/HttpCode"; +import response from "@server/lib/response"; +import { and, count, eq, inArray, or, sql } from "drizzle-orm"; +import { NextFunction, Request, Response } from "express"; +import createHttpError from "http-errors"; +import { z } from "zod"; +import { fromError } from "zod-validation-error"; +import { OpenAPITags, registry } from "@server/openApi"; + +const listClientsParamsSchema = z + .object({ + orgId: z.string() + }) + .strict(); + +const listClientsSchema = z.object({ + limit: z + .string() + .optional() + .default("1000") + .transform(Number) + .pipe(z.number().int().positive()), + offset: z + .string() + .optional() + .default("0") + .transform(Number) + .pipe(z.number().int().nonnegative()) +}); + +function queryClients(orgId: string, accessibleClientIds: number[]) { + return db + .select({ + clientId: clients.clientId, + orgId: clients.orgId, + name: clients.name, + pubKey: clients.pubKey, + subnet: clients.subnet, + megabytesIn: clients.megabytesIn, + megabytesOut: clients.megabytesOut, + orgName: orgs.name, + type: clients.type, + online: clients.online + }) + .from(clients) + .leftJoin(orgs, eq(clients.orgId, orgs.orgId)) + .where( + and( + inArray(clients.clientId, accessibleClientIds), + eq(clients.orgId, orgId) + ) + ); +} + +async function getSiteAssociations(clientIds: number[]) { + if (clientIds.length === 0) return []; + + return db + .select({ + clientId: clientSites.clientId, + siteId: clientSites.siteId, + siteName: sites.name, + siteNiceId: sites.niceId + }) + .from(clientSites) + .leftJoin(sites, eq(clientSites.siteId, sites.siteId)) + .where(inArray(clientSites.clientId, clientIds)); +} + +export type ListClientsResponse = { + clients: Array>[0] & { sites: Array<{ + siteId: number; + siteName: string | null; + siteNiceId: string | null; + }> }>; + pagination: { total: number; limit: number; offset: number }; +}; + +registry.registerPath({ + method: "get", + path: "/org/{orgId}/clients", + description: "List all clients for an organization.", + tags: [OpenAPITags.Client, OpenAPITags.Org], + request: { + query: listClientsSchema, + params: listClientsParamsSchema + }, + responses: {} +}); + +export async function listClients( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedQuery = listClientsSchema.safeParse(req.query); + if (!parsedQuery.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedQuery.error) + ) + ); + } + const { limit, offset } = parsedQuery.data; + + const parsedParams = listClientsParamsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error) + ) + ); + } + const { orgId } = parsedParams.data; + + if (req.user && orgId && orgId !== req.userOrgId) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "User does not have access to this organization" + ) + ); + } + + let accessibleClients; + if (req.user) { + accessibleClients = await db + .select({ + clientId: sql`COALESCE(${userClients.clientId}, ${roleClients.clientId})` + }) + .from(userClients) + .fullJoin( + roleClients, + eq(userClients.clientId, roleClients.clientId) + ) + .where( + or( + eq(userClients.userId, req.user!.userId), + eq(roleClients.roleId, req.userOrgRoleId!) + ) + ); + } else { + accessibleClients = await db + .select({ clientId: clients.clientId }) + .from(clients) + .where(eq(clients.orgId, orgId)); + } + + const accessibleClientIds = accessibleClients.map( + (client) => client.clientId + ); + const baseQuery = queryClients(orgId, accessibleClientIds); + + // Get client count + const countQuery = db + .select({ count: count() }) + .from(clients) + .where( + and( + inArray(clients.clientId, accessibleClientIds), + eq(clients.orgId, orgId) + ) + ); + + const clientsList = await baseQuery.limit(limit).offset(offset); + const totalCountResult = await countQuery; + const totalCount = totalCountResult[0].count; + + // Get associated sites for all clients + const clientIds = clientsList.map(client => client.clientId); + const siteAssociations = await getSiteAssociations(clientIds); + + // Group site associations by client ID + const sitesByClient = siteAssociations.reduce((acc, association) => { + if (!acc[association.clientId]) { + acc[association.clientId] = []; + } + acc[association.clientId].push({ + siteId: association.siteId, + siteName: association.siteName, + siteNiceId: association.siteNiceId + }); + return acc; + }, {} as Record>); + + // Merge clients with their site associations + const clientsWithSites = clientsList.map(client => ({ + ...client, + sites: sitesByClient[client.clientId] || [] + })); + + return response(res, { + data: { + clients: clientsWithSites, + pagination: { + total: totalCount, + limit, + offset + } + }, + success: true, + error: false, + message: "Clients retrieved successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/client/pickClientDefaults.ts b/server/routers/client/pickClientDefaults.ts new file mode 100644 index 00000000..b1459400 --- /dev/null +++ b/server/routers/client/pickClientDefaults.ts @@ -0,0 +1,85 @@ +import { Request, Response, NextFunction } from "express"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { generateId } from "@server/auth/sessions/app"; +import { getNextAvailableClientSubnet } from "@server/lib/ip"; +import { z } from "zod"; +import { fromError } from "zod-validation-error"; +import { OpenAPITags, registry } from "@server/openApi"; + +export type PickClientDefaultsResponse = { + olmId: string; + olmSecret: string; + subnet: string; +}; + +const pickClientDefaultsSchema = z + .object({ + orgId: z.string() + }) + .strict(); + +registry.registerPath({ + method: "get", + path: "/site/{siteId}/pick-client-defaults", + description: "Return pre-requisite data for creating a client.", + tags: [OpenAPITags.Client, OpenAPITags.Site], + request: { + params: pickClientDefaultsSchema + }, + responses: {} +}); + +export async function pickClientDefaults( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = pickClientDefaultsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { orgId } = parsedParams.data; + + const olmId = generateId(15); + const secret = generateId(48); + + const newSubnet = await getNextAvailableClientSubnet(orgId); + if (!newSubnet) { + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "No available subnet found" + ) + ); + } + + const subnet = newSubnet.split("/")[0]; + + return response(res, { + data: { + olmId: olmId, + olmSecret: secret, + subnet: subnet + }, + success: true, + error: false, + message: "Organization retrieved successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/client/updateClient.ts b/server/routers/client/updateClient.ts new file mode 100644 index 00000000..87bb3c47 --- /dev/null +++ b/server/routers/client/updateClient.ts @@ -0,0 +1,225 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db } from "@server/db"; +import { clients, clientSites } 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 { eq, and } from "drizzle-orm"; +import { fromError } from "zod-validation-error"; +import { OpenAPITags, registry } from "@server/openApi"; +import { + addPeer as newtAddPeer, + deletePeer as newtDeletePeer +} from "../newt/peers"; +import { + addPeer as olmAddPeer, + deletePeer as olmDeletePeer +} from "../olm/peers"; + +const updateClientParamsSchema = z + .object({ + clientId: z.string().transform(Number).pipe(z.number().int().positive()) + }) + .strict(); + +const updateClientSchema = z + .object({ + name: z.string().min(1).max(255).optional(), + siteIds: z + .array(z.string().transform(Number).pipe(z.number())) + .optional() + }) + .strict(); + +export type UpdateClientBody = z.infer; + +registry.registerPath({ + method: "post", + path: "/client/{clientId}", + description: "Update a client by its client ID.", + tags: [OpenAPITags.Client], + request: { + params: updateClientParamsSchema, + body: { + content: { + "application/json": { + schema: updateClientSchema + } + } + } + }, + responses: {} +}); + +export async function updateClient( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedBody = updateClientSchema.safeParse(req.body); + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + + const { name, siteIds } = parsedBody.data; + + const parsedParams = updateClientParamsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { clientId } = parsedParams.data; + + // Fetch the client to make sure it exists and the user has access to it + const [client] = await db + .select() + .from(clients) + .where(eq(clients.clientId, clientId)) + .limit(1); + + if (!client) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `Client with ID ${clientId} not found` + ) + ); + } + + if (siteIds) { + let sitesAdded = []; + let sitesRemoved = []; + + // Fetch existing site associations + const existingSites = await db + .select({ siteId: clientSites.siteId }) + .from(clientSites) + .where(eq(clientSites.clientId, clientId)); + + const existingSiteIds = existingSites.map((site) => site.siteId); + + // Determine which sites were added and removed + sitesAdded = siteIds.filter( + (siteId) => !existingSiteIds.includes(siteId) + ); + sitesRemoved = existingSiteIds.filter( + (siteId) => !siteIds.includes(siteId) + ); + + logger.info( + `Adding ${sitesAdded.length} new sites to client ${client.clientId}` + ); + for (const siteId of sitesAdded) { + if (!client.subnet || !client.pubKey || !client.endpoint) { + logger.debug("Client subnet, pubKey or endpoint is not set"); + continue; + } + + const site = await newtAddPeer(siteId, { + publicKey: client.pubKey, + allowedIps: [`${client.subnet.split("/")[0]}/32`], // we want to only allow from that client + endpoint: client.endpoint + }); + if (!site) { + logger.debug("Failed to add peer to newt - missing site"); + continue; + } + + if (!site.endpoint || !site.publicKey) { + logger.debug("Site endpoint or publicKey is not set"); + continue; + } + await olmAddPeer(client.clientId, { + siteId: siteId, + endpoint: site.endpoint, + publicKey: site.publicKey, + serverIP: site.address, + serverPort: site.listenPort + }); + } + + logger.info( + `Removing ${sitesRemoved.length} sites from client ${client.clientId}` + ); + for (const siteId of sitesRemoved) { + if (!client.pubKey) { + logger.debug("Client pubKey is not set"); + continue; + } + const site = await newtDeletePeer(siteId, client.pubKey); + if (!site) { + logger.debug( + "Failed to delete peer from newt - missing site" + ); + continue; + } + if (!site.endpoint || !site.publicKey) { + logger.debug("Site endpoint or publicKey is not set"); + continue; + } + await olmDeletePeer(client.clientId, site.siteId, site.publicKey); + } + } + + await db.transaction(async (trx) => { + // Update client name if provided + if (name) { + await trx + .update(clients) + .set({ name }) + .where(eq(clients.clientId, clientId)); + } + + // Update site associations if provided + if (siteIds) { + // Delete existing site associations + await trx + .delete(clientSites) + .where(eq(clientSites.clientId, clientId)); + + // Create new site associations + if (siteIds.length > 0) { + await trx.insert(clientSites).values( + siteIds.map((siteId) => ({ + clientId, + siteId + })) + ); + } + } + + // Fetch the updated client + const [updatedClient] = await trx + .select() + .from(clients) + .where(eq(clients.clientId, clientId)) + .limit(1); + + return response(res, { + data: updatedClient, + success: true, + error: false, + message: "Client updated successfully", + status: HttpCode.OK + }); + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/domain/createOrgDomain.ts b/server/routers/domain/createOrgDomain.ts new file mode 100644 index 00000000..a1d9bf2a --- /dev/null +++ b/server/routers/domain/createOrgDomain.ts @@ -0,0 +1,287 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db, Domain, domains, OrgDomains, orgDomains } 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 { subdomainSchema } from "@server/lib/schemas"; +import { generateId } from "@server/auth/sessions/app"; +import { eq, and } from "drizzle-orm"; +import { isValidDomain } from "@server/lib/validators"; +import { build } from "@server/build"; + +const paramsSchema = z + .object({ + orgId: z.string() + }) + .strict(); + +const bodySchema = z + .object({ + type: z.enum(["ns", "cname", "wildcard"]), + baseDomain: subdomainSchema + }) + .strict(); + +export type CreateDomainResponse = { + domainId: string; + nsRecords?: string[]; + cnameRecords?: { baseDomain: string; value: string }[]; + txtRecords?: { baseDomain: string; value: string }[]; +}; + +// Helper to check if a domain is a subdomain or equal to another domain +function isSubdomainOrEqual(a: string, b: string): boolean { + const aParts = a.toLowerCase().split("."); + const bParts = b.toLowerCase().split("."); + if (aParts.length < bParts.length) return false; + return aParts.slice(-bParts.length).join(".") === bParts.join("."); +} + +export async function createOrgDomain( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedBody = bodySchema.safeParse(req.body); + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + + const parsedParams = paramsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { orgId } = parsedParams.data; + const { type, baseDomain } = parsedBody.data; + + if (build == "oss") { + if (type !== "wildcard") { + return next( + createHttpError( + HttpCode.NOT_IMPLEMENTED, + "Creating NS or CNAME records is not supported" + ) + ); + } + } else if (build == "enterprise" || build == "saas") { + if (type !== "ns" && type !== "cname") { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Invalid domain type. Only NS, CNAME are allowed." + ) + ); + } + } + + // Validate organization exists + if (!isValidDomain(baseDomain)) { + return next( + createHttpError(HttpCode.BAD_REQUEST, "Invalid domain format") + ); + } + + let numOrgDomains: OrgDomains[] | undefined; + let cnameRecords: CreateDomainResponse["cnameRecords"]; + let txtRecords: CreateDomainResponse["txtRecords"]; + let nsRecords: CreateDomainResponse["nsRecords"]; + let returned: Domain | undefined; + + await db.transaction(async (trx) => { + const [existing] = await trx + .select() + .from(domains) + .where( + and( + eq(domains.baseDomain, baseDomain), + eq(domains.type, type) + ) + ) + .leftJoin( + orgDomains, + eq(orgDomains.domainId, domains.domainId) + ); + + if (existing) { + const { + domains: existingDomain, + orgDomains: existingOrgDomain + } = existing; + + // user alrady added domain to this account + // always reject + if (existingOrgDomain?.orgId === orgId) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Domain is already added to this org" + ) + ); + } + + // domain already exists elsewhere + // check if it's already fully verified + if (existingDomain.verified) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Domain is already verified to an org" + ) + ); + } + } + + // --- Domain overlap logic --- + // Only consider existing verified domains + const verifiedDomains = await trx + .select() + .from(domains) + .where(eq(domains.verified, true)); + + if (type == "cname") { + // Block if a verified CNAME exists at the same name + const cnameExists = verifiedDomains.some( + (d) => d.type === "cname" && d.baseDomain === baseDomain + ); + if (cnameExists) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + `A CNAME record already exists for ${baseDomain}. Only one CNAME record is allowed per domain.` + ) + ); + } + // Block if a verified NS exists at or below (same or subdomain) + const nsAtOrBelow = verifiedDomains.some( + (d) => + d.type === "ns" && + (isSubdomainOrEqual(baseDomain, d.baseDomain) || + baseDomain === d.baseDomain) + ); + if (nsAtOrBelow) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + `A nameserver (NS) record exists at or below ${baseDomain}. You cannot create a CNAME record here.` + ) + ); + } + } else if (type == "ns") { + // Block if a verified NS exists at or below (same or subdomain) + const nsAtOrBelow = verifiedDomains.some( + (d) => + d.type === "ns" && + (isSubdomainOrEqual(baseDomain, d.baseDomain) || + baseDomain === d.baseDomain) + ); + if (nsAtOrBelow) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + `A nameserver (NS) record already exists at or below ${baseDomain}. You cannot create another NS record here.` + ) + ); + } + } else if (type == "wildcard") { + // TODO: Figure out how to handle wildcards + } + + const domainId = generateId(15); + + const [insertedDomain] = await trx + .insert(domains) + .values({ + domainId, + baseDomain, + type, + verified: build == "oss" ? true : false + }) + .returning(); + + returned = insertedDomain; + + // add domain to account + await trx + .insert(orgDomains) + .values({ + orgId, + domainId + }) + .returning(); + + // TODO: This needs to be cross region and not hardcoded + if (type === "ns") { + nsRecords = ["ns-east.fossorial.io", "ns-west.fossorial.io"]; + } else if (type === "cname") { + cnameRecords = [ + { + value: `${domainId}.cname.fossorial.io`, + baseDomain: baseDomain + }, + { + value: `_acme-challenge.${domainId}.cname.fossorial.io`, + baseDomain: `_acme-challenge.${baseDomain}` + } + ]; + } else if (type === "wildcard") { + cnameRecords = [ + { + value: `Server IP Address`, + baseDomain: `*.${baseDomain}` + }, + { + value: `Server IP Address`, + baseDomain: `${baseDomain}` + } + ]; + } + + numOrgDomains = await trx + .select() + .from(orgDomains) + .where(eq(orgDomains.orgId, orgId)); + }); + + if (!returned) { + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Failed to create domain" + ) + ); + } + + return response(res, { + data: { + domainId: returned.domainId, + cnameRecords, + txtRecords, + nsRecords + }, + success: true, + error: false, + message: "Domain created successfully", + status: HttpCode.CREATED + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/domain/deleteOrgDomain.ts b/server/routers/domain/deleteOrgDomain.ts new file mode 100644 index 00000000..aea2a2fa --- /dev/null +++ b/server/routers/domain/deleteOrgDomain.ts @@ -0,0 +1,72 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db, domains, OrgDomains, orgDomains } from "@server/db"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; +import { and, eq } from "drizzle-orm"; + +const paramsSchema = z + .object({ + domainId: z.string(), + orgId: z.string() + }) + .strict(); + +export type DeleteAccountDomainResponse = { + success: boolean; +}; + +export async function deleteAccountDomain( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsed = paramsSchema.safeParse(req.params); + if (!parsed.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsed.error).toString() + ) + ); + } + const { domainId, orgId } = parsed.data; + + let numOrgDomains: OrgDomains[] | undefined; + + await db.transaction(async (trx) => { + await trx + .delete(orgDomains) + .where( + and( + eq(orgDomains.orgId, orgId), + eq(orgDomains.domainId, domainId) + ) + ); + + await trx.delete(domains).where(eq(domains.domainId, domainId)); + + numOrgDomains = await trx + .select() + .from(orgDomains) + .where(eq(orgDomains.orgId, orgId)); + }); + + return response(res, { + data: { success: true }, + success: true, + error: false, + message: "Domain deleted from account successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/domain/index.ts b/server/routers/domain/index.ts index 2233b069..c0cafafe 100644 --- a/server/routers/domain/index.ts +++ b/server/routers/domain/index.ts @@ -1 +1,4 @@ export * from "./listDomains"; +export * from "./createOrgDomain"; +export * from "./deleteOrgDomain"; +export * from "./restartOrgDomain"; \ No newline at end of file diff --git a/server/routers/domain/listDomains.ts b/server/routers/domain/listDomains.ts index a8216c5f..cb968854 100644 --- a/server/routers/domain/listDomains.ts +++ b/server/routers/domain/listDomains.ts @@ -37,7 +37,11 @@ async function queryDomains(orgId: string, limit: number, offset: number) { const res = await db .select({ domainId: domains.domainId, - baseDomain: domains.baseDomain + baseDomain: domains.baseDomain, + verified: domains.verified, + type: domains.type, + failed: domains.failed, + tries: domains.tries, }) .from(orgDomains) .where(eq(orgDomains.orgId, orgId)) @@ -112,7 +116,7 @@ export async function listDomains( }, success: true, error: false, - message: "Users retrieved successfully", + message: "Domains retrieved successfully", status: HttpCode.OK }); } catch (error) { diff --git a/server/routers/domain/restartOrgDomain.ts b/server/routers/domain/restartOrgDomain.ts new file mode 100644 index 00000000..f40f2516 --- /dev/null +++ b/server/routers/domain/restartOrgDomain.ts @@ -0,0 +1,57 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db, domains } from "@server/db"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; +import { and, eq } from "drizzle-orm"; + +const paramsSchema = z + .object({ + domainId: z.string(), + orgId: z.string() + }) + .strict(); + +export type RestartOrgDomainResponse = { + success: boolean; +}; + +export async function restartOrgDomain( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsed = paramsSchema.safeParse(req.params); + if (!parsed.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsed.error).toString() + ) + ); + } + const { domainId, orgId } = parsed.data; + + await db + .update(domains) + .set({ failed: false, tries: 0 }) + .where(and(eq(domains.domainId, domainId))); + + return response(res, { + data: { success: true }, + success: true, + error: false, + message: "Domain restarted successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/external.ts b/server/routers/external.ts index 17794974..b05d9869 100644 --- a/server/routers/external.ts +++ b/server/routers/external.ts @@ -8,6 +8,7 @@ import * as target from "./target"; import * as user from "./user"; import * as auth from "./auth"; import * as role from "./role"; +import * as client from "./client"; import * as supporterKey from "./supporterKey"; import * as accessToken from "./accessToken"; import * as idp from "./idp"; @@ -28,14 +29,20 @@ import { getUserOrgs, verifyUserIsServerAdmin, verifyIsLoggedInUser, - verifyApiKeyAccess + verifyClientAccess, + verifyApiKeyAccess, + verifyDomainAccess, + verifyClientsEnabled, + verifyUserHasAction, + verifyUserIsOrgOwner } from "@server/middlewares"; -import { verifyUserHasAction } from "../middlewares/verifyUserHasAction"; +import { createStore } from "@server/lib/rateLimitStore"; import { ActionsEnum } from "@server/auth/actions"; -import { verifyUserIsOrgOwner } from "../middlewares/verifyUserIsOrgOwner"; -import { createNewt, getToken } from "./newt"; +import { createNewt, getNewtToken } from "./newt"; +import { getOlmToken } from "./olm"; import rateLimit from "express-rate-limit"; import createHttpError from "http-errors"; +import { build } from "@server/build"; // Root routes export const unauthenticated = Router(); @@ -48,8 +55,11 @@ unauthenticated.get("/", (_, res) => { export const authenticated = Router(); authenticated.use(verifySessionUserMiddleware); +authenticated.get("/pick-org-defaults", org.pickOrgDefaults); authenticated.get("/org/checkId", org.checkId); -authenticated.put("/org", getUserOrgs, org.createOrg); +if (build === "oss" || build === "enterprise") { + authenticated.put("/org", getUserOrgs, org.createOrg); +} authenticated.get("/orgs", verifyUserIsServerAdmin, org.listOrgs); authenticated.get("/user/:userId/orgs", verifyIsLoggedInUser, org.listUserOrgs); @@ -104,6 +114,55 @@ authenticated.get( verifyUserHasAction(ActionsEnum.getSite), site.getSite ); + +authenticated.get( + "/org/:orgId/pick-client-defaults", + verifyClientsEnabled, + verifyOrgAccess, + verifyUserHasAction(ActionsEnum.createClient), + client.pickClientDefaults +); + +authenticated.get( + "/org/:orgId/clients", + verifyClientsEnabled, + verifyOrgAccess, + verifyUserHasAction(ActionsEnum.listClients), + client.listClients +); + +authenticated.get( + "/org/:orgId/client/:clientId", + verifyClientsEnabled, + verifyOrgAccess, + verifyUserHasAction(ActionsEnum.getClient), + client.getClient +); + +authenticated.put( + "/org/:orgId/client", + verifyClientsEnabled, + verifyOrgAccess, + verifyUserHasAction(ActionsEnum.createClient), + client.createClient +); + +authenticated.delete( + "/client/:clientId", + verifyClientsEnabled, + verifyClientAccess, + verifyUserHasAction(ActionsEnum.deleteClient), + client.deleteClient +); + +authenticated.post( + "/client/:clientId", + verifyClientsEnabled, + verifyClientAccess, // this will check if the user has access to the client + verifyUserHasAction(ActionsEnum.updateClient), // this will check if the user has permission to update the client + client.updateClient +); + // authenticated.get( // "/site/:siteId/roles", // verifySiteAccess, @@ -698,6 +757,29 @@ authenticated.get( apiKeys.getApiKey ); +authenticated.put( + `/org/:orgId/domain`, + verifyOrgAccess, + verifyUserHasAction(ActionsEnum.createOrgDomain), + domain.createOrgDomain +); + +authenticated.post( + `/org/:orgId/domain/:domainId/restart`, + verifyOrgAccess, + verifyDomainAccess, + verifyUserHasAction(ActionsEnum.restartOrgDomain), + domain.restartOrgDomain +); + +authenticated.delete( + `/org/:orgId/domain/:domainId`, + verifyOrgAccess, + verifyDomainAccess, + verifyUserHasAction(ActionsEnum.deleteOrgDomain), + domain.deleteAccountDomain +); + // Auth routes export const authRouter = Router(); unauthenticated.use("/auth", authRouter); @@ -751,7 +833,20 @@ authRouter.post( return next(createHttpError(HttpCode.TOO_MANY_REQUESTS, message)); } }), - getToken + getNewtToken +); +authRouter.post( + "/olm/get-token", + rateLimit({ + windowMs: 15 * 60 * 1000, + max: 900, + keyGenerator: (req) => `newtGetToken:${req.body.newtId}`, + handler: (req, res, next) => { + const message = `You can only request an Olm token ${900} times every ${15} minutes. Please try again later.`; + return next(createHttpError(HttpCode.TOO_MANY_REQUESTS, message)); + } + }), + getOlmToken ); authRouter.post( @@ -836,7 +931,8 @@ authRouter.post( handler: (req, res, next) => { const message = `You can only request an email verification code ${15} times every ${15} minutes. Please try again later.`; return next(createHttpError(HttpCode.TOO_MANY_REQUESTS, message)); - } + }, + store: createStore() }), auth.requestEmailVerificationCode ); @@ -856,7 +952,8 @@ authRouter.post( handler: (req, res, next) => { const message = `You can only request a password reset ${15} times every ${15} minutes. Please try again later.`; return next(createHttpError(HttpCode.TOO_MANY_REQUESTS, message)); - } + }, + store: createStore() }), auth.requestPasswordReset ); @@ -914,7 +1011,8 @@ authRouter.post( handler: (req, res, next) => { const message = `You can only request an email OTP ${15} times every ${15} minutes. Please try again later.`; return next(createHttpError(HttpCode.TOO_MANY_REQUESTS, message)); - } + }, + store: createStore() }), resource.authWithWhitelist ); diff --git a/server/routers/gerbil/getAllRelays.ts b/server/routers/gerbil/getAllRelays.ts new file mode 100644 index 00000000..abe4d593 --- /dev/null +++ b/server/routers/gerbil/getAllRelays.ts @@ -0,0 +1,160 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { clients, exitNodes, newts, olms, Site, sites, clientSites } from "@server/db"; +import { db } from "@server/db"; +import { eq } from "drizzle-orm"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; + +// Define Zod schema for request validation +const getAllRelaysSchema = z.object({ + publicKey: z.string().optional(), +}); + +// Type for peer destination +interface PeerDestination { + destinationIP: string; + destinationPort: number; +} + +// Updated mappings type to support multiple destinations per endpoint +interface ProxyMapping { + destinations: PeerDestination[]; +} + +export async function getAllRelays( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + // Validate request parameters + const parsedParams = getAllRelaysSchema.safeParse(req.body); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { publicKey } = parsedParams.data; + + if (!publicKey) { + return next(createHttpError(HttpCode.BAD_REQUEST, 'publicKey is required')); + } + + // Fetch exit node + let [exitNode] = await db.select().from(exitNodes).where(eq(exitNodes.publicKey, publicKey)); + if (!exitNode) { + return next(createHttpError(HttpCode.NOT_FOUND, "Exit node not found")); + } + + // Fetch sites for this exit node + const sitesRes = await db.select().from(sites).where(eq(sites.exitNodeId, exitNode.exitNodeId)); + + if (sitesRes.length === 0) { + return res.status(HttpCode.OK).send({ + mappings: {} + }); + } + + // Initialize mappings object for multi-peer support + let mappings: { [key: string]: ProxyMapping } = {}; + + // Process each site + for (const site of sitesRes) { + if (!site.endpoint || !site.subnet || !site.listenPort) { + continue; + } + + // Find all clients associated with this site through clientSites + const clientSitesRes = await db + .select() + .from(clientSites) + .where(eq(clientSites.siteId, site.siteId)); + + for (const clientSite of clientSitesRes) { + // Get client information + const [client] = await db + .select() + .from(clients) + .where(eq(clients.clientId, clientSite.clientId)); + + if (!client || !client.endpoint) { + continue; + } + + // Add this site as a destination for the client + if (!mappings[client.endpoint]) { + mappings[client.endpoint] = { destinations: [] }; + } + + // Add site as a destination for this client + const destination: PeerDestination = { + destinationIP: site.subnet.split("/")[0], + destinationPort: site.listenPort + }; + + // Check if this destination is already in the array to avoid duplicates + const isDuplicate = mappings[client.endpoint].destinations.some( + dest => dest.destinationIP === destination.destinationIP && + dest.destinationPort === destination.destinationPort + ); + + if (!isDuplicate) { + mappings[client.endpoint].destinations.push(destination); + } + } + + // Also handle site-to-site communication (all sites in the same org) + if (site.orgId) { + const orgSites = await db + .select() + .from(sites) + .where(eq(sites.orgId, site.orgId)); + + for (const peer of orgSites) { + // Skip self + if (peer.siteId === site.siteId || !peer.endpoint || !peer.subnet || !peer.listenPort) { + continue; + } + + // Add peer site as a destination for this site + if (!mappings[site.endpoint]) { + mappings[site.endpoint] = { destinations: [] }; + } + + const destination: PeerDestination = { + destinationIP: peer.subnet.split("/")[0], + destinationPort: peer.listenPort + }; + + // Check for duplicates + const isDuplicate = mappings[site.endpoint].destinations.some( + dest => dest.destinationIP === destination.destinationIP && + dest.destinationPort === destination.destinationPort + ); + + if (!isDuplicate) { + mappings[site.endpoint].destinations.push(destination); + } + } + } + } + + logger.debug(`Returning mappings for ${Object.keys(mappings).length} endpoints`); + return res.status(HttpCode.OK).send({ mappings }); + } 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/routers/gerbil/getConfig.ts b/server/routers/gerbil/getConfig.ts index de3da171..601f7655 100644 --- a/server/routers/gerbil/getConfig.ts +++ b/server/routers/gerbil/getConfig.ts @@ -53,7 +53,7 @@ export async function getConfig( } // Fetch exit node - let exitNodeQuery = await db + const exitNodeQuery = await db .select() .from(exitNodes) .where(eq(exitNodes.publicKey, publicKey)); @@ -68,6 +68,10 @@ export async function getConfig( subEndpoint = await getUniqueExitNodeEndpointName(); } + const exitNodeName = + config.getRawConfig().gerbil.exit_node_name || + `Exit Node ${publicKey.slice(0, 8)}`; + // create a new exit node exitNode = await db .insert(exitNodes) @@ -77,7 +81,7 @@ export async function getConfig( address, listenPort, reachableAt, - name: `Exit Node ${publicKey.slice(0, 8)}` + name: exitNodeName }) .returning() .execute(); diff --git a/server/routers/gerbil/index.ts b/server/routers/gerbil/index.ts index 82f82c4c..4a4f3b60 100644 --- a/server/routers/gerbil/index.ts +++ b/server/routers/gerbil/index.ts @@ -1,2 +1,4 @@ export * from "./getConfig"; export * from "./receiveBandwidth"; +export * from "./updateHolePunch"; +export * from "./getAllRelays"; \ No newline at end of file diff --git a/server/routers/gerbil/receiveBandwidth.ts b/server/routers/gerbil/receiveBandwidth.ts index cd025b7e..5e672d0f 100644 --- a/server/routers/gerbil/receiveBandwidth.ts +++ b/server/routers/gerbil/receiveBandwidth.ts @@ -1,12 +1,15 @@ import { Request, Response, NextFunction } from "express"; -import { eq } from "drizzle-orm"; -import { sites, } from "@server/db"; +import { eq, and, lt, inArray, sql } from "drizzle-orm"; +import { sites } from "@server/db"; import { db } from "@server/db"; import logger from "@server/logger"; import createHttpError from "http-errors"; import HttpCode from "@server/types/HttpCode"; import response from "@server/lib/response"; +// Track sites that are already offline to avoid unnecessary queries +const offlineSites = new Set(); + interface PeerBandwidth { publicKey: string; bytesIn: number; @@ -25,47 +28,101 @@ export const receiveBandwidth = async ( throw new Error("Invalid bandwidth data"); } + const currentTime = new Date(); + const oneMinuteAgo = new Date(currentTime.getTime() - 60000); // 1 minute ago + + logger.debug(`Received data: ${JSON.stringify(bandwidthData)}`); + await db.transaction(async (trx) => { - for (const peer of bandwidthData) { - const { publicKey, bytesIn, bytesOut } = peer; + // First, handle sites that are actively reporting bandwidth + const activePeers = bandwidthData.filter(peer => peer.bytesIn > 0); // Bytesout will have data as it tries to send keep alive messages - const [site] = await trx - .select() - .from(sites) - .where(eq(sites.pubKey, publicKey)) - .limit(1); + if (activePeers.length > 0) { + // Remove any active peers from offline tracking since they're sending data + activePeers.forEach(peer => offlineSites.delete(peer.publicKey)); - if (!site) { - logger.warn(`Site not found for public key: ${publicKey}`); - continue; - } - let online = site.online; + // Aggregate usage data by organization + const orgUsageMap = new Map(); + const orgUptimeMap = new Map(); - // if the bandwidth for the site is > 0 then set it to online. if it has been less than 0 (no update) for 5 minutes then set it to offline - if (bytesIn > 0 || bytesOut > 0) { - online = true; - } else if (site.lastBandwidthUpdate) { - const lastBandwidthUpdate = new Date( - site.lastBandwidthUpdate - ); - const currentTime = new Date(); - const diff = - currentTime.getTime() - lastBandwidthUpdate.getTime(); - if (diff < 300000) { - online = false; + // Update all active sites with bandwidth data and get the site data in one operation + const updatedSites = []; + for (const peer of activePeers) { + const updatedSite = await trx + .update(sites) + .set({ + megabytesOut: sql`${sites.megabytesOut} + ${peer.bytesIn}`, + megabytesIn: sql`${sites.megabytesIn} + ${peer.bytesOut}`, + lastBandwidthUpdate: currentTime.toISOString(), + online: true + }) + .where(eq(sites.pubKey, peer.publicKey)) + .returning({ + online: sites.online, + orgId: sites.orgId, + siteId: sites.siteId, + lastBandwidthUpdate: sites.lastBandwidthUpdate, + }); + + if (updatedSite.length > 0) { + updatedSites.push({ ...updatedSite[0], peer }); } } - // Update the site's bandwidth usage - await trx - .update(sites) - .set({ - megabytesOut: (site.megabytesOut || 0) + bytesIn, - megabytesIn: (site.megabytesIn || 0) + bytesOut, - lastBandwidthUpdate: new Date().toISOString(), - online - }) - .where(eq(sites.siteId, site.siteId)); + // Calculate org usage aggregations using the updated site data + for (const { peer, ...site } of updatedSites) { + // Aggregate bandwidth usage for the org + const totalBandwidth = peer.bytesIn + peer.bytesOut; + const currentOrgUsage = orgUsageMap.get(site.orgId) || 0; + orgUsageMap.set(site.orgId, currentOrgUsage + totalBandwidth); + + // Add 10 seconds of uptime for each active site + const currentOrgUptime = orgUptimeMap.get(site.orgId) || 0; + orgUptimeMap.set(site.orgId, currentOrgUptime + 10 / 60); // Store in minutes and jut add 10 seconds + } + } + + // Handle sites that reported zero bandwidth but need online status updated + const zeroBandwidthPeers = bandwidthData.filter(peer => + peer.bytesIn === 0 && !offlineSites.has(peer.publicKey) // Bytesout will have data as it tries to send keep alive messages + ); + + if (zeroBandwidthPeers.length > 0) { + const zeroBandwidthSites = await trx + .select() + .from(sites) + .where(inArray(sites.pubKey, zeroBandwidthPeers.map(p => p.publicKey))); + + for (const site of zeroBandwidthSites) { + let newOnlineStatus = site.online; + + // Check if site should go offline based on last bandwidth update WITH DATA + if (site.lastBandwidthUpdate) { + const lastUpdateWithData = new Date(site.lastBandwidthUpdate); + if (lastUpdateWithData < oneMinuteAgo) { + newOnlineStatus = false; + } + } else { + // No previous data update recorded, set to offline + newOnlineStatus = false; + } + + // Always update lastBandwidthUpdate to show this instance is receiving reports + // Only update online status if it changed + if (site.online !== newOnlineStatus) { + await trx + .update(sites) + .set({ + online: newOnlineStatus + }) + .where(eq(sites.siteId, site.siteId)); + + // If site went offline, add it to our tracking set + if (!newOnlineStatus && site.pubKey) { + offlineSites.add(site.pubKey); + } + } + } } }); @@ -73,7 +130,7 @@ export const receiveBandwidth = async ( data: {}, success: true, error: false, - message: "Organization retrieved successfully", + message: "Bandwidth data updated successfully", status: HttpCode.OK }); } catch (error) { diff --git a/server/routers/gerbil/updateHolePunch.ts b/server/routers/gerbil/updateHolePunch.ts new file mode 100644 index 00000000..c48f7551 --- /dev/null +++ b/server/routers/gerbil/updateHolePunch.ts @@ -0,0 +1,242 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { clients, newts, olms, Site, sites, clientSites } from "@server/db"; +import { db } from "@server/db"; +import { eq } from "drizzle-orm"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; +import { validateNewtSessionToken } from "@server/auth/sessions/newt"; +import { validateOlmSessionToken } from "@server/auth/sessions/olm"; + +// Define Zod schema for request validation +const updateHolePunchSchema = z.object({ + olmId: z.string().optional(), + newtId: z.string().optional(), + token: z.string(), + ip: z.string(), + port: z.number(), + timestamp: z.number() +}); + +// New response type with multi-peer destination support +interface PeerDestination { + destinationIP: string; + destinationPort: number; +} + +export async function updateHolePunch( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + // Validate request parameters + const parsedParams = updateHolePunchSchema.safeParse(req.body); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { olmId, newtId, ip, port, timestamp, token } = parsedParams.data; + + + let currentSiteId: number | undefined; + let destinations: PeerDestination[] = []; + + if (olmId) { + logger.debug(`Got hole punch with ip: ${ip}, port: ${port} for olmId: ${olmId}`); + + const { session, olm: olmSession } = + await validateOlmSessionToken(token); + if (!session || !olmSession) { + return next( + createHttpError(HttpCode.UNAUTHORIZED, "Unauthorized") + ); + } + + if (olmId !== olmSession.olmId) { + logger.warn(`Olm ID mismatch: ${olmId} !== ${olmSession.olmId}`); + return next( + createHttpError(HttpCode.UNAUTHORIZED, "Unauthorized") + ); + } + + const [olm] = await db + .select() + .from(olms) + .where(eq(olms.olmId, olmId)); + + if (!olm || !olm.clientId) { + logger.warn(`Olm not found: ${olmId}`); + return next( + createHttpError(HttpCode.NOT_FOUND, "Olm not found") + ); + } + + const [client] = await db + .update(clients) + .set({ + endpoint: `${ip}:${port}`, + lastHolePunch: timestamp + }) + .where(eq(clients.clientId, olm.clientId)) + .returning(); + + if (!client) { + logger.warn(`Client not found for olm: ${olmId}`); + return next( + createHttpError(HttpCode.NOT_FOUND, "Client not found") + ); + } + + // Get all sites that this client is connected to + const clientSitePairs = await db + .select() + .from(clientSites) + .where(eq(clientSites.clientId, client.clientId)); + + if (clientSitePairs.length === 0) { + logger.warn(`No sites found for client: ${client.clientId}`); + return next( + createHttpError(HttpCode.NOT_FOUND, "No sites found for client") + ); + } + + // Get all sites details + const siteIds = clientSitePairs.map(pair => pair.siteId); + + for (const siteId of siteIds) { + const [site] = await db + .select() + .from(sites) + .where(eq(sites.siteId, siteId)); + + if (site && site.subnet && site.listenPort) { + destinations.push({ + destinationIP: site.subnet.split("/")[0], + destinationPort: site.listenPort + }); + } + } + + } else if (newtId) { + const { session, newt: newtSession } = + await validateNewtSessionToken(token); + + if (!session || !newtSession) { + return next( + createHttpError(HttpCode.UNAUTHORIZED, "Unauthorized") + ); + } + + if (newtId !== newtSession.newtId) { + logger.warn(`Newt ID mismatch: ${newtId} !== ${newtSession.newtId}`); + return next( + createHttpError(HttpCode.UNAUTHORIZED, "Unauthorized") + ); + } + + const [newt] = await db + .select() + .from(newts) + .where(eq(newts.newtId, newtId)); + + if (!newt || !newt.siteId) { + logger.warn(`Newt not found: ${newtId}`); + return next( + createHttpError(HttpCode.NOT_FOUND, "New not found") + ); + } + + currentSiteId = newt.siteId; + + // Update the current site with the new endpoint + const [updatedSite] = await db + .update(sites) + .set({ + endpoint: `${ip}:${port}`, + lastHolePunch: timestamp + }) + .where(eq(sites.siteId, newt.siteId)) + .returning(); + + if (!updatedSite || !updatedSite.subnet) { + logger.warn(`Site not found: ${newt.siteId}`); + return next( + createHttpError(HttpCode.NOT_FOUND, "Site not found") + ); + } + + // Find all clients that connect to this site + const sitesClientPairs = await db + .select() + .from(clientSites) + .where(eq(clientSites.siteId, newt.siteId)); + + // Get client details for each client + for (const pair of sitesClientPairs) { + const [client] = await db + .select() + .from(clients) + .where(eq(clients.clientId, pair.clientId)); + + if (client && client.endpoint) { + const [host, portStr] = client.endpoint.split(':'); + if (host && portStr) { + destinations.push({ + destinationIP: host, + destinationPort: parseInt(portStr, 10) + }); + } + } + } + + // If this is a newt/site, also add other sites in the same org + // if (updatedSite.orgId) { + // const orgSites = await db + // .select() + // .from(sites) + // .where(eq(sites.orgId, updatedSite.orgId)); + + // for (const site of orgSites) { + // // Don't add the current site to the destinations + // if (site.siteId !== currentSiteId && site.subnet && site.endpoint && site.listenPort) { + // const [host, portStr] = site.endpoint.split(':'); + // if (host && portStr) { + // destinations.push({ + // destinationIP: host, + // destinationPort: site.listenPort + // }); + // } + // } + // } + // } + } + + // if (destinations.length === 0) { + // logger.warn( + // `No peer destinations found for olmId: ${olmId} or newtId: ${newtId}` + // ); + // return next(createHttpError(HttpCode.NOT_FOUND, "No peer destinations found")); + // } + + // Return the new multi-peer structure + return res.status(HttpCode.OK).send({ + destinations: destinations + }); + } 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/routers/idp/validateOidcCallback.ts b/server/routers/idp/validateOidcCallback.ts index 3d28db24..6003c63d 100644 --- a/server/routers/idp/validateOidcCallback.ts +++ b/server/routers/idp/validateOidcCallback.ts @@ -11,6 +11,7 @@ import { idpOidcConfig, idpOrg, orgs, + Role, roles, userOrgs, users @@ -307,6 +308,8 @@ export async function validateOidcCallback( let existingUserId = existingUser?.userId; + let orgUserCounts: { orgId: string; userCount: number }[] = []; + // sync the user with the orgs and roles await db.transaction(async (trx) => { let userId = existingUser?.userId; @@ -410,6 +413,19 @@ export async function validateOidcCallback( })) ); } + + // Loop through all the orgs and get the total number of users from the userOrgs table + for (const org of currentUserOrgs) { + const userCount = await trx + .select() + .from(userOrgs) + .where(eq(userOrgs.orgId, org.orgId)); + + orgUserCounts.push({ + orgId: org.orgId, + userCount: userCount.length + }); + } }); const token = generateSessionToken(); diff --git a/server/routers/internal.ts b/server/routers/internal.ts index 345a8b4e..118c8ae3 100644 --- a/server/routers/internal.ts +++ b/server/routers/internal.ts @@ -51,6 +51,8 @@ internalRouter.use("/gerbil", gerbilRouter); gerbilRouter.post("/get-config", gerbil.getConfig); gerbilRouter.post("/receive-bandwidth", gerbil.receiveBandwidth); +gerbilRouter.post("/update-hole-punch", gerbil.updateHolePunch); +gerbilRouter.post("/get-all-relays", gerbil.getAllRelays); // Badger routes const badgerRouter = Router(); diff --git a/server/routers/messageHandlers.ts b/server/routers/messageHandlers.ts index e79f8606..92d2849f 100644 --- a/server/routers/messageHandlers.ts +++ b/server/routers/messageHandlers.ts @@ -1,12 +1,29 @@ import { - handleRegisterMessage, + handleNewtRegisterMessage, + handleReceiveBandwidthMessage, + handleGetConfigMessage, handleDockerStatusMessage, - handleDockerContainersMessage + handleDockerContainersMessage, + handleNewtPingRequestMessage } from "./newt"; +import { + handleOlmRegisterMessage, + handleOlmRelayMessage, + handleOlmPingMessage, + startOfflineChecker +} from "./olm"; import { MessageHandler } from "./ws"; export const messageHandlers: Record = { - "newt/wg/register": handleRegisterMessage, + "newt/wg/register": handleNewtRegisterMessage, + "olm/wg/register": handleOlmRegisterMessage, + "newt/wg/get-config": handleGetConfigMessage, + "newt/receive-bandwidth": handleReceiveBandwidthMessage, + "olm/wg/relay": handleOlmRelayMessage, + "olm/ping": handleOlmPingMessage, "newt/socket/status": handleDockerStatusMessage, - "newt/socket/containers": handleDockerContainersMessage + "newt/socket/containers": handleDockerContainersMessage, + "newt/ping/request": handleNewtPingRequestMessage, }; + +startOfflineChecker(); // this is to handle the offline check for olms diff --git a/server/routers/newt/getToken.ts b/server/routers/newt/getNewtToken.ts similarity index 98% rename from server/routers/newt/getToken.ts rename to server/routers/newt/getNewtToken.ts index 15071348..3bf45dcf 100644 --- a/server/routers/newt/getToken.ts +++ b/server/routers/newt/getNewtToken.ts @@ -24,7 +24,7 @@ export const newtGetTokenBodySchema = z.object({ export type NewtGetTokenBody = z.infer; -export async function getToken( +export async function getNewtToken( req: Request, res: Response, next: NextFunction diff --git a/server/routers/newt/handleGetConfigMessage.ts b/server/routers/newt/handleGetConfigMessage.ts new file mode 100644 index 00000000..8d79d4fd --- /dev/null +++ b/server/routers/newt/handleGetConfigMessage.ts @@ -0,0 +1,165 @@ +import { z } from "zod"; +import { MessageHandler } from "../ws"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; +import { db } from "@server/db"; +import { clients, clientSites, Newt, sites } from "@server/db"; +import { eq } from "drizzle-orm"; +import { updatePeer } from "../olm/peers"; + +const inputSchema = z.object({ + publicKey: z.string(), + port: z.number().int().positive() +}); + +type Input = z.infer; + +export const handleGetConfigMessage: MessageHandler = async (context) => { + const { message, client, sendToClient } = context; + const newt = client as Newt; + + const now = new Date().getTime() / 1000; + + logger.debug("Handling Newt get config message!"); + + if (!newt) { + logger.warn("Newt not found"); + return; + } + + if (!newt.siteId) { + logger.warn("Newt has no site!"); // TODO: Maybe we create the site here? + return; + } + + const parsed = inputSchema.safeParse(message.data); + if (!parsed.success) { + logger.error( + "handleGetConfigMessage: Invalid input: " + + fromError(parsed.error).toString() + ); + return; + } + + const { publicKey, port } = message.data as Input; + const siteId = newt.siteId; + + // Get the current site data + const [existingSite] = await db + .select() + .from(sites) + .where(eq(sites.siteId, siteId)); + + if (!existingSite) { + logger.warn("handleGetConfigMessage: Site not found"); + return; + } + + // we need to wait for hole punch success + if (!existingSite.endpoint) { + logger.warn(`Site ${existingSite.siteId} has no endpoint, skipping`); + return; + } + + if (existingSite.publicKey !== publicKey) { + // TODO: somehow we should make sure a recent hole punch has happened if this occurs (hole punch could be from the last restart if done quickly) + } + + if (existingSite.lastHolePunch && now - existingSite.lastHolePunch > 6) { + logger.warn( + `Site ${existingSite.siteId} last hole punch is too old, skipping` + ); + return; + } + + // update the endpoint and the public key + const [site] = await db + .update(sites) + .set({ + publicKey, + listenPort: port + }) + .where(eq(sites.siteId, siteId)) + .returning(); + + if (!site) { + logger.error("handleGetConfigMessage: Failed to update site"); + return; + } + + // Get all clients connected to this site + const clientsRes = await db + .select() + .from(clients) + .innerJoin(clientSites, eq(clients.clientId, clientSites.clientId)) + .where(eq(clientSites.siteId, siteId)); + + // Prepare peers data for the response + const peers = await Promise.all( + clientsRes + .filter((client) => { + if (!client.clients.pubKey) { + return false; + } + if (!client.clients.subnet) { + return false; + } + if (!client.clients.endpoint) { + return false; + } + if (!client.clients.online) { + return false; + } + + return true; + }) + .map(async (client) => { + // Add or update this peer on the olm if it is connected + try { + if (site.endpoint && site.publicKey) { + await updatePeer(client.clients.clientId, { + siteId: site.siteId, + endpoint: site.endpoint, + publicKey: site.publicKey, + serverIP: site.address, + serverPort: site.listenPort + }); + } + } catch (error) { + logger.error( + `Failed to add/update peer ${client.clients.pubKey} to newt ${newt.newtId}: ${error}` + ); + } + + return { + publicKey: client.clients.pubKey!, + allowedIps: [`${client.clients.subnet.split('/')[0]}/32`], // we want to only allow from that client + endpoint: client.clientSites.isRelayed + ? "" + : client.clients.endpoint! // if its relayed it should be localhost + }; + }) + ); + + // Filter out any null values from peers that didn't have an olm + const validPeers = peers.filter((peer) => peer !== null); + + // Build the configuration response + const configResponse = { + ipAddress: site.address, + peers: validPeers + }; + + logger.debug("Sending config: ", configResponse); + + return { + message: { + type: "newt/wg/receive-config", + data: { + ...configResponse + } + }, + broadcast: false, + excludeSender: false + }; +}; diff --git a/server/routers/newt/handleNewtPingRequestMessage.ts b/server/routers/newt/handleNewtPingRequestMessage.ts new file mode 100644 index 00000000..91266434 --- /dev/null +++ b/server/routers/newt/handleNewtPingRequestMessage.ts @@ -0,0 +1,89 @@ +import { db, sites } from "@server/db"; +import { MessageHandler } from "../ws"; +import { exitNodes, Newt } from "@server/db"; +import logger from "@server/logger"; +import config from "@server/lib/config"; +import { ne, eq, or, and, count } from "drizzle-orm"; + +export const handleNewtPingRequestMessage: MessageHandler = async (context) => { + const { message, client, sendToClient } = context; + const newt = client as Newt; + + logger.info("Handling ping request newt message!"); + + if (!newt) { + logger.warn("Newt not found"); + return; + } + + // TODO: pick which nodes to send and ping better than just all of them + let exitNodesList = await db + .select() + .from(exitNodes); + + exitNodesList = exitNodesList.filter((node) => node.maxConnections !== 0); + + let lastExitNodeId = null; + if (newt.siteId) { + const [lastExitNode] = await db + .select() + .from(sites) + .where(eq(sites.siteId, newt.siteId)) + .limit(1); + lastExitNodeId = lastExitNode?.exitNodeId || null; + } + + const exitNodesPayload = await Promise.all( + exitNodesList.map(async (node) => { + // (MAX_CONNECTIONS - current_connections) / MAX_CONNECTIONS) + // higher = more desirable + // like saying, this node has x% of its capacity left + + let weight = 1; + const maxConnections = node.maxConnections; + if (maxConnections !== null && maxConnections !== undefined) { + const [currentConnections] = await db + .select({ + count: count() + }) + .from(sites) + .where( + and( + eq(sites.exitNodeId, node.exitNodeId), + eq(sites.online, true) + ) + ); + + if (currentConnections.count >= maxConnections) { + return null + } + + weight = + (maxConnections - currentConnections.count) / + maxConnections; + } + + return { + exitNodeId: node.exitNodeId, + exitNodeName: node.name, + endpoint: node.endpoint, + weight, + wasPreviouslyConnected: node.exitNodeId === lastExitNodeId + }; + }) + ); + + // filter out null values + const filteredExitNodes = exitNodesPayload.filter((node) => node !== null); + + return { + message: { + type: "newt/ping/exitNodes", + data: { + exitNodes: filteredExitNodes + } + }, + broadcast: false, // Send to all clients + excludeSender: false // Include sender in broadcast + }; +}; diff --git a/server/routers/newt/handleNewtRegisterMessage.ts b/server/routers/newt/handleNewtRegisterMessage.ts new file mode 100644 index 00000000..5950aa93 --- /dev/null +++ b/server/routers/newt/handleNewtRegisterMessage.ts @@ -0,0 +1,358 @@ +import { db, newts } from "@server/db"; +import { MessageHandler } from "../ws"; +import { exitNodes, Newt, resources, sites, Target, targets } from "@server/db"; +import { eq, and, sql, inArray } from "drizzle-orm"; +import { addPeer, deletePeer } from "../gerbil/peers"; +import logger from "@server/logger"; +import config from "@server/lib/config"; +import { + findNextAvailableCidr, + getNextAvailableClientSubnet +} from "@server/lib/ip"; + +export type ExitNodePingResult = { + exitNodeId: number; + latencyMs: number; + weight: number; + error?: string; + exitNodeName: string; + endpoint: string; + wasPreviouslyConnected: boolean; +}; + +export const handleNewtRegisterMessage: MessageHandler = async (context) => { + const { message, client, sendToClient } = context; + const newt = client as Newt; + + logger.info("Handling register newt message!"); + + if (!newt) { + logger.warn("Newt not found"); + return; + } + + if (!newt.siteId) { + logger.warn("Newt has no site!"); // TODO: Maybe we create the site here? + return; + } + + const siteId = newt.siteId; + + const { publicKey, pingResults, newtVersion, backwardsCompatible } = + message.data; + if (!publicKey) { + logger.warn("Public key not provided"); + return; + } + + if (backwardsCompatible) { + logger.debug( + "Backwards compatible mode detecting - not sending connect message and waiting for ping response." + ); + return; + } + + let exitNodeId: number | undefined; + if (pingResults) { + const bestPingResult = selectBestExitNode( + pingResults as ExitNodePingResult[] + ); + if (!bestPingResult) { + logger.warn("No suitable exit node found based on ping results"); + return; + } + exitNodeId = bestPingResult.exitNodeId; + } + + if (newtVersion) { + // update the newt version in the database + await db + .update(newts) + .set({ + version: newtVersion as string + }) + .where(eq(newts.newtId, newt.newtId)); + } + + const [oldSite] = await db + .select() + .from(sites) + .where(eq(sites.siteId, siteId)) + .limit(1); + + if (!oldSite || !oldSite.exitNodeId) { + logger.warn("Site not found or does not have exit node"); + return; + } + + let siteSubnet = oldSite.subnet; + let exitNodeIdToQuery = oldSite.exitNodeId; + if (exitNodeId && (oldSite.exitNodeId !== exitNodeId || !oldSite.subnet)) { + // This effectively moves the exit node to the new one + exitNodeIdToQuery = exitNodeId; // Use the provided exitNodeId if it differs from the site's exitNodeId + + const sitesQuery = await db + .select({ + subnet: sites.subnet + }) + .from(sites) + .where(eq(sites.exitNodeId, exitNodeId)); + + const [exitNode] = await db + .select() + .from(exitNodes) + .where(eq(exitNodes.exitNodeId, exitNodeIdToQuery)) + .limit(1); + + const blockSize = config.getRawConfig().gerbil.site_block_size; + const subnets = sitesQuery.map((site) => site.subnet).filter((subnet) => subnet !== null); + subnets.push(exitNode.address.replace(/\/\d+$/, `/${blockSize}`)); + const newSubnet = findNextAvailableCidr( + subnets, + blockSize, + exitNode.address + ); + if (!newSubnet) { + logger.error("No available subnets found for the new exit node"); + return; + } + + siteSubnet = newSubnet; + + await db + .update(sites) + .set({ + pubKey: publicKey, + exitNodeId: exitNodeId, + subnet: newSubnet + }) + .where(eq(sites.siteId, siteId)) + .returning(); + } else { + await db + .update(sites) + .set({ + pubKey: publicKey + }) + .where(eq(sites.siteId, siteId)) + .returning(); + } + + const [exitNode] = await db + .select() + .from(exitNodes) + .where(eq(exitNodes.exitNodeId, exitNodeIdToQuery)) + .limit(1); + + if (oldSite.pubKey && oldSite.pubKey !== publicKey) { + logger.info("Public key mismatch. Deleting old peer..."); + await deletePeer(oldSite.exitNodeId, oldSite.pubKey); + } + + if (!siteSubnet) { + logger.warn("Site has no subnet"); + return; + } + + // add the peer to the exit node + await addPeer(exitNodeIdToQuery, { + publicKey: publicKey, + allowedIps: [siteSubnet] + }); + + // Improved version + const allResources = await db.transaction(async (tx) => { + // First get all resources for the site + const resourcesList = await tx + .select({ + resourceId: resources.resourceId, + subdomain: resources.subdomain, + fullDomain: resources.fullDomain, + ssl: resources.ssl, + blockAccess: resources.blockAccess, + sso: resources.sso, + emailWhitelistEnabled: resources.emailWhitelistEnabled, + http: resources.http, + proxyPort: resources.proxyPort, + protocol: resources.protocol + }) + .from(resources) + .where(eq(resources.siteId, siteId)); + + // Get all enabled targets for these resources in a single query + const resourceIds = resourcesList.map((r) => r.resourceId); + const allTargets = + resourceIds.length > 0 + ? await tx + .select({ + resourceId: targets.resourceId, + targetId: targets.targetId, + ip: targets.ip, + method: targets.method, + port: targets.port, + internalPort: targets.internalPort, + enabled: targets.enabled + }) + .from(targets) + .where( + and( + inArray(targets.resourceId, resourceIds), + eq(targets.enabled, true) + ) + ) + : []; + + // Combine the data in JS instead of using SQL for the JSON + return resourcesList.map((resource) => ({ + ...resource, + targets: allTargets.filter( + (target) => target.resourceId === resource.resourceId + ) + })); + }); + + const { tcpTargets, udpTargets } = allResources.reduce( + (acc, resource) => { + // Skip resources with no targets + if (!resource.targets?.length) return acc; + + // Format valid targets into strings + const formattedTargets = resource.targets + .filter( + (target: Target) => + target?.internalPort && target?.ip && target?.port + ) + .map( + (target: Target) => + `${target.internalPort}:${target.ip}:${target.port}` + ); + + // Add to the appropriate protocol array + if (resource.protocol === "tcp") { + acc.tcpTargets.push(...formattedTargets); + } else { + acc.udpTargets.push(...formattedTargets); + } + + return acc; + }, + { tcpTargets: [] as string[], udpTargets: [] as string[] } + ); + + return { + message: { + type: "newt/wg/connect", + data: { + endpoint: `${exitNode.endpoint}:${exitNode.listenPort}`, + publicKey: exitNode.publicKey, + serverIP: exitNode.address.split("/")[0], + tunnelIP: siteSubnet.split("/")[0], + targets: { + udp: udpTargets, + tcp: tcpTargets + } + } + }, + broadcast: false, // Send to all clients + excludeSender: false // Include sender in broadcast + }; +}; + +/** + * Selects the most suitable exit node from a list of ping results. + * + * The selection algorithm follows these steps: + * + * 1. **Filter Invalid Nodes**: Excludes nodes with errors or zero weight. + * + * 2. **Sort by Latency**: Sorts valid nodes in ascending order of latency. + * + * 3. **Preferred Selection**: + * - If the lowest-latency node has sufficient capacity (≥10% weight), + * check if a previously connected node is also acceptable. + * - The previously connected node is preferred if its latency is within + * 30ms or 15% of the best node’s latency. + * + * 4. **Fallback to Next Best**: + * - If the lowest-latency node is under capacity, find the next node + * with acceptable capacity. + * + * 5. **Final Fallback**: + * - If no nodes meet the capacity threshold, fall back to the node + * with the highest weight (i.e., most available capacity). + * + */ +function selectBestExitNode( + pingResults: ExitNodePingResult[] +): ExitNodePingResult | null { + const MIN_CAPACITY_THRESHOLD = 0.1; + const LATENCY_TOLERANCE_MS = 30; + const LATENCY_TOLERANCE_PERCENT = 0.15; + + // Filter out invalid nodes + const validNodes = pingResults.filter((n) => !n.error && n.weight > 0); + + if (validNodes.length === 0) { + logger.error("No valid exit nodes available"); + return null; + } + + // Sort by latency (ascending) + const sortedNodes = validNodes + .slice() + .sort((a, b) => a.latencyMs - b.latencyMs); + const lowestLatencyNode = sortedNodes[0]; + + logger.info( + `Lowest latency node: ${lowestLatencyNode.exitNodeName} (${lowestLatencyNode.latencyMs} ms, weight=${lowestLatencyNode.weight.toFixed(2)})` + ); + + // If lowest latency node has enough capacity, check if previously connected node is acceptable + if (lowestLatencyNode.weight >= MIN_CAPACITY_THRESHOLD) { + const previouslyConnectedNode = sortedNodes.find( + (n) => + n.wasPreviouslyConnected && n.weight >= MIN_CAPACITY_THRESHOLD + ); + + if (previouslyConnectedNode) { + const latencyDiff = + previouslyConnectedNode.latencyMs - lowestLatencyNode.latencyMs; + const percentDiff = latencyDiff / lowestLatencyNode.latencyMs; + + if ( + latencyDiff <= LATENCY_TOLERANCE_MS || + percentDiff <= LATENCY_TOLERANCE_PERCENT + ) { + logger.info( + `Sticking with previously connected node: ${previouslyConnectedNode.exitNodeName} ` + + `(${previouslyConnectedNode.latencyMs} ms), latency diff = ${latencyDiff.toFixed(1)}ms ` + + `/ ${(percentDiff * 100).toFixed(1)}%.` + ); + return previouslyConnectedNode; + } + } + + return lowestLatencyNode; + } + + // Otherwise, find the next node (after the lowest) that has enough capacity + for (let i = 1; i < sortedNodes.length; i++) { + const node = sortedNodes[i]; + if (node.weight >= MIN_CAPACITY_THRESHOLD) { + logger.info( + `Lowest latency node under capacity. Using next best: ${node.exitNodeName} ` + + `(${node.latencyMs} ms, weight=${node.weight.toFixed(2)})` + ); + return node; + } + } + + // Fallback: pick the highest weight node + const fallbackNode = validNodes.reduce((a, b) => + a.weight > b.weight ? a : b + ); + logger.warn( + `No nodes with ≥10% weight. Falling back to highest capacity node: ${fallbackNode.exitNodeName}` + ); + return fallbackNode; +} diff --git a/server/routers/newt/handleReceiveBandwidthMessage.ts b/server/routers/newt/handleReceiveBandwidthMessage.ts new file mode 100644 index 00000000..89b24f78 --- /dev/null +++ b/server/routers/newt/handleReceiveBandwidthMessage.ts @@ -0,0 +1,52 @@ +import { db } from "@server/db"; +import { MessageHandler } from "../ws"; +import { clients, Newt } from "@server/db"; +import { eq } from "drizzle-orm"; +import logger from "@server/logger"; + +interface PeerBandwidth { + publicKey: string; + bytesIn: number; + bytesOut: number; +} + +export const handleReceiveBandwidthMessage: MessageHandler = async (context) => { + const { message, client, sendToClient } = context; + + if (!message.data.bandwidthData) { + logger.warn("No bandwidth data provided"); + } + + const bandwidthData: PeerBandwidth[] = message.data.bandwidthData; + + if (!Array.isArray(bandwidthData)) { + throw new Error("Invalid bandwidth data"); + } + + await db.transaction(async (trx) => { + for (const peer of bandwidthData) { + const { publicKey, bytesIn, bytesOut } = peer; + + // Find the client by public key + const [client] = await trx + .select() + .from(clients) + .where(eq(clients.pubKey, publicKey)) + .limit(1); + + if (!client) { + continue; + } + + // Update the client's bandwidth usage + await trx + .update(clients) + .set({ + megabytesOut: (client.megabytesIn || 0) + bytesIn, + megabytesIn: (client.megabytesOut || 0) + bytesOut, + lastBandwidthUpdate: new Date().toISOString(), + }) + .where(eq(clients.clientId, client.clientId)); + } + }); +}; diff --git a/server/routers/newt/handleRegisterMessage.ts b/server/routers/newt/handleRegisterMessage.ts deleted file mode 100644 index e63de0e0..00000000 --- a/server/routers/newt/handleRegisterMessage.ts +++ /dev/null @@ -1,174 +0,0 @@ -import { db } from "@server/db"; -import { MessageHandler } from "../ws"; -import { - exitNodes, - resources, - sites, - Target, - targets -} from "@server/db"; -import { eq, and, sql, inArray } from "drizzle-orm"; -import { addPeer, deletePeer } from "../gerbil/peers"; -import logger from "@server/logger"; - -export const handleRegisterMessage: MessageHandler = async (context) => { - const { message, newt, sendToClient } = context; - - logger.info("Handling register message!"); - - if (!newt) { - logger.warn("Newt not found"); - return; - } - - if (!newt.siteId) { - logger.warn("Newt has no site!"); // TODO: Maybe we create the site here? - return; - } - - const siteId = newt.siteId; - - const { publicKey } = message.data; - if (!publicKey) { - logger.warn("Public key not provided"); - return; - } - - const [site] = await db - .select() - .from(sites) - .where(eq(sites.siteId, siteId)) - .limit(1); - - if (!site || !site.exitNodeId) { - logger.warn("Site not found or does not have exit node"); - return; - } - - await db - .update(sites) - .set({ - pubKey: publicKey - }) - .where(eq(sites.siteId, siteId)) - .returning(); - - const [exitNode] = await db - .select() - .from(exitNodes) - .where(eq(exitNodes.exitNodeId, site.exitNodeId)) - .limit(1); - - if (site.pubKey && site.pubKey !== publicKey) { - logger.info("Public key mismatch. Deleting old peer..."); - await deletePeer(site.exitNodeId, site.pubKey); - } - - if (!site.subnet) { - logger.warn("Site has no subnet"); - return; - } - - // add the peer to the exit node - await addPeer(site.exitNodeId, { - publicKey: publicKey, - allowedIps: [site.subnet] - }); - - // Improved version - const allResources = await db.transaction(async (tx) => { - // First get all resources for the site - const resourcesList = await tx - .select({ - resourceId: resources.resourceId, - subdomain: resources.subdomain, - fullDomain: resources.fullDomain, - ssl: resources.ssl, - blockAccess: resources.blockAccess, - sso: resources.sso, - emailWhitelistEnabled: resources.emailWhitelistEnabled, - http: resources.http, - proxyPort: resources.proxyPort, - protocol: resources.protocol - }) - .from(resources) - .where(eq(resources.siteId, siteId)); - - // Get all enabled targets for these resources in a single query - const resourceIds = resourcesList.map((r) => r.resourceId); - const allTargets = - resourceIds.length > 0 - ? await tx - .select({ - resourceId: targets.resourceId, - targetId: targets.targetId, - ip: targets.ip, - method: targets.method, - port: targets.port, - internalPort: targets.internalPort, - enabled: targets.enabled - }) - .from(targets) - .where( - and( - inArray(targets.resourceId, resourceIds), - eq(targets.enabled, true) - ) - ) - : []; - - // Combine the data in JS instead of using SQL for the JSON - return resourcesList.map((resource) => ({ - ...resource, - targets: allTargets.filter( - (target) => target.resourceId === resource.resourceId - ) - })); - }); - - const { tcpTargets, udpTargets } = allResources.reduce( - (acc, resource) => { - // Skip resources with no targets - if (!resource.targets?.length) return acc; - - // Format valid targets into strings - const formattedTargets = resource.targets - .filter( - (target: Target) => - target?.internalPort && target?.ip && target?.port - ) - .map( - (target: Target) => - `${target.internalPort}:${target.ip}:${target.port}` - ); - - // Add to the appropriate protocol array - if (resource.protocol === "tcp") { - acc.tcpTargets.push(...formattedTargets); - } else { - acc.udpTargets.push(...formattedTargets); - } - - return acc; - }, - { tcpTargets: [] as string[], udpTargets: [] as string[] } - ); - - return { - message: { - type: "newt/wg/connect", - data: { - endpoint: `${exitNode.endpoint}:${exitNode.listenPort}`, - publicKey: exitNode.publicKey, - serverIP: exitNode.address.split("/")[0], - tunnelIP: site.subnet.split("/")[0], - targets: { - udp: udpTargets, - tcp: tcpTargets - } - } - }, - broadcast: false, // Send to all clients - excludeSender: false // Include sender in broadcast - }; -}; diff --git a/server/routers/newt/handleSocketMessages.ts b/server/routers/newt/handleSocketMessages.ts index 0a217c52..01b7be60 100644 --- a/server/routers/newt/handleSocketMessages.ts +++ b/server/routers/newt/handleSocketMessages.ts @@ -1,9 +1,11 @@ import { MessageHandler } from "../ws"; import logger from "@server/logger"; import { dockerSocketCache } from "./dockerSocket"; +import { Newt } from "@server/db"; export const handleDockerStatusMessage: MessageHandler = async (context) => { - const { message, newt } = context; + const { message, client, sendToClient } = context; + const newt = client as Newt; logger.info("Handling Docker socket check response"); @@ -33,7 +35,8 @@ export const handleDockerStatusMessage: MessageHandler = async (context) => { export const handleDockerContainersMessage: MessageHandler = async ( context ) => { - const { message, newt } = context; + const { message, client, sendToClient } = context; + const newt = client as Newt; logger.info("Handling Docker containers response"); diff --git a/server/routers/newt/index.ts b/server/routers/newt/index.ts index ad6d531c..08f047e3 100644 --- a/server/routers/newt/index.ts +++ b/server/routers/newt/index.ts @@ -1,4 +1,7 @@ export * from "./createNewt"; -export * from "./getToken"; -export * from "./handleRegisterMessage"; -export * from "./handleSocketMessages"; \ No newline at end of file +export * from "./getNewtToken"; +export * from "./handleNewtRegisterMessage"; +export * from "./handleReceiveBandwidthMessage"; +export * from "./handleGetConfigMessage"; +export * from "./handleSocketMessages"; +export * from "./handleNewtPingRequestMessage"; \ No newline at end of file diff --git a/server/routers/newt/peers.ts b/server/routers/newt/peers.ts new file mode 100644 index 00000000..ff57e6fd --- /dev/null +++ b/server/routers/newt/peers.ts @@ -0,0 +1,114 @@ +import { db } from "@server/db"; +import { newts, sites } from "@server/db"; +import { eq } from "drizzle-orm"; +import { sendToClient } from "../ws"; +import logger from "@server/logger"; + +export async function addPeer( + siteId: number, + peer: { + publicKey: string; + allowedIps: string[]; + endpoint: string; + } +) { + const [site] = await db + .select() + .from(sites) + .where(eq(sites.siteId, siteId)) + .limit(1); + if (!site) { + throw new Error(`Exit node with ID ${siteId} not found`); + } + + // get the newt on the site + const [newt] = await db + .select() + .from(newts) + .where(eq(newts.siteId, siteId)) + .limit(1); + if (!newt) { + throw new Error(`Site found for site ${siteId}`); + } + + sendToClient(newt.newtId, { + type: "newt/wg/peer/add", + data: peer + }); + + logger.info(`Added peer ${peer.publicKey} to newt ${newt.newtId}`); + + return site; +} + +export async function deletePeer(siteId: number, publicKey: string) { + const [site] = await db + .select() + .from(sites) + .where(eq(sites.siteId, siteId)) + .limit(1); + if (!site) { + throw new Error(`Site with ID ${siteId} not found`); + } + + // get the newt on the site + const [newt] = await db + .select() + .from(newts) + .where(eq(newts.siteId, siteId)) + .limit(1); + if (!newt) { + throw new Error(`Newt not found for site ${siteId}`); + } + + sendToClient(newt.newtId, { + type: "newt/wg/peer/remove", + data: { + publicKey + } + }); + + logger.info(`Deleted peer ${publicKey} from newt ${newt.newtId}`); + + return site; +} + +export async function updatePeer( + siteId: number, + publicKey: string, + peer: { + allowedIps?: string[]; + endpoint?: string; + } +) { + const [site] = await db + .select() + .from(sites) + .where(eq(sites.siteId, siteId)) + .limit(1); + if (!site) { + throw new Error(`Site with ID ${siteId} not found`); + } + + // get the newt on the site + const [newt] = await db + .select() + .from(newts) + .where(eq(newts.siteId, siteId)) + .limit(1); + if (!newt) { + throw new Error(`Newt not found for site ${siteId}`); + } + + sendToClient(newt.newtId, { + type: "newt/wg/peer/update", + data: { + publicKey, + ...peer + } + }); + + logger.info(`Updated peer ${publicKey} on newt ${newt.newtId}`); + + return site; +} diff --git a/server/routers/olm/createOlm.ts b/server/routers/olm/createOlm.ts new file mode 100644 index 00000000..3066e4ea --- /dev/null +++ b/server/routers/olm/createOlm.ts @@ -0,0 +1,106 @@ +import { NextFunction, Request, Response } from "express"; +import { db } from "@server/db"; +import { hash } from "@node-rs/argon2"; +import HttpCode from "@server/types/HttpCode"; +import { z } from "zod"; +import { newts } from "@server/db"; +import createHttpError from "http-errors"; +import response from "@server/lib/response"; +import { SqliteError } from "better-sqlite3"; +import moment from "moment"; +import { generateSessionToken } from "@server/auth/sessions/app"; +import { createNewtSession } from "@server/auth/sessions/newt"; +import { fromError } from "zod-validation-error"; +import { hashPassword } from "@server/auth/password"; + +export const createNewtBodySchema = z.object({}); + +export type CreateNewtBody = z.infer; + +export type CreateNewtResponse = { + token: string; + newtId: string; + secret: string; +}; + +const createNewtSchema = z + .object({ + newtId: z.string(), + secret: z.string() + }) + .strict(); + +export async function createNewt( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + + const parsedBody = createNewtSchema.safeParse(req.body); + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + + const { newtId, secret } = parsedBody.data; + + if (req.user && !req.userOrgRoleId) { + return next( + createHttpError(HttpCode.FORBIDDEN, "User does not have a role") + ); + } + + const secretHash = await hashPassword(secret); + + await db.insert(newts).values({ + newtId: newtId, + secretHash, + dateCreated: moment().toISOString(), + }); + + // give the newt their default permissions: + // await db.insert(newtActions).values({ + // newtId: newtId, + // actionId: ActionsEnum.createOrg, + // orgId: null, + // }); + + const token = generateSessionToken(); + await createNewtSession(token, newtId); + + return response(res, { + data: { + newtId, + secret, + token, + }, + success: true, + error: false, + message: "Newt created successfully", + status: HttpCode.OK, + }); + } catch (e) { + if (e instanceof SqliteError && e.code === "SQLITE_CONSTRAINT_UNIQUE") { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "A newt with that email address already exists" + ) + ); + } else { + console.error(e); + + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Failed to create newt" + ) + ); + } + } +} diff --git a/server/routers/olm/getOlmToken.ts b/server/routers/olm/getOlmToken.ts new file mode 100644 index 00000000..c26f5936 --- /dev/null +++ b/server/routers/olm/getOlmToken.ts @@ -0,0 +1,119 @@ +import { generateSessionToken } from "@server/auth/sessions/app"; +import { db } from "@server/db"; +import { olms } from "@server/db"; +import HttpCode from "@server/types/HttpCode"; +import response from "@server/lib/response"; +import { eq } from "drizzle-orm"; +import { NextFunction, Request, Response } from "express"; +import createHttpError from "http-errors"; +import { z } from "zod"; +import { fromError } from "zod-validation-error"; +import { + createOlmSession, + validateOlmSessionToken +} from "@server/auth/sessions/olm"; +import { verifyPassword } from "@server/auth/password"; +import logger from "@server/logger"; +import config from "@server/lib/config"; + +export const olmGetTokenBodySchema = z.object({ + olmId: z.string(), + secret: z.string(), + token: z.string().optional() +}); + +export type OlmGetTokenBody = z.infer; + +export async function getOlmToken( + req: Request, + res: Response, + next: NextFunction +): Promise { + const parsedBody = olmGetTokenBodySchema.safeParse(req.body); + + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + + const { olmId, secret, token } = parsedBody.data; + + try { + if (token) { + const { session, olm } = await validateOlmSessionToken(token); + if (session) { + if (config.getRawConfig().app.log_failed_attempts) { + logger.info( + `Olm session already valid. Olm ID: ${olmId}. IP: ${req.ip}.` + ); + } + return response(res, { + data: null, + success: true, + error: false, + message: "Token session already valid", + status: HttpCode.OK + }); + } + } + + const existingOlmRes = await db + .select() + .from(olms) + .where(eq(olms.olmId, olmId)); + if (!existingOlmRes || !existingOlmRes.length) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "No olm found with that olmId" + ) + ); + } + + const existingOlm = existingOlmRes[0]; + + const validSecret = await verifyPassword( + secret, + existingOlm.secretHash + ); + if (!validSecret) { + if (config.getRawConfig().app.log_failed_attempts) { + logger.info( + `Olm id or secret is incorrect. Olm: ID ${olmId}. IP: ${req.ip}.` + ); + } + return next( + createHttpError(HttpCode.BAD_REQUEST, "Secret is incorrect") + ); + } + + logger.debug("Creating new olm session token"); + + const resToken = generateSessionToken(); + await createOlmSession(resToken, existingOlm.olmId); + + logger.debug("Token created successfully"); + + return response<{ token: string }>(res, { + data: { + token: resToken + }, + success: true, + error: false, + message: "Token created successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Failed to authenticate olm" + ) + ); + } +} diff --git a/server/routers/olm/handleOlmPingMessage.ts b/server/routers/olm/handleOlmPingMessage.ts new file mode 100644 index 00000000..941f7638 --- /dev/null +++ b/server/routers/olm/handleOlmPingMessage.ts @@ -0,0 +1,93 @@ +import { db } from "@server/db"; +import { MessageHandler } from "../ws"; +import { clients, Olm } from "@server/db"; +import { eq, lt, isNull } from "drizzle-orm"; +import logger from "@server/logger"; + +// Track if the offline checker interval is running +let offlineCheckerInterval: NodeJS.Timeout | null = null; +const OFFLINE_CHECK_INTERVAL = 30 * 1000; // Check every 30 seconds +const OFFLINE_THRESHOLD_MS = 2 * 60 * 1000; // 2 minutes + +/** + * Starts the background interval that checks for clients that haven't pinged recently + * and marks them as offline + */ +export const startOfflineChecker = (): void => { + if (offlineCheckerInterval) { + return; // Already running + } + + offlineCheckerInterval = setInterval(async () => { + try { + const twoMinutesAgo = new Date(Date.now() - OFFLINE_THRESHOLD_MS); + + // Find clients that haven't pinged in the last 2 minutes and mark them as offline + await db + .update(clients) + .set({ online: false }) + .where( + eq(clients.online, true) && + (lt(clients.lastPing, twoMinutesAgo.toISOString()) || isNull(clients.lastPing)) + ); + + } catch (error) { + logger.error("Error in offline checker interval", { error }); + } + }, OFFLINE_CHECK_INTERVAL); + + logger.info("Started offline checker interval"); +} + +/** + * Stops the background interval that checks for offline clients + */ +export const stopOfflineChecker = (): void => { + if (offlineCheckerInterval) { + clearInterval(offlineCheckerInterval); + offlineCheckerInterval = null; + logger.info("Stopped offline checker interval"); + } +} + +/** + * Handles ping messages from clients and responds with pong + */ +export const handleOlmPingMessage: MessageHandler = async (context) => { + const { message, client: c, sendToClient } = context; + const olm = c as Olm; + + if (!olm) { + logger.warn("Olm not found"); + return; + } + + if (!olm.clientId) { + logger.warn("Olm has no client ID!"); + return; + } + + try { + // Update the client's last ping timestamp + await db + .update(clients) + .set({ + lastPing: new Date().toISOString(), + online: true, + }) + .where(eq(clients.clientId, olm.clientId)); + } catch (error) { + logger.error("Error handling ping message", { error }); + } + + return { + message: { + type: "pong", + data: { + timestamp: new Date().toISOString(), + } + }, + broadcast: false, + excludeSender: false + }; +}; diff --git a/server/routers/olm/handleOlmRegisterMessage.ts b/server/routers/olm/handleOlmRegisterMessage.ts new file mode 100644 index 00000000..9f626a7b --- /dev/null +++ b/server/routers/olm/handleOlmRegisterMessage.ts @@ -0,0 +1,181 @@ +import { db } from "@server/db"; +import { MessageHandler } from "../ws"; +import { + clients, + clientSites, + exitNodes, + Olm, + olms, + sites +} from "@server/db"; +import { eq, inArray } from "drizzle-orm"; +import { addPeer, deletePeer } from "../newt/peers"; +import logger from "@server/logger"; + +export const handleOlmRegisterMessage: MessageHandler = async (context) => { + logger.info("Handling register olm message!"); + const { message, client: c, sendToClient } = context; + const olm = c as Olm; + + const now = new Date().getTime() / 1000; + + if (!olm) { + logger.warn("Olm not found"); + return; + } + if (!olm.clientId) { + logger.warn("Olm has no client ID!"); + return; + } + const clientId = olm.clientId; + const { publicKey } = message.data; + if (!publicKey) { + logger.warn("Public key not provided"); + return; + } + + // Get the client + const [client] = await db + .select() + .from(clients) + .where(eq(clients.clientId, clientId)) + .limit(1); + + if (!client) { + logger.warn("Client not found"); + return; + } + + if (client.exitNodeId) { + // Get the exit node for this site + const [exitNode] = await db + .select() + .from(exitNodes) + .where(eq(exitNodes.exitNodeId, client.exitNodeId)) + .limit(1); + + // Send holepunch message for each site + sendToClient(olm.olmId, { + type: "olm/wg/holepunch", + data: { + serverPubKey: exitNode.publicKey + } + }); + } + + if (now - (client.lastHolePunch || 0) > 6) { + logger.warn("Client last hole punch is too old, skipping all sites"); + return; + } + + if (client.pubKey !== publicKey) { + logger.info( + "Public key mismatch. Updating public key and clearing session info..." + ); + // Update the client's public key + await db + .update(clients) + .set({ + pubKey: publicKey + }) + .where(eq(clients.clientId, olm.clientId)); + + // set isRelay to false for all of the client's sites to reset the connection metadata + await db + .update(clientSites) + .set({ + isRelayed: false + }) + .where(eq(clientSites.clientId, olm.clientId)); + } + + // Get all sites data + const sitesData = await db + .select() + .from(sites) + .innerJoin(clientSites, eq(sites.siteId, clientSites.siteId)) + .where(eq(clientSites.clientId, client.clientId)); + + // Prepare an array to store site configurations + const siteConfigurations = []; + + // Process each site + for (const { sites: site } of sitesData) { + if (!site.exitNodeId) { + logger.warn( + `Site ${site.siteId} does not have exit node, skipping` + ); + continue; + } + + // Validate endpoint and hole punch status + if (!site.endpoint) { + logger.warn(`Site ${site.siteId} has no endpoint, skipping`); + continue; + } + + if (site.lastHolePunch && now - site.lastHolePunch > 6) { + logger.warn( + `Site ${site.siteId} last hole punch is too old, skipping` + ); + continue; + } + + // If public key changed, delete old peer from this site + if (client.pubKey && client.pubKey != publicKey) { + logger.info( + `Public key mismatch. Deleting old peer from site ${site.siteId}...` + ); + await deletePeer(site.siteId, client.pubKey!); + } + + if (!site.subnet) { + logger.warn(`Site ${site.siteId} has no subnet, skipping`); + continue; + } + + // Add the peer to the exit node for this site + if (client.endpoint) { + logger.info( + `Adding peer ${publicKey} to site ${site.siteId} with endpoint ${client.endpoint}` + ); + await addPeer(site.siteId, { + publicKey: publicKey, + allowedIps: [`${client.subnet.split('/')[0]}/32`], // we want to only allow from that client + endpoint: client.endpoint + }); + } else { + logger.warn( + `Client ${client.clientId} has no endpoint, skipping peer addition` + ); + } + + // Add site configuration to the array + siteConfigurations.push({ + siteId: site.siteId, + endpoint: site.endpoint, + publicKey: site.publicKey, + serverIP: site.address, + serverPort: site.listenPort + }); + } + + // If we have no valid site configurations, don't send a connect message + if (siteConfigurations.length === 0) { + logger.warn("No valid site configurations found"); + return; + } + + // Return connect message with all site configurations + return { + message: { + type: "olm/wg/connect", + data: { + sites: siteConfigurations, + tunnelIP: client.subnet + } + }, + broadcast: false, + excludeSender: false + }; +}; diff --git a/server/routers/olm/handleOlmRelayMessage.ts b/server/routers/olm/handleOlmRelayMessage.ts new file mode 100644 index 00000000..83a97a41 --- /dev/null +++ b/server/routers/olm/handleOlmRelayMessage.ts @@ -0,0 +1,58 @@ +import { db } from "@server/db"; +import { MessageHandler } from "../ws"; +import { clients, clientSites, Olm } from "@server/db"; +import { eq } from "drizzle-orm"; +import { updatePeer } from "../newt/peers"; +import logger from "@server/logger"; + +export const handleOlmRelayMessage: MessageHandler = async (context) => { + const { message, client: c, sendToClient } = context; + const olm = c as Olm; + + logger.info("Handling relay olm message!"); + + if (!olm) { + logger.warn("Olm not found"); + return; + } + + if (!olm.clientId) { + logger.warn("Olm has no site!"); // TODO: Maybe we create the site here? + return; + } + + const clientId = olm.clientId; + + const [client] = await db + .select() + .from(clients) + .where(eq(clients.clientId, clientId)) + .limit(1); + + if (!client) { + logger.warn("Site not found or does not have exit node"); + return; + } + + // make sure we hand endpoints for both the site and the client and the lastHolePunch is not too old + if (!client.pubKey) { + logger.warn("Site or client has no endpoint or listen port"); + return; + } + + const { siteId } = message.data; + + await db + .update(clientSites) + .set({ + isRelayed: true + }) + .where(eq(clientSites.clientId, olm.clientId)); + + // update the peer on the exit node + await updatePeer(siteId, client.pubKey, { + endpoint: "" // this removes the endpoint + }); + + return; +}; diff --git a/server/routers/olm/index.ts b/server/routers/olm/index.ts new file mode 100644 index 00000000..8426612e --- /dev/null +++ b/server/routers/olm/index.ts @@ -0,0 +1,5 @@ +export * from "./handleOlmRegisterMessage"; +export * from "./getOlmToken"; +export * from "./createOlm"; +export * from "./handleOlmRelayMessage"; +export * from "./handleOlmPingMessage"; \ No newline at end of file diff --git a/server/routers/olm/peers.ts b/server/routers/olm/peers.ts new file mode 100644 index 00000000..48a915aa --- /dev/null +++ b/server/routers/olm/peers.ts @@ -0,0 +1,92 @@ +import { db } from "@server/db"; +import { clients, olms, newts, sites } from "@server/db"; +import { eq } from "drizzle-orm"; +import { sendToClient } from "../ws"; +import logger from "@server/logger"; + +export async function addPeer( + clientId: number, + peer: { + siteId: number; + publicKey: string; + endpoint: string; + serverIP: string | null; + serverPort: number | null; + } +) { + const [olm] = await db + .select() + .from(olms) + .where(eq(olms.clientId, clientId)) + .limit(1); + if (!olm) { + throw new Error(`Olm with ID ${clientId} not found`); + } + + sendToClient(olm.olmId, { + type: "olm/wg/peer/add", + data: { + siteId: peer.siteId, + publicKey: peer.publicKey, + endpoint: peer.endpoint, + serverIP: peer.serverIP, + serverPort: peer.serverPort + } + }); + + logger.info(`Added peer ${peer.publicKey} to olm ${olm.olmId}`); +} + +export async function deletePeer(clientId: number, siteId: number, publicKey: string) { + const [olm] = await db + .select() + .from(olms) + .where(eq(olms.clientId, clientId)) + .limit(1); + if (!olm) { + throw new Error(`Olm with ID ${clientId} not found`); + } + + sendToClient(olm.olmId, { + type: "olm/wg/peer/remove", + data: { + publicKey, + siteId: siteId + } + }); + + logger.info(`Deleted peer ${publicKey} from olm ${olm.olmId}`); +} + +export async function updatePeer( + clientId: number, + peer: { + siteId: number; + publicKey: string; + endpoint: string; + serverIP: string | null; + serverPort: number | null; + } +) { + const [olm] = await db + .select() + .from(olms) + .where(eq(olms.clientId, clientId)) + .limit(1); + if (!olm) { + throw new Error(`Olm with ID ${clientId} not found`); + } + + sendToClient(olm.olmId, { + type: "olm/wg/peer/update", + data: { + siteId: peer.siteId, + publicKey: peer.publicKey, + endpoint: peer.endpoint, + serverIP: peer.serverIP, + serverPort: peer.serverPort + } + }); + + logger.info(`Added peer ${peer.publicKey} to olm ${olm.olmId}`); +} diff --git a/server/routers/org/createOrg.ts b/server/routers/org/createOrg.ts index ac977063..245e0928 100644 --- a/server/routers/org/createOrg.ts +++ b/server/routers/org/createOrg.ts @@ -23,16 +23,16 @@ import config from "@server/lib/config"; import { fromError } from "zod-validation-error"; import { defaultRoleAllowedActions } from "../role"; import { OpenAPITags, registry } from "@server/openApi"; +import { isValidCIDR } from "@server/lib/validators"; const createOrgSchema = z .object({ orgId: z.string(), - name: z.string().min(1).max(255) + name: z.string().min(1).max(255), + subnet: z.string() }) .strict(); -// const MAX_ORGS = 5; - registry.registerPath({ method: "put", path: "/org", @@ -78,7 +78,32 @@ export async function createOrg( ); } - const { orgId, name } = parsedBody.data; + const { orgId, name, subnet } = parsedBody.data; + + if (!isValidCIDR(subnet)) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Invalid subnet format. Please provide a valid CIDR notation." + ) + ); + } + + // make sure the subnet is unique + const subnetExists = await db + .select() + .from(orgs) + .where(eq(orgs.subnet, subnet)) + .limit(1); + + if (subnetExists.length > 0) { + return next( + createHttpError( + HttpCode.CONFLICT, + `Subnet ${subnet} already exists` + ) + ); + } // make sure the orgId is unique const orgExists = await db @@ -109,7 +134,8 @@ export async function createOrg( .insert(orgs) .values({ orgId, - name + name, + subnet }) .returning(); @@ -142,25 +168,25 @@ export async function createOrg( // Get all actions and create role actions const actionIds = await trx.select().from(actions).execute(); - + if (actionIds.length > 0) { - await trx - .insert(roleActions) - .values( - actionIds.map((action) => ({ - roleId, - actionId: action.actionId, - orgId: newOrg[0].orgId - })) - ); + await trx.insert(roleActions).values( + actionIds.map((action) => ({ + roleId, + actionId: action.actionId, + orgId: newOrg[0].orgId + })) + ); } - await trx.insert(orgDomains).values( - allDomains.map((domain) => ({ - orgId: newOrg[0].orgId, - domainId: domain.domainId - })) - ); + if (allDomains.length) { + await trx.insert(orgDomains).values( + allDomains.map((domain) => ({ + orgId: newOrg[0].orgId, + domainId: domain.domainId + })) + ); + } if (req.user) { await trx.insert(userOrgs).values({ @@ -187,7 +213,7 @@ export async function createOrg( orgId: newOrg[0].orgId, roleId: roleId, isOwner: true - }); + }); } const memberRole = await trx diff --git a/server/routers/org/deleteOrg.ts b/server/routers/org/deleteOrg.ts index 5b2accce..41b491a2 100644 --- a/server/routers/org/deleteOrg.ts +++ b/server/routers/org/deleteOrg.ts @@ -89,6 +89,8 @@ export async function deleteOrg( .where(eq(sites.orgId, orgId)) .limit(1); + const deletedNewtIds: string[] = []; + await db.transaction(async (trx) => { if (sites) { for (const site of orgSites) { @@ -102,11 +104,7 @@ export async function deleteOrg( .where(eq(newts.siteId, site.siteId)) .returning(); if (deletedNewt) { - const payload = { - type: `newt/terminate`, - data: {} - }; - sendToClient(deletedNewt.newtId, payload); + deletedNewtIds.push(deletedNewt.newtId); // delete all of the sessions for the newt await trx @@ -131,6 +129,18 @@ export async function deleteOrg( await trx.delete(orgs).where(eq(orgs.orgId, orgId)); }); + // Send termination messages outside of transaction to prevent blocking + for (const newtId of deletedNewtIds) { + const payload = { + type: `newt/terminate`, + data: {} + }; + // Don't await this to prevent blocking the response + sendToClient(newtId, payload).catch(error => { + logger.error("Failed to send termination message to newt:", error); + }); + } + return response(res, { data: null, success: true, diff --git a/server/routers/org/index.ts b/server/routers/org/index.ts index 5623823d..c9a44d8d 100644 --- a/server/routers/org/index.ts +++ b/server/routers/org/index.ts @@ -6,3 +6,4 @@ export * from "./listUserOrgs"; export * from "./checkId"; export * from "./getOrgOverview"; export * from "./listOrgs"; +export * from "./pickOrgDefaults"; diff --git a/server/routers/org/listUserOrgs.ts b/server/routers/org/listUserOrgs.ts index 694a4fb2..e3c0d06f 100644 --- a/server/routers/org/listUserOrgs.ts +++ b/server/routers/org/listUserOrgs.ts @@ -5,7 +5,7 @@ import { Org, orgs, userOrgs } from "@server/db"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; -import { sql, inArray, eq } from "drizzle-orm"; +import { sql, inArray, eq, and } from "drizzle-orm"; import logger from "@server/logger"; import { fromZodError } from "zod-validation-error"; import { OpenAPITags, registry } from "@server/openApi"; @@ -40,8 +40,10 @@ const listOrgsSchema = z.object({ // responses: {} // }); +type ResponseOrg = Org & { isOwner?: boolean }; + export type ListUserOrgsResponse = { - orgs: Org[]; + orgs: ResponseOrg[]; pagination: { total: number; limit: number; offset: number }; }; @@ -106,6 +108,10 @@ export async function listUserOrgs( .select() .from(orgs) .where(inArray(orgs.orgId, userOrgIds)) + .leftJoin( + userOrgs, + and(eq(userOrgs.orgId, orgs.orgId), eq(userOrgs.userId, userId)) + ) .limit(limit) .offset(offset); @@ -115,9 +121,19 @@ export async function listUserOrgs( .where(inArray(orgs.orgId, userOrgIds)); const totalCount = totalCountResult[0].count; + const responseOrgs = organizations.map((val) => { + const res = { + ...val.orgs + } as ResponseOrg; + if (val.userOrgs && val.userOrgs.isOwner) { + res.isOwner = val.userOrgs.isOwner; + } + return res; + }); + return response(res, { data: { - orgs: organizations, + orgs: responseOrgs, pagination: { total: totalCount, limit, diff --git a/server/routers/org/pickOrgDefaults.ts b/server/routers/org/pickOrgDefaults.ts new file mode 100644 index 00000000..771b0d99 --- /dev/null +++ b/server/routers/org/pickOrgDefaults.ts @@ -0,0 +1,39 @@ +import { Request, Response, NextFunction } from "express"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { getNextAvailableOrgSubnet } from "@server/lib/ip"; +import config from "@server/lib/config"; + +export type PickOrgDefaultsResponse = { + subnet: string; +}; + +export async function pickOrgDefaults( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + // TODO: Why would each org have to have its own subnet? + // const subnet = await getNextAvailableOrgSubnet(); + // Just hard code the subnet for now for everyone + const subnet = config.getRawConfig().orgs.subnet_group; + + return response(res, { + data: { + subnet: subnet + }, + success: true, + error: false, + message: "Organization defaults created successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/resource/createResource.ts b/server/routers/resource/createResource.ts index 1cbfa38e..784dc639 100644 --- a/server/routers/resource/createResource.ts +++ b/server/routers/resource/createResource.ts @@ -21,6 +21,7 @@ import logger from "@server/logger"; import { subdomainSchema } from "@server/lib/schemas"; import config from "@server/lib/config"; import { OpenAPITags, registry } from "@server/openApi"; +import { build } from "@server/build"; const createResourceParamsSchema = z .object({ @@ -36,7 +37,6 @@ const createHttpResourceSchema = z .string() .optional() .transform((val) => val?.toLowerCase()), - isBaseDomain: z.boolean().optional(), siteId: z.number(), http: z.boolean(), protocol: z.enum(["tcp", "udp"]), @@ -52,19 +52,6 @@ const createHttpResourceSchema = z }, { message: "Invalid subdomain" } ) - .refine( - (data) => { - if (!config.getRawConfig().flags?.allow_base_domain_resources) { - if (data.isBaseDomain) { - return false; - } - } - return true; - }, - { - message: "Base domain resources are not allowed" - } - ); const createRawResourceSchema = z .object({ @@ -101,9 +88,12 @@ registry.registerPath({ body: { content: { "application/json": { - schema: createHttpResourceSchema.or( - createRawResourceSchema - ) + schema: + build == "oss" + ? createHttpResourceSchema.or( + createRawResourceSchema + ) + : createHttpResourceSchema } } } @@ -166,6 +156,14 @@ export async function createResource( { siteId, orgId } ); } else { + if (!config.getRawConfig().flags?.allow_raw_resources && build == "oss") { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Raw resources are not allowed" + ) + ); + } return await createRawResource( { req, res, next }, { siteId, orgId } @@ -203,35 +201,81 @@ async function createHttpResource( ); } - const { name, subdomain, isBaseDomain, http, protocol, domainId } = - parsedBody.data; + const { name, subdomain, domainId } = parsedBody.data; - const [orgDomain] = await db + const [domainRes] = await db .select() - .from(orgDomains) - .where( + .from(domains) + .where(eq(domains.domainId, domainId)) + .leftJoin( + orgDomains, and(eq(orgDomains.orgId, orgId), eq(orgDomains.domainId, domainId)) - ) - .leftJoin(domains, eq(orgDomains.domainId, domains.domainId)); + ); - if (!orgDomain || !orgDomain.domains) { + if (!domainRes || !domainRes.domains) { return next( createHttpError( HttpCode.NOT_FOUND, - `Domain with ID ${parsedBody.data.domainId} not found` + `Domain with ID ${domainId} not found` ) ); } - const domain = orgDomain.domains; + if (domainRes.orgDomains && domainRes.orgDomains.orgId !== orgId) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + `Organization does not have access to domain with ID ${domainId}` + ) + ); + } + + if (!domainRes.domains.verified) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + `Domain with ID ${domainRes.domains.domainId} is not verified` + ) + ); + } let fullDomain = ""; - if (isBaseDomain) { - fullDomain = domain.baseDomain; - } else { - fullDomain = `${subdomain}.${domain.baseDomain}`; + if (domainRes.domains.type == "ns") { + if (subdomain) { + fullDomain = `${subdomain}.${domainRes.domains.baseDomain}`; + } else { + fullDomain = domainRes.domains.baseDomain; + } + } else if (domainRes.domains.type == "cname") { + fullDomain = domainRes.domains.baseDomain; + } else if (domainRes.domains.type == "wildcard") { + if (subdomain) { + // the subdomain cant have a dot in it + const parsedSubdomain = subdomainSchema.safeParse(subdomain); + if (!parsedSubdomain.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedSubdomain.error).toString() + ) + ); + } + if (parsedSubdomain.data.includes(".")) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Subdomain cannot contain a dot when using wildcard domains" + ) + ); + } + fullDomain = `${subdomain}.${domainRes.domains.baseDomain}`; + } else { + fullDomain = domainRes.domains.baseDomain; + } } + fullDomain = fullDomain.toLowerCase(); + logger.debug(`Full domain: ${fullDomain}`); // make sure the full domain is unique @@ -261,10 +305,10 @@ async function createHttpResource( orgId, name, subdomain, - http, - protocol, + http: true, + protocol: "tcp", ssl: true, - isBaseDomain + isBaseDomain: false }) .returning(); diff --git a/server/routers/resource/listResources.ts b/server/routers/resource/listResources.ts index 6dc852e4..3fb2a733 100644 --- a/server/routers/resource/listResources.ts +++ b/server/routers/resource/listResources.ts @@ -69,7 +69,8 @@ function queryResources( http: resources.http, protocol: resources.protocol, proxyPort: resources.proxyPort, - enabled: resources.enabled + enabled: resources.enabled, + domainId: resources.domainId }) .from(resources) .leftJoin(sites, eq(resources.siteId, sites.siteId)) @@ -103,7 +104,8 @@ function queryResources( http: resources.http, protocol: resources.protocol, proxyPort: resources.proxyPort, - enabled: resources.enabled + enabled: resources.enabled, + domainId: resources.domainId }) .from(resources) .leftJoin(sites, eq(resources.siteId, sites.siteId)) diff --git a/server/routers/resource/updateResource.ts b/server/routers/resource/updateResource.ts index 68e38a3e..9f623702 100644 --- a/server/routers/resource/updateResource.ts +++ b/server/routers/resource/updateResource.ts @@ -20,6 +20,7 @@ import { tlsNameSchema } from "@server/lib/schemas"; import { subdomainSchema } from "@server/lib/schemas"; import { registry } from "@server/openApi"; import { OpenAPITags } from "@server/openApi"; +import { build } from "@server/build"; const updateResourceParamsSchema = z .object({ @@ -40,7 +41,6 @@ const updateHttpResourceBodySchema = z sso: z.boolean().optional(), blockAccess: z.boolean().optional(), emailWhitelistEnabled: z.boolean().optional(), - isBaseDomain: z.boolean().optional(), applyRules: z.boolean().optional(), domainId: z.string().optional(), enabled: z.boolean().optional(), @@ -61,19 +61,6 @@ const updateHttpResourceBodySchema = z }, { message: "Invalid subdomain" } ) - .refine( - (data) => { - if (!config.getRawConfig().flags?.allow_base_domain_resources) { - if (data.isBaseDomain) { - return false; - } - } - return true; - }, - { - message: "Base domain resources are not allowed" - } - ) .refine( (data) => { if (data.tlsServerName) { @@ -134,9 +121,12 @@ registry.registerPath({ body: { content: { "application/json": { - schema: updateHttpResourceBodySchema.and( - updateRawResourceBodySchema - ) + schema: + build == "oss" + ? updateHttpResourceBodySchema.and( + updateRawResourceBodySchema + ) + : updateHttpResourceBodySchema } } } @@ -242,86 +232,120 @@ async function updateHttpResource( const updateData = parsedBody.data; if (updateData.domainId) { - const [existingDomain] = await db - .select() - .from(orgDomains) - .where( - and( - eq(orgDomains.orgId, org.orgId), - eq(orgDomains.domainId, updateData.domainId) - ) - ) - .leftJoin(domains, eq(orgDomains.domainId, domains.domainId)); + const domainId = updateData.domainId; - if (!existingDomain) { + const [domainRes] = await db + .select() + .from(domains) + .where(eq(domains.domainId, domainId)) + .leftJoin( + orgDomains, + and( + eq(orgDomains.orgId, resource.orgId), + eq(orgDomains.domainId, domainId) + ) + ); + + if (!domainRes || !domainRes.domains) { return next( - createHttpError(HttpCode.NOT_FOUND, `Domain not found`) + createHttpError( + HttpCode.NOT_FOUND, + `Domain with ID ${updateData.domainId} not found` + ) ); } - } - - const domainId = updateData.domainId || resource.domainId!; - const subdomain = updateData.subdomain || resource.subdomain; - - const [domain] = await db - .select() - .from(domains) - .where(eq(domains.domainId, domainId)); - - const isBaseDomain = - updateData.isBaseDomain !== undefined - ? updateData.isBaseDomain - : resource.isBaseDomain; - - let fullDomain: string | null = null; - if (isBaseDomain) { - fullDomain = domain.baseDomain; - } else if (subdomain && domain) { - fullDomain = `${subdomain}.${domain.baseDomain}`; - } - - if (fullDomain) { - const [existingDomain] = await db - .select() - .from(resources) - .where(eq(resources.fullDomain, fullDomain)); if ( - existingDomain && - existingDomain.resourceId !== resource.resourceId + domainRes.orgDomains && + domainRes.orgDomains.orgId !== resource.orgId ) { return next( createHttpError( - HttpCode.CONFLICT, - "Resource with that domain already exists" + HttpCode.FORBIDDEN, + `You do not have permission to use domain with ID ${updateData.domainId}` ) ); } - } - const updatePayload = { - ...updateData, - fullDomain - }; + if (!domainRes.domains.verified) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + `Domain with ID ${updateData.domainId} is not verified` + ) + ); + } + + let fullDomain = ""; + if (domainRes.domains.type == "ns") { + if (updateData.subdomain) { + fullDomain = `${updateData.subdomain}.${domainRes.domains.baseDomain}`; + } else { + fullDomain = domainRes.domains.baseDomain; + } + } else if (domainRes.domains.type == "cname") { + fullDomain = domainRes.domains.baseDomain; + } else if (domainRes.domains.type == "wildcard") { + if (updateData.subdomain) { + // the subdomain cant have a dot in it + const parsedSubdomain = subdomainSchema.safeParse(updateData.subdomain); + if (!parsedSubdomain.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedSubdomain.error).toString() + ) + ); + } + if (parsedSubdomain.data.includes(".")) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Subdomain cannot contain a dot when using wildcard domains" + ) + ); + } + fullDomain = `${updateData.subdomain}.${domainRes.domains.baseDomain}`; + } else { + fullDomain = domainRes.domains.baseDomain; + } + } + + fullDomain = fullDomain.toLowerCase(); + + logger.debug(`Full domain: ${fullDomain}`); + + if (fullDomain) { + const [existingDomain] = await db + .select() + .from(resources) + .where(eq(resources.fullDomain, fullDomain)); + + if ( + existingDomain && + existingDomain.resourceId !== resource.resourceId + ) { + return next( + createHttpError( + HttpCode.CONFLICT, + "Resource with that domain already exists" + ) + ); + } + } + + // update the full domain if it has changed + if (fullDomain && fullDomain !== resource.fullDomain) { + await db + .update(resources) + .set({ fullDomain }) + .where(eq(resources.resourceId, resource.resourceId)); + } + } const updatedResource = await db .update(resources) - .set({ - name: updatePayload.name, - subdomain: updatePayload.subdomain, - ssl: updatePayload.ssl, - sso: updatePayload.sso, - blockAccess: updatePayload.blockAccess, - emailWhitelistEnabled: updatePayload.emailWhitelistEnabled, - isBaseDomain: updatePayload.isBaseDomain, - applyRules: updatePayload.applyRules, - domainId: updatePayload.domainId, - enabled: updatePayload.enabled, - stickySession: updatePayload.stickySession, - tlsServerName: updatePayload.tlsServerName, - setHostHeader: updatePayload.setHostHeader, - fullDomain: updatePayload.fullDomain - }) + .set(updateData) .where(eq(resources.resourceId, resource.resourceId)) .returning(); diff --git a/server/routers/site/createSite.ts b/server/routers/site/createSite.ts index b950644a..9f1a6eb5 100644 --- a/server/routers/site/createSite.ts +++ b/server/routers/site/createSite.ts @@ -14,6 +14,9 @@ import { newts } from "@server/db"; import moment from "moment"; import { OpenAPITags, registry } from "@server/openApi"; import { hashPassword } from "@server/auth/password"; +import { isValidIP } from "@server/lib/validators"; +import { isIpInCidr } from "@server/lib/ip"; +import config from "@server/lib/config"; const createSiteParamsSchema = z .object({ @@ -35,9 +38,18 @@ const createSiteSchema = z subnet: z.string().optional(), newtId: z.string().optional(), secret: z.string().optional(), + address: z.string().optional(), type: z.enum(["newt", "wireguard", "local"]) }) - .strict(); + .strict() + .refine((data) => { + if (data.type === "local") { + return !config.getRawConfig().flags?.disable_local_sites; + } else if (data.type === "wireguard") { + return !config.getRawConfig().flags?.disable_basic_wireguard_sites; + } + return true; + }); export type CreateSiteBody = z.infer; @@ -84,7 +96,8 @@ export async function createSite( pubKey, subnet, newtId, - secret + secret, + address } = parsedBody.data; const parsedParams = createSiteParamsSchema.safeParse(req.params); @@ -116,6 +129,59 @@ export async function createSite( ); } + let updatedAddress = null; + if (address) { + if (!isValidIP(address)) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Invalid subnet format. Please provide a valid CIDR notation." + ) + ); + } + + if (!isIpInCidr(address, org.subnet)) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "IP is not in the CIDR range of the subnet." + ) + ); + } + + updatedAddress = `${address}/${org.subnet.split("/")[1]}`; // we want the block size of the whole org + + // make sure the subnet is unique + const addressExistsSites = await db + .select() + .from(sites) + .where(eq(sites.address, updatedAddress)) + .limit(1); + + if (addressExistsSites.length > 0) { + return next( + createHttpError( + HttpCode.CONFLICT, + `Subnet ${subnet} already exists` + ) + ); + } + + const addressExistsClients = await db + .select() + .from(sites) + .where(eq(sites.subnet, updatedAddress)) + .limit(1); + if (addressExistsClients.length > 0) { + return next( + createHttpError( + HttpCode.CONFLICT, + `Subnet ${subnet} already exists` + ) + ); + } + } + const niceId = await getUniqueSiteName(orgId); await db.transaction(async (trx) => { @@ -139,6 +205,7 @@ export async function createSite( exitNodeId, name, niceId, + // address: updatedAddress || null, subnet, type, dockerSocketEnabled: type == "newt", @@ -154,6 +221,7 @@ export async function createSite( orgId, name, niceId, + // address: updatedAddress || null, type, dockerSocketEnabled: type == "newt", subnet: "0.0.0.0/0" diff --git a/server/routers/site/deleteSite.ts b/server/routers/site/deleteSite.ts index 1554ad2b..4af2feae 100644 --- a/server/routers/site/deleteSite.ts +++ b/server/routers/site/deleteSite.ts @@ -62,6 +62,8 @@ export async function deleteSite( ); } + let deletedNewtId: string | null = null; + await db.transaction(async (trx) => { if (site.pubKey) { if (site.type == "wireguard") { @@ -73,11 +75,7 @@ export async function deleteSite( .where(eq(newts.siteId, siteId)) .returning(); if (deletedNewt) { - const payload = { - type: `newt/terminate`, - data: {} - }; - sendToClient(deletedNewt.newtId, payload); + deletedNewtId = deletedNewt.newtId; // delete all of the sessions for the newt await trx @@ -90,6 +88,18 @@ export async function deleteSite( await trx.delete(sites).where(eq(sites.siteId, siteId)); }); + // Send termination message outside of transaction to prevent blocking + if (deletedNewtId) { + const payload = { + type: `newt/terminate`, + data: {} + }; + // Don't await this to prevent blocking the response + sendToClient(deletedNewtId, payload).catch(error => { + logger.error("Failed to send termination message to newt:", error); + }); + } + return response(res, { data: null, success: true, diff --git a/server/routers/site/listSites.ts b/server/routers/site/listSites.ts index 9114c395..6227ef28 100644 --- a/server/routers/site/listSites.ts +++ b/server/routers/site/listSites.ts @@ -1,4 +1,4 @@ -import { db } from "@server/db"; +import { db, newts } from "@server/db"; import { orgs, roleSites, sites, userSites } from "@server/db"; import logger from "@server/logger"; import HttpCode from "@server/types/HttpCode"; @@ -9,6 +9,42 @@ import createHttpError from "http-errors"; import { z } from "zod"; import { fromError } from "zod-validation-error"; import { OpenAPITags, registry } from "@server/openApi"; +import NodeCache from "node-cache"; +import semver from "semver"; + +const newtVersionCache = new NodeCache({ stdTTL: 3600 }); // 1 hours in seconds + +async function getLatestNewtVersion(): Promise { + try { + const cachedVersion = newtVersionCache.get("latestNewtVersion"); + if (cachedVersion) { + return cachedVersion; + } + + const response = await fetch( + "https://api.github.com/repos/fosrl/newt/tags" + ); + if (!response.ok) { + logger.warn("Failed to fetch latest Newt version from GitHub"); + return null; + } + + const tags = await response.json(); + if (!Array.isArray(tags) || tags.length === 0) { + logger.warn("No tags found for Newt repository"); + return null; + } + + const latestVersion = tags[0].name; + + newtVersionCache.set("latestNewtVersion", latestVersion); + + return latestVersion; + } catch (error) { + logger.error("Error fetching latest Newt version:", error); + return null; + } +} const listSitesParamsSchema = z .object({ @@ -43,10 +79,13 @@ function querySites(orgId: string, accessibleSiteIds: number[]) { megabytesOut: sites.megabytesOut, orgName: orgs.name, type: sites.type, - online: sites.online + online: sites.online, + address: sites.address, + newtVersion: newts.version }) .from(sites) .leftJoin(orgs, eq(sites.orgId, orgs.orgId)) + .leftJoin(newts, eq(newts.siteId, sites.siteId)) .where( and( inArray(sites.siteId, accessibleSiteIds), @@ -55,8 +94,12 @@ function querySites(orgId: string, accessibleSiteIds: number[]) { ); } +type SiteWithUpdateAvailable = Awaited>[0] & { + newtUpdateAvailable?: boolean; +}; + export type ListSitesResponse = { - sites: Awaited>; + sites: SiteWithUpdateAvailable[]; pagination: { total: number; limit: number; offset: number }; }; @@ -147,9 +190,36 @@ export async function listSites( const totalCountResult = await countQuery; const totalCount = totalCountResult[0].count; + const latestNewtVersion = await getLatestNewtVersion(); + + const sitesWithUpdates: SiteWithUpdateAvailable[] = sitesList.map( + (site) => { + const siteWithUpdate: SiteWithUpdateAvailable = { ...site }; + + if ( + site.type === "newt" && + site.newtVersion && + latestNewtVersion + ) { + try { + siteWithUpdate.newtUpdateAvailable = semver.lt( + site.newtVersion, + latestNewtVersion + ); + } catch (error) { + siteWithUpdate.newtUpdateAvailable = false; + } + } else { + siteWithUpdate.newtUpdateAvailable = false; + } + + return siteWithUpdate; + } + ); + return response(res, { data: { - sites: sitesList, + sites: sitesWithUpdates, pagination: { total: totalCount, limit, diff --git a/server/routers/site/pickSiteDefaults.ts b/server/routers/site/pickSiteDefaults.ts index 00e0d58b..2ae25c11 100644 --- a/server/routers/site/pickSiteDefaults.ts +++ b/server/routers/site/pickSiteDefaults.ts @@ -6,10 +6,11 @@ import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; import logger from "@server/logger"; -import { findNextAvailableCidr } from "@server/lib/ip"; +import { findNextAvailableCidr, getNextAvailableClientSubnet } from "@server/lib/ip"; import { generateId } from "@server/auth/sessions/app"; import config from "@server/lib/config"; import { OpenAPITags, registry } from "@server/openApi"; +import { fromError } from "zod-validation-error"; import { z } from "zod"; export type PickSiteDefaultsResponse = { @@ -19,9 +20,10 @@ export type PickSiteDefaultsResponse = { name: string; listenPort: number; endpoint: string; - subnet: string; + subnet: string; // TODO: make optional? newtId: string; newtSecret: string; + clientAddress?: string; }; registry.registerPath({ @@ -38,12 +40,29 @@ registry.registerPath({ responses: {} }); +const pickSiteDefaultsSchema = z + .object({ + orgId: z.string() + }) + .strict(); + export async function pickSiteDefaults( req: Request, res: Response, next: NextFunction ): Promise { try { + const parsedParams = pickSiteDefaultsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { orgId } = parsedParams.data; // TODO: more intelligent way to pick the exit node // make sure there is an exit node by counting the exit nodes table @@ -67,7 +86,7 @@ export async function pickSiteDefaults( .where(eq(sites.exitNodeId, exitNode.exitNodeId)); // TODO: we need to lock this subnet for some time so someone else does not take it - let subnets = sitesQuery.map((site) => site.subnet); + let subnets = sitesQuery.map((site) => site.subnet).filter((subnet) => subnet !== null); // exclude the exit node address by replacing after the / with a site block size subnets.push( exitNode.address.replace( @@ -89,6 +108,18 @@ export async function pickSiteDefaults( ); } + const newClientAddress = await getNextAvailableClientSubnet(orgId); + if (!newClientAddress) { + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "No available subnet found" + ) + ); + } + + const clientAddress = newClientAddress.split("/")[0]; + const newtId = generateId(15); const secret = generateId(48); @@ -100,7 +131,9 @@ export async function pickSiteDefaults( name: exitNode.name, listenPort: exitNode.listenPort, endpoint: exitNode.endpoint, + // subnet: `${newSubnet.split("/")[0]}/${config.getRawConfig().gerbil.block_size}`, // we want the block size of the whole subnet subnet: newSubnet, + clientAddress: clientAddress, newtId, newtSecret: secret }, diff --git a/server/routers/traefik/getTraefikConfig.ts b/server/routers/traefik/getTraefikConfig.ts index 7f70dbc7..95968858 100644 --- a/server/routers/traefik/getTraefikConfig.ts +++ b/server/routers/traefik/getTraefikConfig.ts @@ -1,11 +1,12 @@ import { Request, Response } from "express"; -import { db } from "@server/db"; +import { db, exitNodes } from "@server/db"; import { and, eq, inArray } from "drizzle-orm"; import logger from "@server/logger"; import HttpCode from "@server/types/HttpCode"; import config from "@server/lib/config"; import { orgs, resources, sites, Target, targets } from "@server/db"; -import { sql } from "drizzle-orm"; + +let currentExitNodeId: number; export async function traefikConfigProvider( _: Request, @@ -15,16 +16,48 @@ export async function traefikConfigProvider( // Get all resources with related data const allResources = await db.transaction(async (tx) => { // First query to get resources with site and org info + // Get the current exit node name from config + if (!currentExitNodeId) { + if (config.getRawConfig().gerbil.exit_node_name) { + const exitNodeName = + config.getRawConfig().gerbil.exit_node_name!; + const [exitNode] = await tx + .select({ + exitNodeId: exitNodes.exitNodeId + }) + .from(exitNodes) + .where(eq(exitNodes.name, exitNodeName)); + if (!exitNode) { + logger.error( + `Exit node with name ${exitNodeName} not found in the database` + ); + return []; + } + currentExitNodeId = exitNode.exitNodeId; + } else { + const [exitNode] = await tx + .select({ + exitNodeId: exitNodes.exitNodeId + }) + .from(exitNodes) + .limit(1); + + if (!exitNode) { + logger.error("No exit node found in the database"); + return []; + } + + currentExitNodeId = exitNode.exitNodeId; + } + } + + // Get the site(s) on this exit node const resourcesWithRelations = await tx .select({ // Resource fields resourceId: resources.resourceId, - subdomain: resources.subdomain, fullDomain: resources.fullDomain, ssl: resources.ssl, - blockAccess: resources.blockAccess, - sso: resources.sso, - emailWhitelistEnabled: resources.emailWhitelistEnabled, http: resources.http, proxyPort: resources.proxyPort, protocol: resources.protocol, @@ -34,11 +67,8 @@ export async function traefikConfigProvider( site: { siteId: sites.siteId, type: sites.type, - subnet: sites.subnet - }, - // Org fields - org: { - orgId: orgs.orgId + subnet: sites.subnet, + exitNodeId: sites.exitNodeId, }, enabled: resources.enabled, stickySession: resources.stickySession, @@ -47,7 +77,7 @@ export async function traefikConfigProvider( }) .from(resources) .innerJoin(sites, eq(sites.siteId, resources.siteId)) - .innerJoin(orgs, eq(resources.orgId, orgs.orgId)); + .where(eq(sites.exitNodeId, currentExitNodeId)); // Get all resource IDs from the first query const resourceIds = resourcesWithRelations.map((r) => r.resourceId); @@ -140,7 +170,6 @@ export async function traefikConfigProvider( for (const resource of allResources) { const targets = resource.targets as Target[]; const site = resource.site; - const org = resource.org; const routerName = `${resource.resourceId}-router`; const serviceName = `${resource.resourceId}-service`; @@ -164,11 +193,6 @@ export async function traefikConfigProvider( continue; } - // HTTP configuration remains the same - if (!resource.subdomain && !resource.isBaseDomain) { - continue; - } - // add routers and services empty objects if they don't exist if (!config_output.http.routers) { config_output.http.routers = {}; @@ -192,26 +216,22 @@ export async function traefikConfigProvider( const configDomain = config.getDomain(resource.domainId); - if (!configDomain) { - logger.error( - `Failed to get domain from config for resource ${resource.resourceId}` - ); - continue; + let tls = {}; + if (configDomain) { + tls = { + certResolver: configDomain.cert_resolver, + ...(configDomain.prefer_wildcard_cert + ? { + domains: [ + { + main: wildCard + } + ] + } + : {}) + }; } - const tls = { - certResolver: configDomain.cert_resolver, - ...(configDomain.prefer_wildcard_cert - ? { - domains: [ - { - main: wildCard - } - ] - } - : {}) - }; - const additionalMiddlewares = config.getRawConfig().traefik.additional_middlewares || []; @@ -227,6 +247,7 @@ export async function traefikConfigProvider( ], service: serviceName, rule: `Host(\`${fullDomain}\`)`, + priority: 100, ...(resource.ssl ? { tls } : {}) }; @@ -237,7 +258,8 @@ export async function traefikConfigProvider( ], middlewares: [redirectHttpsMiddlewareName], service: serviceName, - rule: `Host(\`${fullDomain}\`)` + rule: `Host(\`${fullDomain}\`)`, + priority: 100 }; } @@ -262,7 +284,7 @@ export async function traefikConfigProvider( } else if (site.type === "newt") { if ( !target.internalPort || - !target.method + !target.method || !site.subnet ) { return false; } @@ -278,7 +300,7 @@ export async function traefikConfigProvider( url: `${target.method}://${target.ip}:${target.port}` }; } else if (site.type === "newt") { - const ip = site.subnet.split("/")[0]; + const ip = site.subnet!.split("/")[0]; return { url: `${target.method}://${ip}:${target.internalPort}` }; @@ -309,7 +331,9 @@ export async function traefikConfigProvider( // if defined in the static config and here. if not set, self-signed certs won't work insecureSkipVerify: true }; - config_output.http.services![serviceName].loadBalancer.serversTransport = transportName; + config_output.http.services![ + serviceName + ].loadBalancer.serversTransport = transportName; } // Add the host header middleware @@ -317,23 +341,22 @@ export async function traefikConfigProvider( if (!config_output.http.middlewares) { config_output.http.middlewares = {}; } - config_output.http.middlewares[hostHeaderMiddlewareName] = - { - headers: { - customRequestHeaders: { - Host: resource.setHostHeader - } + config_output.http.middlewares[hostHeaderMiddlewareName] = { + headers: { + customRequestHeaders: { + Host: resource.setHostHeader } - }; + } + }; if (!config_output.http.routers![routerName].middlewares) { - config_output.http.routers![routerName].middlewares = []; + config_output.http.routers![routerName].middlewares = + []; } config_output.http.routers![routerName].middlewares = [ ...config_output.http.routers![routerName].middlewares, hostHeaderMiddlewareName ]; } - } else { // Non-HTTP (TCP/UDP) configuration const protocol = resource.protocol.toLowerCase(); @@ -371,7 +394,7 @@ export async function traefikConfigProvider( return false; } } else if (site.type === "newt") { - if (!target.internalPort) { + if (!target.internalPort || !site.subnet) { return false; } } @@ -386,7 +409,7 @@ export async function traefikConfigProvider( address: `${target.ip}:${target.port}` }; } else if (site.type === "newt") { - const ip = site.subnet.split("/")[0]; + const ip = site.subnet!.split("/")[0]; return { address: `${ip}:${target.internalPort}` }; diff --git a/server/routers/user/acceptInvite.ts b/server/routers/user/acceptInvite.ts index 115168b9..73bed018 100644 --- a/server/routers/user/acceptInvite.ts +++ b/server/routers/user/acceptInvite.ts @@ -1,6 +1,6 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; -import { db } from "@server/db"; +import { db, UserOrg } from "@server/db"; import { roles, userInvites, userOrgs, users } from "@server/db"; import { eq } from "drizzle-orm"; import response from "@server/lib/response"; @@ -92,6 +92,7 @@ export async function acceptInvite( } let roleId: number; + let totalUsers: UserOrg[] | undefined; // get the role to make sure it exists const existingRole = await db .select() @@ -122,6 +123,12 @@ export async function acceptInvite( await trx .delete(userInvites) .where(eq(userInvites.inviteId, inviteId)); + + // Get the total number of users in the org now + totalUsers = await db + .select() + .from(userOrgs) + .where(eq(userOrgs.orgId, existingInvite.orgId)); }); return response(res, { diff --git a/server/routers/user/createOrgUser.ts b/server/routers/user/createOrgUser.ts index 264ea3d9..4419772a 100644 --- a/server/routers/user/createOrgUser.ts +++ b/server/routers/user/createOrgUser.ts @@ -6,7 +6,7 @@ import createHttpError from "http-errors"; import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import { OpenAPITags, registry } from "@server/openApi"; -import { db } from "@server/db"; +import { db, UserOrg } from "@server/db"; import { and, eq } from "drizzle-orm"; import { idp, idpOidcConfig, roles, userOrgs, users } from "@server/db"; import { generateId } from "@server/auth/sessions/app"; @@ -135,65 +135,76 @@ export async function createOrgUser( ); } - const [existingUser] = await db - .select() - .from(users) - .where(eq(users.username, username)); + let orgUsers: UserOrg[] | undefined; - if (existingUser) { - const [existingOrgUser] = await db + await db.transaction(async (trx) => { + const [existingUser] = await trx .select() - .from(userOrgs) - .where( - and( - eq(userOrgs.orgId, orgId), - eq(userOrgs.userId, existingUser.userId) - ) - ); + .from(users) + .where(eq(users.username, username)); - if (existingOrgUser) { - return next( - createHttpError( - HttpCode.BAD_REQUEST, - "User already exists in this organization" - ) - ); + if (existingUser) { + const [existingOrgUser] = await trx + .select() + .from(userOrgs) + .where( + and( + eq(userOrgs.orgId, orgId), + eq(userOrgs.userId, existingUser.userId) + ) + ); + + if (existingOrgUser) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "User already exists in this organization" + ) + ); + } + + await trx + .insert(userOrgs) + .values({ + orgId, + userId: existingUser.userId, + roleId: role.roleId + }) + .returning(); + } else { + const userId = generateId(15); + + const [newUser] = await trx + .insert(users) + .values({ + userId: userId, + email, + username, + name, + type: "oidc", + idpId, + dateCreated: new Date().toISOString(), + emailVerified: true + }) + .returning(); + + await trx + .insert(userOrgs) + .values({ + orgId, + userId: newUser.userId, + roleId: role.roleId + }) + .returning(); } - await db - .insert(userOrgs) - .values({ - orgId, - userId: existingUser.userId, - roleId: role.roleId - }) - .returning(); - } else { - const userId = generateId(15); + // List all of the users in the org + orgUsers = await trx + .select() + .from(userOrgs) + .where(eq(userOrgs.orgId, orgId)); + }); - const [newUser] = await db - .insert(users) - .values({ - userId: userId, - email, - username, - name, - type: "oidc", - idpId, - dateCreated: new Date().toISOString(), - emailVerified: true - }) - .returning(); - - await db - .insert(userOrgs) - .values({ - orgId, - userId: newUser.userId, - roleId: role.roleId - }) - .returning(); - } } else { return next( createHttpError(HttpCode.BAD_REQUEST, "User type is required") diff --git a/server/routers/user/inviteUser.ts b/server/routers/user/inviteUser.ts index 5b2e8d1e..837ef179 100644 --- a/server/routers/user/inviteUser.ts +++ b/server/routers/user/inviteUser.ts @@ -99,6 +99,7 @@ export async function inviteUser( regenerate } = parsedBody.data; + // Check if the organization exists const org = await db .select() diff --git a/server/routers/user/removeUserOrg.ts b/server/routers/user/removeUserOrg.ts index 6e57a218..dcd8c6f2 100644 --- a/server/routers/user/removeUserOrg.ts +++ b/server/routers/user/removeUserOrg.ts @@ -1,6 +1,6 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; -import { db, resources, sites } from "@server/db"; +import { db, resources, sites, UserOrg } from "@server/db"; import { userOrgs, userResources, users, userSites } from "@server/db"; import { and, eq, exists } from "drizzle-orm"; import response from "@server/lib/response"; @@ -65,6 +65,8 @@ export async function removeUserOrg( ); } + let userCount: UserOrg[] | undefined; + await db.transaction(async (trx) => { await trx .delete(userOrgs) @@ -108,6 +110,11 @@ export async function removeUserOrg( ) ) ); + + userCount = await trx + .select() + .from(userOrgs) + .where(eq(userOrgs.orgId, orgId)); }); return response(res, { diff --git a/server/routers/ws.ts b/server/routers/ws.ts index 377047f1..c925ac5c 100644 --- a/server/routers/ws.ts +++ b/server/routers/ws.ts @@ -3,25 +3,33 @@ import { Server as HttpServer } from "http"; import { WebSocket, WebSocketServer } from "ws"; import { IncomingMessage } from "http"; import { Socket } from "net"; -import { Newt, newts, NewtSession } from "@server/db"; +import { Newt, newts, NewtSession, olms, Olm, OlmSession } from "@server/db"; import { eq } from "drizzle-orm"; import { db } from "@server/db"; import { validateNewtSessionToken } from "@server/auth/sessions/newt"; +import { validateOlmSessionToken } from "@server/auth/sessions/olm"; import { messageHandlers } from "./messageHandlers"; import logger from "@server/logger"; +import redisManager from "@server/db/redis"; +import { v4 as uuidv4 } from "uuid"; // Custom interfaces interface WebSocketRequest extends IncomingMessage { token?: string; } +type ClientType = 'newt' | 'olm'; + interface AuthenticatedWebSocket extends WebSocket { - newt?: Newt; + client?: Newt | Olm; + clientType?: ClientType; + connectionId?: string; } interface TokenPayload { - newt: Newt; - session: NewtSession; + client: Newt | Olm; + session: NewtSession | OlmSession; + clientType: ClientType; } interface WSMessage { @@ -33,55 +41,121 @@ interface HandlerResponse { message: WSMessage; broadcast?: boolean; excludeSender?: boolean; - targetNewtId?: string; + targetClientId?: string; } interface HandlerContext { message: WSMessage; senderWs: WebSocket; - newt: Newt | undefined; - sendToClient: (newtId: string, message: WSMessage) => boolean; - broadcastToAllExcept: (message: WSMessage, excludeNewtId?: string) => void; + client: Newt | Olm | undefined; + clientType: ClientType; + sendToClient: (clientId: string, message: WSMessage) => Promise; + broadcastToAllExcept: (message: WSMessage, excludeClientId?: string) => Promise; connectedClients: Map; } +interface RedisMessage { + type: 'direct' | 'broadcast'; + targetClientId?: string; + excludeClientId?: string; + message: WSMessage; + fromNodeId: string; +} + export type MessageHandler = (context: HandlerContext) => Promise; const router: Router = Router(); const wss: WebSocketServer = new WebSocketServer({ noServer: true }); -// Client tracking map +// Generate unique node ID for this instance +const NODE_ID = uuidv4(); +const REDIS_CHANNEL = 'websocket_messages'; + +// Client tracking map (local to this node) let connectedClients: Map = new Map(); +// Helper to get map key +const getClientMapKey = (clientId: string) => clientId; + +// Redis keys (generalized) +const getConnectionsKey = (clientId: string) => `ws:connections:${clientId}`; +const getNodeConnectionsKey = (nodeId: string, clientId: string) => `ws:node:${nodeId}:${clientId}`; + +// Initialize Redis subscription for cross-node messaging +const initializeRedisSubscription = async (): Promise => { + if (!redisManager.isRedisEnabled()) return; + + await redisManager.subscribe(REDIS_CHANNEL, async (channel: string, message: string) => { + try { + const redisMessage: RedisMessage = JSON.parse(message); + + // Ignore messages from this node + if (redisMessage.fromNodeId === NODE_ID) return; + + if (redisMessage.type === 'direct' && redisMessage.targetClientId) { + // Send to specific client on this node + await sendToClientLocal(redisMessage.targetClientId, redisMessage.message); + } else if (redisMessage.type === 'broadcast') { + // Broadcast to all clients on this node except excluded + await broadcastToAllExceptLocal(redisMessage.message, redisMessage.excludeClientId); + } + } catch (error) { + logger.error('Error processing Redis message:', error); + } + }); +}; // Helper functions for client management -const addClient = (newtId: string, ws: AuthenticatedWebSocket): void => { - const existingClients = connectedClients.get(newtId) || []; +const addClient = async (clientType: ClientType, clientId: string, ws: AuthenticatedWebSocket): Promise => { + // Generate unique connection ID + const connectionId = uuidv4(); + ws.connectionId = connectionId; + + // Add to local tracking + const mapKey = getClientMapKey(clientId); + const existingClients = connectedClients.get(mapKey) || []; existingClients.push(ws); - connectedClients.set(newtId, existingClients); - logger.info(`Client added to tracking - Newt ID: ${newtId}, Total connections: ${existingClients.length}`); + connectedClients.set(mapKey, existingClients); + + // Add to Redis tracking if enabled + if (redisManager.isRedisEnabled()) { + await redisManager.sadd(getConnectionsKey(clientId), NODE_ID); + await redisManager.hset(getNodeConnectionsKey(NODE_ID, clientId), connectionId, Date.now().toString()); + } + + logger.info(`Client added to tracking - ${clientType.toUpperCase()} ID: ${clientId}, Connection ID: ${connectionId}, Total connections: ${existingClients.length}`); }; -const removeClient = (newtId: string, ws: AuthenticatedWebSocket): void => { - const existingClients = connectedClients.get(newtId) || []; +const removeClient = async (clientType: ClientType, clientId: string, ws: AuthenticatedWebSocket): Promise => { + const mapKey = getClientMapKey(clientId); + const existingClients = connectedClients.get(mapKey) || []; const updatedClients = existingClients.filter(client => client !== ws); - if (updatedClients.length === 0) { - connectedClients.delete(newtId); - logger.info(`All connections removed for Newt ID: ${newtId}`); + connectedClients.delete(mapKey); + + if (redisManager.isRedisEnabled()) { + await redisManager.srem(getConnectionsKey(clientId), NODE_ID); + await redisManager.del(getNodeConnectionsKey(NODE_ID, clientId)); + } + + logger.info(`All connections removed for ${clientType.toUpperCase()} ID: ${clientId}`); } else { - connectedClients.set(newtId, updatedClients); - logger.info(`Connection removed - Newt ID: ${newtId}, Remaining connections: ${updatedClients.length}`); + connectedClients.set(mapKey, updatedClients); + + if (redisManager.isRedisEnabled() && ws.connectionId) { + await redisManager.hdel(getNodeConnectionsKey(NODE_ID, clientId), ws.connectionId); + } + + logger.info(`Connection removed - ${clientType.toUpperCase()} ID: ${clientId}, Remaining connections: ${updatedClients.length}`); } }; -// Helper functions for sending messages -const sendToClient = (newtId: string, message: WSMessage): boolean => { - const clients = connectedClients.get(newtId); +// Local message sending (within this node) +const sendToClientLocal = async (clientId: string, message: WSMessage): Promise => { + const mapKey = getClientMapKey(clientId); + const clients = connectedClients.get(mapKey); if (!clients || clients.length === 0) { - logger.info(`No active connections found for Newt ID: ${newtId}`); return false; } - const messageString = JSON.stringify(message); clients.forEach(client => { if (client.readyState === WebSocket.OPEN) { @@ -91,9 +165,10 @@ const sendToClient = (newtId: string, message: WSMessage): boolean => { return true; }; -const broadcastToAllExcept = (message: WSMessage, excludeNewtId?: string): void => { - connectedClients.forEach((clients, newtId) => { - if (newtId !== excludeNewtId) { +const broadcastToAllExceptLocal = async (message: WSMessage, excludeClientId?: string): Promise => { + connectedClients.forEach((clients, mapKey) => { + const [type, id] = mapKey.split(":"); + if (!(excludeClientId && id === excludeClientId)) { clients.forEach(client => { if (client.readyState === WebSocket.OPEN) { client.send(JSON.stringify(message)); @@ -103,84 +178,152 @@ const broadcastToAllExcept = (message: WSMessage, excludeNewtId?: string): void }); }; -// Token verification middleware (unchanged) -const verifyToken = async (token: string): Promise => { - try { - const { session, newt } = await validateNewtSessionToken(token); +// Cross-node message sending (via Redis) +const sendToClient = async (clientId: string, message: WSMessage): Promise => { + // Try to send locally first + const localSent = await sendToClientLocal(clientId, message); - if (!session || !newt) { - return null; + // If Redis is enabled, also send via Redis pub/sub to other nodes + if (redisManager.isRedisEnabled()) { + const redisMessage: RedisMessage = { + type: 'direct', + targetClientId: clientId, + message, + fromNodeId: NODE_ID + }; + + await redisManager.publish(REDIS_CHANNEL, JSON.stringify(redisMessage)); + } + + return localSent; +}; + +const broadcastToAllExcept = async (message: WSMessage, excludeClientId?: string): Promise => { + // Broadcast locally + await broadcastToAllExceptLocal(message, excludeClientId); + + // If Redis is enabled, also broadcast via Redis pub/sub to other nodes + if (redisManager.isRedisEnabled()) { + const redisMessage: RedisMessage = { + type: 'broadcast', + excludeClientId, + message, + fromNodeId: NODE_ID + }; + + await redisManager.publish(REDIS_CHANNEL, JSON.stringify(redisMessage)); + } +}; + +// Check if a client has active connections across all nodes +const hasActiveConnections = async (clientId: string): Promise => { + if (!redisManager.isRedisEnabled()) { + const mapKey = getClientMapKey(clientId); + const clients = connectedClients.get(mapKey); + return !!(clients && clients.length > 0); + } + + const activeNodes = await redisManager.smembers(getConnectionsKey(clientId)); + return activeNodes.length > 0; +}; + +// Get all active nodes for a client +const getActiveNodes = async (clientType: ClientType, clientId: string): Promise => { + if (!redisManager.isRedisEnabled()) { + const mapKey = getClientMapKey(clientId); + const clients = connectedClients.get(mapKey); + return (clients && clients.length > 0) ? [NODE_ID] : []; + } + + return await redisManager.smembers(getConnectionsKey(clientId)); +}; + +// Token verification middleware +const verifyToken = async (token: string, clientType: ClientType): Promise => { + +try { + if (clientType === 'newt') { + const { session, newt } = await validateNewtSessionToken(token); + if (!session || !newt) { + return null; + } + const existingNewt = await db + .select() + .from(newts) + .where(eq(newts.newtId, newt.newtId)); + if (!existingNewt || !existingNewt[0]) { + return null; + } + return { client: existingNewt[0], session, clientType }; + } else { + const { session, olm } = await validateOlmSessionToken(token); + if (!session || !olm) { + return null; + } + const existingOlm = await db + .select() + .from(olms) + .where(eq(olms.olmId, olm.olmId)); + if (!existingOlm || !existingOlm[0]) { + return null; + } + return { client: existingOlm[0], session, clientType }; } - - const existingNewt = await db - .select() - .from(newts) - .where(eq(newts.newtId, newt.newtId)); - - if (!existingNewt || !existingNewt[0]) { - return null; - } - - return { newt: existingNewt[0], session }; } catch (error) { logger.error("Token verification failed:", error); return null; } }; -const setupConnection = (ws: AuthenticatedWebSocket, newt: Newt): void => { +const setupConnection = async (ws: AuthenticatedWebSocket, client: Newt | Olm, clientType: ClientType): Promise => { logger.info("Establishing websocket connection"); - - if (!newt) { - logger.error("Connection attempt without newt"); + if (!client) { + logger.error("Connection attempt without client"); return ws.terminate(); } - ws.newt = newt; + ws.client = client; + ws.clientType = clientType; // Add client to tracking - addClient(newt.newtId, ws); + const clientId = clientType === 'newt' ? (client as Newt).newtId : (client as Olm).olmId; + await addClient(clientType, clientId, ws); ws.on("message", async (data) => { try { const message: WSMessage = JSON.parse(data.toString()); - // logger.info(`Message received from Newt ID ${newtId}:`, message); - // Validate message format if (!message.type || typeof message.type !== "string") { throw new Error("Invalid message format: missing or invalid type"); } - // Get the appropriate handler for the message type const handler = messageHandlers[message.type]; if (!handler) { throw new Error(`Unsupported message type: ${message.type}`); } - // Process the message and get response const response = await handler({ message, senderWs: ws, - newt: ws.newt, + client: ws.client, + clientType: ws.clientType!, sendToClient, broadcastToAllExcept, connectedClients }); - // Send response if one was returned if (response) { if (response.broadcast) { - // Broadcast to all clients except sender if specified - broadcastToAllExcept(response.message, response.excludeSender ? newt.newtId : undefined); - } else if (response.targetNewtId) { - // Send to specific client if targetNewtId is provided - sendToClient(response.targetNewtId, response.message); + await broadcastToAllExcept( + response.message, + response.excludeSender ? clientId : undefined + ); + } else if (response.targetClientId) { + await sendToClient(response.targetClientId, response.message); } else { - // Send back to sender ws.send(JSON.stringify(response.message)); } } - } catch (error) { logger.error("Message handling error:", error); ws.send(JSON.stringify({ @@ -194,18 +337,18 @@ const setupConnection = (ws: AuthenticatedWebSocket, newt: Newt): void => { }); ws.on("close", () => { - removeClient(newt.newtId, ws); - logger.info(`Client disconnected - Newt ID: ${newt.newtId}`); + removeClient(clientType, clientId, ws); + logger.info(`Client disconnected - ${clientType.toUpperCase()} ID: ${clientId}`); }); ws.on("error", (error: Error) => { - logger.error(`WebSocket error for Newt ID ${newt.newtId}:`, error); + logger.error(`WebSocket error for ${clientType.toUpperCase()} ID ${clientId}:`, error); }); - logger.info(`WebSocket connection established - Newt ID: ${newt.newtId}`); + logger.info(`WebSocket connection established - ${clientType.toUpperCase()} ID: ${clientId}`); }; -// Router endpoint (unchanged) +// Router endpoint router.get("/ws", (req: Request, res: Response) => { res.status(200).send("WebSocket endpoint"); }); @@ -214,18 +357,22 @@ router.get("/ws", (req: Request, res: Response) => { const handleWSUpgrade = (server: HttpServer): void => { server.on("upgrade", async (request: WebSocketRequest, socket: Socket, head: Buffer) => { try { - const token = request.url?.includes("?") - ? new URLSearchParams(request.url.split("?")[1]).get("token") || "" - : request.headers["sec-websocket-protocol"]; + const url = new URL(request.url || '', `http://${request.headers.host}`); + const token = url.searchParams.get('token') || request.headers["sec-websocket-protocol"] || ''; + let clientType = url.searchParams.get('clientType') as ClientType; - if (!token) { - logger.warn("Unauthorized connection attempt: no token..."); + if (!clientType) { + clientType = "newt"; + } + + if (!token || !clientType || !['newt', 'olm'].includes(clientType)) { + logger.warn("Unauthorized connection attempt: invalid token or client type..."); socket.write("HTTP/1.1 401 Unauthorized\r\n\r\n"); socket.destroy(); return; } - const tokenPayload = await verifyToken(token); + const tokenPayload = await verifyToken(token, clientType); if (!tokenPayload) { logger.warn("Unauthorized connection attempt: invalid token..."); socket.write("HTTP/1.1 401 Unauthorized\r\n\r\n"); @@ -234,7 +381,7 @@ const handleWSUpgrade = (server: HttpServer): void => { } wss.handleUpgrade(request, socket, head, (ws: AuthenticatedWebSocket) => { - setupConnection(ws, tokenPayload.newt); + setupConnection(ws, tokenPayload.client, tokenPayload.clientType); }); } catch (error) { logger.error("WebSocket upgrade error:", error); @@ -244,10 +391,54 @@ const handleWSUpgrade = (server: HttpServer): void => { }); }; +// Initialize Redis subscription when the module is loaded +if (redisManager.isRedisEnabled()) { + initializeRedisSubscription().catch(error => { + logger.error('Failed to initialize Redis subscription:', error); + }); + logger.info(`WebSocket handler initialized with Redis support - Node ID: ${NODE_ID}`); +} else { + logger.debug('WebSocket handler initialized in local mode (Redis disabled)'); +} + +// Cleanup function for graceful shutdown +const cleanup = async (): Promise => { + try { + // Close all WebSocket connections + connectedClients.forEach((clients) => { + clients.forEach(client => { + if (client.readyState === WebSocket.OPEN) { + client.terminate(); + } + }); + }); + + // Clean up Redis tracking for this node + if (redisManager.isRedisEnabled()) { + const keys = await redisManager.getClient()?.keys(`ws:node:${NODE_ID}:*`) || []; + if (keys.length > 0) { + await Promise.all(keys.map(key => redisManager.del(key))); + } + } + + logger.info('WebSocket cleanup completed'); + } catch (error) { + logger.error('Error during WebSocket cleanup:', error); + } +}; + +// Handle process termination +process.on('SIGTERM', cleanup); +process.on('SIGINT', cleanup); + export { router, handleWSUpgrade, sendToClient, broadcastToAllExcept, - connectedClients + connectedClients, + hasActiveConnections, + getActiveNodes, + NODE_ID, + cleanup }; diff --git a/server/setup/copyInConfig.ts b/server/setup/copyInConfig.ts index 6ab8d446..376a5df4 100644 --- a/server/setup/copyInConfig.ts +++ b/server/setup/copyInConfig.ts @@ -8,8 +8,31 @@ export async function copyInConfig() { const endpoint = config.getRawConfig().gerbil.base_endpoint; const listenPort = config.getRawConfig().gerbil.start_port; + if (!config.getRawConfig().flags?.disable_config_managed_domains) { + await copyInDomains(); + } + + const exitNodeName = config.getRawConfig().gerbil.exit_node_name; + if (exitNodeName) { + await db + .update(exitNodes) + .set({ endpoint, listenPort }) + .where(eq(exitNodes.name, exitNodeName)); + } else { + await db + .update(exitNodes) + .set({ endpoint }) + .where(ne(exitNodes.endpoint, endpoint)); + await db + .update(exitNodes) + .set({ listenPort }) + .where(ne(exitNodes.listenPort, listenPort)); + } +} + +async function copyInDomains() { await db.transaction(async (trx) => { - const rawDomains = config.getRawConfig().domains; + const rawDomains = config.getRawConfig().domains!; // always defined if disable flag is not set const configDomains = Object.entries(rawDomains).map( ([key, value]) => ({ @@ -40,13 +63,13 @@ export async function copyInConfig() { if (existingDomainKeys.has(domainId)) { await trx .update(domains) - .set({ baseDomain }) + .set({ baseDomain, verified: true, type: "wildcard" }) .where(eq(domains.domainId, domainId)) .execute(); } else { await trx .insert(domains) - .values({ domainId, baseDomain, configManaged: true }) + .values({ domainId, baseDomain, configManaged: true, type: "wildcard" }) .execute(); } } @@ -104,15 +127,4 @@ export async function copyInConfig() { .where(eq(resources.resourceId, resource.resourceId)); } }); - - // TODO: eventually each exit node could have a different endpoint - await db - .update(exitNodes) - .set({ endpoint }) - .where(ne(exitNodes.endpoint, endpoint)); - // TODO: eventually each exit node could have a different port - await db - .update(exitNodes) - .set({ listenPort }) - .where(ne(exitNodes.listenPort, listenPort)); } diff --git a/server/setup/migrationsPg.ts b/server/setup/migrationsPg.ts index c0de0f09..aeec86ff 100644 --- a/server/setup/migrationsPg.ts +++ b/server/setup/migrationsPg.ts @@ -26,6 +26,10 @@ async function run() { } export async function runMigrations() { + if (process.env.DISABLE_MIGRATIONS) { + console.log("Migrations are disabled. Skipping..."); + return; + } try { const appVersion = APP_VERSION; diff --git a/server/setup/migrationsSqlite.ts b/server/setup/migrationsSqlite.ts index 1e279bae..fadb094a 100644 --- a/server/setup/migrationsSqlite.ts +++ b/server/setup/migrationsSqlite.ts @@ -76,6 +76,10 @@ function backupDb() { } export async function runMigrations() { + if (process.env.DISABLE_MIGRATIONS) { + console.log("Migrations are disabled. Skipping..."); + return; + } try { const appVersion = APP_VERSION; diff --git a/src/app/[orgId]/layout.tsx b/src/app/[orgId]/layout.tsx index fa41beb2..3ab0b92e 100644 --- a/src/app/[orgId]/layout.tsx +++ b/src/app/[orgId]/layout.tsx @@ -8,6 +8,7 @@ import { GetOrgUserResponse } from "@server/routers/user"; import { AxiosResponse } from "axios"; import { redirect } from "next/navigation"; import { cache } from "react"; +import SetLastOrgCookie from "@app/components/SetLastOrgCookie"; export default async function OrgLayout(props: { children: React.ReactNode; @@ -52,6 +53,7 @@ export default async function OrgLayout(props: { return ( <> {props.children} + ); } diff --git a/src/app/[orgId]/page.tsx b/src/app/[orgId]/page.tsx index 5f91fb62..d19a6dcc 100644 --- a/src/app/[orgId]/page.tsx +++ b/src/app/[orgId]/page.tsx @@ -8,7 +8,6 @@ import { AxiosResponse } from "axios"; import { authCookieHeader } from "@app/lib/api/cookies"; import { redirect } from "next/navigation"; import { Layout } from "@app/components/Layout"; -import { orgLangingNavItems, orgNavItems, rootNavItems } from "../navigation"; import { ListUserOrgsResponse } from "@server/routers/org"; type OrgPageProps = { @@ -60,7 +59,7 @@ export default async function OrgPage(props: OrgPageProps) { return ( - + {overview && (
[] = [ - { - id: "dots", - cell: ({ row }) => { - const invitation = row.original; - return ( - - - - - - { - setIsRegenerateModalOpen(true); - setSelectedInvitation(invitation); - }} - > - {t('inviteRegenerate')} - - { - setIsDeleteModalOpen(true); - setSelectedInvitation(invitation); - }} - > - - {t('inviteRemove')} - - - - - ); - } - }, { accessorKey: "email", - header: t('email') + header: t("email") }, { accessorKey: "expiresAt", - header: t('expiresAt'), + header: t("expiresAt"), cell: ({ row }) => { const expiresAt = new Date(row.original.expiresAt); const isExpired = expiresAt < new Date(); return ( - {expiresAt.toLocaleString()} + {moment(expiresAt).format("lll")} ); } }, { accessorKey: "role", - header: t('role') + header: t("role") + }, + { + id: "dots", + cell: ({ row }) => { + const invitation = row.original; + return ( +
+ + + + + + { + setIsDeleteModalOpen(true); + setSelectedInvitation(invitation); + }} + > + + {t("inviteRemove")} + + + + + + +
+ ); + } } ]; @@ -115,16 +122,18 @@ export default function InvitationsTable({ .catch((e) => { toast({ variant: "destructive", - title: t('inviteRemoveError'), - description: t('inviteRemoveErrorDescription') + title: t("inviteRemoveError"), + description: t("inviteRemoveErrorDescription") }); }); if (res && res.status === 200) { toast({ variant: "default", - title: t('inviteRemoved'), - description: t('inviteRemovedDescription', {email: selectedInvitation.email}) + title: t("inviteRemoved"), + description: t("inviteRemovedDescription", { + email: selectedInvitation.email + }) }); setInvitations((prev) => @@ -148,20 +157,18 @@ export default function InvitationsTable({ dialog={

- {t('inviteQuestionRemove', {email: selectedInvitation?.email || ""})} -

-

- {t('inviteMessageRemove')} -

-

- {t('inviteMessageConfirm')} + {t("inviteQuestionRemove", { + email: selectedInvitation?.email || "" + })}

+

{t("inviteMessageRemove")}

+

{t("inviteMessageConfirm")}

} - buttonText={t('inviteRemoveConfirm')} + buttonText={t("inviteRemoveConfirm")} onConfirm={removeInvitation} string={selectedInvitation?.email ?? ""} - title={t('inviteRemove')} + title={t("inviteRemove")} /> - + diff --git a/src/app/[orgId]/settings/access/roles/DeleteRoleForm.tsx b/src/app/[orgId]/settings/access/roles/DeleteRoleForm.tsx index 1e910e29..f3042f71 100644 --- a/src/app/[orgId]/settings/access/roles/DeleteRoleForm.tsx +++ b/src/app/[orgId]/settings/access/roles/DeleteRoleForm.tsx @@ -159,7 +159,6 @@ export default function DeleteRoleForm({ -

{t('accessRoleQuestionRemove', {name: roleToDelete.name})} @@ -210,13 +209,13 @@ export default function DeleteRoleForm({ /> -

- - - { - setIsDeleteModalOpen(true); - setUserToRemove(roleRow); - }} - > - - {t('accessRoleDelete')} - - - - - )} -
- - ); - } - }, { accessorKey: "name", header: ({ column }) => { @@ -95,7 +52,7 @@ export default function UsersTable({ roles: r }: RolesTableProps) { column.toggleSorting(column.getIsSorted() === "asc") } > - {t('name')} + {t("name")} ); @@ -103,7 +60,29 @@ export default function UsersTable({ roles: r }: RolesTableProps) { }, { accessorKey: "description", - header: t('description') + header: t("description") + }, + { + id: "actions", + cell: ({ row }) => { + const roleRow = row.original; + + return ( +
+ +
+ ); + } } ]; diff --git a/src/app/[orgId]/settings/access/roles/page.tsx b/src/app/[orgId]/settings/access/roles/page.tsx index fed52c26..8faedbf8 100644 --- a/src/app/[orgId]/settings/access/roles/page.tsx +++ b/src/app/[orgId]/settings/access/roles/page.tsx @@ -6,8 +6,6 @@ import { cache } from "react"; import OrgProvider from "@app/providers/OrgProvider"; import { ListRolesResponse } from "@server/routers/role"; import RolesTable, { RoleRow } from "./RolesTable"; -import { SidebarSettings } from "@app/components/SidebarSettings"; -import AccessPageHeaderAndNav from "../AccessPageHeaderAndNav"; import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; import { getTranslations } from 'next-intl/server'; diff --git a/src/app/[orgId]/settings/access/users/UsersTable.tsx b/src/app/[orgId]/settings/access/users/UsersTable.tsx index d3ee404e..6b8af509 100644 --- a/src/app/[orgId]/settings/access/users/UsersTable.tsx +++ b/src/app/[orgId]/settings/access/users/UsersTable.tsx @@ -20,7 +20,7 @@ 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 { useTranslations } from 'next-intl'; +import { useTranslations } from "next-intl"; export type UserRow = { id: string; @@ -51,65 +51,6 @@ export default function UsersTable({ users: u }: UsersTableProps) { const t = useTranslations(); const columns: ColumnDef[] = [ - { - id: "dots", - cell: ({ row }) => { - const userRow = row.original; - return ( - <> -
- {userRow.isOwner && ( - - )} - {!userRow.isOwner && ( - <> - - - - - - - - {t('accessUsersManage')} - - - {`${userRow.username}-${userRow.idpId}` !== - `${user?.username}-${userRow.idpId}` && ( - { - setIsDeleteModalOpen( - true - ); - setSelectedUser( - userRow - ); - }} - > - - {t('accessUserRemove')} - - - )} - - - - )} -
- - ); - } - }, { accessorKey: "displayUsername", header: ({ column }) => { @@ -120,7 +61,7 @@ export default function UsersTable({ users: u }: UsersTableProps) { column.toggleSorting(column.getIsSorted() === "asc") } > - {t('username')} + {t("username")} ); @@ -136,7 +77,7 @@ export default function UsersTable({ users: u }: UsersTableProps) { column.toggleSorting(column.getIsSorted() === "asc") } > - {t('identityProvider')} + {t("identityProvider")} ); @@ -152,7 +93,7 @@ export default function UsersTable({ users: u }: UsersTableProps) { column.toggleSorting(column.getIsSorted() === "asc") } > - {t('role')} + {t("role")} ); @@ -176,12 +117,68 @@ export default function UsersTable({ users: u }: UsersTableProps) { const userRow = row.original; return (
+ <> +
+ {userRow.isOwner && ( + + )} + {!userRow.isOwner && ( + <> + + + + + + + + {t("accessUsersManage")} + + + {`${userRow.username}-${userRow.idpId}` !== + `${user?.username}-${userRow.idpId}` && ( + { + setIsDeleteModalOpen( + true + ); + setSelectedUser( + userRow + ); + }} + > + + {t( + "accessUserRemove" + )} + + + )} + + + + )} +
+ {userRow.isOwner && ( )} {!userRow.isOwner && ( @@ -189,10 +186,12 @@ export default function UsersTable({ users: u }: UsersTableProps) { href={`/${org?.org.orgId}/settings/access/users/${userRow.id}`} > @@ -210,10 +209,10 @@ export default function UsersTable({ users: u }: UsersTableProps) { .catch((e) => { toast({ variant: "destructive", - title: t('userErrorOrgRemove'), + title: t("userErrorOrgRemove"), description: formatAxiosError( e, - t('userErrorOrgRemoveDescription') + t("userErrorOrgRemoveDescription") ) }); }); @@ -221,8 +220,10 @@ export default function UsersTable({ users: u }: UsersTableProps) { if (res && res.status === 200) { toast({ variant: "default", - title: t('userOrgRemoved'), - description: t('userOrgRemovedDescription', {email: selectedUser.email || ""}) + title: t("userOrgRemoved"), + description: t("userOrgRemovedDescription", { + email: selectedUser.email || "" + }) }); setUsers((prev) => @@ -244,19 +245,21 @@ export default function UsersTable({ users: u }: UsersTableProps) { dialog={

- {t('userQuestionOrgRemove', {email: selectedUser?.email || selectedUser?.name || selectedUser?.username || ""})} + {t("userQuestionOrgRemove", { + email: + selectedUser?.email || + selectedUser?.name || + selectedUser?.username || + "" + })}

-

- {t('userMessageOrgRemove')} -

+

{t("userMessageOrgRemove")}

-

- {t('userMessageOrgConfirm')} -

+

{t("userMessageOrgConfirm")}

} - buttonText={t('userRemoveOrgConfirm')} + buttonText={t("userRemoveOrgConfirm")} onConfirm={removeUser} string={ selectedUser?.email || @@ -264,14 +267,16 @@ export default function UsersTable({ users: u }: UsersTableProps) { selectedUser?.username || "" } - title={t('userRemoveOrg')} + title={t("userRemoveOrg")} /> { - router.push(`/${org?.org.orgId}/settings/access/users/create`); + router.push( + `/${org?.org.orgId}/settings/access/users/create` + ); }} /> diff --git a/src/app/[orgId]/settings/access/users/[userId]/layout.tsx b/src/app/[orgId]/settings/access/users/[userId]/layout.tsx index 82fbba86..7d527f84 100644 --- a/src/app/[orgId]/settings/access/users/[userId]/layout.tsx +++ b/src/app/[orgId]/settings/access/users/[userId]/layout.tsx @@ -5,17 +5,9 @@ import { authCookieHeader } from "@app/lib/api/cookies"; import { GetOrgUserResponse } from "@server/routers/user"; import OrgUserProvider from "@app/providers/OrgUserProvider"; import { HorizontalTabs } from "@app/components/HorizontalTabs"; -import { - Breadcrumb, - BreadcrumbItem, - BreadcrumbList, - BreadcrumbPage, - BreadcrumbSeparator -} from "@app/components/ui/breadcrumb"; -import Link from "next/link"; import { cache } from "react"; import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; -import { getTranslations } from 'next-intl/server'; +import { getTranslations } from "next-intl/server"; interface UserLayoutProps { children: React.ReactNode; @@ -45,7 +37,7 @@ export default async function UserLayoutProps(props: UserLayoutProps) { const navItems = [ { - title: t('accessControls'), + title: t("accessControls"), href: "/{orgId}/settings/access/users/{userId}/access-controls" } ]; @@ -54,12 +46,10 @@ export default async function UserLayoutProps(props: UserLayoutProps) { <> - - {children} - + {children} ); diff --git a/src/app/[orgId]/settings/access/users/create/page.tsx b/src/app/[orgId]/settings/access/users/create/page.tsx index e4ea99fe..a91fd7b9 100644 --- a/src/app/[orgId]/settings/access/users/create/page.tsx +++ b/src/app/[orgId]/settings/access/users/create/page.tsx @@ -78,40 +78,42 @@ export default function Page() { const [dataLoaded, setDataLoaded] = useState(false); const internalFormSchema = z.object({ - email: z.string().email({ message: t('emailInvalid') }), - validForHours: z.string().min(1, { message: t('inviteValidityDuration') }), - roleId: z.string().min(1, { message: t('accessRoleSelectPlease') }) + email: z.string().email({ message: t("emailInvalid") }), + validForHours: z + .string() + .min(1, { message: t("inviteValidityDuration") }), + roleId: z.string().min(1, { message: t("accessRoleSelectPlease") }) }); const externalFormSchema = z.object({ - username: z.string().min(1, { message: t('usernameRequired') }), + username: z.string().min(1, { message: t("usernameRequired") }), email: z .string() - .email({ message: t('emailInvalid') }) + .email({ message: t("emailInvalid") }) .optional() .or(z.literal("")), name: z.string().optional(), - roleId: z.string().min(1, { message: t('accessRoleSelectPlease') }), - idpId: z.string().min(1, { message: t('idpSelectPlease') }) + roleId: z.string().min(1, { message: t("accessRoleSelectPlease") }), + idpId: z.string().min(1, { message: t("idpSelectPlease") }) }); const formatIdpType = (type: string) => { switch (type.toLowerCase()) { case "oidc": - return t('idpGenericOidc'); + return t("idpGenericOidc"); default: return type; } }; const validFor = [ - { hours: 24, name: t('day', {count: 1}) }, - { hours: 48, name: t('day', {count: 2}) }, - { hours: 72, name: t('day', {count: 3}) }, - { hours: 96, name: t('day', {count: 4}) }, - { hours: 120, name: t('day', {count: 5}) }, - { hours: 144, name: t('day', {count: 6}) }, - { hours: 168, name: t('day', {count: 7}) } + { hours: 24, name: t("day", { count: 1 }) }, + { hours: 48, name: t("day", { count: 2 }) }, + { hours: 72, name: t("day", { count: 3 }) }, + { hours: 96, name: t("day", { count: 4 }) }, + { hours: 120, name: t("day", { count: 5 }) }, + { hours: 144, name: t("day", { count: 6 }) }, + { hours: 168, name: t("day", { count: 7 }) } ]; const internalForm = useForm>({ @@ -145,6 +147,14 @@ export default function Page() { } }, [userType, env.email.emailEnabled, internalForm, externalForm]); + const userTypes: UserTypeOption[] = [ + { + id: "internal", + title: t("userTypeInternal"), + description: t("userTypeInternalDescription") + } + ]; + useEffect(() => { if (!userType) { return; @@ -157,10 +167,10 @@ export default function Page() { console.error(e); toast({ variant: "destructive", - title: t('accessRoleErrorFetch'), + title: t("accessRoleErrorFetch"), description: formatAxiosError( e, - t('accessRoleErrorFetchDescription') + t("accessRoleErrorFetchDescription") ) }); }); @@ -180,10 +190,10 @@ export default function Page() { console.error(e); toast({ variant: "destructive", - title: t('idpErrorFetch'), + title: t("idpErrorFetch"), description: formatAxiosError( e, - t('idpErrorFetchDescription') + t("idpErrorFetchDescription") ) }); }); @@ -191,6 +201,14 @@ export default function Page() { if (res?.status === 200) { setIdps(res.data.data.idps); setDataLoaded(true); + + if (res.data.data.idps.length) { + userTypes.push({ + id: "oidc", + title: t("userTypeExternal"), + description: t("userTypeExternalDescription") + }); + } } } @@ -220,16 +238,16 @@ export default function Page() { if (e.response?.status === 409) { toast({ variant: "destructive", - title: t('userErrorExists'), - description: t('userErrorExistsDescription') + title: t("userErrorExists"), + description: t("userErrorExistsDescription") }); } else { toast({ variant: "destructive", - title: t('inviteError'), + title: t("inviteError"), description: formatAxiosError( e, - t('inviteErrorDescription') + t("inviteErrorDescription") ) }); } @@ -239,8 +257,8 @@ export default function Page() { setInviteLink(res.data.data.inviteLink); toast({ variant: "default", - title: t('userInvited'), - description: t('userInvitedDescription') + title: t("userInvited"), + description: t("userInvitedDescription") }); setExpiresInDays(parseInt(values.validForHours) / 24); @@ -266,10 +284,10 @@ export default function Page() { .catch((e) => { toast({ variant: "destructive", - title: t('userErrorCreate'), + title: t("userErrorCreate"), description: formatAxiosError( e, - t('userErrorCreateDescription') + t("userErrorCreateDescription") ) }); }); @@ -277,8 +295,8 @@ export default function Page() { if (res && res.status === 201) { toast({ variant: "default", - title: t('userCreated'), - description: t('userCreatedDescription') + title: t("userCreated"), + description: t("userCreatedDescription") }); router.push(`/${orgId}/settings/access/users`); } @@ -286,25 +304,12 @@ export default function Page() { setLoading(false); } - const userTypes: ReadonlyArray = [ - { - id: "internal", - title: t('userTypeInternal'), - description: t('userTypeInternalDescription') - }, - { - id: "oidc", - title: t('userTypeExternal'), - description: t('userTypeExternalDescription') - } - ]; - return ( <>
- - - - {t('userTypeTitle')} - - - {t('userTypeDescription')} - - - - { - setUserType(value as UserType); - if (value === "internal") { - internalForm.reset(); - } else if (value === "oidc") { - externalForm.reset(); - setSelectedIdp(null); - } - }} - cols={2} - /> - - + {!inviteLink && userTypes.length > 1 ? ( + + + + {t("userTypeTitle")} + + + {t("userTypeDescription")} + + + + { + setUserType(value as UserType); + if (value === "internal") { + internalForm.reset(); + } else if (value === "oidc") { + externalForm.reset(); + setSelectedIdp(null); + } + }} + cols={2} + /> + + + ) : null} {userType === "internal" && dataLoaded && ( <> - - - - {t('userSettings')} - - - {t('userSettingsDescription')} - - - - -
- - ( - - - {t('email')} - - - - - - + {!inviteLink ? ( + + + + {t("userSettings")} + + + {t("userSettingsDescription")} + + + + + + - - {env.email.emailEnabled && ( -
- - setSendEmail( - e as boolean - ) - } - /> - -
- )} - - ( - - - {t('inviteValid')} - - - - {validFor.map( - ( - option - ) => ( - - { - option.name - } - - ) - )} - - - - - )} - /> + + + )} + /> - ( - - - {t('role')} - - + + + + + + + {validFor.map( + ( + option + ) => ( + + { + option.name + } + + ) + )} + + + + + )} + /> + + ( + + + {t("role")} + + + + + )} + /> + + {env.email.emailEnabled && ( +
+ + setSendEmail( + e as boolean + ) + } + /> + +
)} - /> - - {inviteLink && ( -
- {sendEmail && ( -

- {t('inviteEmailSentDescription')} -

- )} - {!sendEmail && ( -

- {t('inviteSentDescription')} -

- )} -

- {t('inviteExpiresIn', {days: expiresInDays})} -

- -
- )} - - -
-
-
+ + +
+
+
+ ) : ( + + + + {t("userInvited")} + + + {sendEmail + ? t( + "inviteEmailSentDescription" + ) + : t("inviteSentDescription")} + + + +
+

+ {t("inviteExpiresIn", { + days: expiresInDays + })} +

+ +
+
+
+ )} )} @@ -533,16 +562,16 @@ export default function Page() { - {t('idpTitle')} + {t("idpTitle")} - {t('idpSelect')} + {t("idpSelect")} {idps.length === 0 ? (

- {t('idpNotConfigured')} + {t("idpNotConfigured")}

) : (
@@ -596,10 +625,10 @@ export default function Page() { - {t('userSettings')} + {t("userSettings")} - {t('userSettingsDescription')} + {t("userSettingsDescription")} @@ -620,7 +649,9 @@ export default function Page() { render={({ field }) => ( - {t('username')} + {t( + "username" + )}

- {t('usernameUniq')} + {t( + "usernameUniq" + )}

@@ -643,7 +676,9 @@ export default function Page() { render={({ field }) => ( - {t('emailOptional')} + {t( + "emailOptional" + )} ( - {t('nameOptional')} + {t( + "nameOptional" + )} ( - {t('role')} + {t("role")} + + + + )} + /> + + ( + + Address + + + + + The address that this client will use for + connectivity. + + + + )} + /> + + ( + + Sites + { + form.setValue( + "siteIds", + newTags as [Tag, ...Tag[]] + ); + }} + enableAutocomplete={true} + autocompleteOptions={sites} + allowDuplicates={false} + restrictTagsToAutocompleteOptions={true} + sortTags={true} + /> + + The client will have connectivity to the + selected sites. The sites must be configured + to accept client connections. + + + + )} + /> + + {olmCommand && ( +
+
+
+ +
+
+ + You will only be able to see the configuration + once. + +
+ )} + +
+ + +
+ + +
+ ); +} diff --git a/src/app/[orgId]/settings/clients/CreateClientsModal.tsx b/src/app/[orgId]/settings/clients/CreateClientsModal.tsx new file mode 100644 index 00000000..a8921cb1 --- /dev/null +++ b/src/app/[orgId]/settings/clients/CreateClientsModal.tsx @@ -0,0 +1,80 @@ +"use client"; + +import { Button } from "@app/components/ui/button"; +import { useState } from "react"; +import { + Credenza, + CredenzaBody, + CredenzaClose, + CredenzaContent, + CredenzaDescription, + CredenzaFooter, + CredenzaHeader, + CredenzaTitle +} from "@app/components/Credenza"; +import CreateClientForm from "./CreateClientsForm"; +import { ClientRow } from "./ClientsTable"; + +type CreateClientFormProps = { + open: boolean; + setOpen: (open: boolean) => void; + onCreate?: (client: ClientRow) => void; + orgId: string; +}; + +export default function CreateClientFormModal({ + open, + setOpen, + onCreate, + orgId +}: CreateClientFormProps) { + const [loading, setLoading] = useState(false); + const [isChecked, setIsChecked] = useState(false); + + return ( + <> + { + setOpen(val); + setLoading(false); + }} + > + + + Create Client + + Create a new client to connect to your sites + + + +
+ setLoading(val)} + setChecked={(val) => setIsChecked(val)} + onCreate={onCreate} + orgId={orgId} + /> +
+
+ + + + + + +
+
+ + ); +} diff --git a/src/app/[orgId]/settings/clients/[clientId]/ClientInfoCard.tsx b/src/app/[orgId]/settings/clients/[clientId]/ClientInfoCard.tsx new file mode 100644 index 00000000..7117b4d5 --- /dev/null +++ b/src/app/[orgId]/settings/clients/[clientId]/ClientInfoCard.tsx @@ -0,0 +1,52 @@ +"use client"; + +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; +import { InfoIcon } from "lucide-react"; +import { useClientContext } from "@app/hooks/useClientContext"; +import { + InfoSection, + InfoSectionContent, + InfoSections, + InfoSectionTitle +} from "@app/components/InfoSection"; + +type ClientInfoCardProps = {}; + +export default function SiteInfoCard({}: ClientInfoCardProps) { + const { client, updateClient } = useClientContext(); + + return ( + + + Client Information + + + <> + + Status + + {client.online ? ( +
+
+ Online +
+ ) : ( +
+
+ Offline +
+ )} +
+
+ + + Address + + {client.subnet.split("/")[0]} + + +
+
+
+ ); +} diff --git a/src/app/[orgId]/settings/clients/[clientId]/general/page.tsx b/src/app/[orgId]/settings/clients/[clientId]/general/page.tsx new file mode 100644 index 00000000..e02e3aaa --- /dev/null +++ b/src/app/[orgId]/settings/clients/[clientId]/general/page.tsx @@ -0,0 +1,231 @@ +"use client"; + +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; +import { Button } from "@/components/ui/button"; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { useClientContext } from "@app/hooks/useClientContext"; +import { useForm } from "react-hook-form"; +import { toast } from "@app/hooks/useToast"; +import { useRouter } from "next/navigation"; +import { + SettingsContainer, + SettingsSection, + SettingsSectionHeader, + SettingsSectionTitle, + SettingsSectionDescription, + SettingsSectionBody, + SettingsSectionForm, + SettingsSectionFooter +} from "@app/components/Settings"; +import { formatAxiosError } from "@app/lib/api"; +import { createApiClient } from "@app/lib/api"; +import { useEnvContext } from "@app/hooks/useEnvContext"; +import { useEffect, useState } from "react"; +import { Tag, TagInput } from "@app/components/tags/tag-input"; +import { AxiosResponse } from "axios"; +import { ListSitesResponse } from "@server/routers/site"; + +const GeneralFormSchema = z.object({ + name: z.string().nonempty("Name is required"), + siteIds: z.array( + z.object({ + id: z.string(), + text: z.string() + }) + ) +}); + +type GeneralFormValues = z.infer; + +export default function GeneralPage() { + const { client, updateClient } = useClientContext(); + const api = createApiClient(useEnvContext()); + const [loading, setLoading] = useState(false); + const router = useRouter(); + const [sites, setSites] = useState([]); + const [clientSites, setClientSites] = useState([]); + const [activeSitesTagIndex, setActiveSitesTagIndex] = useState(null); + + const form = useForm({ + resolver: zodResolver(GeneralFormSchema), + defaultValues: { + name: client?.name, + siteIds: [] + }, + mode: "onChange" + }); + + // Fetch available sites and client's assigned sites + useEffect(() => { + const fetchSites = async () => { + try { + // Fetch all available sites + const res = await api.get>( + `/org/${client?.orgId}/sites/` + ); + + const availableSites = res.data.data.sites + .filter((s) => s.type === "newt" && s.subnet) + .map((site) => ({ + id: site.siteId.toString(), + text: site.name + })); + + setSites(availableSites); + + // Filter sites to only include those assigned to the client + const assignedSites = availableSites.filter((site) => + client?.siteIds?.includes(parseInt(site.id)) + ); + setClientSites(assignedSites); + // Set the default values for the form + form.setValue("siteIds", assignedSites); + } catch (e) { + toast({ + variant: "destructive", + title: "Failed to fetch sites", + description: formatAxiosError( + e, + "An error occurred while fetching sites." + ) + }); + } + }; + + if (client?.clientId) { + fetchSites(); + } + }, [client?.clientId, client?.orgId, api, form]); + + async function onSubmit(data: GeneralFormValues) { + setLoading(true); + + try { + await api.post(`/client/${client?.clientId}`, { + name: data.name, + siteIds: data.siteIds.map(site => site.id) + }); + + updateClient({ name: data.name }); + + toast({ + title: "Client updated", + description: "The client has been updated." + }); + + router.refresh(); + } catch (e) { + toast({ + variant: "destructive", + title: "Failed to update client", + description: formatAxiosError( + e, + "An error occurred while updating the client." + ) + }); + } finally { + setLoading(false); + } + } + + return ( + + + + + General Settings + + + Configure the general settings for this client + + + + + +
+ + ( + + Name + + + + + + This is the display name of the + client. + + + )} + /> + + ( + + Sites + { + form.setValue( + "siteIds", + newTags as [Tag, ...Tag[]] + ); + }} + enableAutocomplete={true} + autocompleteOptions={sites} + allowDuplicates={false} + restrictTagsToAutocompleteOptions={true} + sortTags={true} + /> + + The client will have connectivity to the + selected sites. The sites must be configured + to accept client connections. + + + + )} + /> + + +
+
+ + + + +
+
+ ); +} \ No newline at end of file diff --git a/src/app/[orgId]/settings/clients/[clientId]/layout.tsx b/src/app/[orgId]/settings/clients/[clientId]/layout.tsx new file mode 100644 index 00000000..804162a2 --- /dev/null +++ b/src/app/[orgId]/settings/clients/[clientId]/layout.tsx @@ -0,0 +1,57 @@ +import { internal } from "@app/lib/api"; +import { AxiosResponse } from "axios"; +import { authCookieHeader } from "@app/lib/api/cookies"; +import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; +import { GetClientResponse } from "@server/routers/client"; +import ClientInfoCard from "./ClientInfoCard"; +import ClientProvider from "@app/providers/ClientProvider"; +import { redirect } from "next/navigation"; +import { HorizontalTabs } from "@app/components/HorizontalTabs"; + +type SettingsLayoutProps = { + children: React.ReactNode; + params: Promise<{ clientId: number; orgId: string }>; +} + +export default async function SettingsLayout(props: SettingsLayoutProps) { + const params = await props.params; + + const { children } = props; + + let client = null; + try { + const res = await internal.get>( + `/org/${params.orgId}/client/${params.clientId}`, + await authCookieHeader() + ); + client = res.data.data; + } catch (error) { + console.error("Error fetching client data:", error); + redirect(`/${params.orgId}/settings/clients`); + } + + const navItems = [ + { + title: "General", + href: `/{orgId}/settings/clients/{clientId}/general` + } + ]; + + return ( + <> + + + +
+ + + {children} + +
+
+ + ); +} diff --git a/src/app/[orgId]/settings/clients/[clientId]/page.tsx b/src/app/[orgId]/settings/clients/[clientId]/page.tsx new file mode 100644 index 00000000..c484ec8c --- /dev/null +++ b/src/app/[orgId]/settings/clients/[clientId]/page.tsx @@ -0,0 +1,8 @@ +import { redirect } from "next/navigation"; + +export default async function ClientPage(props: { + params: Promise<{ orgId: string; clientId: number }>; +}) { + const params = await props.params; + redirect(`/${params.orgId}/settings/clients/${params.clientId}/general`); +} diff --git a/src/app/[orgId]/settings/clients/layout.tsx b/src/app/[orgId]/settings/clients/layout.tsx new file mode 100644 index 00000000..59a46414 --- /dev/null +++ b/src/app/[orgId]/settings/clients/layout.tsx @@ -0,0 +1,21 @@ +import { redirect } from "next/navigation"; +import { pullEnv } from "@app/lib/pullEnv"; + +export const dynamic = "force-dynamic"; + +interface SettingsLayoutProps { + children: React.ReactNode; + params: Promise<{ orgId: string }>; +} + +export default async function SettingsLayout(props: SettingsLayoutProps) { + const params = await props.params; + const { children } = props; + const env = pullEnv(); + + if (!env.flags.enableClients) { + redirect(`/${params.orgId}/settings`); + } + + return children; +} diff --git a/src/app/[orgId]/settings/clients/page.tsx b/src/app/[orgId]/settings/clients/page.tsx new file mode 100644 index 00000000..b798bf93 --- /dev/null +++ b/src/app/[orgId]/settings/clients/page.tsx @@ -0,0 +1,58 @@ +import { internal } from "@app/lib/api"; +import { authCookieHeader } from "@app/lib/api/cookies"; +import { AxiosResponse } from "axios"; +import { ClientRow } from "./ClientsTable"; +import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; +import { ListClientsResponse } from "@server/routers/client"; +import ClientsTable from "./ClientsTable"; + +type ClientsPageProps = { + params: Promise<{ orgId: string }>; +}; + +export const dynamic = "force-dynamic"; + +export default async function ClientsPage(props: ClientsPageProps) { + const params = await props.params; + let clients: ListClientsResponse["clients"] = []; + try { + const res = await internal.get>( + `/org/${params.orgId}/clients`, + await authCookieHeader() + ); + clients = res.data.data.clients; + } catch (e) {} + + function formatSize(mb: number): string { + if (mb >= 1024 * 1024) { + return `${(mb / (1024 * 1024)).toFixed(2)} TB`; + } else if (mb >= 1024) { + return `${(mb / 1024).toFixed(2)} GB`; + } else { + return `${mb.toFixed(2)} MB`; + } + } + + const clientRows: ClientRow[] = clients.map((client) => { + return { + name: client.name, + id: client.clientId, + subnet: client.subnet.split("/")[0], + mbIn: formatSize(client.megabytesIn || 0), + mbOut: formatSize(client.megabytesOut || 0), + orgId: params.orgId, + online: client.online + }; + }); + + return ( + <> + + + + + ); +} diff --git a/src/app/[orgId]/settings/domains/CreateDomainForm.tsx b/src/app/[orgId]/settings/domains/CreateDomainForm.tsx new file mode 100644 index 00000000..aeca2963 --- /dev/null +++ b/src/app/[orgId]/settings/domains/CreateDomainForm.tsx @@ -0,0 +1,444 @@ +"use client"; + +import { Button } from "@app/components/ui/button"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage +} from "@app/components/ui/form"; +import { Input } from "@app/components/ui/input"; +import { useToast } from "@app/hooks/useToast"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useState } from "react"; +import { useForm } from "react-hook-form"; +import { z } from "zod"; +import { + Credenza, + CredenzaBody, + CredenzaClose, + CredenzaContent, + CredenzaDescription, + CredenzaFooter, + CredenzaHeader, + CredenzaTitle +} from "@app/components/Credenza"; +import { createApiClient } from "@app/lib/api"; +import { useEnvContext } from "@app/hooks/useEnvContext"; +import { useTranslations } from "next-intl"; +import { formatAxiosError } from "@app/lib/api"; +import { CreateDomainResponse } from "@server/routers/domain/createOrgDomain"; +import { StrategySelect } from "@app/components/StrategySelect"; +import { AxiosResponse } from "axios"; +import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert"; +import { InfoIcon, AlertTriangle } from "lucide-react"; +import CopyToClipboard from "@app/components/CopyToClipboard"; +import { + InfoSection, + InfoSectionContent, + InfoSections, + InfoSectionTitle +} from "@app/components/InfoSection"; +import { useOrgContext } from "@app/hooks/useOrgContext"; +import { build } from "@server/build"; + +const formSchema = z.object({ + baseDomain: z.string().min(1, "Domain is required"), + type: z.enum(["ns", "cname", "wildcard"]) +}); + +type FormValues = z.infer; + +type CreateDomainFormProps = { + open: boolean; + setOpen: (open: boolean) => void; + onCreated?: (domain: CreateDomainResponse) => void; +}; + +export default function CreateDomainForm({ + open, + setOpen, + onCreated +}: CreateDomainFormProps) { + const [loading, setLoading] = useState(false); + const [createdDomain, setCreatedDomain] = + useState(null); + const api = createApiClient(useEnvContext()); + const t = useTranslations(); + const { toast } = useToast(); + const { org } = useOrgContext(); + + const form = useForm({ + resolver: zodResolver(formSchema), + defaultValues: { + baseDomain: "", + type: build == "oss" ? "wildcard" : "ns" + } + }); + + function reset() { + form.reset(); + setLoading(false); + setCreatedDomain(null); + } + + async function onSubmit(values: FormValues) { + setLoading(true); + try { + const response = await api.put>( + `/org/${org.org.orgId}/domain`, + values + ); + const domainData = response.data.data; + setCreatedDomain(domainData); + toast({ + title: t("success"), + description: t("domainCreatedDescription") + }); + onCreated?.(domainData); + } catch (e) { + toast({ + title: t("error"), + description: formatAxiosError(e), + variant: "destructive" + }); + } finally { + setLoading(false); + } + } + + const domainType = form.watch("type"); + const baseDomain = form.watch("baseDomain"); + + let domainOptions: any = []; + if (build == "enterprise" || build == "saas") { + domainOptions = [ + { + id: "ns", + title: t("selectDomainTypeNsName"), + description: t("selectDomainTypeNsDescription") + }, + { + id: "cname", + title: t("selectDomainTypeCnameName"), + description: t("selectDomainTypeCnameDescription") + } + ]; + } else if (build == "oss") { + domainOptions = [ + { + id: "wildcard", + title: t("selectDomainTypeWildcardName"), + description: t("selectDomainTypeWildcardDescription") + } + ]; + } + + return ( + { + setOpen(val); + reset(); + }} + > + + + {t("domainAdd")} + + {t("domainAddDescription")} + + + + {!createdDomain ? ( +
+ + ( + + + + + )} + /> + ( + + {t("domain")} + + + + + + )} + /> + + + ) : ( +
+ + + + Add DNS Records + + + Add the following DNS records to your domain + provider to complete the setup. + + + +
+ {domainType === "ns" && + createdDomain.nsRecords && ( +
+

+ NS Records +

+ + + + Record + + +
+
+ + Type: + + + NS + +
+
+ + Name: + + + {baseDomain} + +
+ + Value: + + {createdDomain.nsRecords.map( + ( + nsRecord, + index + ) => ( +
+ +
+ ) + )} +
+
+
+
+
+ )} + + {domainType === "cname" || + (domainType == "wildcard" && ( + <> + {createdDomain.cnameRecords && + createdDomain.cnameRecords + .length > 0 && ( +
+

+ CNAME Records +

+ + {createdDomain.cnameRecords.map( + ( + cnameRecord, + index + ) => ( + + + Record{" "} + {index + + 1} + + +
+
+ + Type: + + + CNAME + +
+
+ + Name: + + + { + cnameRecord.baseDomain + } + +
+
+ + Value: + + +
+
+
+
+ ) + )} +
+
+ )} + + {createdDomain.txtRecords && + createdDomain.txtRecords + .length > 0 && ( +
+

+ TXT Records +

+ + {createdDomain.txtRecords.map( + ( + txtRecord, + index + ) => ( + + + Record{" "} + {index + + 1} + + +
+
+ + Type: + + + TXT + +
+
+ + Name: + + + { + txtRecord.baseDomain + } + +
+
+ + Value: + + +
+
+
+
+ ) + )} +
+
+ )} + + ))} +
+ + {build == "saas" || + (build == "enterprise" && ( + + + + Save These Records + + + Make sure to save these DNS records + as you will not see them again. + + + ))} + + + + + DNS Propagation + + + DNS changes may take some time to propagate + across the internet. This can take anywhere + from a few minutes to 48 hours, depending on + your DNS provider and TTL settings. + + +
+ )} +
+ + + + + {!createdDomain && ( + + )} + +
+
+ ); +} diff --git a/src/app/[orgId]/settings/domains/DomainsDataTable.tsx b/src/app/[orgId]/settings/domains/DomainsDataTable.tsx new file mode 100644 index 00000000..2008f0e8 --- /dev/null +++ b/src/app/[orgId]/settings/domains/DomainsDataTable.tsx @@ -0,0 +1,37 @@ +"use client"; + +import { ColumnDef } from "@tanstack/react-table"; +import { DataTable } from "@app/components/ui/data-table"; +import { useTranslations } from "next-intl"; + +interface DataTableProps { + columns: ColumnDef[]; + data: TData[]; + onAdd?: () => void; + onRefresh?: () => void; + isRefreshing?: boolean; +} + +export function DomainsDataTable({ + columns, + data, + onAdd, + onRefresh, + isRefreshing +}: DataTableProps) { + const t = useTranslations(); + + return ( + + ); +} diff --git a/src/app/[orgId]/settings/domains/DomainsTable.tsx b/src/app/[orgId]/settings/domains/DomainsTable.tsx new file mode 100644 index 00000000..1f73d5ae --- /dev/null +++ b/src/app/[orgId]/settings/domains/DomainsTable.tsx @@ -0,0 +1,263 @@ +"use client"; + +import { ColumnDef } from "@tanstack/react-table"; +import { DomainsDataTable } from "./DomainsDataTable"; +import { Button } from "@app/components/ui/button"; +import { ArrowUpDown } from "lucide-react"; +import { 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 "./CreateDomainForm"; +import { useToast } from "@app/hooks/useToast"; +import { useOrgContext } from "@app/hooks/useOrgContext"; + +export type DomainRow = { + domainId: string; + baseDomain: string; + type: string; + verified: boolean; + failed: boolean; + tries: number; +}; + +type Props = { + domains: DomainRow[]; +}; + +export default function DomainsTable({ domains }: Props) { + const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); + const [isCreateModalOpen, setIsCreateModalOpen] = useState(false); + const [selectedDomain, setSelectedDomain] = useState( + null + ); + const [isRefreshing, setIsRefreshing] = useState(false); + const [restartingDomains, setRestartingDomains] = useState>(new Set()); + const api = createApiClient(useEnvContext()); + const router = useRouter(); + const t = useTranslations(); + const { toast } = useToast(); + const { org } = useOrgContext(); + + const refreshData = async () => { + setIsRefreshing(true); + try { + router.refresh(); + } catch (error) { + toast({ + title: t("error"), + description: t("refreshError"), + variant: "destructive" + }); + } finally { + setIsRefreshing(false); + } + }; + + const deleteDomain = async (domainId: string) => { + try { + await api.delete(`/org/${org.org.orgId}/domain/${domainId}`); + toast({ + title: t("success"), + description: t("domainDeletedDescription") + }); + setIsDeleteModalOpen(false); + refreshData(); + } catch (e) { + toast({ + title: t("error"), + description: formatAxiosError(e), + variant: "destructive" + }); + } + }; + + const restartDomain = async (domainId: string) => { + setRestartingDomains(prev => new Set(prev).add(domainId)); + try { + await api.post(`/org/${org.org.orgId}/domain/${domainId}/restart`); + toast({ + title: t("success"), + description: t("domainRestartedDescription", { fallback: "Domain verification restarted successfully" }) + }); + refreshData(); + } catch (e) { + toast({ + title: t("error"), + description: formatAxiosError(e), + variant: "destructive" + }); + } finally { + setRestartingDomains(prev => { + const newSet = new Set(prev); + newSet.delete(domainId); + return newSet; + }); + } + }; + + const getTypeDisplay = (type: string) => { + switch (type) { + case "ns": + return t("selectDomainTypeNsName"); + case "cname": + return t("selectDomainTypeCnameName"); + case "wildcard": + return t("selectDomainTypeWildcardName"); + default: + return type; + } + }; + + const columns: ColumnDef[] = [ + { + accessorKey: "baseDomain", + header: ({ column }) => { + return ( + + ); + } + }, + { + accessorKey: "type", + header: ({ column }) => { + return ( + + ); + }, + cell: ({ row }) => { + const type = row.original.type; + return ( + {getTypeDisplay(type)} + ); + } + }, + { + accessorKey: "verified", + header: ({ column }) => { + return ( + + ); + }, + cell: ({ row }) => { + const { verified, failed } = row.original; + if (verified) { + return {t("verified")}; + } else if (failed) { + return {t("failed", { fallback: "Failed" })}; + } else { + return {t("pending")}; + } + } + }, + { + id: "actions", + cell: ({ row }) => { + const domain = row.original; + const isRestarting = restartingDomains.has(domain.domainId); + + return ( +
+ {domain.failed && ( + + )} + +
+ ); + } + } + ]; + + return ( + <> + {selectedDomain && ( + { + setIsDeleteModalOpen(val); + setSelectedDomain(null); + }} + dialog={ +
+

+ {t("domainQuestionRemove", { + domain: selectedDomain.baseDomain + })} +

+

+ {t("domainMessageRemove")} +

+

{t("domainMessageConfirm")}

+
+ } + buttonText={t("domainConfirmDelete")} + onConfirm={async () => + deleteDomain(selectedDomain.domainId) + } + string={selectedDomain.baseDomain} + title={t("domainDelete")} + /> + )} + + { + refreshData(); + }} + /> + + setIsCreateModalOpen(true)} + onRefresh={refreshData} + isRefreshing={isRefreshing} + /> + + ); +} diff --git a/src/app/[orgId]/settings/domains/page.tsx b/src/app/[orgId]/settings/domains/page.tsx new file mode 100644 index 00000000..d20e431f --- /dev/null +++ b/src/app/[orgId]/settings/domains/page.tsx @@ -0,0 +1,60 @@ +import { internal } from "@app/lib/api"; +import { authCookieHeader } from "@app/lib/api/cookies"; +import { AxiosResponse } from "axios"; +import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; +import DomainsTable, { DomainRow } from "./DomainsTable"; +import { getTranslations } from "next-intl/server"; +import { cache } from "react"; +import { GetOrgResponse } from "@server/routers/org"; +import { redirect } from "next/navigation"; +import OrgProvider from "@app/providers/OrgProvider"; +import { ListDomainsResponse } from "@server/routers/domain"; + +type Props = { + params: Promise<{ orgId: string }>; +}; + +export default async function DomainsPage(props: Props) { + const params = await props.params; + + let domains: DomainRow[] = []; + try { + const res = await internal.get< + AxiosResponse + >(`/org/${params.orgId}/domains`, await authCookieHeader()); + domains = res.data.data.domains as DomainRow[]; + } catch (e) { + console.error(e); + } + + let org = null; + try { + const getOrg = cache(async () => + internal.get>( + `/org/${params.orgId}`, + await authCookieHeader() + ) + ); + const res = await getOrg(); + org = res.data.data; + } catch { + redirect(`/${params.orgId}`); + } + + if (!org) { + } + + const t = await getTranslations(); + + return ( + <> + + + + + + ); +} diff --git a/src/app/[orgId]/settings/general/page.tsx b/src/app/[orgId]/settings/general/page.tsx index c692fbc9..b986c832 100644 --- a/src/app/[orgId]/settings/general/page.tsx +++ b/src/app/[orgId]/settings/general/page.tsx @@ -1,5 +1,4 @@ "use client"; - import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog"; import { Button } from "@app/components/ui/button"; import { useOrgContext } from "@app/hooks/useOrgContext"; @@ -22,17 +21,9 @@ import { zodResolver } from "@hookform/resolvers/zod"; import { createApiClient } from "@app/lib/api"; import { useEnvContext } from "@app/hooks/useEnvContext"; import { formatAxiosError } from "@app/lib/api"; -import { AlertTriangle, Trash2 } from "lucide-react"; -import { - Card, - CardContent, - CardFooter, - CardHeader, - CardTitle -} from "@/components/ui/card"; import { AxiosResponse } from "axios"; import { DeleteOrgResponse, ListUserOrgsResponse } from "@server/routers/org"; -import { redirect, useRouter } from "next/navigation"; +import { useRouter } from "next/navigation"; import { SettingsContainer, SettingsSection, @@ -44,23 +35,26 @@ import { SettingsSectionFooter } from "@app/components/Settings"; import { useUserContext } from "@app/hooks/useUserContext"; -import { useTranslations } from 'next-intl'; +import { useTranslations } from "next-intl"; +import { build } from "@server/build"; +// Updated schema to include subnet field const GeneralFormSchema = z.object({ - name: z.string() + name: z.string(), + subnet: z.string().optional() }); type GeneralFormValues = z.infer; export default function GeneralPage() { const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); - const { orgUser } = userOrgUserContext(); const router = useRouter(); const { org } = useOrgContext(); const api = createApiClient(useEnvContext()); const { user } = useUserContext(); const t = useTranslations(); + const { env } = useEnvContext(); const [loadingDelete, setLoadingDelete] = useState(false); const [loadingSave, setLoadingSave] = useState(false); @@ -68,7 +62,8 @@ export default function GeneralPage() { const form = useForm({ resolver: zodResolver(GeneralFormSchema), defaultValues: { - name: org?.org.name + name: org?.org.name, + subnet: org?.org.subnet || "" // Add default value for subnet }, mode: "onChange" }); @@ -79,12 +74,10 @@ export default function GeneralPage() { const res = await api.delete>( `/org/${org?.org.orgId}` ); - toast({ - title: t('orgDeleted'), - description: t('orgDeletedMessage') + title: t("orgDeleted"), + description: t("orgDeletedMessage") }); - if (res.status === 200) { pickNewOrgAndNavigate(); } @@ -92,8 +85,8 @@ export default function GeneralPage() { console.error(err); toast({ variant: "destructive", - title: t('orgErrorDelete'), - description: formatAxiosError(err, t('orgErrorDeleteMessage')) + title: t("orgErrorDelete"), + description: formatAxiosError(err, t("orgErrorDeleteMessage")) }); } finally { setLoadingDelete(false); @@ -120,8 +113,8 @@ export default function GeneralPage() { console.error(err); toast({ variant: "destructive", - title: t('orgErrorFetch'), - description: formatAxiosError(err, t('orgErrorFetchMessage')) + title: t("orgErrorFetch"), + description: formatAxiosError(err, t("orgErrorFetchMessage")) }); } } @@ -130,21 +123,21 @@ export default function GeneralPage() { setLoadingSave(true); await api .post(`/org/${org?.org.orgId}`, { - name: data.name + name: data.name, + subnet: data.subnet // Include subnet in the API request }) .then(() => { toast({ - title: t('orgUpdated'), - description: t('orgUpdatedDescription') + title: t("orgUpdated"), + description: t("orgUpdatedDescription") }); - router.refresh(); }) .catch((e) => { toast({ variant: "destructive", - title: t('orgErrorUpdate'), - description: formatAxiosError(e, t('orgErrorUpdateMessage')) + title: t("orgErrorUpdate"), + description: formatAxiosError(e, t("orgErrorUpdateMessage")) }); }) .finally(() => { @@ -162,32 +155,28 @@ export default function GeneralPage() { dialog={

- {t('orgQuestionRemove', {selectedOrg: org?.org.name})} -

-

- {t('orgMessageRemove')} -

-

- {t('orgMessageConfirm')} + {t("orgQuestionRemove", { + selectedOrg: org?.org.name + })}

+

{t("orgMessageRemove")}

+

{t("orgMessageConfirm")}

} - buttonText={t('orgDeleteConfirm')} + buttonText={t("orgDeleteConfirm")} onConfirm={deleteOrg} string={org?.org.name || ""} - title={t('orgDelete')} + title={t("orgDelete")} /> - - {t('orgGeneralSettings')} + {t("orgGeneralSettings")} - {t('orgGeneralSettingsDescription')} + {t("orgGeneralSettingsDescription")} -
@@ -201,22 +190,44 @@ export default function GeneralPage() { name="name" render={({ field }) => ( - {t('name')} + {t("name")} - {t('orgDisplayName')} + {t("orgDisplayName")} )} /> + {env.flags.enableClients && ( + ( + + Subnet + + + + + + The subnet for this + organization's network + configuration. + + + )} + /> + )}
- - -
- - - - {t('orgDangerZone')} - - {t('orgDangerZoneDescription')} - - - - - + {build === "oss" && ( + + + + {t("orgDangerZone")} + + + {t("orgDangerZoneDescription")} + + + + + + + )} ); } diff --git a/src/app/[orgId]/settings/layout.tsx b/src/app/[orgId]/settings/layout.tsx index 215f554f..7db530dd 100644 --- a/src/app/[orgId]/settings/layout.tsx +++ b/src/app/[orgId]/settings/layout.tsx @@ -1,16 +1,18 @@ import { Metadata } from "next"; import { - Cog, Combine, + KeyRound, LinkIcon, Settings, Users, - Waypoints + Waypoints, + Workflow } from "lucide-react"; import { verifySession } from "@app/lib/auth/verifySession"; import { redirect } from "next/navigation"; import { internal } from "@app/lib/api"; import { AxiosResponse } from "axios"; +import { ListOrgsResponse } from "@server/routers/org"; import { GetOrgResponse, ListUserOrgsResponse } from "@server/routers/org"; import { authCookieHeader } from "@app/lib/api/cookies"; import { cache } from "react"; @@ -18,8 +20,9 @@ import { GetOrgUserResponse } from "@server/routers/user"; import UserProvider from "@app/providers/UserProvider"; import { Layout } from "@app/components/Layout"; import { SidebarNavItem, SidebarNavProps } from "@app/components/SidebarNav"; -import { orgNavItems } from "@app/app/navigation"; -import { getTranslations } from 'next-intl/server'; +import { getTranslations } from "next-intl/server"; +import { pullEnv } from "@app/lib/pullEnv"; +import { orgNavSections } from "@app/app/navigation"; export const dynamic = "force-dynamic"; @@ -41,6 +44,8 @@ export default async function SettingsLayout(props: SettingsLayoutProps) { const getUser = cache(verifySession); const user = await getUser(); + const env = pullEnv(); + if (!user) { redirect(`/`); } @@ -59,7 +64,7 @@ export default async function SettingsLayout(props: SettingsLayoutProps) { const orgUser = await getOrgUser(); if (!orgUser.data.data.isAdmin && !orgUser.data.data.isOwner) { - throw new Error(t('userErrorNotAdminOrOwner')); + throw new Error(t("userErrorNotAdminOrOwner")); } } catch { redirect(`/${params.orgId}`); @@ -81,7 +86,7 @@ export default async function SettingsLayout(props: SettingsLayoutProps) { return ( - + {children} diff --git a/src/app/[orgId]/settings/resources/ResourcesTable.tsx b/src/app/[orgId]/settings/resources/ResourcesTable.tsx index 7c6f4340..e64fb4e3 100644 --- a/src/app/[orgId]/settings/resources/ResourcesTable.tsx +++ b/src/app/[orgId]/settings/resources/ResourcesTable.tsx @@ -31,7 +31,9 @@ import CopyToClipboard from "@app/components/CopyToClipboard"; import { Switch } from "@app/components/ui/switch"; import { AxiosResponse } from "axios"; import { UpdateResourceResponse } from "@server/routers/resource"; -import { useTranslations } from 'next-intl'; +import { useTranslations } from "next-intl"; +import { InfoPopup } from "@app/components/ui/info-popup"; +import { Badge } from "@app/components/ui/badge"; export type ResourceRow = { id: number; @@ -45,6 +47,7 @@ export type ResourceRow = { protocol: string; proxyPort: number | null; enabled: boolean; + domainId?: string; }; type ResourcesTableProps = { @@ -65,11 +68,11 @@ export default function SitesTable({ resources, orgId }: ResourcesTableProps) { const deleteResource = (resourceId: number) => { api.delete(`/resource/${resourceId}`) .catch((e) => { - console.error(t('resourceErrorDelte'), e); + console.error(t("resourceErrorDelte"), e); toast({ variant: "destructive", - title: t('resourceErrorDelte'), - description: formatAxiosError(e, t('resourceErrorDelte')) + title: t("resourceErrorDelte"), + description: formatAxiosError(e, t("resourceErrorDelte")) }); }) .then(() => { @@ -89,50 +92,16 @@ export default function SitesTable({ resources, orgId }: ResourcesTableProps) { .catch((e) => { toast({ variant: "destructive", - title: t('resourcesErrorUpdate'), - description: formatAxiosError(e, t('resourcesErrorUpdateDescription')) + title: t("resourcesErrorUpdate"), + description: formatAxiosError( + e, + t("resourcesErrorUpdateDescription") + ) }); }); } const columns: ColumnDef[] = [ - { - accessorKey: "dots", - header: "", - cell: ({ row }) => { - const resourceRow = row.original; - const router = useRouter(); - - return ( - - - - - - - - {t('viewSettings')} - - - { - setSelectedResource(resourceRow); - setIsDeleteModalOpen(true); - }} - > - {t('delete')} - - - - ); - } - }, { accessorKey: "name", header: ({ column }) => { @@ -143,7 +112,7 @@ export default function SitesTable({ resources, orgId }: ResourcesTableProps) { column.toggleSorting(column.getIsSorted() === "asc") } > - {t('name')} + {t("name")} ); @@ -159,7 +128,7 @@ export default function SitesTable({ resources, orgId }: ResourcesTableProps) { column.toggleSorting(column.getIsSorted() === "asc") } > - {t('site')} + {t("site")} ); @@ -170,7 +139,7 @@ export default function SitesTable({ resources, orgId }: ResourcesTableProps) { - @@ -180,7 +149,7 @@ export default function SitesTable({ resources, orgId }: ResourcesTableProps) { }, { accessorKey: "protocol", - header: t('protocol'), + header: t("protocol"), cell: ({ row }) => { const resourceRow = row.original; return {resourceRow.protocol.toUpperCase()}; @@ -188,16 +157,21 @@ export default function SitesTable({ resources, orgId }: ResourcesTableProps) { }, { accessorKey: "domain", - header: t('access'), + header: t("access"), cell: ({ row }) => { const resourceRow = row.original; return ( -
+
{!resourceRow.http ? ( + ) : !resourceRow.domainId ? ( + ) : ( - {t('authentication')} + {t("authentication")} ); @@ -230,12 +204,12 @@ export default function SitesTable({ resources, orgId }: ResourcesTableProps) { {resourceRow.authState === "protected" ? ( - {t('protected')} + {t("protected")} ) : resourceRow.authState === "not_protected" ? ( - {t('notProtected')} + {t("notProtected")} ) : ( - @@ -246,10 +220,15 @@ export default function SitesTable({ resources, orgId }: ResourcesTableProps) { }, { accessorKey: "enabled", - header: t('enabled'), + header: t("enabled"), cell: ({ row }) => ( toggleResourceEnabled(val, row.original.id) } @@ -262,11 +241,45 @@ export default function SitesTable({ resources, orgId }: ResourcesTableProps) { const resourceRow = row.original; return (
+ + + + + + + + {t("viewSettings")} + + + { + setSelectedResource(resourceRow); + setIsDeleteModalOpen(true); + }} + > + + {t("delete")} + + + + - @@ -288,22 +301,22 @@ export default function SitesTable({ resources, orgId }: ResourcesTableProps) { dialog={

- {t('resourceQuestionRemove', {selectedResource: selectedResource?.name || selectedResource?.id})} + {t("resourceQuestionRemove", { + selectedResource: + selectedResource?.name || + selectedResource?.id + })}

-

- {t('resourceMessageRemove')} -

+

{t("resourceMessageRemove")}

-

- {t('resourceMessageConfirm')} -

+

{t("resourceMessageConfirm")}

} - buttonText={t('resourceDeleteConfirm')} + buttonText={t("resourceDeleteConfirm")} onConfirm={async () => deleteResource(selectedResource!.id)} string={selectedResource.name} - title={t('resourceDelete')} + title={t("resourceDelete")} /> )} diff --git a/src/app/[orgId]/settings/resources/[resourceId]/ResourceInfoBox.tsx b/src/app/[orgId]/settings/resources/[resourceId]/ResourceInfoBox.tsx index 7ccc5e50..717e4d49 100644 --- a/src/app/[orgId]/settings/resources/[resourceId]/ResourceInfoBox.tsx +++ b/src/app/[orgId]/settings/resources/[resourceId]/ResourceInfoBox.tsx @@ -10,10 +10,14 @@ import { InfoSections, InfoSectionTitle } from "@app/components/InfoSection"; -import { createApiClient } from "@app/lib/api"; import { useEnvContext } from "@app/hooks/useEnvContext"; import { useDockerSocket } from "@app/hooks/useDockerSocket"; import { useTranslations } from "next-intl"; +import { AxiosResponse } from "axios"; +import { useEffect, useState } from "react"; +import { Button } from "@/components/ui/button"; +import { RotateCw } from "lucide-react"; +import { createApiClient } from "@app/lib/api"; type ResourceInfoBoxType = {}; diff --git a/src/app/[orgId]/settings/resources/[resourceId]/authentication/page.tsx b/src/app/[orgId]/settings/resources/[resourceId]/authentication/page.tsx index 6182c04a..c8f6255c 100644 --- a/src/app/[orgId]/settings/resources/[resourceId]/authentication/page.tsx +++ b/src/app/[orgId]/settings/resources/[resourceId]/authentication/page.tsx @@ -205,10 +205,10 @@ export default function ResourceAuthenticationPage() { console.error(e); toast({ variant: "destructive", - title: t('resourceErrorAuthFetch'), + title: t("resourceErrorAuthFetch"), description: formatAxiosError( e, - t('resourceErrorAuthFetchDescription') + t("resourceErrorAuthFetchDescription") ) }); } @@ -235,18 +235,18 @@ export default function ResourceAuthenticationPage() { }); toast({ - title: t('resourceWhitelistSave'), - description: t('resourceWhitelistSaveDescription') + title: t("resourceWhitelistSave"), + description: t("resourceWhitelistSaveDescription") }); router.refresh(); } catch (e) { console.error(e); toast({ variant: "destructive", - title: t('resourceErrorWhitelistSave'), + title: t("resourceErrorWhitelistSave"), description: formatAxiosError( e, - t('resourceErrorWhitelistSaveDescription') + t("resourceErrorWhitelistSaveDescription") ) }); } finally { @@ -283,18 +283,18 @@ export default function ResourceAuthenticationPage() { }); toast({ - title: t('resourceAuthSettingsSave'), - description: t('resourceAuthSettingsSaveDescription') + title: t("resourceAuthSettingsSave"), + description: t("resourceAuthSettingsSaveDescription") }); router.refresh(); } catch (e) { console.error(e); toast({ variant: "destructive", - title: t('resourceErrorUsersRolesSave'), + title: t("resourceErrorUsersRolesSave"), description: formatAxiosError( e, - t('resourceErrorUsersRolesSaveDescription') + t("resourceErrorUsersRolesSaveDescription") ) }); } finally { @@ -310,8 +310,8 @@ export default function ResourceAuthenticationPage() { }) .then(() => { toast({ - title: t('resourcePasswordRemove'), - description: t('resourcePasswordRemoveDescription') + title: t("resourcePasswordRemove"), + description: t("resourcePasswordRemoveDescription") }); updateAuthInfo({ @@ -322,10 +322,10 @@ export default function ResourceAuthenticationPage() { .catch((e) => { toast({ variant: "destructive", - title: t('resourceErrorPasswordRemove'), + title: t("resourceErrorPasswordRemove"), description: formatAxiosError( e, - t('resourceErrorPasswordRemoveDescription') + t("resourceErrorPasswordRemoveDescription") ) }); }) @@ -340,8 +340,8 @@ export default function ResourceAuthenticationPage() { }) .then(() => { toast({ - title: t('resourcePincodeRemove'), - description: t('resourcePincodeRemoveDescription') + title: t("resourcePincodeRemove"), + description: t("resourcePincodeRemoveDescription") }); updateAuthInfo({ @@ -352,10 +352,10 @@ export default function ResourceAuthenticationPage() { .catch((e) => { toast({ variant: "destructive", - title: t('resourceErrorPincodeRemove'), + title: t("resourceErrorPincodeRemove"), description: formatAxiosError( e, - t('resourceErrorPincodeRemoveDescription') + t("resourceErrorPincodeRemoveDescription") ) }); }) @@ -400,140 +400,151 @@ export default function ResourceAuthenticationPage() { - {t('resourceUsersRoles')} + {t("resourceUsersRoles")} - {t('resourceUsersRolesDescription')} + {t("resourceUsersRolesDescription")} - setSsoEnabled(val)} - /> + + setSsoEnabled(val)} + /> -
- - {ssoEnabled && ( - <> - ( - - {t('roles')} - - { - usersRolesForm.setValue( - "roles", - newRoles as [ - Tag, - ...Tag[] - ] - ); - }} - enableAutocomplete={ - true - } - autocompleteOptions={ - allRoles - } - allowDuplicates={ - false - } - restrictTagsToAutocompleteOptions={ - true - } - sortTags={true} - /> - - - - {t('resourceRoleDescription')} - - - )} - /> - ( - - {t('users')} - - { - usersRolesForm.setValue( - "users", - newUsers as [ - Tag, - ...Tag[] - ] - ); - }} - enableAutocomplete={ - true - } - autocompleteOptions={ - allUsers - } - allowDuplicates={ - false - } - restrictTagsToAutocompleteOptions={ - true - } - sortTags={true} - /> - - - - )} - /> - - )} - - +
+ + {ssoEnabled && ( + <> + ( + + + {t("roles")} + + + { + usersRolesForm.setValue( + "roles", + newRoles as [ + Tag, + ...Tag[] + ] + ); + }} + enableAutocomplete={ + true + } + autocompleteOptions={ + allRoles + } + allowDuplicates={ + false + } + restrictTagsToAutocompleteOptions={ + true + } + sortTags={true} + /> + + + + {t( + "resourceRoleDescription" + )} + + + )} + /> + ( + + + {t("users")} + + + { + usersRolesForm.setValue( + "users", + newUsers as [ + Tag, + ...Tag[] + ] + ); + }} + enableAutocomplete={ + true + } + autocompleteOptions={ + allUsers + } + allowDuplicates={ + false + } + restrictTagsToAutocompleteOptions={ + true + } + sortTags={true} + /> + + + + )} + /> + + )} + + +
@@ -550,170 +561,195 @@ export default function ResourceAuthenticationPage() { - {t('resourceAuthMethods')} + {t("resourceAuthMethods")} - {t('resourceAuthMethodsDescriptions')} + {t("resourceAuthMethodsDescriptions")} - {/* Password Protection */} -
-
- - - {t('resourcePasswordProtection', {status: authInfo.password? t('enabled') : t('disabled')})} - + + {/* Password Protection */} +
+
+ + + {t("resourcePasswordProtection", { + status: authInfo.password + ? t("enabled") + : t("disabled") + })} + +
+
- -
- {/* PIN Code Protection */} -
-
- - - {t('resourcePincodeProtection', {status: authInfo.pincode ? t('enabled') : t('disabled')})} - + {/* PIN Code Protection */} +
+
+ + + {t("resourcePincodeProtection", { + status: authInfo.pincode + ? t("enabled") + : t("disabled") + })} + +
+
- -
+ - {t('otpEmailTitle')} + {t("otpEmailTitle")} - {t('otpEmailTitleDescription')} + {t("otpEmailTitleDescription")} - {!env.email.emailEnabled && ( - - - - {t('otpEmailSmtpRequired')} - - - {t('otpEmailSmtpRequiredDescription')} - - - )} - + + {!env.email.emailEnabled && ( + + + + {t("otpEmailSmtpRequired")} + + + {t("otpEmailSmtpRequiredDescription")} + + + )} + - {whitelistEnabled && env.email.emailEnabled && ( -
- - ( - - - - - - {/* @ts-ignore */} - { - return z - .string() - .email() - .or( - z - .string() - .regex( - /^\*@[\w.-]+\.[a-zA-Z]{2,}$/, - { - message: t('otpEmailErrorInvalid') - } - ) - ) - .safeParse( - tag - ).success; - }} - setActiveTagIndex={ - setActiveEmailTagIndex - } - placeholder={t('otpEmailEnter')} - tags={ - whitelistForm.getValues() - .emails - } - setTags={( - newRoles - ) => { - whitelistForm.setValue( - "emails", - newRoles as [ - Tag, - ...Tag[] - ] - ); - }} - allowDuplicates={ - false - } - sortTags={true} - /> - - - {t('otpEmailEnterDescription')} - - - )} - /> - - - )} + {whitelistEnabled && env.email.emailEnabled && ( +
+ + ( + + + + + + {/* @ts-ignore */} + { + return z + .string() + .email() + .or( + z + .string() + .regex( + /^\*@[\w.-]+\.[a-zA-Z]{2,}$/, + { + message: + t( + "otpEmailErrorInvalid" + ) + } + ) + ) + .safeParse( + tag + ).success; + }} + setActiveTagIndex={ + setActiveEmailTagIndex + } + placeholder={t( + "otpEmailEnter" + )} + tags={ + whitelistForm.getValues() + .emails + } + setTags={( + newRoles + ) => { + whitelistForm.setValue( + "emails", + newRoles as [ + Tag, + ...Tag[] + ] + ); + }} + allowDuplicates={ + false + } + sortTags={true} + /> + + + {t( + "otpEmailEnterDescription" + )} + + + )} + /> + + + )} +
diff --git a/src/app/[orgId]/settings/resources/[resourceId]/general/page.tsx b/src/app/[orgId]/settings/resources/[resourceId]/general/page.tsx index a0c89773..df04ec2a 100644 --- a/src/app/[orgId]/settings/resources/[resourceId]/general/page.tsx +++ b/src/app/[orgId]/settings/resources/[resourceId]/general/page.tsx @@ -66,6 +66,18 @@ import { } from "@server/routers/resource"; import { SwitchInput } from "@app/components/SwitchInput"; import { useTranslations } from "next-intl"; +import { + Credenza, + CredenzaBody, + CredenzaClose, + CredenzaContent, + CredenzaDescription, + CredenzaFooter, + CredenzaHeader, + CredenzaTitle +} from "@app/components/Credenza"; +import DomainPicker from "@app/components/DomainPicker"; +import { Globe } from "lucide-react"; const TransferFormSchema = z.object({ siteId: z.number() @@ -80,6 +92,7 @@ export default function GeneralForm() { const { org } = useOrgContext(); const router = useRouter(); const t = useTranslations(); + const [editDomainOpen, setEditDomainOpen] = useState(false); const { env } = useEnvContext(); @@ -99,56 +112,31 @@ export default function GeneralForm() { const [domainType, setDomainType] = useState<"subdomain" | "basedomain">( resource.isBaseDomain ? "basedomain" : "subdomain" ); + const [resourceFullDomain, setResourceFullDomain] = useState( + `${resource.ssl ? "https" : "http"}://${resource.fullDomain}` + ); + const [selectedDomain, setSelectedDomain] = useState<{ + domainId: string; + subdomain?: string; + fullDomain: string; + } | null>(null); const GeneralFormSchema = z .object({ + enabled: z.boolean(), subdomain: z.string().optional(), name: z.string().min(1).max(255), - proxyPort: z.number().optional(), - http: z.boolean(), - isBaseDomain: z.boolean().optional(), domainId: z.string().optional() - }) - .refine( - (data) => { - if (!data.http) { - return z - .number() - .int() - .min(1) - .max(65535) - .safeParse(data.proxyPort).success; - } - return true; - }, - { - message: t("proxyErrorInvalidPort"), - path: ["proxyPort"] - } - ) - .refine( - (data) => { - if (data.http && !data.isBaseDomain) { - return subdomainSchema.safeParse(data.subdomain).success; - } - return true; - }, - { - message: t("subdomainErrorInvalid"), - path: ["subdomain"] - } - ); + }); type GeneralFormValues = z.infer; const form = useForm({ resolver: zodResolver(GeneralFormSchema), defaultValues: { + enabled: resource.enabled, name: resource.name, subdomain: resource.subdomain ? resource.subdomain : undefined, - proxyPort: resource.proxyPort ? resource.proxyPort : undefined, - http: resource.http, - isBaseDomain: resource.isBaseDomain ? true : false, domainId: resource.domainId || undefined }, mode: "onChange" @@ -209,11 +197,10 @@ export default function GeneralForm() { .post>( `resource/${resource?.resourceId}`, { + enabled: data.enabled, name: data.name, - subdomain: data.http ? data.subdomain : undefined, - proxyPort: data.proxyPort, - isBaseDomain: data.http ? data.isBaseDomain : undefined, - domainId: data.http ? data.domainId : undefined + subdomain: data.subdomain, + domainId: data.domainId, } ) .catch((e) => { @@ -236,10 +223,9 @@ export default function GeneralForm() { const resource = res.data.data; updateResource({ + enabled: data.enabled, name: data.name, subdomain: data.subdomain, - proxyPort: data.proxyPort, - isBaseDomain: data.isBaseDomain, fullDomain: resource.fullDomain }); @@ -282,469 +268,292 @@ export default function GeneralForm() { setTransferLoading(false); } - async function toggleResourceEnabled(val: boolean) { - const res = await api - .post>( - `resource/${resource.resourceId}`, - { - enabled: val - } - ) - .catch((e) => { - toast({ - variant: "destructive", - title: t("resourceErrorToggle"), - description: formatAxiosError( - e, - t("resourceErrorToggleDescription") - ) - }); - }); - - updateResource({ - enabled: val - }); - } - return ( !loadingPage && ( - - - - - {t("resourceVisibilityTitle")} - - - {t("resourceVisibilityTitleDescription")} - - - - { - await toggleResourceEnabled(val); - }} - /> - - + <> + + + + + {t("resourceGeneral")} + + + {t("resourceGeneralDescription")} + + - - - - {t("resourceGeneral")} - - - {t("resourceGeneralDescription")} - - - - - -
- - ( - - - {t("name")} - - - - - - - )} - /> - - {resource.http && ( - <> - {env.flags - .allowBaseDomainResources && ( - ( - - - {t( - "domainType" - )} - - - - - )} - /> - )} - -
- {domainType === "subdomain" ? ( -
- - {t("subdomain")} - -
-
- ( - - - - - - - )} - /> -
-
- ( - - - - - )} - /> -
-
-
- ) : ( - ( - - - {t( - "baseDomain" - )} - - - - - )} - /> - )} -
- - )} - - {!resource.http && ( + + + + ( + +
+ + + form.setValue( + "enabled", + val + ) + } + /> + +
+ +
+ )} + /> + + ( - {t( - "resourcePortNumber" - )} + {t("name")} - - field.onChange( - e.target - .value - ? parseInt( - e - .target - .value - ) - : null - ) - } - /> + )} /> - )} - - -
-
- - - -
- - - - - {t("resourceTransfer")} - - - {t("resourceTransferDescription")} - - - - - -
- - ( - - - {t("siteDestination")} - - - - - - - - - - - - {t( - "sitesNotFound" - )} - - - {sites.map( - (site) => ( - { - transferForm.setValue( - "siteId", - site.siteId - ); - setOpen( - false - ); - }} - > - { - site.name - } - - - ) - )} - - - - - - + {resource.http && ( +
+ +
+ + + {resourceFullDomain} + + +
+
)} - /> - - -
-
+ + + + - - - -
-
+ + + + + + + + + {t("resourceTransfer")} + + + {t("resourceTransferDescription")} + + + + + +
+ + ( + + + {t("siteDestination")} + + + + + + + + + + + + {t( + "sitesNotFound" + )} + + + {sites.map( + ( + site + ) => ( + { + transferForm.setValue( + "siteId", + site.siteId + ); + setOpen( + false + ); + }} + > + { + site.name + } + + + ) + )} + + + + + + + )} + /> + + +
+
+ + + + +
+
+ + setEditDomainOpen(setOpen)} + > + + + Edit Domain + + Select a domain for your resource + + + + { + const selected = { + domainId: res.domainId, + subdomain: res.subdomain, + fullDomain: res.fullDomain + }; + setSelectedDomain(selected); + }} + /> + + + + + + + + + + ) ); } diff --git a/src/app/[orgId]/settings/resources/[resourceId]/proxy/page.tsx b/src/app/[orgId]/settings/resources/[resourceId]/proxy/page.tsx index c9c5eea6..dee0dd66 100644 --- a/src/app/[orgId]/settings/resources/[resourceId]/proxy/page.tsx +++ b/src/app/[orgId]/settings/resources/[resourceId]/proxy/page.tsx @@ -75,6 +75,7 @@ import { } from "@app/components/ui/collapsible"; import { ContainersSelector } from "@app/components/ContainersSelector"; import { useTranslations } from "next-intl"; +import { build } from "@server/build"; const addTargetSchema = z.object({ ip: z.string().refine(isTargetValid), @@ -319,10 +320,13 @@ export default function ReverseProxyTargets(props: { ); } - async function saveTargets() { + async function saveAllSettings() { try { setTargetsLoading(true); + setHttpsTlsLoading(true); + setProxySettingsLoading(true); + // Save targets for (let target of targets) { const data = { ip: target.ip, @@ -347,16 +351,36 @@ export default function ReverseProxyTargets(props: { await api.delete(`/target/${targetId}`); } - // Save sticky session setting - const stickySessionData = targetsSettingsForm.getValues(); - await api.post(`/resource/${params.resourceId}`, { - stickySession: stickySessionData.stickySession - }); - updateResource({ stickySession: stickySessionData.stickySession }); + if (resource.http) { + // Gather all settings + const stickySessionData = targetsSettingsForm.getValues(); + const tlsData = tlsSettingsForm.getValues(); + const proxyData = proxySettingsForm.getValues(); + + // Combine into one payload + const payload = { + stickySession: stickySessionData.stickySession, + ssl: tlsData.ssl, + tlsServerName: tlsData.tlsServerName || null, + setHostHeader: proxyData.setHostHeader || null + }; + + // Single API call to update all settings + await api.post(`/resource/${params.resourceId}`, payload); + + // Update local resource context + updateResource({ + ...resource, + stickySession: stickySessionData.stickySession, + ssl: tlsData.ssl, + tlsServerName: tlsData.tlsServerName || null, + setHostHeader: proxyData.setHostHeader || null + }); + } toast({ - title: t("targetsUpdated"), - description: t("targetsUpdatedDescription") + title: t("settingsUpdated"), + description: t("settingsUpdatedDescription") }); setTargetsToRemove([]); @@ -365,73 +389,15 @@ export default function ReverseProxyTargets(props: { console.error(err); toast({ variant: "destructive", - title: t("targetsErrorUpdate"), + title: t("settingsErrorUpdate"), description: formatAxiosError( err, - t("targetsErrorUpdateDescription") + t("settingsErrorUpdateDescription") ) }); } finally { setTargetsLoading(false); - } - } - - async function saveTlsSettings(data: TlsSettingsValues) { - try { - setHttpsTlsLoading(true); - await api.post(`/resource/${params.resourceId}`, { - ssl: data.ssl, - tlsServerName: data.tlsServerName || null - }); - updateResource({ - ...resource, - ssl: data.ssl, - tlsServerName: data.tlsServerName || null - }); - toast({ - title: t("targetTlsUpdate"), - description: t("targetTlsUpdateDescription") - }); - } catch (err) { - console.error(err); - toast({ - variant: "destructive", - title: t("targetErrorTlsUpdate"), - description: formatAxiosError( - err, - t("targetErrorTlsUpdateDescription") - ) - }); - } finally { setHttpsTlsLoading(false); - } - } - - async function saveProxySettings(data: ProxySettingsValues) { - try { - setProxySettingsLoading(true); - await api.post(`/resource/${params.resourceId}`, { - setHostHeader: data.setHostHeader || null - }); - updateResource({ - ...resource, - setHostHeader: data.setHostHeader || null - }); - toast({ - title: t("proxyUpdated"), - description: t("proxyUpdatedDescription") - }); - } catch (err) { - console.error(err); - toast({ - variant: "destructive", - title: t("proxyErrorUpdate"), - description: formatAxiosError( - err, - t("proxyErrorUpdateDescription") - ) - }); - } finally { setProxySettingsLoading(false); } } @@ -583,7 +549,7 @@ export default function ReverseProxyTargets(props: {
- + - {resource.http && ( - - - - - {t("targetTlsSettings")} - - - {t("targetTlsSettingsDescription")} - - - - - - + + + + {t("proxyAdditional")} + + + {t("proxyAdditionalDescription")} + + + + + + + {build == "oss" && ( )} /> - -
- - - -
- - ( - - - {t( - "targetTlsSni" - )} - - - - - - {t( - "targetTlsSniDescription" - )} - - - + )} + ( + + + {t("targetTlsSni")} + + + + + + {t( + "targetTlsSniDescription" )} - /> - -
- - -
-
- - - -
- - - - {t("proxyAdditional")} - - - {t("proxyAdditionalDescription")} - - - - -
- + + )} - className="space-y-4" - id="proxy-settings-form" - > - ( - - - {t("proxyCustomHeader")} - - - - - - {t( - "proxyCustomHeaderDescription" - )} - - - - )} - /> - - -
-
- - - -
-
+ /> + + + + + +
+ + ( + + + {t("proxyCustomHeader")} + + + + + + {t( + "proxyCustomHeaderDescription" + )} + + + + )} + /> + + +
+ + )} + +
+ +
); } diff --git a/src/app/[orgId]/settings/resources/[resourceId]/rules/page.tsx b/src/app/[orgId]/settings/resources/[resourceId]/rules/page.tsx index 90c86aea..2f7d03ee 100644 --- a/src/app/[orgId]/settings/resources/[resourceId]/rules/page.tsx +++ b/src/app/[orgId]/settings/resources/[resourceId]/rules/page.tsx @@ -36,7 +36,6 @@ import { TableBody, TableCaption, TableCell, - TableContainer, TableHead, TableHeader, TableRow @@ -234,35 +233,6 @@ export default function ResourceRules(props: { ); } - async function saveApplyRules(val: boolean) { - const res = await api - .post(`/resource/${params.resourceId}`, { - applyRules: val - }) - .catch((err) => { - console.error(err); - toast({ - variant: "destructive", - title: t('rulesErrorUpdate'), - description: formatAxiosError( - err, - t('rulesErrorUpdateDescription') - ) - }); - }); - - if (res && res.status === 200) { - setRulesEnabled(val); - updateResource({ applyRules: val }); - - toast({ - title: t('rulesUpdated'), - description: t('rulesUpdatedDescription') - }); - router.refresh(); - } - } - function getValueHelpText(type: string) { switch (type) { case "CIDR": @@ -274,9 +244,33 @@ export default function ResourceRules(props: { } } - async function saveRules() { + async function saveAllSettings() { try { setLoading(true); + + // Save rules enabled state + const res = await api + .post(`/resource/${params.resourceId}`, { + applyRules: rulesEnabled + }) + .catch((err) => { + console.error(err); + toast({ + variant: "destructive", + title: t('rulesErrorUpdate'), + description: formatAxiosError( + err, + t('rulesErrorUpdateDescription') + ) + }); + throw err; + }); + + if (res && res.status === 200) { + updateResource({ applyRules: rulesEnabled }); + } + + // Save rules for (let rule of rules) { const data = { action: rule.action, @@ -543,67 +537,48 @@ export default function ResourceRules(props: { return ( - - - {t('rulesAbout')} - -
-

- {t('rulesAboutDescription')} -

-
- - - {t('rulesActions')} -
    -
  • - - {t('rulesActionAlwaysAllow')} -
  • -
  • - - {t('rulesActionAlwaysDeny')} -
  • -
-
- - - {t('rulesMatchCriteria')} - -
    -
  • - {t('rulesMatchCriteriaIpAddress')} -
  • -
  • - {t('rulesMatchCriteriaIpAddressRange')} -
  • -
  • - {t('rulesMatchCriteriaUrl')} -
  • -
-
-
-
-
- - - - {t('rulesEnable')} - - {t('rulesEnableDescription')} - - - - { - await saveApplyRules(val); - }} - /> - - + {/* */} + {/* */} + {/* {t('rulesAbout')} */} + {/* */} + {/*
*/} + {/*

*/} + {/* {t('rulesAboutDescription')} */} + {/*

*/} + {/*
*/} + {/* */} + {/* */} + {/* {t('rulesActions')} */} + {/*
    */} + {/*
  • */} + {/* */} + {/* {t('rulesActionAlwaysAllow')} */} + {/*
  • */} + {/*
  • */} + {/* */} + {/* {t('rulesActionAlwaysDeny')} */} + {/*
  • */} + {/*
*/} + {/*
*/} + {/* */} + {/* */} + {/* {t('rulesMatchCriteria')} */} + {/* */} + {/*
    */} + {/*
  • */} + {/* {t('rulesMatchCriteriaIpAddress')} */} + {/*
  • */} + {/*
  • */} + {/* {t('rulesMatchCriteriaIpAddressRange')} */} + {/*
  • */} + {/*
  • */} + {/* {t('rulesMatchCriteriaUrl')} */} + {/*
  • */} + {/*
*/} + {/*
*/} + {/*
*/} + {/*
*/} + {/*
*/} @@ -615,168 +590,179 @@ export default function ResourceRules(props: { -
- -
- ( - - {t('rulesAction')} - - - - - - )} - /> - ( - - {t('rulesMatchType')} - - + + + + + + {RuleAction.ACCEPT} - )} - - {RuleMatch.IP} - - - {RuleMatch.CIDR} - - - - - - - )} - /> - ( - - - - - - - - )} - /> - -
-
- - - - {table.getHeaderGroups().map((headerGroup) => ( - - {headerGroup.headers.map((header) => ( - - {header.isPlaceholder - ? null - : flexRender( - header.column.columnDef - .header, - header.getContext() - )} - - ))} - - ))} - - - {table.getRowModel().rows?.length ? ( - table.getRowModel().rows.map((row) => ( - - {row.getVisibleCells().map((cell) => ( - - {flexRender( - cell.column.columnDef.cell, - cell.getContext() - )} - + + {RuleAction.DROP} + + + + + + + )} + /> + ( + + {t('rulesMatchType')} + + + + + + )} + /> + ( + + + + + + + + )} + /> + + + + +
+ + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => ( + + {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef + .header, + header.getContext() + )} + ))} - )) - ) : ( - - - {t('rulesNoOne')} - - - )} - - - {t('rulesOrder')} - -
+ ))} + + + {table.getRowModel().rows?.length ? ( + table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + {flexRender( + cell.column.columnDef.cell, + cell.getContext() + )} + + ))} + + )) + ) : ( + + + {t('rulesNoOne')} + + + )} + + {/* */} + {/* {t('rulesOrder')} */} + {/* */} + +
- - - + +
+ +
); } diff --git a/src/app/[orgId]/settings/resources/create/page.tsx b/src/app/[orgId]/settings/resources/create/page.tsx index 72d4a4d6..22e9d90c 100644 --- a/src/app/[orgId]/settings/resources/create/page.tsx +++ b/src/app/[orgId]/settings/resources/create/page.tsx @@ -63,6 +63,7 @@ import { SquareArrowOutUpRight } from "lucide-react"; import CopyTextBox from "@app/components/CopyTextBox"; import Link from "next/link"; import { useTranslations } from "next-intl"; +import DomainPicker from "@app/components/DomainPicker"; const baseResourceFormSchema = z.object({ name: z.string().min(1).max(255), @@ -70,17 +71,10 @@ const baseResourceFormSchema = z.object({ http: z.boolean() }); -const httpResourceFormSchema = z.discriminatedUnion("isBaseDomain", [ - z.object({ - isBaseDomain: z.literal(true), - domainId: z.string().min(1) - }), - z.object({ - isBaseDomain: z.literal(false), - domainId: z.string().min(1), - subdomain: z.string().pipe(subdomainSchema) - }) -]); +const httpResourceFormSchema = z.object({ + domainId: z.string().optional(), + subdomain: z.string().optional() +}); const tcpUdpResourceFormSchema = z.object({ protocol: z.string(), @@ -119,15 +113,18 @@ export default function Page() { const resourceTypes: ReadonlyArray = [ { id: "http", - title: t('resourceHTTP'), - description: t('resourceHTTPDescription') + title: t("resourceHTTP"), + description: t("resourceHTTPDescription") }, - { - id: "raw", - title: t('resourceRaw'), - description: t('resourceRawDescription'), - disabled: !env.flags.allowRawResources - } + ...(!env.flags.allowRawResources + ? [] + : [ + { + id: "raw" as ResourceType, + title: t("resourceRaw"), + description: t("resourceRawDescription") + } + ]) ]; const baseForm = useForm({ @@ -140,11 +137,7 @@ export default function Page() { const httpForm = useForm({ resolver: zodResolver(httpResourceFormSchema), - defaultValues: { - subdomain: "", - domainId: "", - isBaseDomain: false - } + defaultValues: {} }); const tcpUdpForm = useForm({ @@ -170,20 +163,11 @@ export default function Page() { if (isHttp) { const httpData = httpForm.getValues(); - if (httpData.isBaseDomain) { - Object.assign(payload, { - domainId: httpData.domainId, - isBaseDomain: true, - protocol: "tcp" - }); - } else { Object.assign(payload, { subdomain: httpData.subdomain, domainId: httpData.domainId, - isBaseDomain: false, - protocol: "tcp" + protocol: "tcp", }); - } } else { const tcpUdpData = tcpUdpForm.getValues(); Object.assign(payload, { @@ -199,10 +183,10 @@ export default function Page() { .catch((e) => { toast({ variant: "destructive", - title: t('resourceErrorCreate'), + title: t("resourceErrorCreate"), description: formatAxiosError( e, - t('resourceErrorCreateDescription') + t("resourceErrorCreateDescription") ) }); }); @@ -219,11 +203,11 @@ export default function Page() { } } } catch (e) { - console.error(t('resourceErrorCreateMessage'), e); + console.error(t("resourceErrorCreateMessage"), e); toast({ variant: "destructive", - title: t('resourceErrorCreate'), - description:t('resourceErrorCreateMessageDescription') + title: t("resourceErrorCreate"), + description: t("resourceErrorCreateMessageDescription") }); } @@ -242,10 +226,10 @@ export default function Page() { .catch((e) => { toast({ variant: "destructive", - title: t('sitesErrorFetch'), + title: t("sitesErrorFetch"), description: formatAxiosError( e, - t('sitesErrorFetchDescription') + t("sitesErrorFetchDescription") ) }); }); @@ -270,10 +254,10 @@ export default function Page() { .catch((e) => { toast({ variant: "destructive", - title: t('domainsErrorFetch'), + title: t("domainsErrorFetch"), description: formatAxiosError( e, - t('domainsErrorFetchDescription') + t("domainsErrorFetchDescription") ) }); }); @@ -300,8 +284,8 @@ export default function Page() { <>
@@ -320,7 +304,7 @@ export default function Page() { - {t('resourceInfo')} + {t("resourceInfo")} @@ -336,7 +320,7 @@ export default function Page() { render={({ field }) => ( - {t('name')} + {t("name")} - {t('resourceNameDescription')} + {t( + "resourceNameDescription" + )} )} @@ -357,7 +343,7 @@ export default function Page() { render={({ field }) => ( - {t('site')} + {t("site")} - + - {t('siteNotFound')} + {t( + "siteNotFound" + )} {sites.map( @@ -433,7 +427,9 @@ export default function Page() { - {t('siteSelectionDescription')} + {t( + "siteSelectionDescription" + )} )} @@ -444,253 +440,74 @@ export default function Page() { - - - - {t('resourceType')} - - - {t('resourceTypeDescription')} - - - - { - baseForm.setValue( - "http", - value === "http" - ); - }} - cols={2} - /> - - + {resourceTypes.length > 1 && ( + + + + {t("resourceType")} + + + {t("resourceTypeDescription")} + + + + { + baseForm.setValue( + "http", + value === "http" + ); + }} + cols={2} + /> + + + )} {baseForm.watch("http") ? ( - {t('resourceHTTPSSettings')} + {t("resourceHTTPSSettings")} - {t('resourceHTTPSSettingsDescription')} + {t( + "resourceHTTPSSettingsDescription" + )} - -
- - {env.flags - .allowBaseDomainResources && ( - ( - - - {t('domainType')} - - - - - )} - /> - )} - - {!httpForm.watch( - "isBaseDomain" - ) && ( - - - {t('subdomain')} - -
-
- ( - - - - - - - )} - /> -
-
- ( - - - - - )} - /> -
-
- - {t('subdomnainDescription')} - -
- )} - - {httpForm.watch( - "isBaseDomain" - ) && ( - ( - - - {t('baseDomain')} - - - - - )} - /> - )} - - -
+ { + httpForm.setValue( + "subdomain", + res.subdomain + ); + httpForm.setValue( + "domainId", + res.domainId + ); + console.log( + "Domain changed:", + res + ); + }} + />
) : ( - {t('resourceRawSettings')} + {t("resourceRawSettings")} - {t('resourceRawSettingsDescription')} + {t( + "resourceRawSettingsDescription" + )} @@ -708,7 +525,9 @@ export default function Page() { render={({ field }) => ( - {t('protocol')} + {t( + "protocol" + )} - {t('resourcePortNumberDescription')} + {t( + "resourcePortNumberDescription" + )} )} @@ -788,16 +615,19 @@ export default function Page() { type="button" variant="outline" onClick={() => - router.push(`/${orgId}/settings/resources`) + router.push( + `/${orgId}/settings/resources` + ) } > - {t('cancel')} + {t("cancel")}
@@ -817,17 +647,17 @@ export default function Page() { - {t('resourceConfig')} + {t("resourceConfig")} - {t('resourceConfigDescription')} + {t("resourceConfigDescription")}

- {t('resourceAddEntrypoints')} + {t("resourceAddEntrypoints")}

- {t('resourceExposePorts')} + {t("resourceExposePorts")}

- - {t('resourceLearnRaw')} - + {t("resourceLearnRaw")}
@@ -868,20 +696,22 @@ export default function Page() { type="button" variant="outline" onClick={() => - router.push(`/${orgId}/settings/resources`) + router.push( + `/${orgId}/settings/resources` + ) } > - {t('resourceBack')} + {t("resourceBack")}
diff --git a/src/app/[orgId]/settings/resources/page.tsx b/src/app/[orgId]/settings/resources/page.tsx index bbd2a582..371b4404 100644 --- a/src/app/[orgId]/settings/resources/page.tsx +++ b/src/app/[orgId]/settings/resources/page.tsx @@ -67,7 +67,8 @@ export default async function ResourcesPage(props: ResourcesPageProps) { resource.whitelist ? "protected" : "not_protected", - enabled: resource.enabled + enabled: resource.enabled, + domainId: resource.domainId || undefined }; }); diff --git a/src/app/[orgId]/settings/share-links/CreateShareLinkForm.tsx b/src/app/[orgId]/settings/share-links/CreateShareLinkForm.tsx index cce81da7..b4b03fc5 100644 --- a/src/app/[orgId]/settings/share-links/CreateShareLinkForm.tsx +++ b/src/app/[orgId]/settings/share-links/CreateShareLinkForm.tsx @@ -392,7 +392,7 @@ export default function CreateShareLinkForm({ defaultValue={field.value.toString()} > - + diff --git a/src/app/[orgId]/settings/share-links/ShareLinksTable.tsx b/src/app/[orgId]/settings/share-links/ShareLinksTable.tsx index de419319..5c6ace73 100644 --- a/src/app/[orgId]/settings/share-links/ShareLinksTable.tsx +++ b/src/app/[orgId]/settings/share-links/ShareLinksTable.tsx @@ -69,11 +69,8 @@ export default function ShareLinksTable({ async function deleteSharelink(id: string) { await api.delete(`/access-token/${id}`).catch((e) => { toast({ - title: t('shareErrorDelete'), - description: formatAxiosError( - e, - t('shareErrorDeleteMessage') - ) + title: t("shareErrorDelete"), + description: formatAxiosError(e, t("shareErrorDeleteMessage")) }); }); @@ -81,53 +78,12 @@ export default function ShareLinksTable({ setRows(newRows); toast({ - title: t('shareDeleted'), - description: t('shareDeletedDescription') + title: t("shareDeleted"), + description: t("shareDeletedDescription") }); } const columns: ColumnDef[] = [ - { - id: "actions", - cell: ({ row }) => { - const router = useRouter(); - - const resourceRow = row.original; - - return ( - <> -
- - - - - - { - deleteSharelink( - resourceRow.accessTokenId - ); - }} - > - - - - -
- - ); - } - }, { accessorKey: "resourceName", header: ({ column }) => { @@ -138,7 +94,7 @@ export default function ShareLinksTable({ column.toggleSorting(column.getIsSorted() === "asc") } > - {t('resource')} + {t("resource")} ); @@ -147,7 +103,7 @@ export default function ShareLinksTable({ const r = row.original; return ( - ); @@ -245,7 +201,7 @@ export default function ShareLinksTable({ column.toggleSorting(column.getIsSorted() === "asc") } > - {t('created')} + {t("created")} ); @@ -265,7 +221,7 @@ export default function ShareLinksTable({ column.toggleSorting(column.getIsSorted() === "asc") } > - {t('expires')} + {t("expires")} ); @@ -275,23 +231,50 @@ export default function ShareLinksTable({ if (r.expiresAt) { return moment(r.expiresAt).format("lll"); } - return t('never'); + return t("never"); } }, { id: "delete", - cell: ({ row }) => ( -
- -
- ) + cell: ({ row }) => { + const resourceRow = row.original; + return ( +
+ {/* */} + {/* */} + {/* */} + {/* */} + {/* */} + {/* { */} + {/* deleteSharelink( */} + {/* resourceRow.accessTokenId */} + {/* ); */} + {/* }} */} + {/* > */} + {/* */} + {/* */} + {/* */} + {/* */} + +
+ ); + } } ]; diff --git a/src/app/[orgId]/settings/sites/CreateSiteForm.tsx b/src/app/[orgId]/settings/sites/CreateSiteForm.tsx index b5633268..3189197b 100644 --- a/src/app/[orgId]/settings/sites/CreateSiteForm.tsx +++ b/src/app/[orgId]/settings/sites/CreateSiteForm.tsx @@ -227,6 +227,7 @@ export default function CreateSiteForm({ name: data.name, id: data.siteId, nice: data.niceId.toString(), + address: data.address?.split("/")[0], mbIn: data.type == "wireguard" || data.type == "newt" ? t('megabytes', {count: 0}) diff --git a/src/app/[orgId]/settings/sites/SitesDataTable.tsx b/src/app/[orgId]/settings/sites/SitesDataTable.tsx index 99445dea..60c395c7 100644 --- a/src/app/[orgId]/settings/sites/SitesDataTable.tsx +++ b/src/app/[orgId]/settings/sites/SitesDataTable.tsx @@ -8,12 +8,16 @@ interface DataTableProps { columns: ColumnDef[]; data: TData[]; createSite?: () => void; + onRefresh?: () => void; + isRefreshing?: boolean; } export function SitesDataTable({ columns, data, - createSite + createSite, + onRefresh, + isRefreshing }: DataTableProps) { const t = useTranslations(); @@ -27,6 +31,8 @@ export function SitesDataTable({ searchColumn="name" onAdd={createSite} addButtonText={t('siteAdd')} + onRefresh={onRefresh} + isRefreshing={isRefreshing} defaultSort={{ id: "name", desc: false diff --git a/src/app/[orgId]/settings/sites/SitesSplashCard.tsx b/src/app/[orgId]/settings/sites/SitesSplashCard.tsx index 7484a15c..8bab93e6 100644 --- a/src/app/[orgId]/settings/sites/SitesSplashCard.tsx +++ b/src/app/[orgId]/settings/sites/SitesSplashCard.tsx @@ -5,10 +5,12 @@ import { Card, CardContent } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; import { ArrowRight, DockIcon as Docker, Globe, Server, X } from "lucide-react"; import Link from "next/link"; +import { useEnvContext } from "@app/hooks/useEnvContext"; import { useTranslations } from 'next-intl'; export const SitesSplashCard = () => { const [isDismissed, setIsDismissed] = useState(true); + const { env } = useEnvContext(); const key = "sites-splash-card-dismissed"; const t = useTranslations(); diff --git a/src/app/[orgId]/settings/sites/SitesTable.tsx b/src/app/[orgId]/settings/sites/SitesTable.tsx index 06ecadcb..da84b75b 100644 --- a/src/app/[orgId]/settings/sites/SitesTable.tsx +++ b/src/app/[orgId]/settings/sites/SitesTable.tsx @@ -19,7 +19,7 @@ import { import Link from "next/link"; import { useRouter } from "next/navigation"; import { AxiosResponse } from "axios"; -import { useState } from "react"; +import { useState, useEffect } from "react"; import CreateSiteForm from "./CreateSiteForm"; import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog"; import { toast } from "@app/hooks/useToast"; @@ -28,7 +28,9 @@ import { createApiClient } from "@app/lib/api"; import { useEnvContext } from "@app/hooks/useEnvContext"; import CreateSiteFormModal from "./CreateSiteModal"; import { useTranslations } from "next-intl"; -import { parseDataSize } from '@app/lib/dataSize'; +import { parseDataSize } from "@app/lib/dataSize"; +import { Badge } from "@app/components/ui/badge"; +import { InfoPopup } from "@app/components/ui/info-popup"; export type SiteRow = { id: number; @@ -38,7 +40,10 @@ export type SiteRow = { mbOut: string; orgId: string; type: "newt" | "wireguard"; + newtVersion?: string; + newtUpdateAvailable?: boolean; online: boolean; + address?: string; }; type SitesTableProps = { @@ -52,18 +57,39 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) { const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); const [selectedSite, setSelectedSite] = useState(null); const [rows, setRows] = useState(sites); + const [isRefreshing, setIsRefreshing] = useState(false); const api = createApiClient(useEnvContext()); const t = useTranslations(); + // Update local state when props change (e.g., after refresh) + useEffect(() => { + setRows(sites); + }, [sites]); + + const refreshData = async () => { + setIsRefreshing(true); + try { + router.refresh(); + } catch (error) { + toast({ + title: t("error"), + description: t("refreshError"), + variant: "destructive" + }); + } finally { + setIsRefreshing(false); + } + }; + const deleteSite = (siteId: number) => { api.delete(`/site/${siteId}`) .catch((e) => { - console.error(t('siteErrorDelete'), e); + console.error(t("siteErrorDelete"), e); toast({ variant: "destructive", - title: t('siteErrorDelete'), - description: formatAxiosError(e, t('siteErrorDelete')) + title: t("siteErrorDelete"), + description: formatAxiosError(e, t("siteErrorDelete")) }); }) .then(() => { @@ -77,42 +103,6 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) { }; const columns: ColumnDef[] = [ - { - id: "dots", - cell: ({ row }) => { - const siteRow = row.original; - const router = useRouter(); - - return ( - - - - - - - - {t('viewSettings')} - - - { - setSelectedSite(siteRow); - setIsDeleteModalOpen(true); - }} - > - {t('delete')} - - - - ); - } - }, { accessorKey: "name", header: ({ column }) => { @@ -123,7 +113,7 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) { column.toggleSorting(column.getIsSorted() === "asc") } > - {t('name')} + {t("name")} ); @@ -139,7 +129,7 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) { column.toggleSorting(column.getIsSorted() === "asc") } > - {t('online')} + {t("online")} ); @@ -154,14 +144,14 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) { return (
- {t('online')} + {t("online")}
); } else { return (
- {t('offline')} + {t("offline")}
); } @@ -179,11 +169,19 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) { onClick={() => column.toggleSorting(column.getIsSorted() === "asc") } + className="hidden md:flex whitespace-nowrap" > - {t('site')} + {t("site")} ); + }, + cell: ({ row }) => { + return ( +
+ {row.original.nice} +
+ ); } }, { @@ -196,13 +194,14 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) { column.toggleSorting(column.getIsSorted() === "asc") } > - {t('dataIn')} + {t("dataIn")} ); }, - sortingFn: (rowA, rowB) => - parseDataSize(rowA.original.mbIn) - parseDataSize(rowB.original.mbIn) + sortingFn: (rowA, rowB) => + parseDataSize(rowA.original.mbIn) - + parseDataSize(rowB.original.mbIn) }, { accessorKey: "mbOut", @@ -214,13 +213,14 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) { column.toggleSorting(column.getIsSorted() === "asc") } > - {t('dataOut')} + {t("dataOut")} ); }, sortingFn: (rowA, rowB) => - parseDataSize(rowA.original.mbOut) - parseDataSize(rowB.original.mbOut), + parseDataSize(rowA.original.mbOut) - + parseDataSize(rowB.original.mbOut) }, { accessorKey: "type", @@ -232,7 +232,7 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) { column.toggleSorting(column.getIsSorted() === "asc") } > - {t('connectionType')} + {t("connectionType")} ); @@ -242,8 +242,22 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) { if (originalRow.type === "newt") { return ( -
- Newt +
+ +
+ Newt + {originalRow.newtVersion && ( + + v{originalRow.newtVersion} + + )} +
+
+ {originalRow.newtUpdateAvailable && ( + + )}
); } @@ -259,23 +273,68 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) { if (originalRow.type === "local") { return (
- {t('local')} + {t("local")}
); } } }, + { + accessorKey: "address", + header: ({ column }) => { + return ( + + ); + } + }, { id: "actions", cell: ({ row }) => { const siteRow = row.original; return ( -
+
+ + + + + + + + {t("viewSettings")} + + + { + setSelectedSite(siteRow); + setIsDeleteModalOpen(true); + }} + > + + {t("delete")} + + + + + - @@ -297,22 +356,21 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) { dialog={

- {t('siteQuestionRemove', {selectedSite: selectedSite?.name || selectedSite?.id})} + {t("siteQuestionRemove", { + selectedSite: + selectedSite?.name || selectedSite?.id + })}

-

- {t('siteMessageRemove')} -

+

{t("siteMessageRemove")}

-

- {t('siteMessageConfirm')} -

+

{t("siteMessageConfirm")}

} - buttonText={t('siteConfirmDelete')} + buttonText={t("siteConfirmDelete")} onConfirm={async () => deleteSite(selectedSite!.id)} string={selectedSite.name} - title={t('siteDelete')} + title={t("siteDelete")} /> )} @@ -322,6 +380,8 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) { createSite={() => router.push(`/${orgId}/settings/sites/create`) } + onRefresh={refreshData} + isRefreshing={isRefreshing} /> ); diff --git a/src/app/[orgId]/settings/sites/[niceId]/general/page.tsx b/src/app/[orgId]/settings/sites/[niceId]/general/page.tsx index 7b97f99f..ba1f877c 100644 --- a/src/app/[orgId]/settings/sites/[niceId]/general/page.tsx +++ b/src/app/[orgId]/settings/sites/[niceId]/general/page.tsx @@ -24,8 +24,7 @@ import { SettingsSectionTitle, SettingsSectionDescription, SettingsSectionBody, - SettingsSectionForm, - SettingsSectionFooter + SettingsSectionForm } from "@app/components/Settings"; import { formatAxiosError } from "@app/lib/api"; import { createApiClient } from "@app/lib/api"; @@ -177,18 +176,18 @@ export default function GeneralPage() { - - - - + +
+ +
); } diff --git a/src/app/[orgId]/settings/sites/[niceId]/layout.tsx b/src/app/[orgId]/settings/sites/[niceId]/layout.tsx index 2eb3bd13..597cc852 100644 --- a/src/app/[orgId]/settings/sites/[niceId]/layout.tsx +++ b/src/app/[orgId]/settings/sites/[niceId]/layout.tsx @@ -5,16 +5,7 @@ import { AxiosResponse } from "axios"; import { redirect } from "next/navigation"; import { authCookieHeader } from "@app/lib/api/cookies"; import { HorizontalTabs } from "@app/components/HorizontalTabs"; -import Link from "next/link"; -import { ArrowLeft } from "lucide-react"; import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; -import { - Breadcrumb, - BreadcrumbItem, - BreadcrumbList, - BreadcrumbPage, - BreadcrumbSeparator -} from "@app/components/ui/breadcrumb"; import SiteInfoCard from "./SiteInfoCard"; import { getTranslations } from "next-intl/server"; diff --git a/src/app/[orgId]/settings/sites/create/page.tsx b/src/app/[orgId]/settings/sites/create/page.tsx index 7cca7454..3546e871 100644 --- a/src/app/[orgId]/settings/sites/create/page.tsx +++ b/src/app/[orgId]/settings/sites/create/page.tsx @@ -21,7 +21,7 @@ import { } from "@app/components/ui/form"; import HeaderTitle from "@app/components/SettingsSectionTitle"; import { z } from "zod"; -import { useEffect, useState } from "react"; +import { createElement, useEffect, useState } from "react"; import { useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import { Input } from "@app/components/ui/input"; @@ -55,15 +55,8 @@ import { import { toast } from "@app/hooks/useToast"; import { AxiosResponse } from "axios"; import { useParams, useRouter } from "next/navigation"; -import { - Breadcrumb, - BreadcrumbItem, - BreadcrumbList, - BreadcrumbPage, - BreadcrumbSeparator -} from "@app/components/ui/breadcrumb"; -import Link from "next/link"; import { QRCodeCanvas } from "qrcode.react"; + import { useTranslations } from "next-intl"; type SiteType = "newt" | "wireguard" | "local"; @@ -105,22 +98,24 @@ export default function Page() { .object({ name: z .string() - .min(2, { message: t('nameMin', {len: 2}) }) + .min(2, { message: t("nameMin", { len: 2 }) }) .max(30, { - message: t('nameMax', {len: 30}) + message: t("nameMax", { len: 30 }) }), method: z.enum(["newt", "wireguard", "local"]), - copied: z.boolean() + copied: z.boolean(), + clientAddress: z.string().optional() }) .refine( (data) => { if (data.method !== "local") { - return data.copied; + // return data.copied; + return true; } return true; }, { - message: t('sitesConfirmCopy'), + message: t("sitesConfirmCopy"), path: ["copied"] } ); @@ -132,21 +127,29 @@ export default function Page() { >([ { id: "newt", - title: t('siteNewtTunnel'), - description: t('siteNewtTunnelDescription'), + title: t("siteNewtTunnel"), + description: t("siteNewtTunnelDescription"), disabled: true }, - { - id: "wireguard", - title: t('siteWg'), - description: t('siteWgDescription'), - disabled: true - }, - { - id: "local", - title: t('local'), - description: t('siteLocalDescription') - } + ...(env.flags.disableBasicWireguardSites + ? [] + : [ + { + id: "wireguard" as SiteType, + title: t("siteWg"), + description: t("siteWgDescription"), + disabled: true + } + ]), + ...(env.flags.disableLocalSites + ? [] + : [ + { + id: "local" as SiteType, + title: t("local"), + description: t("siteLocalDescription") + } + ]) ]); const [loadingPage, setLoadingPage] = useState(true); @@ -158,7 +161,7 @@ export default function Page() { const [newtId, setNewtId] = useState(""); const [newtSecret, setNewtSecret] = useState(""); const [newtEndpoint, setNewtEndpoint] = useState(""); - + const [clientAddress, setClientAddress] = useState(""); const [publicKey, setPublicKey] = useState(""); const [privateKey, setPrivateKey] = useState(""); const [wgConfig, setWgConfig] = useState(""); @@ -324,7 +327,7 @@ WantedBy=default.target` }; const getCommand = () => { - const placeholder = [t('unknownCommand')]; + const placeholder = [t("unknownCommand")]; if (!commands) { return placeholder; } @@ -369,20 +372,28 @@ WantedBy=default.target` const form = useForm({ resolver: zodResolver(createSiteFormSchema), - defaultValues: { name: "", copied: false, method: "newt" } + defaultValues: { + name: "", + copied: false, + method: "newt", + clientAddress: "" + } }); async function onSubmit(data: CreateSiteFormValues) { setCreateLoading(true); - let payload: CreateSiteBody = { name: data.name, type: data.method }; + let payload: CreateSiteBody = { + name: data.name, + type: data.method as "newt" | "wireguard" | "local" + }; if (data.method == "wireguard") { if (!siteDefaults || !wgConfig) { toast({ variant: "destructive", - title: t('siteErrorCreate'), - description: t('siteErrorCreateKeyPair') + title: t("siteErrorCreate"), + description: t("siteErrorCreateKeyPair") }); setCreateLoading(false); return; @@ -399,8 +410,8 @@ WantedBy=default.target` if (!siteDefaults) { toast({ variant: "destructive", - title: t('siteErrorCreate'), - description: t('siteErrorCreateDefaults') + title: t("siteErrorCreate"), + description: t("siteErrorCreateDefaults") }); setCreateLoading(false); return; @@ -411,7 +422,8 @@ WantedBy=default.target` subnet: siteDefaults.subnet, exitNodeId: siteDefaults.exitNodeId, secret: siteDefaults.newtSecret, - newtId: siteDefaults.newtId + newtId: siteDefaults.newtId, + address: clientAddress }; } @@ -422,7 +434,7 @@ WantedBy=default.target` .catch((e) => { toast({ variant: "destructive", - title: t('siteErrorCreate'), + title: t("siteErrorCreate"), description: formatAxiosError(e) }); }); @@ -448,14 +460,23 @@ WantedBy=default.target` ); if (!response.ok) { throw new Error( - t('newtErrorFetchReleases', {err: response.statusText}) + t("newtErrorFetchReleases", { + err: response.statusText + }) ); } const data = await response.json(); const latestVersion = data.tag_name; newtVersion = latestVersion; } catch (error) { - console.error(t('newtErrorFetchLatest', {err: error instanceof Error ? error.message : String(error)})); + console.error( + t("newtErrorFetchLatest", { + err: + error instanceof Error + ? error.message + : String(error) + }) + ); } const generatedKeypair = generateKeypair(); @@ -481,10 +502,12 @@ WantedBy=default.target` const newtId = data.newtId; const newtSecret = data.newtSecret; const newtEndpoint = data.endpoint; + const clientAddress = data.clientAddress; setNewtId(newtId); setNewtSecret(newtSecret); setNewtEndpoint(newtEndpoint); + setClientAddress(clientAddress); hydrateCommands( newtId, @@ -520,8 +543,8 @@ WantedBy=default.target` <>
@@ -539,7 +562,7 @@ WantedBy=default.target` - {t('siteInfo')} + {t("siteInfo")} @@ -555,7 +578,7 @@ WantedBy=default.target` render={({ field }) => ( - {t('name')} + {t("name")} - {t('siteNameDescription')} + {t( + "siteNameDescription" + )} )} /> + {env.flags.enableClients && ( + ( + + + Site Address + + + { + setClientAddress( + e + .target + .value + ); + field.onChange( + e + .target + .value + ); + }} + /> + + + + Specify the IP + address of the + host. + + + )} + /> + )} - - - - {t('tunnelType')} - - - {t('siteTunnelDescription')} - - - - { - form.setValue("method", value); - }} - cols={3} - /> - - + {tunnelTypes.length > 1 && ( + + + + {t("tunnelType")} + + + {t("siteTunnelDescription")} + + + + { + form.setValue("method", value); + }} + cols={3} + /> + + + )} {form.watch("method") === "newt" && ( <> - {t('siteNewtCredentials')} + {t("siteNewtCredentials")} - {t('siteNewtCredentialsDescription')} + {t( + "siteNewtCredentialsDescription" + )} - {t('newtEndpoint')} + {t("newtEndpoint")} - {t('newtId')} + {t("newtId")} - {t('newtSecretKey')} + {t("newtSecretKey")} - {t('siteCredentialsSave')} + {t("siteCredentialsSave")} - {t('siteCredentialsSaveDescription')} + {t( + "siteCredentialsSaveDescription" + )} -
- - ( - -
- { - form.setValue( - "copied", - e as boolean - ); - }} - /> - -
- -
- )} - /> - - + {/*
*/} + {/* */} + {/* ( */} + {/* */} + {/*
*/} + {/* { */} + {/* form.setValue( */} + {/* "copied", */} + {/* e as boolean */} + {/* ); */} + {/* }} */} + {/* /> */} + {/* */} + {/*
*/} + {/* */} + {/*
*/} + {/* )} */} + {/* /> */} + {/* */} + {/* */}
- - {t('siteInstallNewt')} + {t("siteInstallNewt")} - {t('siteInstallNewtDescription')} + {t("siteInstallNewtDescription")}

- {t('operatingSystem')} + {t("operatingSystem")}

{platforms.map((os) => ( @@ -720,7 +791,7 @@ WantedBy=default.target` ? "squareOutlinePrimary" : "squareOutline" } - className={`flex-1 min-w-[120px] ${platform === os ? "bg-primary/10" : ""}`} + className={`flex-1 min-w-[120px] ${platform === os ? "bg-primary/10" : ""} shadow-none`} onClick={() => { setPlatform(os); }} @@ -737,8 +808,8 @@ WantedBy=default.target` {["docker", "podman"].includes( platform ) - ? t('method') - : t('architecture')} + ? t("method") + : t("architecture")}

{getArchitectures().map( @@ -751,7 +822,7 @@ WantedBy=default.target` ? "squareOutlinePrimary" : "squareOutline" } - className={`flex-1 min-w-[120px] ${architecture === arch ? "bg-primary/10" : ""}`} + className={`flex-1 min-w-[120px] ${architecture === arch ? "bg-primary/10" : ""} shadow-none`} onClick={() => setArchitecture( arch @@ -765,7 +836,7 @@ WantedBy=default.target`

- {t('commands')} + {t("commands")}

- {t('WgConfiguration')} + {t("WgConfiguration")} - {t('WgConfigurationDescription')} + {t("WgConfigurationDescription")} @@ -810,10 +881,12 @@ WantedBy=default.target` - {t('siteCredentialsSave')} + {t("siteCredentialsSave")} - {t('siteCredentialsSaveDescription')} + {t( + "siteCredentialsSaveDescription" + )} @@ -848,7 +921,9 @@ WantedBy=default.target` htmlFor="terms" className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70" > - {t('siteConfirmCopy')} + {t( + "siteConfirmCopy" + )}
@@ -870,15 +945,17 @@ WantedBy=default.target` router.push(`/${orgId}/settings/sites`); }} > - {t('cancel')} + {t("cancel")}
diff --git a/src/app/[orgId]/settings/sites/page.tsx b/src/app/[orgId]/settings/sites/page.tsx index 401fb2e5..10bcad53 100644 --- a/src/app/[orgId]/settings/sites/page.tsx +++ b/src/app/[orgId]/settings/sites/page.tsx @@ -44,11 +44,14 @@ export default async function SitesPage(props: SitesPageProps) { name: site.name, id: site.siteId, nice: site.niceId.toString(), + address: site.address?.split("/")[0], mbIn: formatSize(site.megabytesIn || 0, site.type), mbOut: formatSize(site.megabytesOut || 0, site.type), orgId: params.orgId, type: site.type as any, - online: site.online + online: site.online, + newtVersion: site.newtVersion || undefined, + newtUpdateAvailable: site.newtUpdateAvailable || false, }; }); diff --git a/src/app/admin/api-keys/ApiKeysTable.tsx b/src/app/admin/api-keys/ApiKeysTable.tsx index 517505ef..02aead9e 100644 --- a/src/app/admin/api-keys/ApiKeysTable.tsx +++ b/src/app/admin/api-keys/ApiKeysTable.tsx @@ -46,11 +46,14 @@ export default function ApiKeysTable({ apiKeys }: ApiKeyTableProps) { const deleteSite = (apiKeyId: string) => { api.delete(`/api-key/${apiKeyId}`) .catch((e) => { - console.error(t('apiKeysErrorDelete'), e); + console.error(t("apiKeysErrorDelete"), e); toast({ variant: "destructive", - title: t('apiKeysErrorDelete'), - description: formatAxiosError(e, t('apiKeysErrorDeleteMessage')) + title: t("apiKeysErrorDelete"), + description: formatAxiosError( + e, + t("apiKeysErrorDeleteMessage") + ) }); }) .then(() => { @@ -64,41 +67,6 @@ export default function ApiKeysTable({ apiKeys }: ApiKeyTableProps) { }; const columns: ColumnDef[] = [ - { - id: "dots", - cell: ({ row }) => { - const apiKeyROw = row.original; - const router = useRouter(); - - return ( - - - - - - { - setSelected(apiKeyROw); - }} - > - {t('viewSettings')} - - { - setSelected(apiKeyROw); - setIsDeleteModalOpen(true); - }} - > - {t('delete')} - - - - ); - } - }, { accessorKey: "name", header: ({ column }) => { @@ -109,7 +77,7 @@ export default function ApiKeysTable({ apiKeys }: ApiKeyTableProps) { column.toggleSorting(column.getIsSorted() === "asc") } > - {t('name')} + {t("name")} ); @@ -117,7 +85,7 @@ export default function ApiKeysTable({ apiKeys }: ApiKeyTableProps) { }, { accessorKey: "key", - header: t('key'), + header: t("key"), cell: ({ row }) => { const r = row.original; return {r.key}; @@ -125,7 +93,7 @@ export default function ApiKeysTable({ apiKeys }: ApiKeyTableProps) { }, { accessorKey: "createdAt", - header: t('createdAt'), + header: t("createdAt"), cell: ({ row }) => { const r = row.original; return {moment(r.createdAt).format("lll")} ; @@ -136,13 +104,44 @@ export default function ApiKeysTable({ apiKeys }: ApiKeyTableProps) { cell: ({ row }) => { const r = row.original; return ( -
- - - +
+ + + + + + { + setSelected(r); + }} + > + {t("viewSettings")} + + { + setSelected(r); + setIsDeleteModalOpen(true); + }} + > + + {t("delete")} + + + + +
+ + + +
); } @@ -161,24 +160,23 @@ export default function ApiKeysTable({ apiKeys }: ApiKeyTableProps) { dialog={

- {t('apiKeysQuestionRemove', {selectedApiKey: selected?.name || selected?.id})} + {t("apiKeysQuestionRemove", { + selectedApiKey: + selected?.name || selected?.id + })}

- - {t('apiKeysMessageRemove')} - + {t("apiKeysMessageRemove")}

-

- {t('apiKeysMessageConfirm')} -

+

{t("apiKeysMessageConfirm")}

} - buttonText={t('apiKeysDeleteConfirm')} + buttonText={t("apiKeysDeleteConfirm")} onConfirm={async () => deleteSite(selected!.id)} string={selected.name} - title={t('apiKeysDelete')} + title={t("apiKeysDelete")} /> )} diff --git a/src/app/admin/api-keys/[apiKeyId]/layout.tsx b/src/app/admin/api-keys/[apiKeyId]/layout.tsx index 0d6f7bdb..7e9e579f 100644 --- a/src/app/admin/api-keys/[apiKeyId]/layout.tsx +++ b/src/app/admin/api-keys/[apiKeyId]/layout.tsx @@ -2,16 +2,7 @@ import { internal } from "@app/lib/api"; import { AxiosResponse } from "axios"; import { redirect } from "next/navigation"; import { authCookieHeader } from "@app/lib/api/cookies"; -import { SidebarSettings } from "@app/components/SidebarSettings"; -import Link from "next/link"; import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; -import { - Breadcrumb, - BreadcrumbItem, - BreadcrumbList, - BreadcrumbPage, - BreadcrumbSeparator -} from "@app/components/ui/breadcrumb"; import { GetApiKeyResponse } from "@server/routers/apiKeys"; import ApiKeyProvider from "@app/providers/ApiKeyProvider"; import { HorizontalTabs } from "@app/components/HorizontalTabs"; diff --git a/src/app/admin/api-keys/create/page.tsx b/src/app/admin/api-keys/create/page.tsx index 5ca647c5..3b6aac82 100644 --- a/src/app/admin/api-keys/create/page.tsx +++ b/src/app/admin/api-keys/create/page.tsx @@ -25,21 +25,12 @@ import { zodResolver } from "@hookform/resolvers/zod"; import { Input } from "@app/components/ui/input"; import { InfoIcon } from "lucide-react"; import { Button } from "@app/components/ui/button"; -import { Checkbox, CheckboxWithLabel } from "@app/components/ui/checkbox"; import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert"; import { createApiClient, formatAxiosError } from "@app/lib/api"; import { useEnvContext } from "@app/hooks/useEnvContext"; import { toast } from "@app/hooks/useToast"; import { AxiosResponse } from "axios"; -import { useParams, useRouter } from "next/navigation"; -import { - Breadcrumb, - BreadcrumbItem, - BreadcrumbList, - BreadcrumbPage, - BreadcrumbSeparator -} from "@app/components/ui/breadcrumb"; -import Link from "next/link"; +import { useRouter } from "next/navigation"; import { CreateOrgApiKeyBody, CreateOrgApiKeyResponse @@ -108,7 +99,7 @@ export default function Page() { const copiedForm = useForm({ resolver: zodResolver(copiedFormSchema), defaultValues: { - copied: false + copied: true } }); @@ -299,54 +290,54 @@ export default function Page() { -

- {t('apiKeysInfo')} -

+ {/*

*/} + {/* {t('apiKeysInfo')} */} + {/*

*/} -
- - ( - -
- { - copiedForm.setValue( - "copied", - e as boolean - ); - }} - /> - -
- -
- )} - /> - - + {/*
*/} + {/* */} + {/* ( */} + {/* */} + {/*
*/} + {/* { */} + {/* copiedForm.setValue( */} + {/* "copied", */} + {/* e as boolean */} + {/* ); */} + {/* }} */} + {/* /> */} + {/* */} + {/*
*/} + {/* */} + {/*
*/} + {/* )} */} + {/* /> */} + {/* */} + {/* */} )} diff --git a/src/app/admin/idp/AdminIdpTable.tsx b/src/app/admin/idp/AdminIdpTable.tsx index c55a2b35..fa7de6da 100644 --- a/src/app/admin/idp/AdminIdpTable.tsx +++ b/src/app/admin/idp/AdminIdpTable.tsx @@ -43,14 +43,14 @@ export default function IdpTable({ idps }: Props) { try { await api.delete(`/idp/${idpId}`); toast({ - title: t('success'), - description: t('idpDeletedDescription') + title: t("success"), + description: t("idpDeletedDescription") }); setIsDeleteModalOpen(false); router.refresh(); } catch (e) { toast({ - title: t('error'), + title: t("error"), description: formatAxiosError(e), variant: "destructive" }); @@ -67,41 +67,6 @@ export default function IdpTable({ idps }: Props) { }; const columns: ColumnDef[] = [ - { - id: "dots", - cell: ({ row }) => { - const r = row.original; - - return ( - - - - - - - - {t('viewSettings')} - - - { - setSelectedIdp(r); - setIsDeleteModalOpen(true); - }} - > - {t('delete')} - - - - ); - } - }, { accessorKey: "idpId", header: ({ column }) => { @@ -128,7 +93,7 @@ export default function IdpTable({ idps }: Props) { column.toggleSorting(column.getIsSorted() === "asc") } > - {t('name')} + {t("name")} ); @@ -144,7 +109,7 @@ export default function IdpTable({ idps }: Props) { column.toggleSorting(column.getIsSorted() === "asc") } > - {t('type')} + {t("type")} ); @@ -162,9 +127,43 @@ export default function IdpTable({ idps }: Props) { const siteRow = row.original; return (
+ + + + + + + + {t("viewSettings")} + + + { + setSelectedIdp(siteRow); + setIsDeleteModalOpen(true); + }} + > + + {t("delete")} + + + + - @@ -186,22 +185,20 @@ export default function IdpTable({ idps }: Props) { dialog={

- {t('idpQuestionRemove', {name: selectedIdp.name})} + {t("idpQuestionRemove", { + name: selectedIdp.name + })}

- - {t('idpMessageRemove')} - -

-

- {t('idpMessageConfirm')} + {t("idpMessageRemove")}

+

{t("idpMessageConfirm")}

} - buttonText={t('idpConfirmDelete')} + buttonText={t("idpConfirmDelete")} onConfirm={async () => deleteIdp(selectedIdp.idpId)} string={selectedIdp.name} - title={t('idpDelete')} + title={t("idpDelete")} /> )} diff --git a/src/app/admin/idp/[idpId]/layout.tsx b/src/app/admin/idp/[idpId]/layout.tsx index 79b1e196..af64e440 100644 --- a/src/app/admin/idp/[idpId]/layout.tsx +++ b/src/app/admin/idp/[idpId]/layout.tsx @@ -4,17 +4,7 @@ import { AxiosResponse } from "axios"; import { redirect } from "next/navigation"; import { authCookieHeader } from "@app/lib/api/cookies"; import { HorizontalTabs } from "@app/components/HorizontalTabs"; -import { ProfessionalContentOverlay } from "@app/components/ProfessionalContentOverlay"; -import Link from "next/link"; -import { ArrowLeft } from "lucide-react"; import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; -import { - Breadcrumb, - BreadcrumbItem, - BreadcrumbList, - BreadcrumbPage, - BreadcrumbSeparator -} from "@app/components/ui/breadcrumb"; import { getTranslations } from "next-intl/server"; interface SettingsLayoutProps { diff --git a/src/app/admin/idp/[idpId]/policies/PolicyTable.tsx b/src/app/admin/idp/[idpId]/policies/PolicyTable.tsx index 9c11f9b9..f68a00c7 100644 --- a/src/app/admin/idp/[idpId]/policies/PolicyTable.tsx +++ b/src/app/admin/idp/[idpId]/policies/PolicyTable.tsx @@ -140,7 +140,7 @@ export default function PolicyTable({ policies, onDelete, onAdd, onEdit }: Props return (
)} @@ -422,7 +490,10 @@ export default function ResetPasswordForm({ {isSubmitting && ( )} - {t('passwordResetSubmit')} + {quickstart + ? t('accountSetupSubmit') + : t('passwordResetSubmit') + } )} diff --git a/src/app/auth/reset-password/page.tsx b/src/app/auth/reset-password/page.tsx index a0466208..f06c7c4c 100644 --- a/src/app/auth/reset-password/page.tsx +++ b/src/app/auth/reset-password/page.tsx @@ -13,6 +13,7 @@ export default async function Page(props: { redirect: string | undefined; email: string | undefined; token: string | undefined; + quickstart?: string | undefined; }>; }) { const searchParams = await props.searchParams; @@ -35,6 +36,9 @@ export default async function Page(props: { redirect={searchParams.redirect} tokenParam={searchParams.token} emailParam={searchParams.email} + quickstart={ + searchParams.quickstart === "true" ? true : undefined + } />

@@ -46,7 +50,7 @@ export default async function Page(props: { } className="underline" > - {t('loginBack')} + {t("loginBack")}

diff --git a/src/app/auth/resource/[resourceId]/ResourceAuthPortal.tsx b/src/app/auth/resource/[resourceId]/ResourceAuthPortal.tsx index 3c4e8023..bf703309 100644 --- a/src/app/auth/resource/[resourceId]/ResourceAuthPortal.tsx +++ b/src/app/auth/resource/[resourceId]/ResourceAuthPortal.tsx @@ -43,6 +43,7 @@ import { createApiClient } from "@app/lib/api"; import { useEnvContext } from "@app/hooks/useEnvContext"; import { toast } from "@app/hooks/useToast"; import Link from "next/link"; +import Image from "next/image"; import { useSupporterStatusContext } from "@app/hooks/useSupporterStatusContext"; import { useTranslations } from "next-intl"; @@ -185,8 +186,8 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) { setOtpState("otp_sent"); submitOtpForm.setValue("email", values.email); toast({ - title: t('otpEmailSent'), - description: t('otpEmailSentDescription') + title: t("otpEmailSent"), + description: t("otpEmailSentDescription") }); return; } @@ -202,7 +203,7 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) { .catch((e) => { console.error(e); setWhitelistError( - formatAxiosError(e, t('otpEmailErrorAuthenticate')) + formatAxiosError(e, t("otpEmailErrorAuthenticate")) ); }) .then(() => setLoadingLogin(false)); @@ -227,7 +228,7 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) { .catch((e) => { console.error(e); setPincodeError( - formatAxiosError(e, t('pincodeErrorAuthenticate')) + formatAxiosError(e, t("pincodeErrorAuthenticate")) ); }) .then(() => setLoadingLogin(false)); @@ -255,7 +256,7 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) { .catch((e) => { console.error(e); setPasswordError( - formatAxiosError(e, t('passwordErrorAuthenticate')) + formatAxiosError(e, t("passwordErrorAuthenticate")) ); }) .finally(() => setLoadingLogin(false)); @@ -276,30 +277,25 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) { } } + function getTitle() { + return t("authenticationRequired"); + } + + function getSubtitle(resourceName: string) { + return numMethods > 1 + ? t("authenticationMethodChoose", { name: props.resource.name }) + : t("authenticationRequest", { name: props.resource.name }); + } + return (
{!accessDenied ? (
-
- - {t('poweredBy')}{" "} - - Pangolin - - -
- {t('authenticationRequired')} + {getTitle()} - {numMethods > 1 - ? t('authenticationMethodChoose', {name: props.resource.name}) - : t('authenticationRequest', {name: props.resource.name})} + {getSubtitle(props.resource.name)} @@ -329,19 +325,19 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) { {props.methods.password && ( {" "} - {t('password')} + {t("password")} )} {props.methods.sso && ( {" "} - {t('user')} + {t("user")} )} {props.methods.whitelist && ( {" "} - {t('email')} + {t("email")} )} @@ -364,7 +360,9 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) { render={({ field }) => ( - {t('pincodeInput')} + {t( + "pincodeInput" + )}
@@ -433,7 +431,7 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) { disabled={loadingLogin} > - {t('pincodeSubmit')} + {t("pincodeSubmit")} @@ -459,7 +457,7 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) { render={({ field }) => ( - {t('password')} + {t("password")} - {t('passwordSubmit')} + {t("passwordSubmit")} @@ -528,7 +526,7 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) { render={({ field }) => ( - {t('email')} + {t("email")} - {t('otpEmailDescription')} + {t( + "otpEmailDescription" + )} @@ -559,7 +559,7 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) { disabled={loadingLogin} > - {t('otpEmailSend')} + {t("otpEmailSend")} @@ -581,7 +581,9 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) { render={({ field }) => ( - {t('otpEmail')} + {t( + "otpEmail" + )} - {t('otpEmailSubmit')} + {t("otpEmailSubmit")} @@ -634,7 +636,7 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) { {supporterStatus?.visible && (
- {t('noSupportKey')} + {t("noSupportKey")}
)} diff --git a/src/app/auth/signup/SignupForm.tsx b/src/app/auth/signup/SignupForm.tsx index bd693180..27f4921c 100644 --- a/src/app/auth/signup/SignupForm.tsx +++ b/src/app/auth/signup/SignupForm.tsx @@ -57,7 +57,9 @@ export default function SignupForm({ }: SignupFormProps) { const router = useRouter(); - const api = createApiClient(useEnvContext()); + const { env } = useEnvContext(); + + const api = createApiClient({ env }); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); @@ -116,8 +118,8 @@ export default function SignupForm({ } return ( - - + +
- +
{t('password')} - + @@ -177,10 +176,7 @@ export default function SignupForm({ {t('confirmPassword')} - + diff --git a/src/app/auth/signup/page.tsx b/src/app/auth/signup/page.tsx index 242bf10b..debd7c58 100644 --- a/src/app/auth/signup/page.tsx +++ b/src/app/auth/signup/page.tsx @@ -6,7 +6,7 @@ import { Mail } from "lucide-react"; import Link from "next/link"; import { redirect } from "next/navigation"; import { cache } from "react"; -import { getTranslations } from 'next-intl/server'; +import { getTranslations } from "next-intl/server"; export const dynamic = "force-dynamic"; @@ -15,7 +15,7 @@ export default async function Page(props: { }) { const searchParams = await props.searchParams; const getUser = cache(verifySession); - const user = await getUser(); + const user = await getUser({ skipCheckVerifyEmail: true }); const t = await getTranslations(); const env = pullEnv(); @@ -56,10 +56,10 @@ export default async function Page(props: {

- {t('inviteAlready')} + {t("inviteAlready")}

- {t('inviteAlreadyDescription')} + {t("inviteAlreadyDescription")}

@@ -72,7 +72,7 @@ export default async function Page(props: { />

- {t('signupQuestion')}{" "} + {t("signupQuestion")}{" "} - {t('login')} + {t("login")}

diff --git a/src/app/auth/verify-email/VerifyEmailForm.tsx b/src/app/auth/verify-email/VerifyEmailForm.tsx index cbe1e5fb..e9761eef 100644 --- a/src/app/auth/verify-email/VerifyEmailForm.tsx +++ b/src/app/auth/verify-email/VerifyEmailForm.tsx @@ -10,7 +10,7 @@ import { CardContent, CardDescription, CardHeader, - CardTitle, + CardTitle } from "@/components/ui/card"; import { Form, @@ -19,21 +19,21 @@ import { FormField, FormItem, FormLabel, - FormMessage, + FormMessage } from "@/components/ui/form"; import { Input } from "@/components/ui/input"; import { InputOTP, InputOTPGroup, - InputOTPSlot, + InputOTPSlot } from "@/components/ui/input-otp"; import { AxiosResponse } from "axios"; import { VerifyEmailResponse } from "@server/routers/auth"; -import { Loader2 } from "lucide-react"; +import { ArrowRight, IdCard, Loader2 } from "lucide-react"; import { Alert, AlertDescription } from "../../../components/ui/alert"; import { toast } from "@app/hooks/useToast"; import { useRouter } from "next/navigation"; -import { formatAxiosError } from "@app/lib/api";; +import { formatAxiosError } from "@app/lib/api"; import { createApiClient } from "@app/lib/api"; import { useEnvContext } from "@app/hooks/useEnvContext"; import { cleanRedirect } from "@app/lib/cleanRedirect"; @@ -46,7 +46,7 @@ export type VerifyEmailFormProps = { export default function VerifyEmailForm({ email, - redirect, + redirect }: VerifyEmailFormProps) { const router = useRouter(); const t = useTranslations(); @@ -58,19 +58,34 @@ export default function VerifyEmailForm({ const api = createApiClient(useEnvContext()); + function logout() { + api.post("/auth/logout") + .catch((e) => { + console.error(t("logoutError"), e); + toast({ + title: t("logoutError"), + description: formatAxiosError(e, t("logoutError")) + }); + }) + .then(() => { + router.push("/auth/login"); + router.refresh(); + }); + } + const FormSchema = z.object({ - email: z.string().email({ message: t('emailInvalid') }), + email: z.string().email({ message: t("emailInvalid") }), pin: z.string().min(8, { - message: t('verificationCodeLengthRequirements'), - }), + message: t("verificationCodeLengthRequirements") + }) }); const form = useForm>({ resolver: zodResolver(FormSchema), defaultValues: { email: email, - pin: "", - }, + pin: "" + } }); async function onSubmit(data: z.infer) { @@ -78,19 +93,17 @@ export default function VerifyEmailForm({ const res = await api .post>("/auth/verify-email", { - code: data.pin, + code: data.pin }) .catch((e) => { - setError(formatAxiosError(e, t('errorOccurred'))); - console.error(t('emailErrorVerify'), e); + setError(formatAxiosError(e, t("errorOccurred"))); + console.error(t("emailErrorVerify"), e); setIsSubmitting(false); }); if (res && res.data?.data?.valid) { setError(null); - setSuccessMessage( - t('emailVerified') - ); + setSuccessMessage(t("emailVerified")); setTimeout(() => { if (redirect) { const safe = cleanRedirect(redirect); @@ -107,16 +120,16 @@ export default function VerifyEmailForm({ setIsResending(true); const res = await api.post("/auth/verify-email/request").catch((e) => { - setError(formatAxiosError(e, t('errorOccurred'))); - console.error(t('verificationCodeErrorResend'), e); + setError(formatAxiosError(e, t("errorOccurred"))); + console.error(t("verificationCodeErrorResend"), e); }); if (res) { setError(null); toast({ variant: "default", - title: t('verificationCodeResend'), - description: t('verificationCodeResendDescription'), + title: t("verificationCodeResend"), + description: t("verificationCodeResendDescription") }); } @@ -127,40 +140,26 @@ export default function VerifyEmailForm({
- {t('emailVerify')} + {t("emailVerify")} - {t('emailVerifyDescription')} + {t("emailVerifyDescription")} +

+ {email} +

- ( - - {t('email')} - - - - - - )} - /> - ( - {t('verificationCode')}
- - {t('verificationCodeEmailSent')} - )} /> +
+ +
+ {error && ( {error} @@ -222,29 +231,26 @@ export default function VerifyEmailForm({ type="submit" className="w-full" disabled={isSubmitting} + form="verify-email-form" > {isSubmitting && ( )} - {t('submit')} + {t("submit")} + + + - -
- -
); } diff --git a/src/app/globals.css b/src/app/globals.css index 9b6c18bc..e643cfb6 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -1,125 +1,142 @@ @import url("https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&family=Space+Grotesk:wght@300..700&display=swap"); -@import 'tw-animate-css'; -@import 'tailwindcss'; +@import "tw-animate-css"; +@import "tailwindcss"; @custom-variant dark (&:is(.dark *)); :root { - --background: hsl(0 0% 98%); - --foreground: hsl(20 0% 10%); - --card: hsl(0 0% 100%); - --card-foreground: hsl(20 0% 10%); - --popover: hsl(0 0% 100%); - --popover-foreground: hsl(20 0% 10%); - --primary: hsl(24.6 95% 53.1%); - --primary-foreground: hsl(60 9.1% 97.8%); - --secondary: hsl(60 4.8% 95.9%); - --secondary-foreground: hsl(24 9.8% 10%); - --muted: hsl(60 4.8% 85%); - --muted-foreground: hsl(25 5.3% 44.7%); - --accent: hsl(60 4.8% 90%); - --accent-foreground: hsl(24 9.8% 10%); - --destructive: hsl(0 84.2% 60.2%); - --destructive-foreground: hsl(60 9.1% 97.8%); - --border: hsl(20 5.9% 90%); - --input: hsl(20 5.9% 75%); - --ring: hsl(24.6 95% 53.1%); - --radius: 0.75rem; - --chart-1: hsl(12 76% 61%); - --chart-2: hsl(173 58% 39%); - --chart-3: hsl(197 37% 24%); - --chart-4: hsl(43 74% 66%); - --chart-5: hsl(27 87% 67%); + --radius: 0.65rem; + --background: oklch(0.99 0 0); + --foreground: oklch(0.141 0.005 285.823); + --card: oklch(1 0 0); + --card-foreground: oklch(0.141 0.005 285.823); + --popover: oklch(1 0 0); + --popover-foreground: oklch(0.141 0.005 285.823); + --primary: oklch(0.6717 0.1946 41.93); + --primary-foreground: oklch(0.98 0.016 73.684); + --secondary: oklch(0.967 0.001 286.375); + --secondary-foreground: oklch(0.21 0.006 285.885); + --muted: oklch(0.967 0.001 286.375); + --muted-foreground: oklch(0.552 0.016 285.938); + --accent: oklch(0.967 0.001 286.375); + --accent-foreground: oklch(0.21 0.006 285.885); + --destructive: oklch(0.577 0.245 27.325); + --border: oklch(0.92 0.004 286.32); + --input: oklch(0.92 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-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-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: hsl(20 0% 8%); - --foreground: hsl(60 9.1% 97.8%); - --card: hsl(20 0% 10%); - --card-foreground: hsl(60 9.1% 97.8%); - --popover: hsl(20 0% 10%); - --popover-foreground: hsl(60 9.1% 97.8%); - --primary: hsl(20.5 90.2% 48.2%); - --primary-foreground: hsl(60 9.1% 97.8%); - --secondary: hsl(12 6.5% 15%); - --secondary-foreground: hsl(60 9.1% 97.8%); - --muted: hsl(12 6.5% 25%); - --muted-foreground: hsl(24 5.4% 63.9%); - --accent: hsl(12 2.5% 15%); - --accent-foreground: hsl(60 9.1% 97.8%); - --destructive: hsl(0 72.2% 50.6%); - --destructive-foreground: hsl(60 9.1% 97.8%); - --border: hsl(12 6.5% 15%); - --input: hsl(12 6.5% 35%); - --ring: hsl(20.5 90.2% 48.2%); - --chart-1: hsl(220 70% 50%); - --chart-2: hsl(160 60% 45%); - --chart-3: hsl(30 80% 55%); - --chart-4: hsl(280 65% 60%); - --chart-5: hsl(340 75% 55%); + --background: oklch(0.20 0.006 285.885); + --foreground: oklch(0.985 0 0); + --card: oklch(0.21 0.006 285.885); + --card-foreground: oklch(0.985 0 0); + --popover: oklch(0.21 0.006 285.885); + --popover-foreground: oklch(0.985 0 0); + --primary: oklch(0.6717 0.1946 41.93); + --primary-foreground: oklch(0.98 0.016 73.684); + --secondary: oklch(0.274 0.006 286.033); + --secondary-foreground: oklch(0.985 0 0); + --muted: oklch(0.274 0.006 286.033); + --muted-foreground: oklch(0.705 0.015 286.067); + --accent: oklch(0.274 0.006 286.033); + --accent-foreground: oklch(0.985 0 0); + --destructive: oklch(0.704 0.191 22.216); + --border: oklch(1 0 0 / 10%); + --input: oklch(1 0 0 / 15%); + --ring: oklch(0.646 0.222 41.116); + --chart-1: oklch(0.488 0.243 264.376); + --chart-2: oklch(0.696 0.17 162.48); + --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-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-foreground: oklch(0.985 0 0); + --sidebar-border: oklch(1 0 0 / 10%); + --sidebar-ring: oklch(0.646 0.222 41.116); } @theme inline { - --color-background: var(--background); - --color-foreground: var(--foreground); + --color-background: var(--background); + --color-foreground: var(--foreground); - --color-card: var(--card); - --color-card-foreground: var(--card-foreground); + --color-card: var(--card); + --color-card-foreground: var(--card-foreground); - --color-popover: var(--popover); - --color-popover-foreground: var(--popover-foreground); + --color-popover: var(--popover); + --color-popover-foreground: var(--popover-foreground); - --color-primary: var(--primary); - --color-primary-foreground: var(--primary-foreground); + --color-primary: var(--primary); + --color-primary-foreground: var(--primary-foreground); - --color-secondary: var(--secondary); - --color-secondary-foreground: var(--secondary-foreground); + --color-secondary: var(--secondary); + --color-secondary-foreground: var(--secondary-foreground); - --color-muted: var(--muted); - --color-muted-foreground: var(--muted-foreground); + --color-muted: var(--muted); + --color-muted-foreground: var(--muted-foreground); - --color-accent: var(--accent); - --color-accent-foreground: var(--accent-foreground); + --color-accent: var(--accent); + --color-accent-foreground: var(--accent-foreground); - --color-destructive: var(--destructive); - --color-destructive-foreground: var(--destructive-foreground); + --color-destructive: var(--destructive); + --color-destructive-foreground: var(--destructive-foreground); - --color-border: var(--border); - --color-input: var(--input); - --color-ring: var(--ring); + --color-border: var(--border); + --color-input: var(--input); + --color-ring: var(--ring); - --color-chart-1: var(--chart-1); - --color-chart-2: var(--chart-2); - --color-chart-3: var(--chart-3); - --color-chart-4: var(--chart-4); - --color-chart-5: var(--chart-5); + --color-chart-1: var(--chart-1); + --color-chart-2: var(--chart-2); + --color-chart-3: var(--chart-3); + --color-chart-4: var(--chart-4); + --color-chart-5: var(--chart-5); - --radius-lg: var(--radius); - --radius-md: calc(var(--radius) - 2px); - --radius-sm: calc(var(--radius) - 4px); + --radius-lg: var(--radius); + --radius-md: calc(var(--radius) - 2px); + --radius-sm: calc(var(--radius) - 4px); + + --shadow-2xs: 0 1px 1px rgba(0, 0, 0, 0.03); + --inset-shadow-2xs: inset 0 1px 1px rgba(0, 0, 1, 0.03); } @layer base { - *, - ::after, - ::before, - ::backdrop, - ::file-selector-button { - border-color: var(--color-gray-200, currentcolor); - } + *, + ::after, + ::before, + ::backdrop, + ::file-selector-button { + border-color: var(--color-gray-200, currentcolor); + } } @layer base { - * { - @apply border-border; - } + * { + @apply border-border; + } - body { - @apply bg-background text-foreground; - } + body { + @apply bg-background text-foreground; + } } p { - word-break: keep-all; - white-space: normal; -} \ No newline at end of file + word-break: keep-all; + white-space: normal; +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index dd02c489..1ad8d10a 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,7 +1,6 @@ import type { Metadata } from "next"; import "./globals.css"; import { Inter } from "next/font/google"; -import { Toaster } from "@/components/ui/toaster"; import { ThemeProvider } from "@app/providers/ThemeProvider"; import EnvProvider from "@app/providers/EnvProvider"; import { pullEnv } from "@app/lib/pullEnv"; @@ -11,14 +10,15 @@ import { AxiosResponse } from "axios"; import { IsSupporterKeyVisibleResponse } from "@server/routers/supporterKey"; import LicenseStatusProvider from "@app/providers/LicenseStatusProvider"; import { GetLicenseStatusResponse } from "@server/routers/license"; -import LicenseViolation from "./components/LicenseViolation"; +import LicenseViolation from "@app/components/LicenseViolation"; import { cache } from "react"; import { NextIntlClientProvider } from "next-intl"; import { getLocale } from "next-intl/server"; +import { Toaster } from "@app/components/ui/toaster"; export const metadata: Metadata = { title: `Dashboard - Pangolin`, - description: "" + description: "", }; export const dynamic = "force-dynamic"; @@ -83,4 +83,4 @@ export default async function RootLayout({ ); -} +} \ No newline at end of file diff --git a/src/app/navigation.tsx b/src/app/navigation.tsx index af4eea75..a18659f2 100644 --- a/src/app/navigation.tsx +++ b/src/app/navigation.tsx @@ -1,4 +1,5 @@ import { SidebarNavItem } from "@app/components/SidebarNav"; +import { build } from "@server/build"; import { Home, Settings, @@ -7,95 +8,121 @@ import { Waypoints, Combine, Fingerprint, + Workflow, KeyRound, - TicketCheck + TicketCheck, + User, + Globe, + MonitorUp } from "lucide-react"; -export const orgLangingNavItems: SidebarNavItem[] = [ - { - title: "sidebarOverview", - href: "/{orgId}", - icon: - } -]; +export type SidebarNavSection = { + heading: string; + items: SidebarNavItem[]; +}; -export const rootNavItems: SidebarNavItem[] = [ +export const orgNavSections = ( + enableClients: boolean = true +): SidebarNavSection[] => [ { - title: "sidebarHome", - href: "/", - icon: - } -]; - -export const orgNavItems: SidebarNavItem[] = [ - { - title: "sidebarSites", - href: "/{orgId}/settings/sites", - icon: - }, - { - title: "sidebarResources", - href: "/{orgId}/settings/resources", - icon: - }, - { - title: "sidebarAccessControl", - href: "/{orgId}/settings/access", - icon: , - autoExpand: true, - children: [ + heading: "General", + items: [ { - title: "sidebarUsers", - href: "/{orgId}/settings/access/users", - children: [ - { - title: "sidebarInvitations", - href: "/{orgId}/settings/access/invitations" - } - ] + title: "sidebarSites", + href: "/{orgId}/settings/sites", + icon: }, { - title: "sidebarRoles", - href: "/{orgId}/settings/access/roles" + title: "sidebarResources", + href: "/{orgId}/settings/resources", + icon: + }, + ...(enableClients + ? [ + { + title: "sidebarClients", + href: "/{orgId}/settings/clients", + icon: + } + ] + : []), + { + title: "sidebarDomains", + href: "/{orgId}/settings/domains", + icon: } ] }, { - title: "sidebarShareableLinks", - href: "/{orgId}/settings/share-links", - icon: + heading: "Access Control", + items: [ + { + title: "sidebarUsers", + href: "/{orgId}/settings/access/users", + icon: + }, + { + title: "sidebarRoles", + href: "/{orgId}/settings/access/roles", + icon: + }, + { + title: "sidebarInvitations", + href: "/{orgId}/settings/access/invitations", + icon: + }, + { + title: "sidebarShareableLinks", + href: "/{orgId}/settings/share-links", + icon: + } + ] }, { - title: "sidebarApiKeys", - href: "/{orgId}/settings/api-keys", - icon: - }, - { - title: "sidebarSettings", - href: "/{orgId}/settings/general", - icon: + heading: "Organization", + items: [ + { + title: "sidebarApiKeys", + href: "/{orgId}/settings/api-keys", + icon: + }, + { + title: "sidebarSettings", + href: "/{orgId}/settings/general", + icon: + } + ] } ]; -export const adminNavItems: SidebarNavItem[] = [ +export const adminNavSections: SidebarNavSection[] = [ { - title: "sidebarAllUsers", - href: "/admin/users", - icon: - }, - { - title: "sidebarApiKeys", - href: "/admin/api-keys", - icon: - }, - { - title: "sidebarIdentityProviders", - href: "/admin/idp", - icon: - }, - { - title: "sidebarLicense", - href: "/admin/license", - icon: + heading: "Admin", + items: [ + { + title: "sidebarAllUsers", + href: "/admin/users", + icon: + }, + { + title: "sidebarApiKeys", + href: "/admin/api-keys", + icon: + }, + { + title: "sidebarIdentityProviders", + href: "/admin/idp", + icon: + }, + ...(build == "enterprise" + ? [ + { + title: "sidebarLicense", + href: "/admin/license", + icon: + } + ] + : []) + ] } ]; diff --git a/src/app/page.tsx b/src/app/page.tsx index 14738ac7..91ca5686 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -6,12 +6,12 @@ import { ListUserOrgsResponse } from "@server/routers/org"; import { AxiosResponse } from "axios"; import { redirect } from "next/navigation"; import { cache } from "react"; -import OrganizationLanding from "./components/OrganizationLanding"; +import OrganizationLanding from "@app/components/OrganizationLanding"; import { pullEnv } from "@app/lib/pullEnv"; import { cleanRedirect } from "@app/lib/cleanRedirect"; import { Layout } from "@app/components/Layout"; -import { rootNavItems } from "./navigation"; import { InitialSetupCompleteResponse } from "@server/routers/auth"; +import { cookies } from "next/headers"; export const dynamic = "force-dynamic"; @@ -72,21 +72,36 @@ export default async function Page(props: { } } - return ( - - -
- ({ - name: org.name, - id: org.orgId - }))} - /> -
-
-
- ); + const allCookies = await cookies(); + const lastOrgCookie = allCookies.get("pangolin-last-org")?.value; + + const lastOrgExists = orgs.some((org) => org.orgId === lastOrgCookie); + if (lastOrgExists) { + redirect(`/${lastOrgCookie}`); + } else { + const ownedOrg = orgs.find((org) => org.isOwner); + if (ownedOrg) { + redirect(`/${ownedOrg.orgId}`); + } else { + redirect("/setup"); + } + } + + // return ( + // + // + //
+ // ({ + // name: org.name, + // id: org.orgId + // }))} + // /> + //
+ //
+ //
+ // ); } diff --git a/src/app/setup/layout.tsx b/src/app/setup/layout.tsx index e254037d..06dd3300 100644 --- a/src/app/setup/layout.tsx +++ b/src/app/setup/layout.tsx @@ -6,7 +6,6 @@ import UserProvider from "@app/providers/UserProvider"; import { Metadata } from "next"; import { redirect } from "next/navigation"; import { cache } from "react"; -import { rootNavItems } from "../navigation"; import { ListUserOrgsResponse } from "@server/routers/org"; import { internal } from "@app/lib/api"; import { AxiosResponse } from "axios"; @@ -54,11 +53,7 @@ export default async function SetupLayout({ return ( <> - +
{children}
diff --git a/src/app/setup/page.tsx b/src/app/setup/page.tsx index 2a1447ee..995893d8 100644 --- a/src/app/setup/page.tsx +++ b/src/app/setup/page.tsx @@ -2,8 +2,6 @@ import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; -import Link from "next/link"; import { toast } from "@app/hooks/useToast"; import { useCallback, useEffect, useState } from "react"; import { @@ -13,8 +11,7 @@ import { CardHeader, CardTitle } from "@app/components/ui/card"; -import CopyTextBox from "@app/components/CopyTextBox"; -import { formatAxiosError } from "@app/lib/api";; +import { formatAxiosError } from "@app/lib/api"; import { createApiClient } from "@app/lib/api"; import { useEnvContext } from "@app/hooks/useEnvContext"; import { Separator } from "@/components/ui/separator"; @@ -41,6 +38,7 @@ export default function StepperForm() { const [currentStep, setCurrentStep] = useState("org"); const [orgIdTaken, setOrgIdTaken] = useState(false); const t = useTranslations(); + const { env } = useEnvContext(); const [loading, setLoading] = useState(false); const [isChecked, setIsChecked] = useState(false); @@ -48,36 +46,62 @@ export default function StepperForm() { const [orgCreated, setOrgCreated] = useState(false); const orgSchema = z.object({ - orgName: z.string().min(1, { message: t('orgNameRequired') }), - orgId: z.string().min(1, { message: t('orgIdRequired') }) + orgName: z.string().min(1, { message: t("orgNameRequired") }), + orgId: z.string().min(1, { message: t("orgIdRequired") }), + subnet: z.string().min(1, { message: t("subnetRequired") }) }); const orgForm = useForm>({ resolver: zodResolver(orgSchema), defaultValues: { orgName: "", - orgId: "" + orgId: "", + subnet: "" } }); const api = createApiClient(useEnvContext()); const router = useRouter(); - const checkOrgIdAvailability = useCallback(async (value: string) => { - if (loading || orgCreated) { - return; - } + // Fetch default subnet on component mount + useEffect(() => { + fetchDefaultSubnet(); + }, []); + + const fetchDefaultSubnet = async () => { try { - const res = await api.get(`/org/checkId`, { - params: { - orgId: value - } + const res = await api.get(`/pick-org-defaults`); + if (res && res.data && res.data.data) { + orgForm.setValue("subnet", res.data.data.subnet); + } + } catch (e) { + console.error("Failed to fetch default subnet:", e); + toast({ + title: "Error", + description: "Failed to fetch default subnet", + variant: "destructive" }); - setOrgIdTaken(res.status !== 404); - } catch (error) { - setOrgIdTaken(false); } - }, [loading, orgCreated, api]); + }; + + const checkOrgIdAvailability = useCallback( + async (value: string) => { + if (loading || orgCreated) { + return; + } + try { + const res = await api.get(`/org/checkId`, { + params: { + orgId: value + } + }); + setOrgIdTaken(res.status !== 404); + } catch (error) { + setOrgIdTaken(false); + } + }, + [loading, orgCreated, api] + ); const debouncedCheckOrgIdAvailability = useCallback( debounce(checkOrgIdAvailability, 300), @@ -105,7 +129,8 @@ export default function StepperForm() { try { const res = await api.put(`/org`, { orgId: values.orgId, - name: values.orgName + name: values.orgName, + subnet: values.subnet }); if (res && res.status === 201) { @@ -114,9 +139,7 @@ export default function StepperForm() { } } catch (e) { console.error(e); - setError( - formatAxiosError(e, t('orgErrorCreate')) - ); + setError(formatAxiosError(e, t("orgErrorCreate"))); } setLoading(false); @@ -126,10 +149,8 @@ export default function StepperForm() { <> - {t('setupNewOrg')} - - {t('setupCreate')} - + {t("setupNewOrg")} + {t("setupCreate")}
@@ -151,7 +172,7 @@ export default function StepperForm() { : "text-muted-foreground" }`} > - {t('setupCreateOrg')} + {t("setupCreateOrg")}
@@ -171,7 +192,7 @@ export default function StepperForm() { : "text-muted-foreground" }`} > - {t('siteCreate')} + {t("siteCreate")}
@@ -191,7 +212,7 @@ export default function StepperForm() { : "text-muted-foreground" }`} > - {t('setupCreateResources')} + {t("setupCreateResources")}
@@ -210,7 +231,7 @@ export default function StepperForm() { render={({ field }) => ( - {t('setupOrgName')} + {t("setupOrgName")} { // Prevent "/" in orgName input - const sanitizedValue = e.target.value.replace(/\//g, "-"); - const orgId = generateId(sanitizedValue); + const sanitizedValue = + e.target.value.replace( + /\//g, + "-" + ); + const orgId = + generateId( + sanitizedValue + ); orgForm.setValue( "orgId", orgId @@ -232,12 +260,15 @@ export default function StepperForm() { orgId ); }} - value={field.value.replace(/\//g, "-")} + value={field.value.replace( + /\//g, + "-" + )} /> - {t('orgDisplayName')} + {t("orgDisplayName")} )} @@ -248,7 +279,7 @@ export default function StepperForm() { render={({ field }) => ( - {t('orgId')} + {t("orgId")} - {t('setupIdentifierMessage')} + {t( + "setupIdentifierMessage" + )} )} /> + {env.flags.enableClients && ( + ( + + + Subnet + + + + + + + Network subnet for this + organization. A default + value has been provided. + + + )} + /> + )} + {orgIdTaken && !orgCreated ? ( - {t('setupErrorIdentifier')} + {t("setupErrorIdentifier")} ) : null} @@ -290,7 +349,7 @@ export default function StepperForm() { orgIdTaken } > - {t('setupCreateOrg')} + {t("setupCreateOrg")}
diff --git a/src/components/AuthFooter.tsx b/src/components/AuthFooter.tsx deleted file mode 100644 index a1c0795e..00000000 --- a/src/components/AuthFooter.tsx +++ /dev/null @@ -1,3 +0,0 @@ -"use client"; - -export function AuthFooter() {} diff --git a/src/components/Breadcrumbs.tsx b/src/components/Breadcrumbs.tsx deleted file mode 100644 index 25366ffa..00000000 --- a/src/components/Breadcrumbs.tsx +++ /dev/null @@ -1,42 +0,0 @@ -"use client"; - -import { usePathname } from "next/navigation"; -import Link from "next/link"; -import { ChevronRight } from "lucide-react"; -import { cn } from "@app/lib/cn"; - -interface BreadcrumbItem { - label: string; - href: string; -} - -export function Breadcrumbs() { - const pathname = usePathname(); - const segments = pathname.split("/").filter(Boolean); - - const breadcrumbs: BreadcrumbItem[] = segments.map((segment, index) => { - const href = `/${segments.slice(0, index + 1).join("/")}`; - let label = decodeURIComponent(segment); - return { label, href }; - }); - - return ( - - ); -} diff --git a/src/components/ConfirmDeleteDialog.tsx b/src/components/ConfirmDeleteDialog.tsx index 5ca8ca8d..cd053a14 100644 --- a/src/components/ConfirmDeleteDialog.tsx +++ b/src/components/ConfirmDeleteDialog.tsx @@ -72,7 +72,7 @@ export default function InviteUserForm({ const formSchema = z.object({ string: z.string().refine((val) => val === string, { - message: t('inviteErrorInvalidConfirmation') + message: t("inviteErrorInvalidConfirmation") }) }); @@ -108,7 +108,9 @@ export default function InviteUserForm({ {title} -
{dialog}
+
+ {dialog} +
- + +
+ + {/* Loading State */} + {isChecking && ( +
+
+
+ {t("domainPickerCheckingAvailability")} +
+
+ )} + + {/* No Options */} + {!isChecking && + filteredOptions.length === 0 && + userInput.trim() && ( + + + + {t("domainPickerNoMatchingDomains", { userInput })} + + + )} + + {/* Domain Options */} + {!isChecking && filteredOptions.length > 0 && ( +
+ {/* Organization Domains */} + {organizationOptions.length > 0 && ( +
+
+ +

+ {t("domainPickerOrganizationDomains")} +

+
+
+ {organizationOptions.map((option) => ( +
+ option.verified && + handleOptionSelect(option) + } + > +
+
+
+

+ {option.domain} +

+ {/* */} + {/* {option.domainType} */} + {/* */} + {option.verified ? ( + + ) : ( + + )} +
+ {option.subdomain && ( +

+ {t( + "domainPickerSubdomain", + { + subdomain: + option.subdomain + } + )} +

+ )} + {!option.verified && ( +

+ Domain is unverified +

+ )} +
+ {selectedOption?.id === + option.id && ( + + )} +
+
+ ))} +
+
+ )} + + {/* Provided Domains */} + {providedOptions.length > 0 && ( +
+
+ +
+ {t("domainPickerProvidedDomains")} +
+
+
+ {providedOptions.map((option) => ( +
+ handleOptionSelect(option) + } + > +
+
+

+ {option.domain} +

+

+ {t( + "domainPickerNamespace", + { + namespace: + option.domainNamespaceId as string + } + )} +

+
+ {selectedOption?.id === + option.id && ( + + )} +
+
+ ))} +
+ {hasMoreProvided && ( + + )} +
+ )} +
+ )} +
+ ); +} + +function debounce any>( + func: T, + wait: number +): (...args: Parameters) => void { + let timeout: NodeJS.Timeout | null = null; + + return (...args: Parameters) => { + if (timeout) clearTimeout(timeout); + + timeout = setTimeout(() => { + func(...args); + }, wait); + }; +} diff --git a/src/components/HorizontalTabs.tsx b/src/components/HorizontalTabs.tsx index 258bace3..b529dbba 100644 --- a/src/components/HorizontalTabs.tsx +++ b/src/components/HorizontalTabs.tsx @@ -38,6 +38,7 @@ export function HorizontalTabs({ .replace("{resourceId}", params.resourceId as string) .replace("{niceId}", params.niceId as string) .replace("{userId}", params.userId as string) + .replace("{clientId}", params.clientId as string) .replace("{apiKeyId}", params.apiKeyId as string); } diff --git a/src/components/Layout.tsx b/src/components/Layout.tsx index 28ef307b..7d99a773 100644 --- a/src/components/Layout.tsx +++ b/src/components/Layout.tsx @@ -1,276 +1,82 @@ -"use client"; - -import React, { useEffect, useState } from "react"; -import { SidebarNav } from "@app/components/SidebarNav"; -import { OrgSelector } from "@app/components/OrgSelector"; +import React from "react"; import { cn } from "@app/lib/cn"; import { ListUserOrgsResponse } from "@server/routers/org"; -import SupporterStatus from "@app/components/SupporterStatus"; -import { Button } from "@app/components/ui/button"; -import { ExternalLink, Menu, X, Server } from "lucide-react"; -import Image from "next/image"; -import ProfileIcon from "@app/components/ProfileIcon"; -import { - Sheet, - SheetContent, - SheetTrigger, - SheetTitle, - SheetDescription -} from "@app/components/ui/sheet"; -import { useEnvContext } from "@app/hooks/useEnvContext"; -import { Breadcrumbs } from "@app/components/Breadcrumbs"; -import Link from "next/link"; -import { usePathname } from "next/navigation"; -import { useUserContext } from "@app/hooks/useUserContext"; -import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext"; -import { useTheme } from "next-themes"; -import { useTranslations } from "next-intl"; +import type { SidebarNavSection } from "@app/app/navigation"; +import { LayoutSidebar } from "@app/components/LayoutSidebar"; +import { LayoutHeader } from "@app/components/LayoutHeader"; +import { LayoutMobileMenu } from "@app/components/LayoutMobileMenu"; +import { cookies } from "next/headers"; interface LayoutProps { children: React.ReactNode; orgId?: string; orgs?: ListUserOrgsResponse["orgs"]; - navItems?: Array<{ - title: string; - href: string; - icon?: React.ReactNode; - children?: Array<{ - title: string; - href: string; - icon?: React.ReactNode; - }>; - }>; + navItems?: SidebarNavSection[]; showSidebar?: boolean; - showBreadcrumbs?: boolean; showHeader?: boolean; showTopBar?: boolean; + defaultSidebarCollapsed?: boolean; } -export function Layout({ +export async function Layout({ children, orgId, orgs, navItems = [], showSidebar = true, - showBreadcrumbs = true, showHeader = true, - showTopBar = true + showTopBar = true, + defaultSidebarCollapsed = false }: LayoutProps) { - const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false); - const { env } = useEnvContext(); - const pathname = usePathname(); - const isAdminPage = pathname?.startsWith("/admin"); - const { user } = useUserContext(); - const { isUnlocked } = useLicenseStatusContext(); + const allCookies = await cookies(); + const sidebarStateCookie = allCookies.get("pangolin-sidebar-state")?.value; - const { theme } = useTheme(); - const [path, setPath] = useState(""); // Default logo path - - useEffect(() => { - function getPath() { - let lightOrDark = theme; - - if (theme === "system" || !theme) { - lightOrDark = window.matchMedia("(prefers-color-scheme: dark)") - .matches - ? "dark" - : "light"; - } - - if (lightOrDark === "light") { - // return "/logo/word_mark_black.png"; - return "/logo/pangolin_orange.svg"; - } - - // return "/logo/word_mark_white.png"; - return "/logo/pangolin_orange.svg"; - } - - setPath(getPath()); - }, [theme, env]); - - const t = useTranslations(); + const initialSidebarCollapsed = + sidebarStateCookie === "collapsed" || + (sidebarStateCookie !== "expanded" && defaultSidebarCollapsed); return ( -
- {/* Full width header */} - {showHeader && ( -
-
-
- {showSidebar && ( -
- - - - - - - {t('navbar')} - - - {t('navbarDescription')} - -
-
- - setIsMobileMenuOpen( - false - ) - } - /> -
- {!isAdminPage && - user.serverAdmin && ( -
- - setIsMobileMenuOpen( - false - ) - } - > - - {t('serverAdmin')} - -
- )} -
-
- - - {env?.app?.version && ( -
- v{env.app.version} -
- )} -
-
-
-
- )} - - {path && ( - Pangolin Logo - )} - - {showBreadcrumbs && ( -
- -
- )} -
- {showTopBar && ( -
-
- - {t('navbarDocsLink')} - -
-
- -
-
- )} -
- {showBreadcrumbs && ( -
- -
- )} -
+
+ {/* Desktop Sidebar */} + {showSidebar && ( + )} -
- {/* Desktop Sidebar */} - {showSidebar && ( -
-
-
- -
- {!isAdminPage && user.serverAdmin && ( -
- - - {t('serverAdmin')} - -
- )} -
-
- - -
-
- - {!isUnlocked() - ? t('communityEdition') - : t('commercialEdition')} - - -
- {env?.app?.version && ( -
- v{env.app.version} -
- )} -
-
-
+ {/* Main content area */} +
+ {/* Mobile header */} + {showHeader && ( + )} + {/* Desktop header */} + {showHeader && } + {/* Main content */} -
-
-
- {children} -
-
-
+
+
+ {children} +
+
); diff --git a/src/components/LayoutHeader.tsx b/src/components/LayoutHeader.tsx new file mode 100644 index 00000000..5c391cba --- /dev/null +++ b/src/components/LayoutHeader.tsx @@ -0,0 +1,75 @@ +"use client"; + +import React, { useEffect, useState } from "react"; +import { cn } from "@app/lib/cn"; +import Image from "next/image"; +import Link from "next/link"; +import ProfileIcon from "@app/components/ProfileIcon"; +import ThemeSwitcher from "@app/components/ThemeSwitcher"; +import { useTheme } from "next-themes"; +import { useEnvContext } from "@app/hooks/useEnvContext"; +import { Badge } from "./ui/badge"; + +interface LayoutHeaderProps { + showTopBar: boolean; +} + +export function LayoutHeader({ showTopBar }: LayoutHeaderProps) { + const { theme } = useTheme(); + const [path, setPath] = useState(""); + const { env } = useEnvContext(); + + useEffect(() => { + function getPath() { + let lightOrDark = theme; + + if (theme === "system" || !theme) { + lightOrDark = window.matchMedia("(prefers-color-scheme: dark)") + .matches + ? "dark" + : "light"; + } + + if (lightOrDark === "light") { + return "/logo/word_mark_black.png"; + } + + return "/logo/word_mark_white.png"; + } + + setPath(getPath()); + }, [theme]); + + return ( +
+
+
+
+
+ + {path && ( + Pangolin + )} + +
+ + {showTopBar && ( +
+ + +
+ )} +
+
+
+
+ ); +} + +export default LayoutHeader; diff --git a/src/components/LayoutMobileMenu.tsx b/src/components/LayoutMobileMenu.tsx new file mode 100644 index 00000000..cdec0d98 --- /dev/null +++ b/src/components/LayoutMobileMenu.tsx @@ -0,0 +1,142 @@ +"use client"; + +import React, { useState } from "react"; +import { SidebarNav } from "@app/components/SidebarNav"; +import { OrgSelector } from "@app/components/OrgSelector"; +import { cn } from "@app/lib/cn"; +import { ListUserOrgsResponse } from "@server/routers/org"; +import SupporterStatus from "@app/components/SupporterStatus"; +import { Button } from "@app/components/ui/button"; +import { Menu, Server } from "lucide-react"; +import Link from "next/link"; +import { usePathname } from "next/navigation"; +import { useUserContext } from "@app/hooks/useUserContext"; +import { useEnvContext } from "@app/hooks/useEnvContext"; +import { useTranslations } from "next-intl"; +import ProfileIcon from "@app/components/ProfileIcon"; +import ThemeSwitcher from "@app/components/ThemeSwitcher"; +import type { SidebarNavSection } from "@app/app/navigation"; +import { + Sheet, + SheetContent, + SheetTrigger, + SheetTitle, + SheetDescription +} from "@app/components/ui/sheet"; +import { Abel } from "next/font/google"; + +interface LayoutMobileMenuProps { + orgId?: string; + orgs?: ListUserOrgsResponse["orgs"]; + navItems: SidebarNavSection[]; + showSidebar: boolean; + showTopBar: boolean; +} + +export function LayoutMobileMenu({ + orgId, + orgs, + navItems, + showSidebar, + showTopBar +}: LayoutMobileMenuProps) { + const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false); + const pathname = usePathname(); + const isAdminPage = pathname?.startsWith("/admin"); + const { user } = useUserContext(); + const { env } = useEnvContext(); + const t = useTranslations(); + + return ( +
+
+
+ {showSidebar && ( +
+ + + + + + + {t("navbar")} + + + {t("navbarDescription")} + +
+
+ +
+
+ {!isAdminPage && + user.serverAdmin && ( +
+ + setIsMobileMenuOpen( + false + ) + } + > + + + + + {t( + "serverAdmin" + )} + + +
+ )} + + setIsMobileMenuOpen(false) + } + /> +
+
+
+ + {env?.app?.version && ( +
+ v{env.app.version} +
+ )} +
+
+
+
+ )} +
+ {showTopBar && ( +
+
+ + +
+
+ )} +
+
+ ); +} + +export default LayoutMobileMenu; diff --git a/src/components/LayoutSidebar.tsx b/src/components/LayoutSidebar.tsx new file mode 100644 index 00000000..98ef87eb --- /dev/null +++ b/src/components/LayoutSidebar.tsx @@ -0,0 +1,178 @@ +"use client"; + +import React, { useEffect, useState } from "react"; +import { SidebarNav } from "@app/components/SidebarNav"; +import { OrgSelector } from "@app/components/OrgSelector"; +import { cn } from "@app/lib/cn"; +import { ListUserOrgsResponse } from "@server/routers/org"; +import SupporterStatus from "@app/components/SupporterStatus"; +import { ExternalLink, Server, BookOpenText } from "lucide-react"; +import Link from "next/link"; +import { usePathname } from "next/navigation"; +import { useUserContext } from "@app/hooks/useUserContext"; +import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext"; +import { useEnvContext } from "@app/hooks/useEnvContext"; +import { useTranslations } from "next-intl"; +import type { SidebarNavSection } from "@app/app/navigation"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger +} from "@app/components/ui/tooltip"; + +interface LayoutSidebarProps { + orgId?: string; + orgs?: ListUserOrgsResponse["orgs"]; + navItems: SidebarNavSection[]; + defaultSidebarCollapsed: boolean; +} + +export function LayoutSidebar({ + orgId, + orgs, + navItems, + defaultSidebarCollapsed +}: LayoutSidebarProps) { + const [isSidebarCollapsed, setIsSidebarCollapsed] = useState( + defaultSidebarCollapsed + ); + const pathname = usePathname(); + const isAdminPage = pathname?.startsWith("/admin"); + const { user } = useUserContext(); + const { isUnlocked } = useLicenseStatusContext(); + const { env } = useEnvContext(); + const t = useTranslations(); + + const setSidebarStateCookie = (collapsed: boolean) => { + if (typeof window !== "undefined") { + const isSecure = window.location.protocol === "https:"; + document.cookie = `pangolin-sidebar-state=${collapsed ? "collapsed" : "expanded"}; path=/; max-age=${60 * 60 * 24 * 30}; samesite=lax${isSecure ? "; secure" : ""}`; + } + }; + + useEffect(() => { + setSidebarStateCookie(isSidebarCollapsed); + }, [isSidebarCollapsed]); + + return ( +
+
+
+ +
+
+ {!isAdminPage && user.serverAdmin && ( +
+ + + + + {!isSidebarCollapsed && ( + {t("serverAdmin")} + )} + +
+ )} + +
+
+
+ + {!isSidebarCollapsed && ( +
+
+ + {!isUnlocked() + ? t("communityEdition") + : t("commercialEdition")} + + +
+
+ + {t("documentation")} + + +
+ {env?.app?.version && ( +
+ v{env.app.version} +
+ )} +
+ )} +
+ + {/* Collapse button */} + + + + + + +

+ {isSidebarCollapsed + ? t("sidebarExpand") + : t("sidebarCollapse")} +

+
+
+
+
+ ); +} + +export default LayoutSidebar; diff --git a/src/app/components/LicenseViolation.tsx b/src/components/LicenseViolation.tsx similarity index 100% rename from src/app/components/LicenseViolation.tsx rename to src/components/LicenseViolation.tsx diff --git a/src/components/LocaleSwitcher.tsx b/src/components/LocaleSwitcher.tsx index fe1dece5..0e883a3c 100644 --- a/src/components/LocaleSwitcher.tsx +++ b/src/components/LocaleSwitcher.tsx @@ -1,59 +1,59 @@ import { useLocale } from "next-intl"; -import LocaleSwitcherSelect from './LocaleSwitcherSelect'; +import LocaleSwitcherSelect from "./LocaleSwitcherSelect"; export default function LocaleSwitcher() { - const locale = useLocale(); + const locale = useLocale(); - return ( - - ); + return ( + + ); } diff --git a/src/components/LocaleSwitcherSelect.tsx b/src/components/LocaleSwitcherSelect.tsx index 53c25d8f..201aeb18 100644 --- a/src/components/LocaleSwitcherSelect.tsx +++ b/src/components/LocaleSwitcherSelect.tsx @@ -1,71 +1,71 @@ -'use client'; +"use client"; import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger -} from '@app/components/ui/dropdown-menu'; -import { Button } from '@app/components/ui/button'; -import { Check, Globe, Languages } from 'lucide-react'; -import clsx from 'clsx'; -import { useTransition } from 'react'; -import { Locale } from '@/i18n/config'; -import { setUserLocale } from '@/services/locale'; + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger +} from "@app/components/ui/dropdown-menu"; +import { Button } from "@app/components/ui/button"; +import { Check, Globe, Languages } from "lucide-react"; +import clsx from "clsx"; +import { useTransition } from "react"; +import { Locale } from "@/i18n/config"; +import { setUserLocale } from "@/services/locale"; type Props = { - defaultValue: string; - items: Array<{ value: string; label: string }>; - label: string; + defaultValue: string; + items: Array<{ value: string; label: string }>; + label: string; }; export default function LocaleSwitcherSelect({ - defaultValue, - items, - label + defaultValue, + items, + label }: Props) { - const [isPending, startTransition] = useTransition(); + const [isPending, startTransition] = useTransition(); - function onChange(value: string) { - const locale = value as Locale; - startTransition(() => { - setUserLocale(locale); - }); - } + function onChange(value: string) { + const locale = value as Locale; + startTransition(() => { + setUserLocale(locale); + }); + } - const selected = items.find((item) => item.value === defaultValue); + const selected = items.find((item) => item.value === defaultValue); - return ( - - - - - - {items.map((item) => ( - onChange(item.value)} - className="flex items-center gap-2" - > - {item.value === defaultValue && ( - - )} - {item.label} - - ))} - - - ); + return ( + + + + + + {items.map((item) => ( + onChange(item.value)} + className="flex items-center gap-2" + > + {item.value === defaultValue && ( + + )} + {item.label} + + ))} + + + ); } diff --git a/src/components/OrgSelector.tsx b/src/components/OrgSelector.tsx index b402e0de..abf34a45 100644 --- a/src/components/OrgSelector.tsx +++ b/src/components/OrgSelector.tsx @@ -15,10 +15,16 @@ import { PopoverContent, PopoverTrigger } from "@app/components/ui/popover"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@app/components/ui/tooltip"; import { useEnvContext } from "@app/hooks/useEnvContext"; import { cn } from "@app/lib/cn"; import { ListUserOrgsResponse } from "@server/routers/org"; -import { Check, ChevronsUpDown, Plus } from "lucide-react"; +import { Check, ChevronsUpDown, Plus, Building2, Users } from "lucide-react"; import { useRouter } from "next/navigation"; import { useState } from "react"; import { useUserContext } from "@app/hooks/useUserContext"; @@ -27,94 +33,110 @@ import { useTranslations } from "next-intl"; interface OrgSelectorProps { orgId?: string; orgs?: ListUserOrgsResponse["orgs"]; + isCollapsed?: boolean; } -export function OrgSelector({ orgId, orgs }: OrgSelectorProps) { +export function OrgSelector({ orgId, orgs, isCollapsed = false }: OrgSelectorProps) { const { user } = useUserContext(); const [open, setOpen] = useState(false); const router = useRouter(); const { env } = useEnvContext(); const t = useTranslations(); - return ( + const selectedOrg = orgs?.find((org) => org.orgId === orgId); + + const orgSelectorContent = ( - - - - - {t('orgNotFound2')} + + + + +
+ {t('orgNotFound2')} +
- {(!env.flags.disableUserCreateOrg || - user.serverAdmin) && ( + {(!env.flags.disableUserCreateOrg || user.serverAdmin) && ( <> - + { - router.push( - "/setup" - ); + onSelect={() => { + setOpen(false); + router.push("/setup"); }} + className="mx-2 rounded-md" > - - {t('setupNewOrg')} +
+ +
+
+ {t('setupNewOrg')} + {t('createNewOrgDescription')} +
- + )} - + {orgs?.map((org) => ( { - router.push( - `/${org.orgId}/settings` - ); + onSelect={() => { + setOpen(false); + router.push(`/${org.orgId}/settings`); }} + className="mx-2 rounded-md" > +
+ +
+
+ {org.name} + {t('organization')} +
- {org.name}
))}
@@ -123,4 +145,24 @@ export function OrgSelector({ orgId, orgs }: OrgSelectorProps) {
); + + if (isCollapsed) { + return ( + + + + {orgSelectorContent} + + +
+

{selectedOrg?.name || t('noneSelected')}

+

{t('org')}

+
+
+
+
+ ); + } + + return orgSelectorContent; } diff --git a/src/app/components/OrganizationLanding.tsx b/src/components/OrganizationLanding.tsx similarity index 97% rename from src/app/components/OrganizationLanding.tsx rename to src/components/OrganizationLanding.tsx index a443fcf3..2f3125b0 100644 --- a/src/app/components/OrganizationLanding.tsx +++ b/src/components/OrganizationLanding.tsx @@ -11,6 +11,7 @@ import { import { Button } from "@/components/ui/button"; import Link from "next/link"; import { ArrowRight, Plus } from "lucide-react"; +import { useEnvContext } from "@app/hooks/useEnvContext"; import { useTranslations } from "next-intl"; interface Organization { @@ -29,6 +30,8 @@ export default function OrganizationLanding({ }: OrganizationLandingProps) { const [selectedOrg, setSelectedOrg] = useState(null); + const { env } = useEnvContext(); + const handleOrgClick = (orgId: string) => { setSelectedOrg(orgId); }; diff --git a/src/components/ProfileIcon.tsx b/src/components/ProfileIcon.tsx index 970e79a4..15fe46d7 100644 --- a/src/components/ProfileIcon.tsx +++ b/src/components/ProfileIcon.tsx @@ -24,7 +24,7 @@ import SecurityKeyForm from "./SecurityKeyForm"; import Enable2FaDialog from "./Enable2FaDialog"; import SupporterStatus from "./SupporterStatus"; import { UserType } from "@server/types/UserTypes"; -import LocaleSwitcher from '@app/components/LocaleSwitcher'; +import LocaleSwitcher from "@app/components/LocaleSwitcher"; import { useTranslations } from "next-intl"; export default function ProfileIcon() { @@ -58,10 +58,10 @@ export default function ProfileIcon() { function logout() { api.post("/auth/logout") .catch((e) => { - console.error(t('logoutError'), e); + console.error(t("logoutError"), e); toast({ - title: t('logoutError'), - description: formatAxiosError(e, t('logoutError')) + title: t("logoutError"), + description: formatAxiosError(e, t("logoutError")) }); }) .then(() => { @@ -74,111 +74,123 @@ export default function ProfileIcon() { <> - + -
- - {user.email || user.name || user.username} - - - - - - + + + + + +
+

+ {t("signingAs")} +

+

+ {user.email || user.name || user.username} +

+
+ {user.serverAdmin ? ( +

+ {t("serverAdmin")} +

+ ) : ( +

+ {user.idpName || t("idpNameInternal")} +

+ )} +
+ + {user?.type === UserType.Internal && ( + <> + {!user.twoFactorEnabled && ( + setOpenEnable2fa(true)} + > + {t("otpEnable")} + )} - - - {user?.type === UserType.Internal && ( - <> - {!user.twoFactorEnabled && ( - setOpenEnable2fa(true)} - > - {t('otpEnable')} - - )} - {user.twoFactorEnabled && ( - setOpenDisable2fa(true)} - > - {t('otpDisable')} - - )} + {user.twoFactorEnabled && ( setOpenSecurityKey(true)} + onClick={() => setOpenDisable2fa(true)} > - {t('securityKeyManage')} + {t("otpDisable")} - - - )} - {t("theme")} - {(["light", "dark", "system"] as const).map( - (themeOption) => ( + )} + setOpenSecurityKey(true)} + > + {t("securityKeyManage")} + + + + )} + + {user?.type === UserType.Internal && ( + <> + {!user.twoFactorEnabled && ( - handleThemeChange(themeOption) - } + onClick={() => setOpenEnable2fa(true)} > - {themeOption === "light" && ( - - )} - {themeOption === "dark" && ( - - )} - {themeOption === "system" && ( - - )} - - {t(themeOption)} + {t("otpEnable")} + + )} + {user.twoFactorEnabled && ( + setOpenDisable2fa(true)} + > + {t("otpDisable")} + + )} + + + )} + {t("theme")} + {(["light", "dark", "system"] as const).map( + (themeOption) => ( + handleThemeChange(themeOption)} + > + {themeOption === "light" && ( + + )} + {themeOption === "dark" && ( + + )} + {themeOption === "system" && ( + + )} + + {t(themeOption)} + + {userTheme === themeOption && ( + + - {userTheme === themeOption && ( - - - - )} - - ) - )} - - - - logout()}> - {/* */} - {t('logout')} - -
-
-
+ )} + + ) + )} + + + + logout()}> + {/* */} + {t("logout")} + + + ); } diff --git a/src/components/SetLastOrgCookie.tsx b/src/components/SetLastOrgCookie.tsx new file mode 100644 index 00000000..a142df9e --- /dev/null +++ b/src/components/SetLastOrgCookie.tsx @@ -0,0 +1,19 @@ +"use client"; + +import { useEffect } from "react"; + +interface SetLastOrgCookieProps { + orgId: string; +} + +export default function SetLastOrgCookie({ orgId }: SetLastOrgCookieProps) { + useEffect(() => { + const isSecure = + typeof window !== "undefined" && + window.location.protocol === "https:"; + + document.cookie = `pangolin-last-org=${orgId}; path=/; max-age=${60 * 60 * 24 * 30}; samesite=lax${isSecure ? "; secure" : ""}`; + }, [orgId]); + + return null; +} diff --git a/src/components/Settings.tsx b/src/components/Settings.tsx index 410d3093..796a4bc8 100644 --- a/src/components/Settings.tsx +++ b/src/components/Settings.tsx @@ -19,7 +19,7 @@ export function SettingsSectionForm({ }: { children: React.ReactNode; }) { - return
{children}
; + return
{children}
; } export function SettingsSectionTitle({ diff --git a/src/components/SidebarNav.tsx b/src/components/SidebarNav.tsx index d90a5093..7e8ad336 100644 --- a/src/components/SidebarNav.tsx +++ b/src/components/SidebarNav.tsx @@ -1,35 +1,45 @@ "use client"; -import React, { useState, useEffect } from "react"; +import React from "react"; import Link from "next/link"; import { useParams, usePathname } from "next/navigation"; import { cn } from "@app/lib/cn"; -import { ChevronDown, ChevronRight } from "lucide-react"; import { useUserContext } from "@app/hooks/useUserContext"; import { Badge } from "@app/components/ui/badge"; import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext"; import { useTranslations } from "next-intl"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger +} from "@app/components/ui/tooltip"; -export interface SidebarNavItem { +export type SidebarNavItem = { href: string; title: string; icon?: React.ReactNode; - children?: SidebarNavItem[]; - autoExpand?: boolean; showProfessional?: boolean; -} +}; + +export type SidebarNavSection = { + heading: string; + items: SidebarNavItem[]; +}; export interface SidebarNavProps extends React.HTMLAttributes { - items: SidebarNavItem[]; + sections: SidebarNavSection[]; disabled?: boolean; onItemClick?: () => void; + isCollapsed?: boolean; } export function SidebarNav({ className, - items, + sections, disabled = false, onItemClick, + isCollapsed = false, ...props }: SidebarNavProps) { const pathname = usePathname(); @@ -38,34 +48,10 @@ export function SidebarNav({ const niceId = params.niceId as string; const resourceId = params.resourceId as string; const userId = params.userId as string; - const [expandedItems, setExpandedItems] = useState>(() => { - const autoExpanded = new Set(); - - function findAutoExpandedAndActivePath( - items: SidebarNavItem[], - parentHrefs: string[] = [] - ) { - items.forEach((item) => { - const hydratedHref = hydrateHref(item.href); - const currentPath = [...parentHrefs, hydratedHref]; - - if (item.autoExpand || pathname.startsWith(hydratedHref)) { - currentPath.forEach((href) => autoExpanded.add(href)); - } - - if (item.children) { - findAutoExpandedAndActivePath(item.children, currentPath); - } - }); - } - - findAutoExpandedAndActivePath(items); - return autoExpanded; - }); + const apiKeyId = params.apiKeyId as string; + const clientId = params.clientId as string; const { licenseStatus, isUnlocked } = useLicenseStatusContext(); - const { user } = useUserContext(); - const t = useTranslations(); function hydrateHref(val: string): string { @@ -73,119 +59,114 @@ export function SidebarNav({ .replace("{orgId}", orgId) .replace("{niceId}", niceId) .replace("{resourceId}", resourceId) - .replace("{userId}", userId); + .replace("{userId}", userId) + .replace("{apiKeyId}", apiKeyId) + .replace("{clientId}", clientId); } - function toggleItem(href: string) { - setExpandedItems((prev) => { - const newSet = new Set(prev); - if (newSet.has(href)) { - newSet.delete(href); - } else { - newSet.add(href); - } - return newSet; - }); - } + const renderNavItem = ( + item: SidebarNavItem, + hydratedHref: string, + isActive: boolean, + isDisabled: boolean + ) => { + const tooltipText = + item.showProfessional && !isUnlocked() + ? `${t(item.title)} (${t("licenseBadge")})` + : t(item.title); - function renderItems(items: SidebarNavItem[], level = 0) { - return items.map((item) => { - const hydratedHref = hydrateHref(item.href); - const isActive = pathname.startsWith(hydratedHref); - const hasChildren = item.children && item.children.length > 0; - const isExpanded = expandedItems.has(hydratedHref); - const indent = level * 28; // Base indent for each level - const isProfessional = item.showProfessional && !isUnlocked(); - const isDisabled = disabled || isProfessional; - - return ( -
-
{ + if (isDisabled) { + e.preventDefault(); + } else if (onItemClick) { + onItemClick(); + } + }} + tabIndex={isDisabled ? -1 : undefined} + aria-disabled={isDisabled} + > + {item.icon && ( + -
- { - if (isDisabled) { - e.preventDefault(); - } else if (onItemClick) { - onItemClick(); - } - }} - tabIndex={isDisabled ? -1 : undefined} - aria-disabled={isDisabled} - > -
- {item.icon && ( - - {item.icon} - - )} - {t(item.title)} -
- {isProfessional && ( - - {t('licenseBadge')} - - )} - - {hasChildren && ( - - )} -
-
- {hasChildren && isExpanded && ( -
- {renderItems(item.children || [], level + 1)} -
- )} -
+ {item.icon} + + )} + {!isCollapsed && ( + <> + {t(item.title)} + {item.showProfessional && !isUnlocked() && ( + + {t("licenseBadge")} + + )} + + )} + + ); + + if (isCollapsed) { + return ( + + + {itemContent} + +

{tooltipText}

+
+
+
); - }); - } + } + + return ( + {itemContent} + ); + }; return ( ); } diff --git a/src/components/SidebarSettings.tsx b/src/components/SidebarSettings.tsx deleted file mode 100644 index 04b68810..00000000 --- a/src/components/SidebarSettings.tsx +++ /dev/null @@ -1,35 +0,0 @@ -"use client"; - -import { SidebarNav } from "@app/components/SidebarNav"; -import React from "react"; - -interface SideBarSettingsProps { - children: React.ReactNode; - sidebarNavItems: Array<{ - title: string; - href: string; - icon?: React.ReactNode; - }>; - disabled?: boolean; - limitWidth?: boolean; -} - -export function SidebarSettings({ - children, - sidebarNavItems, - disabled, - limitWidth -}: SideBarSettingsProps) { - return ( -
-
- -
- {children} -
-
-
- ); -} diff --git a/src/app/components/SupporterMessage.tsx b/src/components/SupporterMessage.tsx similarity index 100% rename from src/app/components/SupporterMessage.tsx rename to src/components/SupporterMessage.tsx diff --git a/src/components/SupporterStatus.tsx b/src/components/SupporterStatus.tsx index a17b9b9f..74d4bf8b 100644 --- a/src/components/SupporterStatus.tsx +++ b/src/components/SupporterStatus.tsx @@ -9,6 +9,12 @@ import { PopoverContent, PopoverTrigger } from "@app/components/ui/popover"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@app/components/ui/tooltip"; import { Button } from "./ui/button"; import { Credenza, @@ -46,18 +52,23 @@ import { CardHeader, CardTitle } from "./ui/card"; -import { Check, ExternalLink } from "lucide-react"; +import { Check, ExternalLink, Heart } from "lucide-react"; import confetti from "canvas-confetti"; import { useTranslations } from "next-intl"; -export default function SupporterStatus() { +interface SupporterStatusProps { + isCollapsed?: boolean; +} + +export default function SupporterStatus({ isCollapsed = false }: SupporterStatusProps) { const { supporterStatus, updateSupporterStatus } = useSupporterStatusContext(); const [supportOpen, setSupportOpen] = useState(false); const [keyOpen, setKeyOpen] = useState(false); const [purchaseOptionsOpen, setPurchaseOptionsOpen] = useState(false); - const api = createApiClient(useEnvContext()); + const { env } = useEnvContext(); + const api = createApiClient({ env }); const t = useTranslations(); const formSchema = z.object({ @@ -411,16 +422,36 @@ export default function SupporterStatus() { {supporterStatus?.visible ? ( - + isCollapsed ? ( + + + + + + +

{t('supportKeyBuy')}

+
+
+
+ ) : ( + + ) ) : null} ); diff --git a/src/components/SwitchInput.tsx b/src/components/SwitchInput.tsx index fd312115..a2291c2e 100644 --- a/src/components/SwitchInput.tsx +++ b/src/components/SwitchInput.tsx @@ -4,7 +4,7 @@ import { Label } from "./ui/label"; interface SwitchComponentProps { id: string; - label: string; + label?: string; description?: string; checked?: boolean; defaultChecked?: boolean; @@ -31,7 +31,7 @@ export function SwitchInput({ onCheckedChange={onCheckedChange} disabled={disabled} /> - + {label && }
{description && ( diff --git a/src/components/ThemeSwitcher.tsx b/src/components/ThemeSwitcher.tsx new file mode 100644 index 00000000..5605ec31 --- /dev/null +++ b/src/components/ThemeSwitcher.tsx @@ -0,0 +1,78 @@ +"use client"; + +import { Button } from "@app/components/ui/button"; +import { Laptop, Moon, Sun } from "lucide-react"; +import { useTranslations } from "next-intl"; +import { useTheme } from "next-themes"; +import { useEffect, useState } from "react"; + +export default function ThemeSwitcher() { + const { setTheme, theme, resolvedTheme } = useTheme(); + const [mounted, setMounted] = useState(false); + + const t = useTranslations(); + + useEffect(() => { + setMounted(true); + }, []); + + if (!mounted) { + return ( + + ); + } + + function cycleTheme() { + const currentTheme = theme || "system"; + + if (currentTheme === "light") { + setTheme("dark"); + } else if (currentTheme === "dark") { + setTheme("system"); + } else { + setTheme("light"); + } + } + + function getThemeIcon() { + const currentTheme = theme || "system"; + + if (currentTheme === "light") { + return ; + } else if (currentTheme === "dark") { + return ; + } else { + // When theme is "system", show icon based on resolved theme + if (resolvedTheme === "light") { + return ; + } else if (resolvedTheme === "dark") { + return ; + } else { + // Fallback to laptop icon if resolvedTheme is not available + return ; + } + } + } + + function getThemeText() { + const currentTheme = theme || "system"; + const translated = t(currentTheme); + return translated.charAt(0).toUpperCase() + translated.slice(1); + } + + return ( + + ); +} diff --git a/src/components/tags/tag-input.tsx b/src/components/tags/tag-input.tsx index ad8c0d03..789a127c 100644 --- a/src/components/tags/tag-input.tsx +++ b/src/components/tags/tag-input.tsx @@ -173,7 +173,7 @@ const TagInput = React.forwardRef( (maxTags !== undefined && maxTags < 0) || (props.minTags !== undefined && props.minTags < 0) ) { - console.warn(t('tagsWarnCannotBeLessThanZero')); + console.warn(t("tagsWarnCannotBeLessThanZero")); // error return null; } @@ -197,22 +197,28 @@ const TagInput = React.forwardRef( (option) => option.text === newTagText ) ) { - console.warn(t('tagsWarnNotAllowedAutocompleteOptions')); + console.warn( + t("tagsWarnNotAllowedAutocompleteOptions") + ); return; } if (validateTag && !validateTag(newTagText)) { - console.warn(t('tagsWarnInvalid')); + console.warn(t("tagsWarnInvalid")); return; } if (minLength && newTagText.length < minLength) { - console.warn(t('tagWarnTooShort', {tagText: newTagText})); + console.warn( + t("tagWarnTooShort", { tagText: newTagText }) + ); return; } if (maxLength && newTagText.length > maxLength) { - console.warn(t('tagWarnTooLong', {tagText: newTagText})); + console.warn( + t("tagWarnTooLong", { tagText: newTagText }) + ); return; } @@ -229,10 +235,12 @@ const TagInput = React.forwardRef( setTags((prevTags) => [...prevTags, newTag]); onTagAdd?.(newTagText); } else { - console.warn(t('tagsWarnReachedMaxNumber')); + console.warn(t("tagsWarnReachedMaxNumber")); } } else { - console.warn(t('tagWarnDuplicate', {tagText: newTagText})); + console.warn( + t("tagWarnDuplicate", { tagText: newTagText }) + ); } }); setInputValue(""); @@ -258,12 +266,12 @@ const TagInput = React.forwardRef( } if (minLength && newTagText.length < minLength) { - console.warn(t('tagWarnTooShort')); + console.warn(t("tagWarnTooShort")); return; } if (maxLength && newTagText.length > maxLength) { - console.warn(t('tagWarnTooLong')); + console.warn(t("tagWarnTooLong")); return; } @@ -308,7 +316,7 @@ const TagInput = React.forwardRef( } if (minLength && newTagText.length < minLength) { - console.warn(t('tagWarnTooShort')); + console.warn(t("tagWarnTooShort")); // error return; } @@ -316,7 +324,7 @@ const TagInput = React.forwardRef( // Validate maxLength if (maxLength && newTagText.length > maxLength) { // error - console.warn(t('tagWarnTooLong')); + console.warn(t("tagWarnTooLong")); return; } @@ -489,7 +497,7 @@ const TagInput = React.forwardRef(
@@ -536,7 +544,7 @@ const TagInput = React.forwardRef( onBlur={handleInputBlur} {...inputProps} className={cn( - "border-0 h-5 bg-transparent focus-visible:ring-0 focus-visible:ring-transparent focus-visible:ring-offset-0 flex-1 w-fit", + "border-0 h-5 bg-transparent focus-visible:ring-0 focus-visible:ring-transparent focus-visible:ring-offset-0 flex-1 w-fit shadow-none inset-shadow-none", // className, styleClasses?.input )} @@ -622,7 +630,7 @@ const TagInput = React.forwardRef( onBlur={handleInputBlur} {...inputProps} className={cn( - "border-0 h-5 bg-transparent focus-visible:ring-0 focus-visible:ring-transparent focus-visible:ring-offset-0 flex-1 w-fit", + "border-0 h-5 bg-transparent focus-visible:ring-0 focus-visible:ring-transparent focus-visible:ring-offset-0 flex-1 w-fit shadow-none inset-shadow-none", // className, styleClasses?.input )} @@ -643,7 +651,7 @@ const TagInput = React.forwardRef( ) : (
@@ -710,7 +718,7 @@ const TagInput = React.forwardRef( onBlur={handleInputBlur} {...inputProps} className={cn( - "border-0 h-5 bg-transparent focus-visible:ring-0 focus-visible:ring-transparent focus-visible:ring-offset-0 flex-1 w-fit", + "border-0 h-5 bg-transparent focus-visible:ring-0 focus-visible:ring-transparent focus-visible:ring-offset-0 flex-1 w-fit shadow-none inset-shadow-none", // className, styleClasses?.input )} @@ -791,7 +799,7 @@ const TagInput = React.forwardRef( onBlur={handleInputBlur} {...inputProps} className={cn( - "border-0 h-5 bg-transparent focus-visible:ring-0 focus-visible:ring-transparent focus-visible:ring-offset-0 flex-1 w-fit", + "border-0 h-5 bg-transparent focus-visible:ring-0 focus-visible:ring-transparent focus-visible:ring-offset-0 flex-1 w-fit shadow-none inset-shadow-none", // className, styleClasses?.input )} @@ -834,7 +842,8 @@ const TagInput = React.forwardRef( onBlur={handleInputBlur} {...inputProps} className={cn( - styleClasses?.input + styleClasses?.input, + "shadow-none inset-shadow-none" // className )} autoComplete={ @@ -908,7 +917,7 @@ const TagInput = React.forwardRef( tags.length >= maxTags) } className={cn( - "border-0 w-full", + "border-0 w-full shadow-none inset-shadow-none", styleClasses?.input // className )} diff --git a/src/components/tags/tag.tsx b/src/components/tags/tag.tsx index ccc489e4..938a2669 100644 --- a/src/components/tags/tag.tsx +++ b/src/components/tags/tag.tsx @@ -127,7 +127,7 @@ export const Tag: React.FC = ({ { "justify-between w-full": direction === "column", "cursor-pointer": draggable, - "ring-ring ring-offset-2 ring-2 ring-offset-background": + "ring-ring ring-offset-0 ring-2 ring-offset-background": isActiveTag }, tagClasses?.body diff --git a/src/components/ui/alert.tsx b/src/components/ui/alert.tsx index 3783ecfe..e6fad743 100644 --- a/src/components/ui/alert.tsx +++ b/src/components/ui/alert.tsx @@ -14,14 +14,13 @@ const alertVariants = cva( "border-destructive/50 border bg-destructive/10 text-destructive dark:border-destructive [&>svg]:text-destructive", success: "border-green-500/50 border bg-green-500/10 text-green-500 dark:border-success [&>svg]:text-green-500", - info: - "border-blue-500/50 border bg-blue-500/10 text-blue-500 dark:border-blue-400 [&>svg]:text-blue-500", - }, + info: "border-blue-500/50 border bg-blue-500/10 text-blue-500 dark:border-blue-400 [&>svg]:text-blue-500" + } }, defaultVariants: { - variant: "default", - }, - }, + variant: "default" + } + } ); const Alert = React.forwardRef< @@ -45,7 +44,7 @@ const AlertTitle = React.forwardRef< ref={ref} className={cn( "mb-1 font-medium leading-none tracking-tight", - className, + className )} {...props} /> diff --git a/src/components/ui/badge.tsx b/src/components/ui/badge.tsx index 222a234f..3bcf2bea 100644 --- a/src/components/ui/badge.tsx +++ b/src/components/ui/badge.tsx @@ -4,7 +4,7 @@ import { cva, type VariantProps } from "class-variance-authority"; import { cn } from "@app/lib/cn"; const badgeVariants = cva( - "inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2", + "inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-0", { variants: { variant: { @@ -16,9 +16,9 @@ const badgeVariants = cva( destructive: "border-transparent bg-destructive text-destructive-foreground", outline: "text-foreground", - green: "border-transparent bg-green-500", - yellow: "border-transparent bg-yellow-500", - red: "border-transparent bg-red-300", + green: "border-green-600 bg-green-500/20 text-green-700 dark:text-green-300", + yellow: "border-yellow-600 bg-yellow-500/20 text-yellow-700 dark:text-yellow-300", + red: "border-red-400 bg-red-300/20 text-red-600 dark:text-red-300", }, }, defaultVariants: { diff --git a/src/components/ui/button.tsx b/src/components/ui/button.tsx index 43f420d7..d77bc39a 100644 --- a/src/components/ui/button.tsx +++ b/src/components/ui/button.tsx @@ -6,35 +6,35 @@ import { cn } from "@app/lib/cn"; import { Loader2 } from "lucide-react"; const buttonVariants = cva( - "cursor-pointer inline-flex items-center justify-center rounded-full whitespace-nowrap text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50", + "cursor-pointer inline-flex items-center justify-center whitespace-nowrap text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-0 disabled:pointer-events-none disabled:opacity-50", { variants: { variant: { default: - "bg-primary text-primary-foreground hover:bg-primary/90", + "bg-primary text-primary-foreground hover:bg-primary/90 shadow-2xs", destructive: - "bg-destructive text-destructive-foreground hover:bg-destructive/90", + "bg-destructive text-white dark:text-destructive-foreground hover:bg-destructive/90 shadow-2xs", outline: - "border border-input bg-card hover:bg-accent hover:text-accent-foreground", + "border border-input bg-card hover:bg-accent hover:text-accent-foreground shadow-2xs", outlinePrimary: - "border border-primary bg-card hover:bg-primary/10 text-primary", + "border border-primary bg-card hover:bg-primary/10 text-primary shadow-2xs", secondary: - "bg-secondary border border-input border text-secondary-foreground hover:bg-secondary/80", + "bg-secondary border border-input border text-secondary-foreground hover:bg-secondary/80 shadow-2xs", ghost: "hover:bg-accent hover:text-accent-foreground", squareOutlinePrimary: - "border border-primary bg-card hover:bg-primary/10 text-primary rounded-md", + "border border-primary bg-card hover:bg-primary/10 text-primary rounded-md shadow-2xs", squareOutline: - "border border-input bg-card hover:bg-accent hover:text-accent-foreground rounded-md", + "border border-input bg-card hover:bg-accent hover:text-accent-foreground rounded-md shadow-2xs", squareDefault: - "bg-primary text-primary-foreground hover:bg-primary/90 rounded-md", + "bg-primary text-primary-foreground hover:bg-primary/90 rounded-md shadow-2xs", text: "", link: "text-primary underline-offset-4 hover:underline" }, size: { - default: "h-9 px-4 py-2", + default: "h-9 rounded-md px-3", sm: "h-8 rounded-md px-3", lg: "h-10 rounded-md px-8", - icon: "h-9 w-9" + icon: "h-9 w-9 rounded-md" } }, defaultVariants: { diff --git a/src/components/ui/checkbox.tsx b/src/components/ui/checkbox.tsx index ad22b8fa..e1bd4ddc 100644 --- a/src/components/ui/checkbox.tsx +++ b/src/components/ui/checkbox.tsx @@ -9,7 +9,7 @@ import { cva, type VariantProps } from "class-variance-authority"; // Define checkbox variants const checkboxVariants = cva( - "peer h-4 w-4 shrink-0 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50", + "peer h-4 w-4 shrink-0 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-0 disabled:cursor-not-allowed disabled:opacity-50", { variants: { variant: { diff --git a/src/components/ui/command.tsx b/src/components/ui/command.tsx index 447bbb44..59e98ed3 100644 --- a/src/components/ui/command.tsx +++ b/src/components/ui/command.tsx @@ -1,155 +1,183 @@ "use client"; import * as React from "react"; -import { type DialogProps } from "@radix-ui/react-dialog"; import { Command as CommandPrimitive } from "cmdk"; -import { Search } from "lucide-react"; +import { SearchIcon } from "lucide-react"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle +} from "@/components/ui/dialog"; import { cn } from "@app/lib/cn"; -import { Dialog, DialogContent } from "@/components/ui/dialog"; -const Command = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)); -Command.displayName = CommandPrimitive.displayName; +function Command({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} -interface CommandDialogProps extends DialogProps {} +function CommandDialog({ + title = "Command Palette", + description = "Search for a command to run...", + children, + className, + showCloseButton = true, + ...props +}: React.ComponentProps & { + title?: string; + description?: string; + className?: string; + showCloseButton?: boolean; +}) { + return ( + + + {title} + {description} + + + + {children} + + + + ); +} -const CommandDialog = ({ children, ...props }: CommandDialogProps) => { - return ( - - - - {children} - - - - ); -}; +function CommandInput({ + className, + ...props +}: React.ComponentProps) { + return ( +
+ + +
+ ); +} -const CommandInput = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( -
- - -
-)); +function CommandList({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} -CommandInput.displayName = CommandPrimitive.Input.displayName; +function CommandEmpty({ + ...props +}: React.ComponentProps) { + return ( + + ); +} -const CommandList = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)); +function CommandGroup({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} -CommandList.displayName = CommandPrimitive.List.displayName; +function CommandSeparator({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} -const CommandEmpty = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->((props, ref) => ( - -)); +function CommandItem({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} -CommandEmpty.displayName = CommandPrimitive.Empty.displayName; - -const CommandGroup = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)); - -CommandGroup.displayName = CommandPrimitive.Group.displayName; - -const CommandSeparator = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)); -CommandSeparator.displayName = CommandPrimitive.Separator.displayName; - -const CommandItem = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)); - -CommandItem.displayName = CommandPrimitive.Item.displayName; - -const CommandShortcut = ({ - className, - ...props -}: React.HTMLAttributes) => { - return ( - - ); -}; -CommandShortcut.displayName = "CommandShortcut"; +function CommandShortcut({ + className, + ...props +}: React.ComponentProps<"span">) { + return ( + + ); +} export { - Command, - CommandDialog, - CommandInput, - CommandList, - CommandEmpty, - CommandGroup, - CommandItem, - CommandShortcut, - CommandSeparator, + Command, + CommandDialog, + CommandInput, + CommandList, + CommandEmpty, + CommandGroup, + CommandItem, + CommandShortcut, + CommandSeparator }; diff --git a/src/components/ui/data-table.tsx b/src/components/ui/data-table.tsx index c9556052..75a37e58 100644 --- a/src/components/ui/data-table.tsx +++ b/src/components/ui/data-table.tsx @@ -23,13 +23,14 @@ import { Button } from "@app/components/ui/button"; import { useState } from "react"; import { Input } from "@app/components/ui/input"; import { DataTablePagination } from "@app/components/DataTablePagination"; -import { Plus, Search } from "lucide-react"; +import { Plus, Search, RefreshCw } from "lucide-react"; import { Card, CardContent, CardHeader, CardTitle } from "@app/components/ui/card"; +import { useTranslations } from "next-intl"; type DataTableProps = { columns: ColumnDef[]; @@ -37,6 +38,8 @@ type DataTableProps = { title?: string; addButtonText?: string; onAdd?: () => void; + onRefresh?: () => void; + isRefreshing?: boolean; searchPlaceholder?: string; searchColumn?: string; defaultSort?: { @@ -51,6 +54,8 @@ export function DataTable({ title, addButtonText, onAdd, + onRefresh, + isRefreshing, searchPlaceholder = "Search...", searchColumn = "name", defaultSort @@ -60,6 +65,7 @@ export function DataTable({ ); const [columnFilters, setColumnFilters] = useState([]); const [globalFilter, setGlobalFilter] = useState([]); + const t = useTranslations(); const table = useReactTable({ data, @@ -87,8 +93,8 @@ export function DataTable({ return (
- -
+ +
({ />
- {onAdd && addButtonText && ( - - )} +
+ {onRefresh && ( + + )} + {onAdd && addButtonText && ( + + )} +
diff --git a/src/components/ui/dialog.tsx b/src/components/ui/dialog.tsx index 33dc0438..da9e6504 100644 --- a/src/components/ui/dialog.tsx +++ b/src/components/ui/dialog.tsx @@ -38,13 +38,13 @@ const DialogContent = React.forwardRef< {children} - + Close diff --git a/src/components/ui/dropdown-menu.tsx b/src/components/ui/dropdown-menu.tsx index efe369d8..be0861d8 100644 --- a/src/components/ui/dropdown-menu.tsx +++ b/src/components/ui/dropdown-menu.tsx @@ -2,199 +2,263 @@ import * as React from "react"; import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"; -import { Check, ChevronRight, Circle } from "lucide-react"; - +import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"; import { cn } from "@app/lib/cn"; -const DropdownMenu = DropdownMenuPrimitive.Root; +function DropdownMenu({ + ...props +}: React.ComponentProps) { + return ; +} -const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger; +function DropdownMenuPortal({ + ...props +}: React.ComponentProps) { + return ( + + ); +} -const DropdownMenuGroup = DropdownMenuPrimitive.Group; +function DropdownMenuTrigger({ + ...props +}: React.ComponentProps) { + return ( + + ); +} -const DropdownMenuPortal = DropdownMenuPrimitive.Portal; +function DropdownMenuContent({ + className, + sideOffset = 4, + ...props +}: React.ComponentProps) { + return ( + + + + ); +} -const DropdownMenuSub = DropdownMenuPrimitive.Sub; +function DropdownMenuGroup({ + ...props +}: React.ComponentProps) { + return ( + + ); +} -const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup; +function DropdownMenuItem({ + className, + inset, + variant = "default", + ...props +}: React.ComponentProps & { + inset?: boolean; + variant?: "default" | "destructive"; +}) { + return ( + + ); +} -const DropdownMenuSubTrigger = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef & { - inset?: boolean - } ->(({ className, inset, children, ...props }, ref) => ( - - {children} - - -)); -DropdownMenuSubTrigger.displayName = - DropdownMenuPrimitive.SubTrigger.displayName; +function DropdownMenuCheckboxItem({ + className, + children, + checked, + ...props +}: React.ComponentProps) { + return ( + + + + + + + {children} + + ); +} -const DropdownMenuSubContent = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)); -DropdownMenuSubContent.displayName = - DropdownMenuPrimitive.SubContent.displayName; +function DropdownMenuRadioGroup({ + ...props +}: React.ComponentProps) { + return ( + + ); +} -const DropdownMenuContent = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, sideOffset = 4, ...props }, ref) => ( - - - -)); -DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName; +function DropdownMenuRadioItem({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + + + + + + + {children} + + ); +} -const DropdownMenuItem = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef & { - inset?: boolean - } ->(({ className, inset, ...props }, ref) => ( - -)); -DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName; +function DropdownMenuLabel({ + className, + inset, + ...props +}: React.ComponentProps & { + inset?: boolean; +}) { + return ( + + ); +} -const DropdownMenuCheckboxItem = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, children, checked, ...props }, ref) => ( - - - - - - - {children} - -)); -DropdownMenuCheckboxItem.displayName = - DropdownMenuPrimitive.CheckboxItem.displayName; +function DropdownMenuSeparator({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} -const DropdownMenuRadioItem = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, children, ...props }, ref) => ( - - - - - - - {children} - -)); -DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName; +function DropdownMenuShortcut({ + className, + ...props +}: React.ComponentProps<"span">) { + return ( + + ); +} -const DropdownMenuLabel = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef & { - inset?: boolean - } ->(({ className, inset, ...props }, ref) => ( - -)); -DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName; +function DropdownMenuSub({ + ...props +}: React.ComponentProps) { + return ( + + ); +} -const DropdownMenuSeparator = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)); -DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName; +function DropdownMenuSubTrigger({ + className, + inset, + children, + ...props +}: React.ComponentProps & { + inset?: boolean; +}) { + return ( + + {children} + + + ); +} -const DropdownMenuShortcut = ({ - className, - ...props -}: React.HTMLAttributes) => { - return ( - - ); -}; -DropdownMenuShortcut.displayName = "DropdownMenuShortcut"; +function DropdownMenuSubContent({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} export { - DropdownMenu, - DropdownMenuTrigger, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuCheckboxItem, - DropdownMenuRadioItem, - DropdownMenuLabel, - DropdownMenuSeparator, - DropdownMenuShortcut, - DropdownMenuGroup, - DropdownMenuPortal, - DropdownMenuSub, - DropdownMenuSubContent, - DropdownMenuSubTrigger, - DropdownMenuRadioGroup, + DropdownMenu, + DropdownMenuPortal, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuLabel, + DropdownMenuItem, + DropdownMenuCheckboxItem, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuSub, + DropdownMenuSubTrigger, + DropdownMenuSubContent }; diff --git a/src/components/ui/form.tsx b/src/components/ui/form.tsx index 963404e7..1d9a7250 100644 --- a/src/components/ui/form.tsx +++ b/src/components/ui/form.tsx @@ -5,15 +5,16 @@ import * as LabelPrimitive from "@radix-ui/react-label"; import { Slot } from "@radix-ui/react-slot"; import { Controller, - ControllerProps, - FieldPath, - FieldValues, FormProvider, - useFormContext + useFormContext, + useFormState, + type ControllerProps, + type FieldPath, + type FieldValues } from "react-hook-form"; -import { cn } from "@app/lib/cn"; import { Label } from "@/components/ui/label"; +import { cn } from "@app/lib/cn"; const Form = FormProvider; @@ -44,8 +45,8 @@ const FormField = < const useFormField = () => { const fieldContext = React.useContext(FormFieldContext); const itemContext = React.useContext(FormItemContext); - const { getFieldState, formState } = useFormContext(); - + const { getFieldState } = useFormContext(); + const formState = useFormState({ name: fieldContext.name }); const fieldState = getFieldState(fieldContext.name, formState); if (!fieldContext) { @@ -72,47 +73,44 @@ const FormItemContext = React.createContext( {} as FormItemContextValue ); -const FormItem = React.forwardRef< - HTMLDivElement, - React.HTMLAttributes ->(({ className, ...props }, ref) => { +function FormItem({ className, ...props }: React.ComponentProps<"div">) { const id = React.useId(); return ( -
+
); -}); -FormItem.displayName = "FormItem"; +} -const FormLabel = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => { +function FormLabel({ + className, + ...props +}: React.ComponentProps) { const { error, formItemId } = useFormField(); return (