diff --git a/.gitignore b/.gitignore index dd935c30..cd73cef1 100644 --- a/.gitignore +++ b/.gitignore @@ -32,3 +32,4 @@ installer bin .secrets test_event.json +.idea/ \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index b0798e34..6ec9e23d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,8 +2,9 @@ FROM node:20-alpine AS builder WORKDIR /app -COPY package.json package-lock.json ./ -RUN npm ci +# COPY package.json package-lock.json ./ +COPY package.json ./ +RUN npm install COPY . . @@ -18,8 +19,9 @@ WORKDIR /app # Curl used for the health checks RUN apk add --no-cache curl -COPY package.json package-lock.json ./ -RUN npm ci --only=production && npm cache clean --force +# COPY package.json package-lock.json ./ +COPY package.json ./ +RUN npm install --only=production && npm cache clean --force COPY --from=builder /app/.next/standalone ./ COPY --from=builder /app/.next/static ./.next/static diff --git a/LICENSE b/LICENSE index 0ad25db4..8c5cfb89 100644 --- a/LICENSE +++ b/LICENSE @@ -1,3 +1,35 @@ +Copyright (c) 2025 Fossorial, LLC. + +Portions of this software are licensed as follows: + +* All files that include a header specifying they are licensed under the + "Fossorial Commercial License" are governed by the Fossorial Commercial + License terms. The specific terms applicable to each customer depend on the + commercial license tier agreed upon in writing with Fossorial LLC. + Unauthorized use, copying, modification, or distribution is strictly + prohibited. + +* All files that include a header specifying they are licensed under the GNU + Affero General Public License, Version 3 ("AGPL-3"), are governed by the + AGPL-3 terms. A full copy of the AGPL-3 license is provided below. However, + these files are also available under the Fossorial Commercial License if a + separate commercial license agreement has been executed between the customer + and Fossorial LLC. + +* All files without a license header are, by default, licensed under the GNU + Affero General Public License, Version 3 (AGPL-3). These files may also be + made available under the Fossorial Commercial License upon agreement with + Fossorial LLC. + +* All third-party components included in this repository are licensed under + their respective original licenses, as provided by their authors. + +Please consult the header of each individual file to determine the applicable +license. For AGPL-3 licensed files, dual-licensing under the Fossorial +Commercial License is available subject to written agreement with Fossorial +LLC. + + GNU AFFERO GENERAL PUBLIC LICENSE Version 3, 19 November 2007 diff --git a/README.md b/README.md index 461ccc1e..5eb19098 100644 --- a/README.md +++ b/README.md @@ -34,53 +34,58 @@ _Your own self-hosted zero trust tunnel._ Pangolin is a self-hosted tunneled reverse proxy server with identity and access control, designed to securely expose private resources on distributed networks. Acting as a central hub, it connects isolated networks — even those behind restrictive firewalls — through encrypted tunnels, enabling easy access to remote services without opening ports. -Preview +Preview -_Sites page of Pangolin dashboard (dark mode) showing multiple tunnels connected to the central server._ +_Resources page of Pangolin dashboard (dark mode) showing multiple resources available to connect._ ## Key Features ### Reverse Proxy Through WireGuard Tunnel -- Expose private resources on your network **without opening ports** (firewall punching). -- Secure and easy to configure site-to-site connectivity via a custom **user space WireGuard client**, [Newt](https://github.com/fosrl/newt). -- Built-in support for any WireGuard client. -- Automated **SSL certificates** (https) via [LetsEncrypt](https://letsencrypt.org/). -- Support for HTTP/HTTPS and **raw TCP/UDP services**. -- Load balancing. +- Expose private resources on your network **without opening ports** (firewall punching). +- Secure and easy to configure site-to-site connectivity via a custom **user space WireGuard client**, [Newt](https://github.com/fosrl/newt). +- Built-in support for any WireGuard client. +- Automated **SSL certificates** (https) via [LetsEncrypt](https://letsencrypt.org/). +- Support for HTTP/HTTPS and **raw TCP/UDP services**. +- Load balancing. ### Identity & Access Management -- Centralized authentication system using platform SSO. **Users will only have to manage one login.** -- **Define access control rules for IPs, IP ranges, and URL paths per resource.** -- TOTP with backup codes for two-factor authentication. -- Create organizations, each with multiple sites, users, and roles. -- **Role-based access control** to manage resource access permissions. -- Additional authentication options include: - - Email whitelisting with **one-time passcodes.** - - **Temporary, self-destructing share links.** - - Resource specific pin codes. - - Resource specific passwords. +- Centralized authentication system using platform SSO. **Users will only have to manage one login.** +- **Define access control rules for IPs, IP ranges, and URL paths per resource.** +- TOTP with backup codes for two-factor authentication. +- Create organizations, each with multiple sites, users, and roles. +- **Role-based access control** to manage resource access permissions. +- Additional authentication options include: + - Email whitelisting with **one-time passcodes.** + - **Temporary, self-destructing share links.** + - Resource specific pin codes. + - Resource specific passwords. +- External identity provider (IdP) support with OAuth2/OIDC, such as Authentik, Keycloak, Okta, and others. + - Auto-provision users and roles from your IdP. ### Simple Dashboard UI -- Manage sites, users, and roles with a clean and intuitive UI. -- Monitor site usage and connectivity. -- Light and dark mode options. -- Mobile friendly. +- Manage sites, users, and roles with a clean and intuitive UI. +- Monitor site usage and connectivity. +- Light and dark mode options. +- Mobile friendly. ### Easy Deployment -- Run on any cloud provider or on-premises. -- **Docker Compose based setup** for simplified deployment. -- Future-proof installation script for streamlined setup and feature additions. -- Use any WireGuard client to connect, or use **Newt, our custom user space client** for the best experience. +- Run on any cloud provider or on-premises. +- **Docker Compose based setup** for simplified deployment. +- Future-proof installation script for streamlined setup and feature additions. +- Use any WireGuard client to connect, or use **Newt, our custom user space client** for the best experience. +- Use the API to create custom integrations and scripts. + - Fine-grained access control to the API via scoped API keys. + - Comprehensive Swagger documentation for the API. ### Modular Design -- Extend functionality with existing [Traefik](https://github.com/traefik/traefik) plugins, such as [CrowdSec](https://plugins.traefik.io/plugins/6335346ca4caa9ddeffda116/crowdsec-bouncer-traefik-plugin) and [Geoblock](https://github.com/PascalMinder/geoblock). +- Extend functionality with existing [Traefik](https://github.com/traefik/traefik) plugins, such as [CrowdSec](https://plugins.traefik.io/plugins/6335346ca4caa9ddeffda116/crowdsec-bouncer-traefik-plugin) and [Geoblock](github.com/PascalMinder/geoblock). - **Automatically install and configure Crowdsec via Pangolin's installer script.** -- Attach as many sites to the central server as you wish. +- Attach as many sites to the central server as you wish. Collage @@ -88,7 +93,7 @@ _Sites page of Pangolin dashboard (dark mode) showing multiple tunnels connected 1. **Deploy the Central Server**: - - Deploy the Docker Compose stack onto a VPS hosted on a cloud platform like RackNerd, Amazon EC2, DigitalOcean Droplet, or similar. There are many cheap VPS hosting options available to suit your needs. + - Deploy the Docker Compose stack onto a VPS hosted on a cloud platform like RackNerd, Amazon EC2, DigitalOcean Droplet, or similar. There are many cheap VPS hosting options available to suit your needs. > [!TIP] > Many of our users have had a great experience with [RackNerd](https://my.racknerd.com/aff.php?aff=13788). Depending on promotions, you can likely get a **VPS with 1 vCPU, 1GB RAM, and ~20GB SSD for just around $12/year**. That's a great deal! @@ -108,24 +113,24 @@ _Sites page of Pangolin dashboard (dark mode) showing multiple tunnels connected - Add resources to the central server and configure access control rules. - Access these resources securely from anywhere. -**Use Case Example - Bypassing Port Restrictions in Home Lab**: +**Use Case Example - Bypassing Port Restrictions in Home Lab**: Imagine private sites where the ISP restricts port forwarding. By connecting these sites to Pangolin via WireGuard, you can securely expose HTTP and HTTPS resources on the private network without any networking complexity. -**Use Case Example - IoT Networks**: +**Use Case Example - Deploying Services For Your Business**: +You can use Pangolin as an easy way to expose your business applications to your users behind a safe authentication portal you can integrate into your IdP solution. Expose resources on prem and on the cloud. + +**Use Case Example - IoT Networks**: IoT networks are often fragmented and difficult to manage. By deploying Pangolin on a central server, you can connect all your IoT sites via Newt or another WireGuard client. This creates a simple, secure, and centralized way to access IoT resources without the need for intricate networking setups. - -Resources - _Resources page of Pangolin dashboard (dark mode) showing HTTPS and TCP resources with access control rules._ ## Similar Projects and Inspirations -**Cloudflare Tunnels**: - A similar approach to proxying private resources securely, but Pangolin is a self-hosted alternative, giving you full control over your infrastructure. +**Cloudflare Tunnels**: + A similar approach to proxying private resources securely, but Pangolin is a self-hosted alternative, giving you full control over your infrastructure. -**Authentik and Authelia**: - These projects inspired Pangolin’s centralized authentication system for proxies, enabling robust user and role management. +**Authelia**: + This inspired Pangolin’s centralized authentication system for proxies, enabling robust user and role management. ## Project Development / Roadmap @@ -136,7 +141,7 @@ View the [project board](https://github.com/orgs/fosrl/projects/1) for more deta ## Licensing -Pangolin is dual licensed under the AGPL-3 and the Fossorial Commercial license. To see our commercial offerings, please see our [website](https://fossorial.io) for details. For inquiries about commercial licensing, please contact us at [numbat@fossorial.io](mailto:numbat@fossorial.io). +Pangolin is dual licensed under the AGPL-3 and the Fossorial Commercial license. Please see the [LICENSE](./LICENSE) file in the repository for details. For inquiries about commercial licensing, please contact us at [numbat@fossorial.io](mailto:numbat@fossorial.io). ## Contributions diff --git a/config/config.example.yml b/config/config.example.yml index f3ab8d6e..7b5c144d 100644 --- a/config/config.example.yml +++ b/config/config.example.yml @@ -18,6 +18,7 @@ server: internal_hostname: "pangolin" session_cookie_name: "p_session_token" resource_access_token_param: "p_token" + secret: "your_secret_key_here" resource_access_token_headers: id: "P-Access-Token-Id" token: "P-Access-Token" diff --git a/docker-compose.example.yml b/docker-compose.example.yml index ad755174..973d27fa 100644 --- a/docker-compose.example.yml +++ b/docker-compose.example.yml @@ -10,7 +10,7 @@ services: test: ["CMD", "curl", "-f", "http://localhost:3001/api/v1/"] interval: "3s" timeout: "3s" - retries: 5 + retries: 15 gerbil: image: fosrl/gerbil:latest diff --git a/esbuild.mjs b/esbuild.mjs index 321c6288..48a2fb31 100644 --- a/esbuild.mjs +++ b/esbuild.mjs @@ -52,6 +52,7 @@ esbuild bundle: true, outfile: argv.out, format: "esm", + minify: true, banner: { js: banner, }, diff --git a/install/config/config.yml b/install/config/config.yml index de406ee9..f7d4552d 100644 --- a/install/config/config.yml +++ b/install/config/config.yml @@ -22,6 +22,7 @@ server: id: "P-Access-Token-Id" token: "P-Access-Token" resource_session_request_param: "p_session_request" + secret: {{.Secret}} cors: origins: ["https://{{.DashboardDomain}}"] methods: ["GET", "POST", "PUT", "DELETE", "PATCH"] diff --git a/install/config/crowdsec/docker-compose.yml b/install/config/crowdsec/docker-compose.yml index 20c69387..28470d14 100644 --- a/install/config/crowdsec/docker-compose.yml +++ b/install/config/crowdsec/docker-compose.yml @@ -9,6 +9,9 @@ services: PARSERS: crowdsecurity/whitelists ENROLL_TAGS: docker healthcheck: + interval: 10s + retries: 15 + timeout: 10s test: ["CMD", "cscli", "capi", "status"] labels: - "traefik.enable=false" # Disable traefik for crowdsec diff --git a/install/crowdsec.go b/install/crowdsec.go index 9fadadc6..c17bf540 100644 --- a/install/crowdsec.go +++ b/install/crowdsec.go @@ -3,9 +3,12 @@ package main import ( "bytes" "fmt" + "log" "os" "os/exec" "strings" + + "gopkg.in/yaml.v3" ) func installCrowdsec(config Config) error { @@ -63,6 +66,12 @@ func installCrowdsec(config Config) error { os.Exit(1) } + // check and add the service dependency of crowdsec to traefik + if err := CheckAndAddCrowdsecDependency("docker-compose.yml"); err != nil { + fmt.Printf("Error adding crowdsec dependency to traefik: %v\n", err) + os.Exit(1) + } + if err := startContainers(); err != nil { return fmt.Errorf("failed to start containers: %v", err) } @@ -135,3 +144,58 @@ func checkIfTextInFile(file, text string) bool { // Check for text return bytes.Contains(content, []byte(text)) } + +func CheckAndAddCrowdsecDependency(composePath string) error { + // Read the docker-compose.yml file + data, err := os.ReadFile(composePath) + if err != nil { + return fmt.Errorf("error reading compose file: %w", err) + } + + // Parse YAML into a generic map + var compose map[string]interface{} + if err := yaml.Unmarshal(data, &compose); err != nil { + return fmt.Errorf("error parsing compose file: %w", err) + } + + // Get services section + services, ok := compose["services"].(map[string]interface{}) + if !ok { + return fmt.Errorf("services section not found or invalid") + } + + // Get traefik service + traefik, ok := services["traefik"].(map[string]interface{}) + if !ok { + return fmt.Errorf("traefik service not found or invalid") + } + + // Get dependencies + dependsOn, ok := traefik["depends_on"].(map[string]interface{}) + if ok { + // Append the new block for crowdsec + dependsOn["crowdsec"] = map[string]interface{}{ + "condition": "service_healthy", + } + } else { + // No dependencies exist, create it + traefik["depends_on"] = map[string]interface{}{ + "crowdsec": map[string]interface{}{ + "condition": "service_healthy", + }, + } + } + + // Marshal the modified data back to YAML with indentation + modifiedData, err := MarshalYAMLWithIndent(compose, 2) // Set indentation to 2 spaces + if err != nil { + log.Fatalf("error marshaling YAML: %v", err) + } + + if err := os.WriteFile(composePath, modifiedData, 0644); err != nil { + return fmt.Errorf("error writing updated compose file: %w", err) + } + + fmt.Println("Added dependency of crowdsec to traefik") + return nil +} diff --git a/install/go.mod b/install/go.mod index 536ac2dd..1d12aa12 100644 --- a/install/go.mod +++ b/install/go.mod @@ -3,7 +3,8 @@ module installer go 1.23.0 require ( - golang.org/x/sys v0.29.0 // indirect - golang.org/x/term v0.28.0 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect + golang.org/x/term v0.28.0 + gopkg.in/yaml.v3 v3.0.1 ) + +require golang.org/x/sys v0.29.0 // indirect diff --git a/install/go.sum b/install/go.sum index 3316e039..169165e4 100644 --- a/install/go.sum +++ b/install/go.sum @@ -2,6 +2,7 @@ golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU= golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/term v0.28.0 h1:/Ts8HFuMR2E6IP/jlo7QVLZHggjKQbhu/7H0LJFr3Gg= golang.org/x/term v0.28.0/go.mod h1:Sw/lC2IAUZ92udQNf3WodGtn4k/XoLyZoh8v/8uiwek= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/install/main.go b/install/main.go index 9f07bbcf..abb67acd 100644 --- a/install/main.go +++ b/install/main.go @@ -16,6 +16,7 @@ import ( "text/template" "time" "unicode" + "math/rand" "golang.org/x/term" ) @@ -50,6 +51,7 @@ type Config struct { InstallGerbil bool TraefikBouncerKey string DoCrowdsecInstall bool + Secret string } func main() { @@ -63,6 +65,7 @@ func main() { var config Config config.DoCrowdsecInstall = false + config.Secret = generateRandomSecretKey() // check if there is already a config file if _, err := os.Stat("config/config.yml"); err != nil { @@ -87,7 +90,15 @@ func main() { if isDockerInstalled() { if readBool(reader, "Would you like to install and start the containers?", true) { - pullAndStartContainers() + if err := pullContainers(); err != nil { + fmt.Println("Error: ", err) + return + } + + if err := startContainers(); err != nil { + fmt.Println("Error: ", err) + return + } } } } else { @@ -427,24 +438,24 @@ func installDocker() error { apt-get install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin `, dockerArch)) case strings.Contains(osRelease, "ID=fedora"): - installCmd = exec.Command("bash", "-c", fmt.Sprintf(` + installCmd = exec.Command("bash", "-c", ` dnf -y install dnf-plugins-core && dnf config-manager --add-repo https://download.docker.com/linux/fedora/docker-ce.repo && dnf install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin - `)) + `) case strings.Contains(osRelease, "ID=opensuse") || strings.Contains(osRelease, "ID=\"opensuse-"): installCmd = exec.Command("bash", "-c", ` zypper install -y docker docker-compose && systemctl enable docker `) case strings.Contains(osRelease, "ID=rhel") || strings.Contains(osRelease, "ID=\"rhel"): - installCmd = exec.Command("bash", "-c", fmt.Sprintf(` + installCmd = exec.Command("bash", "-c", ` dnf remove -y runc && dnf -y install yum-utils && dnf config-manager --add-repo https://download.docker.com/linux/rhel/docker-ce.repo && dnf install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin && systemctl enable docker - `)) + `) case strings.Contains(osRelease, "ID=amzn"): installCmd = exec.Command("bash", "-c", ` yum update -y && @@ -468,162 +479,76 @@ func isDockerInstalled() bool { return true } -func getCommandString(useNewStyle bool) string { - if useNewStyle { - return "'docker compose'" - } - return "'docker-compose'" -} - -func pullAndStartContainers() error { - fmt.Println("Starting containers...") - - // Check which docker compose command is available +// executeDockerComposeCommandWithArgs executes the appropriate docker command with arguments supplied +func executeDockerComposeCommandWithArgs(args ...string) error { + var cmd *exec.Cmd var useNewStyle bool + + if !isDockerInstalled() { + return fmt.Errorf("docker is not installed") + } + checkCmd := exec.Command("docker", "compose", "version") if err := checkCmd.Run(); err == nil { useNewStyle = true } else { - // Check if docker-compose (old style) is available checkCmd = exec.Command("docker-compose", "version") - if err := checkCmd.Run(); err != nil { - return fmt.Errorf("neither 'docker compose' nor 'docker-compose' command is available: %v", err) - } - } - - // Helper function to execute docker compose commands - executeCommand := func(args ...string) error { - var cmd *exec.Cmd - if useNewStyle { - cmd = exec.Command("docker", append([]string{"compose"}, args...)...) + if err := checkCmd.Run(); err == nil { + useNewStyle = false } else { - cmd = exec.Command("docker-compose", args...) + return fmt.Errorf("neither 'docker compose' nor 'docker-compose' command is available") } - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - return cmd.Run() + } + + if useNewStyle { + cmd = exec.Command("docker", append([]string{"compose"}, args...)...) + } else { + cmd = exec.Command("docker-compose", args...) } - // Pull containers - fmt.Printf("Using %s command to pull containers...\n", getCommandString(useNewStyle)) - if err := executeCommand("-f", "docker-compose.yml", "pull"); err != nil { - return fmt.Errorf("failed to pull containers: %v", err) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + return cmd.Run() +} + +// pullContainers pulls the containers using the appropriate command. +func pullContainers() error { + fmt.Println("Pulling the container images...") + + if err := executeDockerComposeCommandWithArgs("-f", "docker-compose.yml", "pull", "--policy", "always"); err != nil { + return fmt.Errorf("failed to pull the containers: %v", err) } - // Start containers - fmt.Printf("Using %s command to start containers...\n", getCommandString(useNewStyle)) - if err := executeCommand("-f", "docker-compose.yml", "up", "-d"); err != nil { + return nil +} + +// startContainers starts the containers using the appropriate command. +func startContainers() error { + fmt.Println("Starting containers...") + if err := executeDockerComposeCommandWithArgs("-f", "docker-compose.yml", "up", "-d", "--force-recreate"); err != nil { return fmt.Errorf("failed to start containers: %v", err) } return nil } -// bring containers down +// stopContainers stops the containers using the appropriate command. func stopContainers() error { fmt.Println("Stopping containers...") - - // Check which docker compose command is available - var useNewStyle bool - checkCmd := exec.Command("docker", "compose", "version") - if err := checkCmd.Run(); err == nil { - useNewStyle = true - } else { - // Check if docker-compose (old style) is available - checkCmd = exec.Command("docker-compose", "version") - if err := checkCmd.Run(); err != nil { - return fmt.Errorf("neither 'docker compose' nor 'docker-compose' command is available: %v", err) - } - } - - // Helper function to execute docker compose commands - executeCommand := func(args ...string) error { - var cmd *exec.Cmd - if useNewStyle { - cmd = exec.Command("docker", append([]string{"compose"}, args...)...) - } else { - cmd = exec.Command("docker-compose", args...) - } - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - return cmd.Run() - } - - if err := executeCommand("-f", "docker-compose.yml", "down"); err != nil { + + if err := executeDockerComposeCommandWithArgs("-f", "docker-compose.yml", "down"); err != nil { return fmt.Errorf("failed to stop containers: %v", err) } return nil } -// just start containers -func startContainers() error { - fmt.Println("Starting containers...") - - // Check which docker compose command is available - var useNewStyle bool - checkCmd := exec.Command("docker", "compose", "version") - if err := checkCmd.Run(); err == nil { - useNewStyle = true - } else { - // Check if docker-compose (old style) is available - checkCmd = exec.Command("docker-compose", "version") - if err := checkCmd.Run(); err != nil { - return fmt.Errorf("neither 'docker compose' nor 'docker-compose' command is available: %v", err) - } - } - - // Helper function to execute docker compose commands - executeCommand := func(args ...string) error { - var cmd *exec.Cmd - if useNewStyle { - cmd = exec.Command("docker", append([]string{"compose"}, args...)...) - } else { - cmd = exec.Command("docker-compose", args...) - } - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - return cmd.Run() - } - - if err := executeCommand("-f", "docker-compose.yml", "up", "-d"); err != nil { - return fmt.Errorf("failed to start containers: %v", err) - } - - return nil -} - +// restartContainer restarts a specific container using the appropriate command. func restartContainer(container string) error { - fmt.Printf("Restarting %s container...\n", container) - - // Check which docker compose command is available - var useNewStyle bool - checkCmd := exec.Command("docker", "compose", "version") - if err := checkCmd.Run(); err == nil { - useNewStyle = true - } else { - // Check if docker-compose (old style) is available - checkCmd = exec.Command("docker-compose", "version") - if err := checkCmd.Run(); err != nil { - return fmt.Errorf("neither 'docker compose' nor 'docker-compose' command is available: %v", err) - } - } - - // Helper function to execute docker compose commands - executeCommand := func(args ...string) error { - var cmd *exec.Cmd - if useNewStyle { - cmd = exec.Command("docker", append([]string{"compose"}, args...)...) - } else { - cmd = exec.Command("docker-compose", args...) - } - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - return cmd.Run() - } - - if err := executeCommand("-f", "docker-compose.yml", "restart", container); err != nil { - return fmt.Errorf("failed to restart %s container: %v", container, err) + fmt.Println("Restarting containers...") + + if err := executeDockerComposeCommandWithArgs("-f", "docker-compose.yml", "restart", container); err != nil { + return fmt.Errorf("failed to stop the container \"%s\": %v", container, err) } return nil @@ -681,3 +606,17 @@ func waitForContainer(containerName string) error { return fmt.Errorf("container %s did not start within %v seconds", containerName, maxAttempts*int(retryInterval.Seconds())) } + +func generateRandomSecretKey() string { + const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" + const length = 32 + + var seededRand *rand.Rand = rand.New( + rand.NewSource(time.Now().UnixNano())) + + b := make([]byte, length) + for i := range b { + b[i] = charset[seededRand.Intn(len(charset))] + } + return string(b) +} \ No newline at end of file diff --git a/internationalization/de.md b/internationalization/de.md index 1acd5b12..c84249f7 100644 --- a/internationalization/de.md +++ b/internationalization/de.md @@ -1,3 +1,23 @@ +## Authentication Site + +| EN | DE | Notes | +| -------------------------------------------------------- | ---------------------------------------------------------------------------------- | ---------- | +| Powered by [Pangolin](https://github.com/fosrl/pangolin) | Bereitgestellt von [Pangolin](https://github.com/fosrl/pangolin) | | +| Authentication Required | Authentifizierung erforderlich | | +| Choose your preferred method to access {resource} | Wählen Sie Ihre bevorzugte Methode, um auf {resource} zuzugreifen | | +| PIN | PIN | | +| User | Benutzer | | +| 6-digit PIN Code | 6-stelliger PIN-Code | pin login | +| Login in with PIN | Mit PIN anmelden | pin login | +| Email | E-Mail | user login | +| Enter your email | Geben Sie Ihre E-Mail-Adresse ein | user login | +| Password | Passwort | user login | +| Enter your password | Geben Sie Ihr Passwort ein | user login | +| Forgot your password? | Passwort vergessen? | user login | +| Log in | Anmelden | user login | + +--- + ## Login site | EN | DE | Notes | diff --git a/internationalization/tr.md b/internationalization/tr.md new file mode 100644 index 00000000..9e5bd274 --- /dev/null +++ b/internationalization/tr.md @@ -0,0 +1,310 @@ +## Authentication Site + +| EN | TR | Notes | +| -------------------------------------------------------- | ---------------------------------------------------------------------------------- | ---------- | +| Powered by [Pangolin](https://github.com/fosrl/pangolin) | Pangolin Tarafından Destekleniyor | | +| Authentication Required | Kimlik Doğrulaması Gerekli | | +| Choose your preferred method to access {resource} | {resource}'a erişmek için tercih ettiğiniz yöntemi seçin | | +| PIN | PIN | | +| User | Kullanıcı | | +| 6-digit PIN Code | 6 haneli PIN Kodu | pin login | +| Login in with PIN | PIN ile Giriş Yap | pin login | +| Email | E-posta | user login | +| Enter your email | E-postanızı girin | user login | +| Password | Şifre | user login | +| Enter your password | Şifrenizi girin | user login | +| Forgot your password? | Şifrenizi mi unuttunuz? | user login | +| Log in | Giriş Yap | user login | + +--- + +## Login site + +| EN | TR | Notes | +| --------------------- | ------------------------------------------------------ | ----------- | +| Welcome to Pangolin | Pangolin'e Hoşgeldiniz | | +| Log in to get started | Başlamak için giriş yapın | | +| Email | E-posta | | +| Enter your email | E-posta adresinizi girin | placeholder | +| Password | Şifre | | +| Enter your password | Şifrenizi girin | placeholder | +| Forgot your password? | Şifrenizi mi unuttunuz? | | +| Log in | Giriş Yap | | + +--- + +# Organization site after successful login + +| EN | TR | Notes | +| ----------------------------------------- | ------------------------------------------------------------------- | ----- | +| Welcome to Pangolin | Pangolin'e Hoşgeldiniz | | +| You're a member of {number} organization. | {number} organizasyonunun üyesiniz. | | + +--- + +## Shared Header, Navbar and Footer + +##### Header + +| EN | TR | Notes | +| ------------------- | -------------------------- | ----- | +| Documentation | Dokümantasyon | | +| Support | Destek | | +| Organization {name} | Organizasyon {name} | | + +##### Organization selector + +| EN | TR | Notes | +| ---------------- | ---------------------- | ----- | +| Search… | Ara… | | +| Create | Oluştur | | +| New Organization | Yeni Organizasyon | | +| Organizations | Organizasyonlar | | + +##### Navbar + +| EN | TR | Notes | +| --------------- | ------------------------------- | ----- | +| Sites | Siteler | | +| Resources | Kaynaklar | | +| User & Roles | Kullanıcılar ve Roller | | +| Shareable Links | Paylaşılabilir Linkler | | +| General | Genel | | + +##### Footer + +| EN | TR | Notes | +| ------------------------- | ------------------------------------------------ | -------------------- | +| Page {number} of {number} | Sayfa {number} / {number} | | +| Rows per page | Sayfa başına satırlar | | +| Pangolin | Pangolin | Footer'da yer alır | +| Built by Fossorial | Fossorial tarafından oluşturuldu | Footer'da yer alır | +| Open Source | Açık Kaynak | Footer'da yer alır | +| Documentation | Dokümantasyon | Footer'da yer alır | +| {version} | {version} | Footer'da yer alır | + +--- + +## Main “Sites” + +##### “Hero” section + +| EN | TR | Notes | +| ------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------- | ----- | +| Newt (Recommended) | Newt (Tavsiye Edilen) | | +| For the best user experience, use Newt. It uses WireGuard under the hood and allows you to address your private resources by their LAN address on your private network from within the Pangolin dashboard. | En iyi kullanıcı deneyimi için Newt'i kullanın. Newt, arka planda WireGuard kullanır ve Pangolin kontrol paneli üzerinden özel ağınızdaki kaynaklarınıza LAN adresleriyle erişmenizi sağlar. | | +| Runs in Docker | Docker üzerinde çalışır | | +| Runs in shell on macOS, Linux, and Windows | macOS, Linux ve Windows’ta komut satırında çalışır | | +| Install Newt | Newt'i Yükle | | +| Basic WireGuard
| Temel WireGuard
| | +| Compatible with all WireGuard clients
| Tüm WireGuard istemcileriyle uyumlu
| | +| Manual configuration required | Manuel yapılandırma gereklidir | | + +##### Content + +| EN | TR | Notes | +| --------------------------------------------------------- | --------------------------------------------------------------------------- | ------------ | +| Manage Sites | Siteleri Yönet | | +| Allow connectivity to your network through secure tunnels | Güvenli tüneller aracılığıyla ağınıza bağlantı sağlayın | | +| Search sites | Siteleri ara | placeholder | +| Add Site | Site Ekle | | +| Name | Ad | Table Header | +| Online | Çevrimiçi | Table Header | +| Site | Site | Table Header | +| Data In | Gelen Veri | Table Header | +| Data Out | Giden Veri | Table Header | +| Connection Type | Bağlantı Türü | Table Header | +| Online | Çevrimiçi | Site state | +| Offline | Çevrimdışı | Site state | +| Edit → | Düzenle → | | +| View settings | Ayarları Görüntüle | Popup | +| Delete | Sil | Popup | + +##### Add Site Popup + +| EN | TR | Notes | +| ------------------------------------------------------ | ------------------------------------------------------------------------------------------- | ----------- | +| Create Site | Site Oluştur | | +| Create a new site to start connection for this site | Bu site için bağlantıyı başlatmak amacıyla yeni bir site oluşturun | | +| Name | Ad | | +| Site name | Site adı | placeholder | +| This is the name that will be displayed for this site. | Bu, site için görüntülenecek addır. | desc | +| Method | Yöntem | | +| Local | Yerel | | +| Newt | Newt | | +| WireGuard | WireGuard | | +| This is how you will expose connections. | Bağlantılarınızı bu şekilde açığa çıkaracaksınız. | | +| You will only be able to see the configuration once. | Yapılandırmayı yalnızca bir kez görüntüleyebilirsiniz. | | +| Learn how to install Newt on your system | Sisteminizde Newt'in nasıl kurulacağını öğrenin | | +| I have copied the config | Yapılandırmayı kopyaladım | | +| Create Site | Site Oluştur | | +| Close | Kapat | | + +--- + +## Main “Resources” + +##### “Hero” section + +| EN | TR | Notes | +| ------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------ | ----- | +| Resources | Kaynaklar | | +| Ressourcen sind Proxy-Server für Anwendungen, die in Ihrem privaten Netzwerk laufen. Erstellen Sie eine Ressource für jede HTTP- oder HTTPS-Anwendung in Ihrem privaten Netzwerk. Jede Ressource muss mit einer Website verbunden sein, um eine private und sichere Verbindung über den verschlüsselten WireGuard-Tunnel zu ermöglichen. | Kaynaklar, özel ağınızda çalışan uygulamalar için proxy sunucularıdır. Özel ağınızdaki her HTTP veya HTTPS uygulaması için bir kaynak oluşturun. Her kaynağın, şifrelenmiş WireGuard tüneli üzerinden özel ve güvenli bağlantı sağlamak üzere bir siteyle ilişkili olması gerekir. | | +| Secure connectivity with WireGuard encryption | WireGuard şifrelemesiyle güvenli bağlantı | | +| Configure multiple authentication methods | Birden çok kimlik doğrulama yöntemini yapılandırın | | +| User and role-based access control | Kullanıcı ve role dayalı erişim kontrolü | | + +##### Content + +| EN | TR | Notes | +| -------------------------------------------------- | ------------------------------------------------------------- | -------------------- | +| Manage Resources | Kaynakları Yönet | | +| Create secure proxies to your private applications | Özel uygulamalarınız için güvenli proxy’ler oluşturun | | +| Search resources | Kaynakları ara | placeholder | +| Name | Ad | | +| Site | Site | | +| Full URL | Tam URL | | +| Authentication | Kimlik Doğrulama | | +| Not Protected | Korunmayan | authentication state | +| Protected | Korunan | authentication state | +| Edit → | Düzenle → | | +| Add Resource | Kaynak Ekle | | + +##### Add Resource Popup + +| EN | TR | Notes | +| ------------------------------------------------------------ | ----------------------------------------------------------------------------------------------- | ------------- | +| Create Resource | Kaynak Oluştur | | +| Create a new resource to proxy request to your app | Uygulamanıza gelen istekleri yönlendirmek için yeni bir kaynak oluşturun | | +| Name | Ad | | +| My Resource | Kaynağım | name placeholder | +| This is the name that will be displayed for this resource. | Bu, kaynağın görüntülenecek adıdır. | | +| Subdomain | Alt alan adı | | +| Enter subdomain | Alt alan adını girin | | +| This is the fully qualified domain name that will be used to access the resource. | Kaynağa erişmek için kullanılacak tam nitelikli alan adıdır. | | +| Site | Site | | +| Search site… | Site ara… | Site selector popup | +| This is the site that will be used in the dashboard. | Kontrol panelinde kullanılacak sitedir. | | +| Create Resource | Kaynak Oluştur | | +| Close | Kapat | | + +--- + +## Main “User & Roles” + +##### Content + +| EN | TR | Notes | +| ------------------------------------------------------------ | ------------------------------------------------------------------------------------------ | ----------------------------- | +| Manage User & Roles | Kullanıcılar ve Rolleri Yönet | | +| Invite users and add them to roles to manage access to your organization | Organizasyonunuza erişimi yönetmek için kullanıcıları davet edin ve rollere atayın | | +| Users | Kullanıcılar | sidebar item | +| Roles | Roller | sidebar item | +| **User tab** | **Kullanıcı Sekmesi** | | +| Search users | Kullanıcıları ara | placeholder | +| Invite User | Kullanıcı Davet Et | addbutton | +| Email | E-posta | table header | +| Status | Durum | table header | +| Role | Rol | table header | +| Confirmed | Onaylandı | account status | +| Not confirmed (?) | Onaylanmadı (?) | account status | +| Owner | Sahip | role | +| Admin | Yönetici | role | +| Member | Üye | role | +| **Roles Tab** | **Roller Sekmesi** | | +| Search roles | Rolleri ara | placeholder | +| Add Role | Rol Ekle | addbutton | +| Name | Ad | table header | +| Description | Açıklama | table header | +| Admin | Yönetici | role | +| Member | Üye | role | +| Admin role with the most permissions | En fazla yetkiye sahip yönetici rolü | admin role desc | +| Members can only view resources | Üyeler yalnızca kaynakları görüntüleyebilir | member role desc | + +##### Invite User popup + +| EN | TR | Notes | +| ----------------- | ----------------------------------------------------------------------- | ----------- | +| Invite User | Kullanıcı Davet Et | | +| Email | E-posta | | +| Enter an email | Bir e-posta adresi girin | placeholder | +| Role | Rol | | +| Select role | Rol seçin | placeholder | +| Gültig für | Geçerlilik Süresi | | +| 1 day | 1 gün | | +| 2 days | 2 gün | | +| 3 days | 3 gün | | +| 4 days | 4 gün | | +| 5 days | 5 gün | | +| 6 days | 6 gün | | +| 7 days | 7 gün | | +| Create Invitation | Davetiye Oluştur | | +| Close | Kapat | | + +--- + +## Main “Shareable Links” + +##### “Hero” section + +| EN | TR | Notes | +| ------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------- | ----- | +| Shareable Links | Paylaşılabilir Bağlantılar | | +| Create shareable links to your resources. Links provide temporary or unlimited access to your resource. You can configure the expiration duration of the link when you create one. | Kaynaklarınıza paylaşılabilir bağlantılar oluşturun. Bağlantılar, kaynağınıza geçici veya sınırsız erişim sağlar. Oluştururken bağlantının geçerlilik süresini ayarlayabilirsiniz. | | +| Easy to create and share | Oluşturması ve paylaşması kolay | | +| Configurable expiration duration | Yapılandırılabilir geçerlilik süresi | | +| Secure and revocable | Güvenli ve iptal edilebilir | | + +##### Content + +| EN | TR | Notes | +| ------------------------------------------------------------ | ---------------------------------------------------------------------------------------- | -------------- | +| Manage Shareable Links | Paylaşılabilir Bağlantıları Yönet | | +| Create shareable links to grant temporary or permanent access to your resources | Kaynaklarınıza geçici veya kalıcı erişim sağlamak için paylaşılabilir bağlantılar oluşturun | | +| Search links | Bağlantıları ara | placeholder | +| Create Share Link | Bağlantı Oluştur | addbutton | +| Resource | Kaynak | table header | +| Title | Başlık | table header | +| Created | Oluşturulma Tarihi | table header | +| Expires | Son Kullanma Tarihi | table header | +| No links. Create one to get started. | Bağlantı yok. Başlamak için bir tane oluşturun. | table placeholder | + +##### Create Shareable Link popup + +| EN | TR | Notes | +| ------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------- | ----------------------- | +| Create Shareable Link | Paylaşılabilir Bağlantı Oluştur | | +| Anyone with this link can access the resource | Bu bağlantıya sahip olan herkes kaynağa erişebilir | | +| Resource | Kaynak | | +| Select resource | Kaynak seçin | | +| Search resources… | Kaynak ara… | resource selector popup | +| Title (optional) | Başlık (isteğe bağlı) | | +| Enter title | Başlık girin | placeholder | +| Expire in | Sona Erme Süresi | | +| Minutes | Dakika | | +| Hours | Saat | | +| Days | Gün | | +| Months | Ay | | +| Years | Yıl | | +| Never expire | Asla sona erme | | +| Expiration time is how long the link will be usable and provide access to the resource. After this time, the link will no longer work, and users who used this link will lose access to the resource. | Bağlantının geçerlilik süresi, bağlantının ne kadar süreyle kullanılabilir olacağını ve kaynağa erişim sağlayacağını belirler. Bu sürenin sonunda bağlantı çalışmaz hale gelir ve bağlantıyı kullananlar kaynağa erişimini kaybeder. | | +| Create Link | Bağlantı Oluştur | | +| Close | Kapat | | + +--- + +## Main “General” + +| EN | TR | Notes | +| ------------------------------------------------------------ | ------------------------------------------------------------------------------------------- | ------------ | +| General | Genel | | +| Configure your organization’s general settings | Organizasyonunuzun genel ayarlarını yapılandırın | | +| General | Genel | sidebar item | +| Organization Settings | Organizasyon Ayarları | | +| Manage your organization details and configuration | Organizasyonunuzun detaylarını ve yapılandırmasını yönetin | | +| Name | Ad | | +| This is the display name of the org | Bu, organizasyonunuzun görüntülenecek adıdır. | | +| Save Settings | Ayarları Kaydet | | +| Danger Zone | Tehlikeli Bölge | | +| Once you delete this org, there is no going back. Please be certain. | Bu organizasyonu sildikten sonra geri dönüş yoktur. Lütfen emin olun. | | +| Delete Organization Data | Organizasyon Verilerini Sil | | diff --git a/package-lock.json b/package-lock.json index c9e0a334..c6da9176 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "0.0.0", "license": "SEE LICENSE IN LICENSE AND README.md", "dependencies": { + "@asteasolutions/zod-to-openapi": "^7.3.0", "@hookform/resolvers": "3.9.1", "@node-rs/argon2": "2.0.2", "@oslojs/crypto": "1.0.1", @@ -21,6 +22,7 @@ "@radix-ui/react-icons": "1.3.2", "@radix-ui/react-label": "2.1.1", "@radix-ui/react-popover": "1.1.4", + "@radix-ui/react-progress": "^1.1.4", "@radix-ui/react-radio-group": "1.2.2", "@radix-ui/react-select": "2.1.4", "@radix-ui/react-separator": "1.1.1", @@ -28,16 +30,23 @@ "@radix-ui/react-switch": "1.1.2", "@radix-ui/react-tabs": "1.1.2", "@radix-ui/react-toast": "1.2.4", - "@react-email/components": "0.0.31", + "@react-email/components": "0.0.36", + "@react-email/render": "^1.0.6", "@react-email/tailwind": "1.0.4", + "@tailwindcss/forms": "^0.5.10", "@tanstack/react-table": "8.20.6", - "axios": "1.7.9", + "arctic": "^3.6.0", + "axios": "1.8.4", "better-sqlite3": "11.7.0", + "canvas-confetti": "1.9.3", "class-variance-authority": "0.7.1", "clsx": "2.1.1", "cmdk": "1.0.4", + "cookie": "^1.0.2", "cookie-parser": "1.4.7", + "cookies": "^0.9.1", "cors": "2.8.5", + "crypto-js": "^4.2.0", "drizzle-orm": "0.38.3", "eslint": "9.17.0", "eslint-config-next": "15.1.3", @@ -48,10 +57,12 @@ "http-errors": "2.0.0", "i": "^0.3.7", "input-otp": "1.4.1", + "jmespath": "^0.16.0", "js-yaml": "4.1.0", + "jsonwebtoken": "^9.0.2", "lucide-react": "0.469.0", "moment": "2.30.1", - "next": "15.1.3", + "next": "15.2.4", "next-themes": "0.4.4", "node-cache": "5.1.2", "node-fetch": "3.3.2", @@ -66,8 +77,10 @@ "react-icons": "^5.5.0", "rebuild": "0.1.2", "semver": "7.6.3", + "swagger-ui-express": "^5.0.1", "tailwind-merge": "2.6.0", - "tailwindcss-animate": "1.0.7", + "tw-animate-css": "^1.2.5", + "uuid": "^11.1.0", "vaul": "1.1.2", "winston": "3.17.0", "winston-daily-rotate-file": "5.0.0", @@ -78,26 +91,31 @@ "devDependencies": { "@dotenvx/dotenvx": "1.32.0", "@esbuild-plugins/tsconfig-paths": "0.1.2", + "@tailwindcss/postcss": "^4.1.3", "@types/better-sqlite3": "7.6.12", "@types/cookie-parser": "1.4.8", "@types/cors": "2.8.17", + "@types/crypto-js": "^4.2.2", "@types/express": "5.0.0", + "@types/jmespath": "^0.15.2", "@types/js-yaml": "4.0.9", + "@types/jsonwebtoken": "^9.0.9", "@types/node": "^22", "@types/nodemailer": "6.4.17", - "@types/react": "19.0.2", - "@types/react-dom": "19.0.2", + "@types/react": "19.1.1", + "@types/react-dom": "19.1.2", "@types/semver": "7.5.8", + "@types/swagger-ui-express": "^4.1.8", "@types/ws": "8.5.13", "@types/yargs": "17.0.33", - "drizzle-kit": "0.30.1", - "esbuild": "0.24.2", - "esbuild-node-externals": "1.16.0", + "drizzle-kit": "0.30.6", + "esbuild": "0.25.2", + "esbuild-node-externals": "1.18.0", "postcss": "^8", - "react-email": "3.0.4", - "tailwindcss": "^3.4.17", + "react-email": "4.0.6", + "tailwindcss": "^4.1.4", "tsc-alias": "1.8.10", - "tsx": "4.19.2", + "tsx": "4.19.3", "typescript": "^5", "yargs": "17.7.2" } @@ -106,6 +124,7 @@ "version": "5.2.0", "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "dev": true, "license": "MIT", "engines": { "node": ">=10" @@ -114,18 +133,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@ampproject/remapping": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", - "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", - "dev": true, - "license": "Apache-2.0", + "node_modules/@asteasolutions/zod-to-openapi": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/@asteasolutions/zod-to-openapi/-/zod-to-openapi-7.3.0.tgz", + "integrity": "sha512-7tE/r1gXwMIvGnXVUdIqUhCU1RevEFC4Jk6Bussa0fk1ecbnnINkZzj1EOAJyE/M3AI25DnHT/zKQL1/FPFi8Q==", + "license": "MIT", "dependencies": { - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.24" + "openapi3-ts": "^4.1.2" }, - "engines": { - "node": ">=6.0.0" + "peerDependencies": { + "zod": "^3.20.2" } }, "node_modules/@babel/code-frame": { @@ -143,66 +160,15 @@ "node": ">=6.9.0" } }, - "node_modules/@babel/compat-data": { - "version": "7.26.5", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.26.5.tgz", - "integrity": "sha512-XvcZi1KWf88RVbF9wn8MN6tYFloU5qX8KjuF3E1PVBmJ9eypXfs4GRiJwLuTZL0iSnJUKn1BFPa5BPZZJyFzPg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/core": { - "version": "7.24.5", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.24.5.tgz", - "integrity": "sha512-tVQRucExLQ02Boi4vdPp49svNGcfL2GhdTCT9aldhXgCJVAI21EtRfBettiuLUwce/7r6bFdgs6JFkcdTiFttA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@ampproject/remapping": "^2.2.0", - "@babel/code-frame": "^7.24.2", - "@babel/generator": "^7.24.5", - "@babel/helper-compilation-targets": "^7.23.6", - "@babel/helper-module-transforms": "^7.24.5", - "@babel/helpers": "^7.24.5", - "@babel/parser": "^7.24.5", - "@babel/template": "^7.24.0", - "@babel/traverse": "^7.24.5", - "@babel/types": "^7.24.5", - "convert-source-map": "^2.0.0", - "debug": "^4.1.0", - "gensync": "^1.0.0-beta.2", - "json5": "^2.2.3", - "semver": "^6.3.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/babel" - } - }, - "node_modules/@babel/core/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - } - }, "node_modules/@babel/generator": { - "version": "7.26.5", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.26.5.tgz", - "integrity": "sha512-2caSP6fN9I7HOe6nqhtft7V4g7/V/gfDsC3Ag4W7kEzzvRGKqiv0pu0HogPiZ3KaVSoNDhUws6IJjDjpfmYIXw==", + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.27.0.tgz", + "integrity": "sha512-VybsKvpiN1gU1sdMZIp7FcqphVVKEwcuj02x73uvcHE0PTihx1nlBcowYWhDwjpoAXRv43+gDzyggGnn1XZhVw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/parser": "^7.26.5", - "@babel/types": "^7.26.5", + "@babel/parser": "^7.27.0", + "@babel/types": "^7.27.0", "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.25", "jsesc": "^3.0.2" @@ -212,13 +178,13 @@ } }, "node_modules/@babel/generator/node_modules/@babel/parser": { - "version": "7.26.7", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.26.7.tgz", - "integrity": "sha512-kEvgGGgEjRUutvdVvZhbn/BxVt+5VSpwXz1j3WYXQbXDo8KzFOPNG2GQbdAiNq8g6wn1yKk7C/qrke03a84V+w==", + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.27.0.tgz", + "integrity": "sha512-iaepho73/2Pz7w2eMS0Q5f83+0RKI7i4xmiYeBmDzfRVbQtTOG7Ts0S4HzJVsTMGI9keU8rNfuZr8DKfSt7Yyg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/types": "^7.26.7" + "@babel/types": "^7.27.0" }, "bin": { "parser": "bin/babel-parser.js" @@ -227,75 +193,6 @@ "node": ">=6.0.0" } }, - "node_modules/@babel/helper-compilation-targets": { - "version": "7.26.5", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.26.5.tgz", - "integrity": "sha512-IXuyn5EkouFJscIDuFF5EsiSolseme1s0CZB+QxVugqJLYmKdxI1VfIBOst0SUu4rnk2Z7kqTwmoO1lp3HIfnA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/compat-data": "^7.26.5", - "@babel/helper-validator-option": "^7.25.9", - "browserslist": "^4.24.0", - "lru-cache": "^5.1.1", - "semver": "^6.3.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-compilation-targets/node_modules/lru-cache": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", - "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", - "dev": true, - "license": "ISC", - "dependencies": { - "yallist": "^3.0.2" - } - }, - "node_modules/@babel/helper-compilation-targets/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - } - }, - "node_modules/@babel/helper-module-imports": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.25.9.tgz", - "integrity": "sha512-tnUA4RsrmflIM6W6RFTLFSXITtl0wKjgpnLgXyowocVPrbYrLUXSBXDgTs8BlbmIzIdlBySRQjINYs2BAkiLtw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/traverse": "^7.25.9", - "@babel/types": "^7.25.9" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-module-transforms": { - "version": "7.26.0", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.26.0.tgz", - "integrity": "sha512-xO+xu6B5K2czEnQye6BHA7DolFFmS3LB7stHZFaOLb1pAwO1HWLS8fXA+eh0A2yIvltPVmx3eNNDBJA2SLHXFw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-module-imports": "^7.25.9", - "@babel/helper-validator-identifier": "^7.25.9", - "@babel/traverse": "^7.25.9" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, "node_modules/@babel/helper-string-parser": { "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.25.9.tgz", @@ -316,30 +213,6 @@ "node": ">=6.9.0" } }, - "node_modules/@babel/helper-validator-option": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.25.9.tgz", - "integrity": "sha512-e/zv1co8pp55dNdEcCynfj9X7nyUKUXoUEwfXqaZt0omVOmDe9oOTdKStH4GmAw6zxMFs50ZayuMfHDKlO7Tfw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helpers": { - "version": "7.26.7", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.26.7.tgz", - "integrity": "sha512-8NHiL98vsi0mbPQmYAGWwfcFaOy4j2HY49fXJCfuDcdE7fMIsH9a7GdaeXpIBsbT7307WU8KCMp5pUVDNL4f9A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/template": "^7.25.9", - "@babel/types": "^7.26.7" - }, - "engines": { - "node": ">=6.9.0" - } - }, "node_modules/@babel/parser": { "version": "7.24.5", "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.24.5.tgz", @@ -354,28 +227,28 @@ } }, "node_modules/@babel/template": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.25.9.tgz", - "integrity": "sha512-9DGttpmPvIxBb/2uwpVo3dqJ+O6RooAFOS+lB+xDqoE2PVCE8nfoHMdZLpfCQRLwvohzXISPZcgxt80xLfsuwg==", + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.0.tgz", + "integrity": "sha512-2ncevenBqXI6qRMukPlXwHKHchC7RyMuu4xv5JBXRfOGVcTy1mXCD12qrp7Jsoxll1EV3+9sE4GugBVRjT2jFA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.25.9", - "@babel/parser": "^7.25.9", - "@babel/types": "^7.25.9" + "@babel/code-frame": "^7.26.2", + "@babel/parser": "^7.27.0", + "@babel/types": "^7.27.0" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/template/node_modules/@babel/parser": { - "version": "7.26.7", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.26.7.tgz", - "integrity": "sha512-kEvgGGgEjRUutvdVvZhbn/BxVt+5VSpwXz1j3WYXQbXDo8KzFOPNG2GQbdAiNq8g6wn1yKk7C/qrke03a84V+w==", + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.27.0.tgz", + "integrity": "sha512-iaepho73/2Pz7w2eMS0Q5f83+0RKI7i4xmiYeBmDzfRVbQtTOG7Ts0S4HzJVsTMGI9keU8rNfuZr8DKfSt7Yyg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/types": "^7.26.7" + "@babel/types": "^7.27.0" }, "bin": { "parser": "bin/babel-parser.js" @@ -385,17 +258,17 @@ } }, "node_modules/@babel/traverse": { - "version": "7.26.7", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.26.7.tgz", - "integrity": "sha512-1x1sgeyRLC3r5fQOM0/xtQKsYjyxmFjaOrLJNtZ81inNjyJHGIolTULPiSc/2qe1/qfpFLisLQYFnnZl7QoedA==", + "version": "7.25.6", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.25.6.tgz", + "integrity": "sha512-9Vrcx5ZW6UwK5tvqsj0nGpp/XzqthkT0dqIc9g1AdtygFToNtTF67XzYS//dm+SAK9cp3B9R4ZO/46p63SCjlQ==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.26.2", - "@babel/generator": "^7.26.5", - "@babel/parser": "^7.26.7", - "@babel/template": "^7.25.9", - "@babel/types": "^7.26.7", + "@babel/code-frame": "^7.24.7", + "@babel/generator": "^7.25.6", + "@babel/parser": "^7.25.6", + "@babel/template": "^7.25.0", + "@babel/types": "^7.25.6", "debug": "^4.3.1", "globals": "^11.1.0" }, @@ -404,13 +277,13 @@ } }, "node_modules/@babel/traverse/node_modules/@babel/parser": { - "version": "7.26.7", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.26.7.tgz", - "integrity": "sha512-kEvgGGgEjRUutvdVvZhbn/BxVt+5VSpwXz1j3WYXQbXDo8KzFOPNG2GQbdAiNq8g6wn1yKk7C/qrke03a84V+w==", + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.27.0.tgz", + "integrity": "sha512-iaepho73/2Pz7w2eMS0Q5f83+0RKI7i4xmiYeBmDzfRVbQtTOG7Ts0S4HzJVsTMGI9keU8rNfuZr8DKfSt7Yyg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/types": "^7.26.7" + "@babel/types": "^7.27.0" }, "bin": { "parser": "bin/babel-parser.js" @@ -430,9 +303,9 @@ } }, "node_modules/@babel/types": { - "version": "7.26.7", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.26.7.tgz", - "integrity": "sha512-t8kDRGrKXyp6+tjUh7hw2RLyclsW4TRoRvRHtSyAX9Bb5ldlFh+90YAYY6awRXrlB4G5G2izNeGySpATlFzmOg==", + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.27.0.tgz", + "integrity": "sha512-H45s8fVLYjbhFH62dIJ3WtmJ6RSPt/3DRO0ZcT2SUiYiQyz3BLVb9ADEnLl91m74aQPS3AzzeajZHYOalWe3bg==", "dev": true, "license": "MIT", "dependencies": { @@ -510,31 +383,11 @@ "@noble/ciphers": "^1.0.0" } }, - "node_modules/@emnapi/core": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.3.1.tgz", - "integrity": "sha512-pVGjBIt1Y6gg3EJN8jTcfpP/+uuRksIo055oE/OBkDNcjZqVbfkWCksG1Jp4yZnj3iKWyWX8fdG/j6UDYPbFog==", - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/wasi-threads": "1.0.1", - "tslib": "^2.4.0" - } - }, "node_modules/@emnapi/runtime": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.3.1.tgz", "integrity": "sha512-kEBmG8KyqtxJZv+ygbEim+KCGtIq1fC22Ms3S4ziXmYKm8uyoLX0MHONVKwp+9opg390VaKRNt4a7A9NwmpNhw==", - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/wasi-threads": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.0.1.tgz", - "integrity": "sha512-iIBu7mwkq4UQGeMEM8bLwNK962nXdhodeScX4slfQnRhEMMzvYivHhutCIk8uojvmASXXPC2WNEjwxFWk72Oqw==", + "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -994,9 +847,9 @@ } }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.24.2.tgz", - "integrity": "sha512-thpVCb/rhxE/BnMLQ7GReQLLN8q9qbHmI55F4489/ByVg2aQaQ6kbcLb6FHkocZzQhxc4gx0sCk0tJkKBFzDhA==", + "version": "0.25.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.2.tgz", + "integrity": "sha512-wCIboOL2yXZym2cgm6mlA742s9QeJ8DjGVaL39dLN4rRwrOgOyYSnOaFPhKZGLb2ngj4EyfAFjsNJwPXZvseag==", "cpu": [ "ppc64" ], @@ -1011,9 +864,9 @@ } }, "node_modules/@esbuild/android-arm": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.24.2.tgz", - "integrity": "sha512-tmwl4hJkCfNHwFB3nBa8z1Uy3ypZpxqxfTQOcHX+xRByyYgunVbZ9MzUUfb0RxaHIMnbHagwAxuTL+tnNM+1/Q==", + "version": "0.25.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.2.tgz", + "integrity": "sha512-NQhH7jFstVY5x8CKbcfa166GoV0EFkaPkCKBQkdPJFvo5u+nGXLEH/ooniLb3QI8Fk58YAx7nsPLozUWfCBOJA==", "cpu": [ "arm" ], @@ -1028,9 +881,9 @@ } }, "node_modules/@esbuild/android-arm64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.24.2.tgz", - "integrity": "sha512-cNLgeqCqV8WxfcTIOeL4OAtSmL8JjcN6m09XIgro1Wi7cF4t/THaWEa7eL5CMoMBdjoHOTh/vwTO/o2TRXIyzg==", + "version": "0.25.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.2.tgz", + "integrity": "sha512-5ZAX5xOmTligeBaeNEPnPaeEuah53Id2tX4c2CVP3JaROTH+j4fnfHCkr1PjXMd78hMst+TlkfKcW/DlTq0i4w==", "cpu": [ "arm64" ], @@ -1045,9 +898,9 @@ } }, "node_modules/@esbuild/android-x64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.24.2.tgz", - "integrity": "sha512-B6Q0YQDqMx9D7rvIcsXfmJfvUYLoP722bgfBlO5cGvNVb5V/+Y7nhBE3mHV9OpxBf4eAS2S68KZztiPaWq4XYw==", + "version": "0.25.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.2.tgz", + "integrity": "sha512-Ffcx+nnma8Sge4jzddPHCZVRvIfQ0kMsUsCMcJRHkGJ1cDmhe4SsrYIjLUKn1xpHZybmOqCWwB0zQvsjdEHtkg==", "cpu": [ "x64" ], @@ -1062,9 +915,9 @@ } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.24.2.tgz", - "integrity": "sha512-kj3AnYWc+CekmZnS5IPu9D+HWtUI49hbnyqk0FLEJDbzCIQt7hg7ucF1SQAilhtYpIujfaHr6O0UHlzzSPdOeA==", + "version": "0.25.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.2.tgz", + "integrity": "sha512-MpM6LUVTXAzOvN4KbjzU/q5smzryuoNjlriAIx+06RpecwCkL9JpenNzpKd2YMzLJFOdPqBpuub6eVRP5IgiSA==", "cpu": [ "arm64" ], @@ -1079,9 +932,9 @@ } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.24.2.tgz", - "integrity": "sha512-WeSrmwwHaPkNR5H3yYfowhZcbriGqooyu3zI/3GGpF8AyUdsrrP0X6KumITGA9WOyiJavnGZUwPGvxvwfWPHIA==", + "version": "0.25.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.2.tgz", + "integrity": "sha512-5eRPrTX7wFyuWe8FqEFPG2cU0+butQQVNcT4sVipqjLYQjjh8a8+vUTfgBKM88ObB85ahsnTwF7PSIt6PG+QkA==", "cpu": [ "x64" ], @@ -1096,9 +949,9 @@ } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.24.2.tgz", - "integrity": "sha512-UN8HXjtJ0k/Mj6a9+5u6+2eZ2ERD7Edt1Q9IZiB5UZAIdPnVKDoG7mdTVGhHJIeEml60JteamR3qhsr1r8gXvg==", + "version": "0.25.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.2.tgz", + "integrity": "sha512-mLwm4vXKiQ2UTSX4+ImyiPdiHjiZhIaE9QvC7sw0tZ6HoNMjYAqQpGyui5VRIi5sGd+uWq940gdCbY3VLvsO1w==", "cpu": [ "arm64" ], @@ -1113,9 +966,9 @@ } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.24.2.tgz", - "integrity": "sha512-TvW7wE/89PYW+IevEJXZ5sF6gJRDY/14hyIGFXdIucxCsbRmLUcjseQu1SyTko+2idmCw94TgyaEZi9HUSOe3Q==", + "version": "0.25.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.2.tgz", + "integrity": "sha512-6qyyn6TjayJSwGpm8J9QYYGQcRgc90nmfdUb0O7pp1s4lTY+9D0H9O02v5JqGApUyiHOtkz6+1hZNvNtEhbwRQ==", "cpu": [ "x64" ], @@ -1130,9 +983,9 @@ } }, "node_modules/@esbuild/linux-arm": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.24.2.tgz", - "integrity": "sha512-n0WRM/gWIdU29J57hJyUdIsk0WarGd6To0s+Y+LwvlC55wt+GT/OgkwoXCXvIue1i1sSNWblHEig00GBWiJgfA==", + "version": "0.25.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.2.tgz", + "integrity": "sha512-UHBRgJcmjJv5oeQF8EpTRZs/1knq6loLxTsjc3nxO9eXAPDLcWW55flrMVc97qFPbmZP31ta1AZVUKQzKTzb0g==", "cpu": [ "arm" ], @@ -1147,9 +1000,9 @@ } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.24.2.tgz", - "integrity": "sha512-7HnAD6074BW43YvvUmE/35Id9/NB7BeX5EoNkK9obndmZBUk8xmJJeU7DwmUeN7tkysslb2eSl6CTrYz6oEMQg==", + "version": "0.25.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.2.tgz", + "integrity": "sha512-gq/sjLsOyMT19I8obBISvhoYiZIAaGF8JpeXu1u8yPv8BE5HlWYobmlsfijFIZ9hIVGYkbdFhEqC0NvM4kNO0g==", "cpu": [ "arm64" ], @@ -1164,9 +1017,9 @@ } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.24.2.tgz", - "integrity": "sha512-sfv0tGPQhcZOgTKO3oBE9xpHuUqguHvSo4jl+wjnKwFpapx+vUDcawbwPNuBIAYdRAvIDBfZVvXprIj3HA+Ugw==", + "version": "0.25.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.2.tgz", + "integrity": "sha512-bBYCv9obgW2cBP+2ZWfjYTU+f5cxRoGGQ5SeDbYdFCAZpYWrfjjfYwvUpP8MlKbP0nwZ5gyOU/0aUzZ5HWPuvQ==", "cpu": [ "ia32" ], @@ -1181,9 +1034,9 @@ } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.24.2.tgz", - "integrity": "sha512-CN9AZr8kEndGooS35ntToZLTQLHEjtVB5n7dl8ZcTZMonJ7CCfStrYhrzF97eAecqVbVJ7APOEe18RPI4KLhwQ==", + "version": "0.25.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.2.tgz", + "integrity": "sha512-SHNGiKtvnU2dBlM5D8CXRFdd+6etgZ9dXfaPCeJtz+37PIUlixvlIhI23L5khKXs3DIzAn9V8v+qb1TRKrgT5w==", "cpu": [ "loong64" ], @@ -1198,9 +1051,9 @@ } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.24.2.tgz", - "integrity": "sha512-iMkk7qr/wl3exJATwkISxI7kTcmHKE+BlymIAbHO8xanq/TjHaaVThFF6ipWzPHryoFsesNQJPE/3wFJw4+huw==", + "version": "0.25.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.2.tgz", + "integrity": "sha512-hDDRlzE6rPeoj+5fsADqdUZl1OzqDYow4TB4Y/3PlKBD0ph1e6uPHzIQcv2Z65u2K0kpeByIyAjCmjn1hJgG0Q==", "cpu": [ "mips64el" ], @@ -1215,9 +1068,9 @@ } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.24.2.tgz", - "integrity": "sha512-shsVrgCZ57Vr2L8mm39kO5PPIb+843FStGt7sGGoqiiWYconSxwTiuswC1VJZLCjNiMLAMh34jg4VSEQb+iEbw==", + "version": "0.25.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.2.tgz", + "integrity": "sha512-tsHu2RRSWzipmUi9UBDEzc0nLc4HtpZEI5Ba+Omms5456x5WaNuiG3u7xh5AO6sipnJ9r4cRWQB2tUjPyIkc6g==", "cpu": [ "ppc64" ], @@ -1232,9 +1085,9 @@ } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.24.2.tgz", - "integrity": "sha512-4eSFWnU9Hhd68fW16GD0TINewo1L6dRrB+oLNNbYyMUAeOD2yCK5KXGK1GH4qD/kT+bTEXjsyTCiJGHPZ3eM9Q==", + "version": "0.25.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.2.tgz", + "integrity": "sha512-k4LtpgV7NJQOml/10uPU0s4SAXGnowi5qBSjaLWMojNCUICNu7TshqHLAEbkBdAszL5TabfvQ48kK84hyFzjnw==", "cpu": [ "riscv64" ], @@ -1249,9 +1102,9 @@ } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.24.2.tgz", - "integrity": "sha512-S0Bh0A53b0YHL2XEXC20bHLuGMOhFDO6GN4b3YjRLK//Ep3ql3erpNcPlEFed93hsQAjAQDNsvcK+hV90FubSw==", + "version": "0.25.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.2.tgz", + "integrity": "sha512-GRa4IshOdvKY7M/rDpRR3gkiTNp34M0eLTaC1a08gNrh4u488aPhuZOCpkF6+2wl3zAN7L7XIpOFBhnaE3/Q8Q==", "cpu": [ "s390x" ], @@ -1266,9 +1119,9 @@ } }, "node_modules/@esbuild/linux-x64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.24.2.tgz", - "integrity": "sha512-8Qi4nQcCTbLnK9WoMjdC9NiTG6/E38RNICU6sUNqK0QFxCYgoARqVqxdFmWkdonVsvGqWhmm7MO0jyTqLqwj0Q==", + "version": "0.25.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.2.tgz", + "integrity": "sha512-QInHERlqpTTZ4FRB0fROQWXcYRD64lAoiegezDunLpalZMjcUcld3YzZmVJ2H/Cp0wJRZ8Xtjtj0cEHhYc/uUg==", "cpu": [ "x64" ], @@ -1283,9 +1136,9 @@ } }, "node_modules/@esbuild/netbsd-arm64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.24.2.tgz", - "integrity": "sha512-wuLK/VztRRpMt9zyHSazyCVdCXlpHkKm34WUyinD2lzK07FAHTq0KQvZZlXikNWkDGoT6x3TD51jKQ7gMVpopw==", + "version": "0.25.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.2.tgz", + "integrity": "sha512-talAIBoY5M8vHc6EeI2WW9d/CkiO9MQJ0IOWX8hrLhxGbro/vBXJvaQXefW2cP0z0nQVTdQ/eNyGFV1GSKrxfw==", "cpu": [ "arm64" ], @@ -1300,9 +1153,9 @@ } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.24.2.tgz", - "integrity": "sha512-VefFaQUc4FMmJuAxmIHgUmfNiLXY438XrL4GDNV1Y1H/RW3qow68xTwjZKfj/+Plp9NANmzbH5R40Meudu8mmw==", + "version": "0.25.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.2.tgz", + "integrity": "sha512-voZT9Z+tpOxrvfKFyfDYPc4DO4rk06qamv1a/fkuzHpiVBMOhpjK+vBmWM8J1eiB3OLSMFYNaOaBNLXGChf5tg==", "cpu": [ "x64" ], @@ -1317,9 +1170,9 @@ } }, "node_modules/@esbuild/openbsd-arm64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.24.2.tgz", - "integrity": "sha512-YQbi46SBct6iKnszhSvdluqDmxCJA+Pu280Av9WICNwQmMxV7nLRHZfjQzwbPs3jeWnuAhE9Jy0NrnJ12Oz+0A==", + "version": "0.25.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.2.tgz", + "integrity": "sha512-dcXYOC6NXOqcykeDlwId9kB6OkPUxOEqU+rkrYVqJbK2hagWOMrsTGsMr8+rW02M+d5Op5NNlgMmjzecaRf7Tg==", "cpu": [ "arm64" ], @@ -1334,9 +1187,9 @@ } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.24.2.tgz", - "integrity": "sha512-+iDS6zpNM6EnJyWv0bMGLWSWeXGN/HTaF/LXHXHwejGsVi+ooqDfMCCTerNFxEkM3wYVcExkeGXNqshc9iMaOA==", + "version": "0.25.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.2.tgz", + "integrity": "sha512-t/TkWwahkH0Tsgoq1Ju7QfgGhArkGLkF1uYz8nQS/PPFlXbP5YgRpqQR3ARRiC2iXoLTWFxc6DJMSK10dVXluw==", "cpu": [ "x64" ], @@ -1351,9 +1204,9 @@ } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.24.2.tgz", - "integrity": "sha512-hTdsW27jcktEvpwNHJU4ZwWFGkz2zRJUz8pvddmXPtXDzVKTTINmlmga3ZzwcuMpUvLw7JkLy9QLKyGpD2Yxig==", + "version": "0.25.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.2.tgz", + "integrity": "sha512-cfZH1co2+imVdWCjd+D1gf9NjkchVhhdpgb1q5y6Hcv9TP6Zi9ZG/beI3ig8TvwT9lH9dlxLq5MQBBgwuj4xvA==", "cpu": [ "x64" ], @@ -1368,9 +1221,9 @@ } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.24.2.tgz", - "integrity": "sha512-LihEQ2BBKVFLOC9ZItT9iFprsE9tqjDjnbulhHoFxYQtQfai7qfluVODIYxt1PgdoyQkz23+01rzwNwYfutxUQ==", + "version": "0.25.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.2.tgz", + "integrity": "sha512-7Loyjh+D/Nx/sOTzV8vfbB3GJuHdOQyrOryFdZvPHLf42Tk9ivBU5Aedi7iyX+x6rbn2Mh68T4qq1SDqJBQO5Q==", "cpu": [ "arm64" ], @@ -1385,9 +1238,9 @@ } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.24.2.tgz", - "integrity": "sha512-q+iGUwfs8tncmFC9pcnD5IvRHAzmbwQ3GPS5/ceCyHdjXubwQWI12MKWSNSMYLJMq23/IUCvJMS76PDqXe1fxA==", + "version": "0.25.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.2.tgz", + "integrity": "sha512-WRJgsz9un0nqZJ4MfhabxaD9Ft8KioqU3JMinOTvobbX6MOSUigSBlogP8QB3uxpJDsFS6yN+3FDBdqE5lg9kg==", "cpu": [ "ia32" ], @@ -1402,9 +1255,9 @@ } }, "node_modules/@esbuild/win32-x64": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.24.2.tgz", - "integrity": "sha512-7VTgWzgMGvup6aSqDPLiW5zHaxYJGTO4OokMjIlrCtf+VpEL+cXKtCvg723iguPYI5oaUNdS+/V7OU2gvXVWEg==", + "version": "0.25.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.2.tgz", + "integrity": "sha512-kM3HKb16VIXZyIeVrM1ygYmZBKybX8N4p754bw390wGO3Tf2j4L2/WYL+4suWujpgf6GBYs3jv7TyUivdd05JA==", "cpu": [ "x64" ], @@ -1664,7 +1517,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -1680,29 +1532,6 @@ "@img/sharp-libvips-darwin-arm64": "1.0.4" } }, - "node_modules/@img/sharp-darwin-x64": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.33.5.tgz", - "integrity": "sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q==", - "cpu": [ - "x64" - ], - "dev": true, - "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.0.4" - } - }, "node_modules/@img/sharp-libvips-darwin-arm64": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.0.4.tgz", @@ -1710,7 +1539,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1720,323 +1548,6 @@ "url": "https://opencollective.com/libvips" } }, - "node_modules/@img/sharp-libvips-darwin-x64": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.0.4.tgz", - "integrity": "sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ==", - "cpu": [ - "x64" - ], - "dev": true, - "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.0.5", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.0.5.tgz", - "integrity": "sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==", - "cpu": [ - "arm" - ], - "dev": true, - "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.0.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.0.4.tgz", - "integrity": "sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==", - "cpu": [ - "arm64" - ], - "dev": true, - "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.0.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.0.4.tgz", - "integrity": "sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA==", - "cpu": [ - "s390x" - ], - "dev": true, - "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.0.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.0.4.tgz", - "integrity": "sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linuxmusl-arm64": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.0.4.tgz", - "integrity": "sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==", - "cpu": [ - "arm64" - ], - "dev": true, - "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.0.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.0.4.tgz", - "integrity": "sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-linux-arm": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.33.5.tgz", - "integrity": "sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==", - "cpu": [ - "arm" - ], - "dev": true, - "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.0.5" - } - }, - "node_modules/@img/sharp-linux-arm64": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.33.5.tgz", - "integrity": "sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==", - "cpu": [ - "arm64" - ], - "dev": true, - "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.0.4" - } - }, - "node_modules/@img/sharp-linux-s390x": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.33.5.tgz", - "integrity": "sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q==", - "cpu": [ - "s390x" - ], - "dev": true, - "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.0.4" - } - }, - "node_modules/@img/sharp-linux-x64": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.33.5.tgz", - "integrity": "sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==", - "cpu": [ - "x64" - ], - "dev": true, - "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-x64": "1.0.4" - } - }, - "node_modules/@img/sharp-linuxmusl-arm64": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.33.5.tgz", - "integrity": "sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==", - "cpu": [ - "arm64" - ], - "dev": true, - "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.0.4" - } - }, - "node_modules/@img/sharp-linuxmusl-x64": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.33.5.tgz", - "integrity": "sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==", - "cpu": [ - "x64" - ], - "dev": true, - "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-x64": "1.0.4" - } - }, - "node_modules/@img/sharp-wasm32": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.33.5.tgz", - "integrity": "sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg==", - "cpu": [ - "wasm32" - ], - "dev": true, - "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", - "optional": true, - "dependencies": { - "@emnapi/runtime": "^1.2.0" - }, - "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.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.33.5.tgz", - "integrity": "sha512-T36PblLaTwuVJ/zw/LaH0PdZkRz5rd3SmMHX8GSmR7vtNSP5Z6bQkExdSK7xGWyxLw4sUknBuugTelgw2faBbQ==", - "cpu": [ - "ia32" - ], - "dev": true, - "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.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.33.5.tgz", - "integrity": "sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg==", - "cpu": [ - "x64" - ], - "dev": true, - "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/@isaacs/cliui": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", @@ -2058,6 +1569,7 @@ "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/set-array": "^1.2.1", @@ -2072,6 +1584,7 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, "license": "MIT", "engines": { "node": ">=6.0.0" @@ -2081,6 +1594,7 @@ "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" @@ -2090,34 +1604,24 @@ "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.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": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, - "node_modules/@napi-rs/wasm-runtime": { - "version": "0.2.6", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.6.tgz", - "integrity": "sha512-z8YVS3XszxFTO73iwvFDNpQIzdMmSDTP/mB3E/ucR37V3Sx57hSExcXyMoNwaucWxnsWf4xfbZv0iZ30jr0M4Q==", - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/core": "^1.3.1", - "@emnapi/runtime": "^1.3.1", - "@tybys/wasm-util": "^0.9.0" - } - }, "node_modules/@next/env": { - "version": "15.1.3", - "resolved": "https://registry.npmjs.org/@next/env/-/env-15.1.3.tgz", - "integrity": "sha512-Q1tXwQCGWyA3ehMph3VO+E6xFPHDKdHFYosadt0F78EObYxPio0S09H9UGYznDe6Wc8eLKLG89GqcFJJDiK5xw==", + "version": "15.2.4", + "resolved": "https://registry.npmjs.org/@next/env/-/env-15.2.4.tgz", + "integrity": "sha512-+SFtMgoiYP3WoSswuNmxJOCwi06TdWE733D+WPjpXIe4LXGULwEaofiiAy6kbS0+XjM5xF5n3lKuBwN2SnqD9g==", "license": "MIT" }, "node_modules/@next/eslint-plugin-next": { @@ -2130,9 +1634,9 @@ } }, "node_modules/@next/swc-darwin-arm64": { - "version": "15.1.3", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.1.3.tgz", - "integrity": "sha512-aZtmIh8jU89DZahXQt1La0f2EMPt/i7W+rG1sLtYJERsP7GRnNFghsciFpQcKHcGh4dUiyTB5C1X3Dde/Gw8gg==", + "version": "15.2.4", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.2.4.tgz", + "integrity": "sha512-1AnMfs655ipJEDC/FHkSr0r3lXBgpqKo4K1kiwfUf3iE68rDFXZ1TtHdMvf7D0hMItgDZ7Vuq3JgNMbt/+3bYw==", "cpu": [ "arm64" ], @@ -2146,9 +1650,9 @@ } }, "node_modules/@next/swc-darwin-x64": { - "version": "15.1.3", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.1.3.tgz", - "integrity": "sha512-aw8901rjkVBK5mbq5oV32IqkJg+CQa6aULNlN8zyCWSsePzEG3kpDkAFkkTOh3eJ0p95KbkLyWBzslQKamXsLA==", + "version": "15.2.4", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.2.4.tgz", + "integrity": "sha512-3qK2zb5EwCwxnO2HeO+TRqCubeI/NgCe+kL5dTJlPldV/uwCnUgC7VbEzgmxbfrkbjehL4H9BPztWOEtsoMwew==", "cpu": [ "x64" ], @@ -2162,9 +1666,9 @@ } }, "node_modules/@next/swc-linux-arm64-gnu": { - "version": "15.1.3", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.1.3.tgz", - "integrity": "sha512-YbdaYjyHa4fPK4GR4k2XgXV0p8vbU1SZh7vv6El4bl9N+ZSiMfbmqCuCuNU1Z4ebJMumafaz6UCC2zaJCsdzjw==", + "version": "15.2.4", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.2.4.tgz", + "integrity": "sha512-HFN6GKUcrTWvem8AZN7tT95zPb0GUGv9v0d0iyuTb303vbXkkbHDp/DxufB04jNVD+IN9yHy7y/6Mqq0h0YVaQ==", "cpu": [ "arm64" ], @@ -2178,9 +1682,9 @@ } }, "node_modules/@next/swc-linux-arm64-musl": { - "version": "15.1.3", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.1.3.tgz", - "integrity": "sha512-qgH/aRj2xcr4BouwKG3XdqNu33SDadqbkqB6KaZZkozar857upxKakbRllpqZgWl/NDeSCBYPmUAZPBHZpbA0w==", + "version": "15.2.4", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.2.4.tgz", + "integrity": "sha512-Oioa0SORWLwi35/kVB8aCk5Uq+5/ZIumMK1kJV+jSdazFm2NzPDztsefzdmzzpx5oGCJ6FkUC7vkaUseNTStNA==", "cpu": [ "arm64" ], @@ -2194,9 +1698,9 @@ } }, "node_modules/@next/swc-linux-x64-gnu": { - "version": "15.1.3", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.1.3.tgz", - "integrity": "sha512-uzafnTFwZCPN499fNVnS2xFME8WLC9y7PLRs/yqz5lz1X/ySoxfaK2Hbz74zYUdEg+iDZPd8KlsWaw9HKkLEVw==", + "version": "15.2.4", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.2.4.tgz", + "integrity": "sha512-yb5WTRaHdkgOqFOZiu6rHV1fAEK0flVpaIN2HB6kxHVSy/dIajWbThS7qON3W9/SNOH2JWkVCyulgGYekMePuw==", "cpu": [ "x64" ], @@ -2210,9 +1714,9 @@ } }, "node_modules/@next/swc-linux-x64-musl": { - "version": "15.1.3", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.1.3.tgz", - "integrity": "sha512-el6GUFi4SiDYnMTTlJJFMU+GHvw0UIFnffP1qhurrN1qJV3BqaSRUjkDUgVV44T6zpw1Lc6u+yn0puDKHs+Sbw==", + "version": "15.2.4", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.2.4.tgz", + "integrity": "sha512-Dcdv/ix6srhkM25fgXiyOieFUkz+fOYkHlydWCtB0xMST6X9XYI3yPDKBZt1xuhOytONsIFJFB08xXYsxUwJLw==", "cpu": [ "x64" ], @@ -2226,9 +1730,9 @@ } }, "node_modules/@next/swc-win32-arm64-msvc": { - "version": "15.1.3", - "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.1.3.tgz", - "integrity": "sha512-6RxKjvnvVMM89giYGI1qye9ODsBQpHSHVo8vqA8xGhmRPZHDQUE4jcDbhBwK0GnFMqBnu+XMg3nYukNkmLOLWw==", + "version": "15.2.4", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.2.4.tgz", + "integrity": "sha512-dW0i7eukvDxtIhCYkMrZNQfNicPDExt2jPb9AZPpL7cfyUo7QSNl1DjsHjmmKp6qNAqUESyT8YFl/Aw91cNJJg==", "cpu": [ "arm64" ], @@ -2242,9 +1746,9 @@ } }, "node_modules/@next/swc-win32-x64-msvc": { - "version": "15.1.3", - "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.1.3.tgz", - "integrity": "sha512-VId/f5blObG7IodwC5Grf+aYP0O8Saz1/aeU3YcWqNdIUAmFQY3VEPKPaIzfv32F/clvanOb2K2BR5DtDs6XyQ==", + "version": "15.2.4", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.2.4.tgz", + "integrity": "sha512-SbnWkJmkS7Xl3kre8SdMF6F/XDh1DTFEhp0jRTj/uB8iPKoU2bb2NDfcu+iifv1+mxQEd1g2vvSxcZbXSKyWiQ==", "cpu": [ "x64" ], @@ -2324,38 +1828,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", @@ -2372,182 +1844,6 @@ "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", - "integrity": "sha512-ZM3jrHuJ0dKOhvA80gKJqBpBRmTJTFSo2+xVZR+phQcbAKRlDMSZMFDiKbSTnctkfwNFtjgDdh5g1vaEV04AvA==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@node-rs/argon2-linux-x64-musl": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/@node-rs/argon2-linux-x64-musl/-/argon2-linux-x64-musl-2.0.2.tgz", - "integrity": "sha512-of5uPqk7oCRF/44a89YlWTEfjsftPywyTULwuFDKyD8QtVZoonrJR6ZWvfFE/6jBT68S0okAkAzzMEdBVWdxWw==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "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", @@ -2577,38 +1873,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", @@ -2625,215 +1889,6 @@ "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", - "integrity": "sha512-DyyhDHDsLBsCKz1tZ1hLvUZSc1DK0FU0v52jK6IBQxrj24WscSU9zZe7ie/V9kdmA4Ep57BfpWX8Dsa2JxGdgQ==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@node-rs/bcrypt-linux-x64-musl": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/@node-rs/bcrypt-linux-x64-musl/-/bcrypt-linux-x64-musl-1.9.0.tgz", - "integrity": "sha512-duIiuqQ+Lew8ASSAYm6ZRqcmfBGWwsi81XLUwz86a2HR7Qv6V4yc3ZAUQovAikhjCsIqe8C11JlAZSK6+PlXYg==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "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", @@ -2909,10 +1964,33 @@ "integrity": "sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ==", "license": "MIT" }, + "node_modules/@oslojs/jwt": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/@oslojs/jwt/-/jwt-0.2.0.tgz", + "integrity": "sha512-bLE7BtHrURedCn4Mco3ma9L4Y1GR2SMBuIvjWr7rmQ4/W/4Jy70TIAgZ+0nIlk0xHz1vNP8x8DCns45Sb2XRbg==", + "license": "MIT", + "dependencies": { + "@oslojs/encoding": "0.4.1" + } + }, + "node_modules/@oslojs/jwt/node_modules/@oslojs/encoding": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@oslojs/encoding/-/encoding-0.4.1.tgz", + "integrity": "sha512-hkjo6MuIK/kQR5CrGNdAPZhS01ZCXuWDRJ187zh6qqF2+yMHZpD9fAYpX8q2bOO6Ryhl3XpCT6kUX76N8hhm4Q==", + "license": "MIT" + }, + "node_modules/@petamoriken/float16": { + "version": "3.9.2", + "resolved": "https://registry.npmjs.org/@petamoriken/float16/-/float16-3.9.2.tgz", + "integrity": "sha512-VgffxawQde93xKxT3qap3OH+meZf7VaSB5Sqd4Rqc+FP5alWbpOyan/7tRbOAvynjpG3GpdtAuGU/NdhQpmrog==", + "dev": true, + "license": "MIT" + }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "dev": true, "license": "MIT", "optional": true, "engines": { @@ -3473,6 +2551,101 @@ } } }, + "node_modules/@radix-ui/react-progress": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-progress/-/react-progress-1.1.4.tgz", + "integrity": "sha512-8rl9w7lJdcVPor47Dhws9mUHRHLE+8JEgyJRdNWCpGPa6HIlr3eh+Yn9gyx1CnCLbw5naHsI2gaO9dBWO50vzw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.0" + }, + "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-progress/node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz", + "integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-progress/node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-progress/node_modules/@radix-ui/react-primitive": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.0.tgz", + "integrity": "sha512-/J/FhLdK0zVcILOwt5g+dH4KnkonCtkVJsa2G6JmvbbtZfBEI1gMsO3QMjseL4F/SwfAMt1Vc/0XKYKq+xJ1sw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.0" + }, + "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-progress/node_modules/@radix-ui/react-slot": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.0.tgz", + "integrity": "sha512-ujc+V6r0HNDviYqIK3rW4ffgYiZ8g5DEHrGJVk4x7kTlLXRDILnKX9vAUYeIsLOoDpDJ0ujpqMkjH4w2ofuo6w==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-radio-group": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/@radix-ui/react-radio-group/-/react-radio-group-1.2.2.tgz", @@ -3881,12 +3054,12 @@ } }, "node_modules/@react-email/code-block": { - "version": "0.0.11", - "resolved": "https://registry.npmjs.org/@react-email/code-block/-/code-block-0.0.11.tgz", - "integrity": "sha512-4D43p+LIMjDzm66gTDrZch0Flkip5je91mAT7iGs6+SbPyalHgIA+lFQoQwhz/VzHHLxuD0LV6gwmU/WUQ2WEg==", + "version": "0.0.12", + "resolved": "https://registry.npmjs.org/@react-email/code-block/-/code-block-0.0.12.tgz", + "integrity": "sha512-Faw3Ij9+/Qwq6moWaeHnV8Hn7ekc/EqyAzPi6yUar21dhcqYugCC4Da1x4d9nA9zC0H9KU3lYVJczh8D3cA+Eg==", "license": "MIT", "dependencies": { - "prismjs": "1.29.0" + "prismjs": "1.30.0" }, "engines": { "node": ">=18.0.0" @@ -3920,14 +3093,14 @@ } }, "node_modules/@react-email/components": { - "version": "0.0.31", - "resolved": "https://registry.npmjs.org/@react-email/components/-/components-0.0.31.tgz", - "integrity": "sha512-rQsTY9ajobncix9raexhBjC7O6cXUMc87eNez2gnB1FwtkUO8DqWZcktbtwOJi7GKmuAPTx0o/IOFtiBNXziKA==", + "version": "0.0.36", + "resolved": "https://registry.npmjs.org/@react-email/components/-/components-0.0.36.tgz", + "integrity": "sha512-VMh+OQplAnG8JMLlJjdnjt+ThJZ+JVkp0q2YMS2NEz+T88N22bLD2p7DZO0QgtNaKgumOhJI/0a2Q7VzCrwu5g==", "license": "MIT", "dependencies": { "@react-email/body": "0.0.11", "@react-email/button": "0.0.19", - "@react-email/code-block": "0.0.11", + "@react-email/code-block": "0.0.12", "@react-email/code-inline": "0.0.5", "@react-email/column": "0.0.13", "@react-email/container": "0.0.15", @@ -3940,11 +3113,11 @@ "@react-email/link": "0.0.12", "@react-email/markdown": "0.0.14", "@react-email/preview": "0.0.12", - "@react-email/render": "1.0.3", + "@react-email/render": "1.0.6", "@react-email/row": "0.0.12", "@react-email/section": "0.0.16", "@react-email/tailwind": "1.0.4", - "@react-email/text": "0.0.11" + "@react-email/text": "0.1.1" }, "engines": { "node": ">=18.0.0" @@ -4074,13 +3247,13 @@ } }, "node_modules/@react-email/render": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@react-email/render/-/render-1.0.3.tgz", - "integrity": "sha512-VQ8g4SuIq/jWdfBTdTjb7B8Np0jj+OoD7VebfdHhLTZzVQKesR2aigpYqE/ZXmwj4juVxDm8T2b6WIIu48rPCg==", + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/@react-email/render/-/render-1.0.6.tgz", + "integrity": "sha512-zNueW5Wn/4jNC1c5LFgXzbUdv5Lhms+FWjOvWAhal7gx5YVf0q6dPJ0dnR70+ifo59gcMLwCZEaTS9EEuUhKvQ==", "license": "MIT", "dependencies": { "html-to-text": "9.0.5", - "prettier": "3.3.3", + "prettier": "3.5.3", "react-promise-suspense": "0.3.4" }, "engines": { @@ -4128,9 +3301,9 @@ } }, "node_modules/@react-email/text": { - "version": "0.0.11", - "resolved": "https://registry.npmjs.org/@react-email/text/-/text-0.0.11.tgz", - "integrity": "sha512-a7nl/2KLpRHOYx75YbYZpWspUbX1DFY7JIZbOv5x0QU8SvwDbJt+Hm01vG34PffFyYvHEXrc6Qnip2RTjljNjg==", + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/@react-email/text/-/text-0.1.1.tgz", + "integrity": "sha512-Zo9tSEzkO3fODLVH1yVhzVCiwETfeEL5wU93jXKWo2DHoMuiZ9Iabaso3T0D0UjhrCB1PBMeq2YiejqeToTyIQ==", "license": "MIT", "engines": { "node": ">=18.0.0" @@ -4151,6 +3324,13 @@ "integrity": "sha512-kkKUDVlII2DQiKy7UstOR1ErJP8kUKAQ4oa+SQtM0K+lPdmmjj0YnnxBgtTVYH7mUKtbsxeFC9y0AmK7Yb78/A==", "license": "MIT" }, + "node_modules/@scarf/scarf": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@scarf/scarf/-/scarf-1.4.0.tgz", + "integrity": "sha512-xxeapPiUXdZAE3che6f3xogoJPeZgig6omHEy1rIY5WVsB3H2BHNnZH+gHG6x91SCWyQCzWGsuL2Hh3ClO5/qQ==", + "hasInstallScript": true, + "license": "Apache-2.0" + }, "node_modules/@selderee/plugin-htmlparser2": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/@selderee/plugin-htmlparser2/-/plugin-htmlparser2-0.11.0.tgz", @@ -4186,6 +3366,286 @@ "tslib": "^2.8.0" } }, + "node_modules/@tailwindcss/forms": { + "version": "0.5.10", + "resolved": "https://registry.npmjs.org/@tailwindcss/forms/-/forms-0.5.10.tgz", + "integrity": "sha512-utI1ONF6uf/pPNO68kmN1b8rEwNXv3czukalo8VtJH8ksIkZXr3Q3VYudZLkCsDd4Wku120uF02hYK25XGPorw==", + "license": "MIT", + "dependencies": { + "mini-svg-data-uri": "^1.2.3" + }, + "peerDependencies": { + "tailwindcss": ">=3.0.0 || >= 3.0.0-alpha.1 || >= 4.0.0-alpha.20 || >= 4.0.0-beta.1" + } + }, + "node_modules/@tailwindcss/node": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.4.tgz", + "integrity": "sha512-MT5118zaiO6x6hNA04OWInuAiP1YISXql8Z+/Y8iisV5nuhM8VXlyhRuqc2PEviPszcXI66W44bCIk500Oolhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "enhanced-resolve": "^5.18.1", + "jiti": "^2.4.2", + "lightningcss": "1.29.2", + "tailwindcss": "4.1.4" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.4.tgz", + "integrity": "sha512-p5wOpXyOJx7mKh5MXh5oKk+kqcz8T+bA3z/5VWWeQwFrmuBItGwz8Y2CHk/sJ+dNb9B0nYFfn0rj/cKHZyjahQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.1.4", + "@tailwindcss/oxide-darwin-arm64": "4.1.4", + "@tailwindcss/oxide-darwin-x64": "4.1.4", + "@tailwindcss/oxide-freebsd-x64": "4.1.4", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.4", + "@tailwindcss/oxide-linux-arm64-gnu": "4.1.4", + "@tailwindcss/oxide-linux-arm64-musl": "4.1.4", + "@tailwindcss/oxide-linux-x64-gnu": "4.1.4", + "@tailwindcss/oxide-linux-x64-musl": "4.1.4", + "@tailwindcss/oxide-wasm32-wasi": "4.1.4", + "@tailwindcss/oxide-win32-arm64-msvc": "4.1.4", + "@tailwindcss/oxide-win32-x64-msvc": "4.1.4" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.4.tgz", + "integrity": "sha512-xMMAe/SaCN/vHfQYui3fqaBDEXMu22BVwQ33veLc8ep+DNy7CWN52L+TTG9y1K397w9nkzv+Mw+mZWISiqhmlA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.4.tgz", + "integrity": "sha512-JGRj0SYFuDuAGilWFBlshcexev2hOKfNkoX+0QTksKYq2zgF9VY/vVMq9m8IObYnLna0Xlg+ytCi2FN2rOL0Sg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.4.tgz", + "integrity": "sha512-sdDeLNvs3cYeWsEJ4H1DvjOzaGios4QbBTNLVLVs0XQ0V95bffT3+scptzYGPMjm7xv4+qMhCDrkHwhnUySEzA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.4.tgz", + "integrity": "sha512-VHxAqxqdghM83HslPhRsNhHo91McsxRJaEnShJOMu8mHmEj9Ig7ToHJtDukkuLWLzLboh2XSjq/0zO6wgvykNA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.4.tgz", + "integrity": "sha512-OTU/m/eV4gQKxy9r5acuesqaymyeSCnsx1cFto/I1WhPmi5HDxX1nkzb8KYBiwkHIGg7CTfo/AcGzoXAJBxLfg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.4.tgz", + "integrity": "sha512-hKlLNvbmUC6z5g/J4H+Zx7f7w15whSVImokLPmP6ff1QqTVE+TxUM9PGuNsjHvkvlHUtGTdDnOvGNSEUiXI1Ww==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.4.tgz", + "integrity": "sha512-X3As2xhtgPTY/m5edUtddmZ8rCruvBvtxYLMw9OsZdH01L2gS2icsHRwxdU0dMItNfVmrBezueXZCHxVeeb7Aw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.4.tgz", + "integrity": "sha512-2VG4DqhGaDSmYIu6C4ua2vSLXnJsb/C9liej7TuSO04NK+JJJgJucDUgmX6sn7Gw3Cs5ZJ9ZLrnI0QRDOjLfNQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.4.tgz", + "integrity": "sha512-v+mxVgH2kmur/X5Mdrz9m7TsoVjbdYQT0b4Z+dr+I4RvreCNXyCFELZL/DO0M1RsidZTrm6O1eMnV6zlgEzTMQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.4.tgz", + "integrity": "sha512-2TLe9ir+9esCf6Wm+lLWTMbgklIjiF0pbmDnwmhR9MksVOq+e8aP3TSsXySnBDDvTTVd/vKu1aNttEGj3P6l8Q==", + "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.0", + "@emnapi/runtime": "^1.4.0", + "@emnapi/wasi-threads": "^1.0.1", + "@napi-rs/wasm-runtime": "^0.2.8", + "@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.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.4.tgz", + "integrity": "sha512-VlnhfilPlO0ltxW9/BgfLI5547PYzqBMPIzRrk4W7uupgCt8z6Trw/tAj6QUtF2om+1MH281Pg+HHUJoLesmng==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.4.tgz", + "integrity": "sha512-+7S63t5zhYjslUGb8NcgLpFXD+Kq1F/zt5Xv5qTv7HaFTG/DHyHD9GA6ieNAxhgyA4IcKa/zy7Xx4Oad2/wuhw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/postcss": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.1.4.tgz", + "integrity": "sha512-bjV6sqycCEa+AQSt2Kr7wpGF1bOZJ5wsqnLEkqSbM/JEHxx/yhMH8wHmdkPyApF9xhHeMSwnnkDUUMMM/hYnXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "@tailwindcss/node": "4.1.4", + "@tailwindcss/oxide": "4.1.4", + "postcss": "^8.4.41", + "tailwindcss": "4.1.4" + } + }, "node_modules/@tanstack/react-table": { "version": "8.20.6", "resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.20.6.tgz", @@ -4219,16 +3679,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", @@ -4280,6 +3730,13 @@ "@types/node": "*" } }, + "node_modules/@types/crypto-js": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@types/crypto-js/-/crypto-js-4.2.2.tgz", + "integrity": "sha512-sDOLlVbHhXpAUAL0YHDUUwDZf3iN4Bwi4W6a0W0b+QcAezUbRtH4FVb+9J4h+XFPW7l/gQ9F8qC7P+Ec4k8QVQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz", @@ -4319,6 +3776,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/jmespath": { + "version": "0.15.2", + "resolved": "https://registry.npmjs.org/@types/jmespath/-/jmespath-0.15.2.tgz", + "integrity": "sha512-pegh49FtNsC389Flyo9y8AfkVIZn9MMPE9yJrO9svhq6Fks2MwymULWjZqySuxmctd3ZH4/n7Mr98D+1Qo5vGA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/js-yaml": { "version": "4.0.9", "resolved": "https://registry.npmjs.org/@types/js-yaml/-/js-yaml-4.0.9.tgz", @@ -4338,6 +3802,17 @@ "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==", "license": "MIT" }, + "node_modules/@types/jsonwebtoken": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.9.tgz", + "integrity": "sha512-uoe+GxEuHbvy12OUQct2X9JenKM3qAscquYymuQN4fMWG9DBQtykrQEFcAbVACF7qaLw9BePSodUL0kquqBJpQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/ms": "*", + "@types/node": "*" + } + }, "node_modules/@types/mime": { "version": "1.3.5", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", @@ -4345,6 +3820,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/node": { "version": "22.10.10", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.10.10.tgz", @@ -4380,9 +3862,9 @@ "license": "MIT" }, "node_modules/@types/react": { - "version": "19.0.2", - "resolved": "https://registry.npmjs.org/@types/react/-/react-19.0.2.tgz", - "integrity": "sha512-USU8ZI/xyKJwFTpjSVIrSeHBVAGagkHQKPNbxeWwql/vDmnTIBgx+TJnhFnj1NXgz8XfprU0egV2dROLGpsBEg==", + "version": "19.1.1", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.1.tgz", + "integrity": "sha512-ePapxDL7qrgqSF67s0h9m412d9DbXyC1n59O2st+9rjuuamWsZuD2w55rqY12CbzsZ7uVXb5Nw0gEp9Z8MMutQ==", "devOptional": true, "license": "MIT", "dependencies": { @@ -4390,9 +3872,9 @@ } }, "node_modules/@types/react-dom": { - "version": "19.0.2", - "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.0.2.tgz", - "integrity": "sha512-c1s+7TKFaDRRxr1TxccIX2u7sfCnc3RxkVyBIUA2lCpyqCF+QoAwQ/CBg7bsMdVwP120HEH143VQezKtef5nCg==", + "version": "19.1.2", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.1.2.tgz", + "integrity": "sha512-XGJkWF41Qq305SKWEILa1O8vzhb3aOo3ogBlSmiqNko/WmRb6QIaweuZCXjKygVDXpzXb5wyxKTSOsmkuqj+Qw==", "devOptional": true, "license": "MIT", "peerDependencies": { @@ -4429,6 +3911,17 @@ "@types/send": "*" } }, + "node_modules/@types/swagger-ui-express": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@types/swagger-ui-express/-/swagger-ui-express-4.1.8.tgz", + "integrity": "sha512-AhZV8/EIreHFmBV5wAs0gzJUNq9JbbSXgJLQubCC0jtIo6prnI9MIRRxnU4MZX9RB9yXxF1V4R7jtLl/Wcj31g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/express": "*", + "@types/serve-static": "*" + } + }, "node_modules/@types/triple-beam": { "version": "1.3.5", "resolved": "https://registry.npmjs.org/@types/triple-beam/-/triple-beam-1.3.5.tgz", @@ -4763,16 +4256,11 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/any-promise": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", - "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", - "license": "MIT" - }, "node_modules/anymatch": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, "license": "ISC", "dependencies": { "normalize-path": "^3.0.0", @@ -4786,6 +4274,7 @@ "version": "2.3.1", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, "license": "MIT", "engines": { "node": ">=8.6" @@ -4794,11 +4283,16 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/arg": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", - "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", - "license": "MIT" + "node_modules/arctic": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/arctic/-/arctic-3.6.0.tgz", + "integrity": "sha512-egHDsCqEacb6oSHz5QSSxNhp07J+QJwJdPvs0katL+mNM5LaGQVqxmcdq1KwfaSNSAlVumBBs0MRExS88TxbMg==", + "license": "MIT", + "dependencies": { + "@oslojs/crypto": "1.0.1", + "@oslojs/encoding": "1.1.0", + "@oslojs/jwt": "0.2.0" + } }, "node_modules/argparse": { "version": "2.0.1", @@ -5056,9 +4550,9 @@ } }, "node_modules/axios": { - "version": "1.7.9", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.7.9.tgz", - "integrity": "sha512-LhLcE7Hbiryz8oMDdDptSrWowmB4Bl6RCt6sIJKpRB4XtVf0iEgewX3au/pJqm+Py1kCASkb/FFKjxQaLtxJvw==", + "version": "1.8.4", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.8.4.tgz", + "integrity": "sha512-eBSYY4Y68NNlHbHBMdeDmKNtDgXWhQsJcGqzO3iLUM0GraQFSS9cVgPX5I9b3lbdFKyYoAEGAZF1DwhTaljNAw==", "license": "MIT", "dependencies": { "follow-redirects": "^1.15.6", @@ -5126,6 +4620,7 @@ "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -5215,39 +4710,6 @@ "node": ">=8" } }, - "node_modules/browserslist": { - "version": "4.24.4", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.24.4.tgz", - "integrity": "sha512-KDi1Ny1gSePi1vm0q4oxSF8b4DR44GF4BbmS2YdhPLOEqd8pDviZOGH/GsmRwoWJ2+5Lr085X7naowMwKHDG1A==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/browserslist" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "caniuse-lite": "^1.0.30001688", - "electron-to-chromium": "^1.5.73", - "node-releases": "^2.0.19", - "update-browserslist-db": "^1.1.1" - }, - "bin": { - "browserslist": "cli.js" - }, - "engines": { - "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" - } - }, "node_modules/buffer": { "version": "5.7.1", "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", @@ -5272,6 +4734,12 @@ "ieee754": "^1.1.13" } }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, "node_modules/buffer-from": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", @@ -5355,15 +4823,6 @@ "node": ">=6" } }, - "node_modules/camelcase-css": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", - "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", - "license": "MIT", - "engines": { - "node": ">= 6" - } - }, "node_modules/caniuse-lite": { "version": "1.0.30001695", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001695.tgz", @@ -5384,6 +4843,16 @@ ], "license": "CC-BY-4.0" }, + "node_modules/canvas-confetti": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/canvas-confetti/-/canvas-confetti-1.9.3.tgz", + "integrity": "sha512-rFfTURMvmVEX1gyXFgn5QMn81bYk70qa0HLzcIOSVEyl57n6o9ItHeBtUSWdvKAPY0xlvBHno4/v3QPrT83q9g==", + "license": "ISC", + "funding": { + "type": "donate", + "url": "https://www.paypal.me/kirilvatev" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -5705,20 +5174,13 @@ "node": ">= 0.6" } }, - "node_modules/convert-source-map": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", - "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", - "dev": true, - "license": "MIT" - }, "node_modules/cookie": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", - "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.0.2.tgz", + "integrity": "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==", "license": "MIT", "engines": { - "node": ">= 0.6" + "node": ">=18" } }, "node_modules/cookie-parser": { @@ -5734,12 +5196,34 @@ "node": ">= 0.8.0" } }, + "node_modules/cookie-parser/node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/cookie-signature": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==", "license": "MIT" }, + "node_modules/cookies": { + "version": "0.9.1", + "resolved": "https://registry.npmjs.org/cookies/-/cookies-0.9.1.tgz", + "integrity": "sha512-TG2hpqe4ELx54QER/S3HQ9SRVnQnGBtKUz5bLQWtYAQ+o6GpgMs6sYUvaiJjVxb+UXwhRhAEP3m7LbsIZ77Hmw==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "keygrip": "~1.1.0" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/cors": { "version": "2.8.5", "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", @@ -5788,17 +5272,11 @@ "node": ">= 8" } }, - "node_modules/cssesc": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", - "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", - "license": "MIT", - "bin": { - "cssesc": "bin/cssesc" - }, - "engines": { - "node": ">=4" - } + "node_modules/crypto-js": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.2.0.tgz", + "integrity": "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==", + "license": "MIT" }, "node_modules/csstype": { "version": "3.1.3", @@ -6032,12 +5510,6 @@ "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", "license": "MIT" }, - "node_modules/didyoumean": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", - "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", - "license": "Apache-2.0" - }, "node_modules/dir-glob": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", @@ -6051,12 +5523,6 @@ "node": ">=8" } }, - "node_modules/dlv": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", - "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", - "license": "MIT" - }, "node_modules/doctrine": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", @@ -6138,16 +5604,17 @@ } }, "node_modules/drizzle-kit": { - "version": "0.30.1", - "resolved": "https://registry.npmjs.org/drizzle-kit/-/drizzle-kit-0.30.1.tgz", - "integrity": "sha512-HmA/NeewvHywhJ2ENXD3KvOuM/+K2dGLJfxVfIHsGwaqKICJnS+Ke2L6UcSrSrtMJLJaT0Im1Qv4TFXfaZShyw==", + "version": "0.30.6", + "resolved": "https://registry.npmjs.org/drizzle-kit/-/drizzle-kit-0.30.6.tgz", + "integrity": "sha512-U4wWit0fyZuGuP7iNmRleQyK2V8wCuv57vf5l3MnG4z4fzNTjY/U13M8owyQ5RavqvqxBifWORaR3wIUzlN64g==", "dev": true, "license": "MIT", "dependencies": { "@drizzle-team/brocli": "^0.10.2", "@esbuild-kit/esm-loader": "^2.5.5", "esbuild": "^0.19.7", - "esbuild-register": "^3.5.0" + "esbuild-register": "^3.5.0", + "gel": "^2.0.0" }, "bin": { "drizzle-kit": "bin.cjs" @@ -6728,6 +6195,15 @@ "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", "license": "MIT" }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, "node_modules/eciesjs": { "version": "0.4.13", "resolved": "https://registry.npmjs.org/eciesjs/-/eciesjs-0.4.13.tgz", @@ -6752,13 +6228,6 @@ "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", "license": "MIT" }, - "node_modules/electron-to-chromium": { - "version": "1.5.88", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.88.tgz", - "integrity": "sha512-K3C2qf1o+bGzbilTDCTBhTQcMS9KW60yTAaTeeXsfvQuTDDwlokLam/AdqlqcSy9u4UainDgsHV23ksXAOgamw==", - "dev": true, - "license": "ISC" - }, "node_modules/emoji-regex": { "version": "9.2.2", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", @@ -6790,9 +6259,9 @@ } }, "node_modules/engine.io": { - "version": "6.6.3", - "resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.6.3.tgz", - "integrity": "sha512-2hkLItQMBkoYSagneiisupWGvsQlWXqzhSMvsjaM8GYbnfUsX7tzYQq9QARnate5LRedVTX+MbkSZAANAr3NtQ==", + "version": "6.6.4", + "resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.6.4.tgz", + "integrity": "sha512-ZCkIjSYNDyGn0R6ewHDtXgns/Zre/NT6Agvq1/WobF7JXgFff4SeDroKiCO3fNJreU9YG429Sc81o4w5ok/W5g==", "dev": true, "license": "MIT", "dependencies": { @@ -6800,7 +6269,7 @@ "@types/node": ">=10.0.0", "accepts": "~1.3.4", "base64id": "2.0.0", - "cookie": "~1.0.2", + "cookie": "~0.7.2", "cors": "~2.8.5", "debug": "~4.3.1", "engine.io-parser": "~5.2.1", @@ -6821,13 +6290,13 @@ } }, "node_modules/engine.io/node_modules/cookie": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.0.2.tgz", - "integrity": "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==", + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", "dev": true, "license": "MIT", "engines": { - "node": ">=18" + "node": ">= 0.6" } }, "node_modules/engine.io/node_modules/debug": { @@ -6871,9 +6340,9 @@ } }, "node_modules/enhanced-resolve": { - "version": "5.18.0", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.0.tgz", - "integrity": "sha512-0/r0MySGYG8YqlayBZ6MuCfECmHFdJ5qyPh8s8wa5Hnm6SaFLSK1VYCbj+NKp090Nm1caZhD+QTnmxO7esYGyQ==", + "version": "5.18.1", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.1.tgz", + "integrity": "sha512-ZSW3ma5GkcQBIpwZTSRAI8N71Uuwgs93IezB7mf7R60tC8ZbJideoDNKjHn2O9KIlx6rkGTTEk1xUCK2E1Y2Yg==", "license": "MIT", "dependencies": { "graceful-fs": "^4.2.4", @@ -6895,6 +6364,19 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, + "node_modules/env-paths": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-3.0.0.tgz", + "integrity": "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/es-abstract": { "version": "1.23.9", "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.23.9.tgz", @@ -7059,9 +6541,9 @@ } }, "node_modules/esbuild": { - "version": "0.24.2", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.24.2.tgz", - "integrity": "sha512-+9egpBW8I3CD5XPe0n6BfT5fxLzxrlDzqydF3aviG+9ni1lDC/OvMHcxqEFV0+LANZG5R1bFMWfUrjVsdwxJvA==", + "version": "0.25.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.2.tgz", + "integrity": "sha512-16854zccKPnC+toMywC+uKNeYSv+/eXkevRAfwRD/G9Cleq66m8XFIrigkbvauLLlCfDL45Q2cWegSg53gGBnQ==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -7072,48 +6554,47 @@ "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.24.2", - "@esbuild/android-arm": "0.24.2", - "@esbuild/android-arm64": "0.24.2", - "@esbuild/android-x64": "0.24.2", - "@esbuild/darwin-arm64": "0.24.2", - "@esbuild/darwin-x64": "0.24.2", - "@esbuild/freebsd-arm64": "0.24.2", - "@esbuild/freebsd-x64": "0.24.2", - "@esbuild/linux-arm": "0.24.2", - "@esbuild/linux-arm64": "0.24.2", - "@esbuild/linux-ia32": "0.24.2", - "@esbuild/linux-loong64": "0.24.2", - "@esbuild/linux-mips64el": "0.24.2", - "@esbuild/linux-ppc64": "0.24.2", - "@esbuild/linux-riscv64": "0.24.2", - "@esbuild/linux-s390x": "0.24.2", - "@esbuild/linux-x64": "0.24.2", - "@esbuild/netbsd-arm64": "0.24.2", - "@esbuild/netbsd-x64": "0.24.2", - "@esbuild/openbsd-arm64": "0.24.2", - "@esbuild/openbsd-x64": "0.24.2", - "@esbuild/sunos-x64": "0.24.2", - "@esbuild/win32-arm64": "0.24.2", - "@esbuild/win32-ia32": "0.24.2", - "@esbuild/win32-x64": "0.24.2" + "@esbuild/aix-ppc64": "0.25.2", + "@esbuild/android-arm": "0.25.2", + "@esbuild/android-arm64": "0.25.2", + "@esbuild/android-x64": "0.25.2", + "@esbuild/darwin-arm64": "0.25.2", + "@esbuild/darwin-x64": "0.25.2", + "@esbuild/freebsd-arm64": "0.25.2", + "@esbuild/freebsd-x64": "0.25.2", + "@esbuild/linux-arm": "0.25.2", + "@esbuild/linux-arm64": "0.25.2", + "@esbuild/linux-ia32": "0.25.2", + "@esbuild/linux-loong64": "0.25.2", + "@esbuild/linux-mips64el": "0.25.2", + "@esbuild/linux-ppc64": "0.25.2", + "@esbuild/linux-riscv64": "0.25.2", + "@esbuild/linux-s390x": "0.25.2", + "@esbuild/linux-x64": "0.25.2", + "@esbuild/netbsd-arm64": "0.25.2", + "@esbuild/netbsd-x64": "0.25.2", + "@esbuild/openbsd-arm64": "0.25.2", + "@esbuild/openbsd-x64": "0.25.2", + "@esbuild/sunos-x64": "0.25.2", + "@esbuild/win32-arm64": "0.25.2", + "@esbuild/win32-ia32": "0.25.2", + "@esbuild/win32-x64": "0.25.2" } }, "node_modules/esbuild-node-externals": { - "version": "1.16.0", - "resolved": "https://registry.npmjs.org/esbuild-node-externals/-/esbuild-node-externals-1.16.0.tgz", - "integrity": "sha512-g16pp/yDFqBJ9/9D+UIWPj5uC8MPslMK62HmAXW+ZomZWJifOFTuJgado86UUiMeBrk03z2uvdS6cIGi0OTRcg==", + "version": "1.18.0", + "resolved": "https://registry.npmjs.org/esbuild-node-externals/-/esbuild-node-externals-1.18.0.tgz", + "integrity": "sha512-suFVX3SzZlXrGIS9Yqx+ZaHL4w1p0e/j7dQbOM9zk8SfFpnAGnDplHUKXIf9kcPEAfZRL66JuYeVSVlsSEQ5Eg==", "dev": true, "license": "MIT", "dependencies": { - "find-up": "^5.0.0", - "tslib": "^2.4.1" + "find-up": "^5.0.0" }, "engines": { "node": ">=12" }, "peerDependencies": { - "esbuild": "0.12 - 0.24" + "esbuild": "0.12 - 0.25" } }, "node_modules/esbuild-register": { @@ -8041,17 +7522,11 @@ "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", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -8100,14 +7575,25 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/gensync": { - "version": "1.0.0-beta.2", - "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", - "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "node_modules/gel": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/gel/-/gel-2.0.2.tgz", + "integrity": "sha512-XTKpfNR9HZOw+k0Bl04nETZjuP5pypVAXsZADSdwr3EtyygTTe1RqvftU2FjGu7Tp9e576a9b/iIOxWrRBxMiQ==", "dev": true, - "license": "MIT", + "license": "Apache-2.0", + "dependencies": { + "@petamoriken/float16": "^3.8.7", + "debug": "^4.3.4", + "env-paths": "^3.0.0", + "semver": "^7.6.2", + "shell-quote": "^1.8.1", + "which": "^4.0.0" + }, + "bin": { + "gel": "dist/cli.mjs" + }, "engines": { - "node": ">=6.9.0" + "node": ">= 18.0.0" } }, "node_modules/get-caller-file": { @@ -8683,6 +8169,7 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, "license": "MIT", "dependencies": { "binary-extensions": "^2.0.0" @@ -9096,12 +8583,22 @@ } }, "node_modules/jiti": { - "version": "1.21.7", - "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", - "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.4.2.tgz", + "integrity": "sha512-rg9zJN+G4n2nfJl5MW3BMygZX56zKPNVEYYqq7adpmMh4Jn2QNEwhvQlFy6jPVdcod7txZtKHWnyZiA3a0zP7A==", + "devOptional": true, "license": "MIT", "bin": { - "jiti": "bin/jiti.js" + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/jmespath": { + "version": "0.16.0", + "resolved": "https://registry.npmjs.org/jmespath/-/jmespath-0.16.0.tgz", + "integrity": "sha512-9FzQjJ7MATs1tSpnco1K6ayiYE3figslrXA72G2HQ/n76RzvYlofyi5QM+iX4YRs/pu3yzxlVQSST23+dMDknw==", + "license": "Apache-2.0", + "engines": { + "node": ">= 0.6.0" } }, "node_modules/js-tokens": { @@ -9153,17 +8650,26 @@ "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", "license": "MIT" }, - "node_modules/json5": { - "version": "2.2.3", - "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", - "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", - "dev": true, + "node_modules/jsonwebtoken": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz", + "integrity": "sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==", "license": "MIT", - "bin": { - "json5": "lib/cli.js" + "dependencies": { + "jws": "^3.2.2", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" }, "engines": { - "node": ">=6" + "node": ">=12", + "npm": ">=6" } }, "node_modules/jsx-ast-utils": { @@ -9181,6 +8687,39 @@ "node": ">=4.0" } }, + "node_modules/jwa": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.1.tgz", + "integrity": "sha512-qiLX/xhEEFKUAJ6FiBMbes3w9ATzyk5W7Hvzpa/SLYdxNtng+gcurvrI7TbACjIXlsJyr05/S1oUhZrc63evQA==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz", + "integrity": "sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==", + "license": "MIT", + "dependencies": { + "jwa": "^1.4.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/keygrip": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/keygrip/-/keygrip-1.1.0.tgz", + "integrity": "sha512-iYSchDJ+liQ8iwbSI2QqsQOvqv58eJCEanyJPJi+Khyu8smkcKSFUCbPwzFcL7YVtZ6eONjqRX/38caJ7QjRAQ==", + "license": "MIT", + "dependencies": { + "tsscmp": "1.0.6" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -9236,23 +8775,244 @@ "node": ">= 0.8.0" } }, - "node_modules/lilconfig": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", - "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", - "license": "MIT", + "node_modules/lightningcss": { + "version": "1.29.2", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.29.2.tgz", + "integrity": "sha512-6b6gd/RUXKaw5keVdSEtqFVdzWnU5jMxTUjA2bVcMNPLwSQ08Sv/UodBVtETLCn7k4S1Ibxwh7k68IwLZPgKaA==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, "engines": { - "node": ">=14" + "node": ">= 12.0.0" }, "funding": { - "url": "https://github.com/sponsors/antonk52" + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-darwin-arm64": "1.29.2", + "lightningcss-darwin-x64": "1.29.2", + "lightningcss-freebsd-x64": "1.29.2", + "lightningcss-linux-arm-gnueabihf": "1.29.2", + "lightningcss-linux-arm64-gnu": "1.29.2", + "lightningcss-linux-arm64-musl": "1.29.2", + "lightningcss-linux-x64-gnu": "1.29.2", + "lightningcss-linux-x64-musl": "1.29.2", + "lightningcss-win32-arm64-msvc": "1.29.2", + "lightningcss-win32-x64-msvc": "1.29.2" } }, - "node_modules/lines-and-columns": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", - "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", - "license": "MIT" + "node_modules/lightningcss-darwin-arm64": { + "version": "1.29.2", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.29.2.tgz", + "integrity": "sha512-cK/eMabSViKn/PG8U/a7aCorpeKLMlK0bQeNHmdb7qUnBkNPnL+oV5DjJUo0kqWsJUapZsM4jCfYItbqBDvlcA==", + "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.29.2", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.29.2.tgz", + "integrity": "sha512-j5qYxamyQw4kDXX5hnnCKMf3mLlHvG44f24Qyi2965/Ycz829MYqjrVg2H8BidybHBp9kom4D7DR5VqCKDXS0w==", + "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.29.2", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.29.2.tgz", + "integrity": "sha512-wDk7M2tM78Ii8ek9YjnY8MjV5f5JN2qNVO+/0BAGZRvXKtQrBC4/cn4ssQIpKIPP44YXw6gFdpUF+Ps+RGsCwg==", + "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.29.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.29.2.tgz", + "integrity": "sha512-IRUrOrAF2Z+KExdExe3Rz7NSTuuJ2HvCGlMKoquK5pjvo2JY4Rybr+NrKnq0U0hZnx5AnGsuFHjGnNT14w26sg==", + "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.29.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.29.2.tgz", + "integrity": "sha512-KKCpOlmhdjvUTX/mBuaKemp0oeDIBBLFiU5Fnqxh1/DZ4JPZi4evEH7TKoSBFOSOV3J7iEmmBaw/8dpiUvRKlQ==", + "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.29.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.29.2.tgz", + "integrity": "sha512-Q64eM1bPlOOUgxFmoPUefqzY1yV3ctFPE6d/Vt7WzLW4rKTv7MyYNky+FWxRpLkNASTnKQUaiMJ87zNODIrrKQ==", + "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.29.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.29.2.tgz", + "integrity": "sha512-0v6idDCPG6epLXtBH/RPkHvYx74CVziHo6TMYga8O2EiQApnUPZsbR9nFNrg2cgBzk1AYqEd95TlrsL7nYABQg==", + "cpu": [ + "x64" + ], + "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-musl": { + "version": "1.29.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.29.2.tgz", + "integrity": "sha512-rMpz2yawkgGT8RULc5S4WiZopVMOFWjiItBT7aSfDX4NQav6M44rhn5hjtkKzB+wMTRlLLqxkeYEtQ3dd9696w==", + "cpu": [ + "x64" + ], + "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-win32-arm64-msvc": { + "version": "1.29.2", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.29.2.tgz", + "integrity": "sha512-nL7zRW6evGQqYVu/bKGK+zShyz8OVzsCotFgc7judbt6wnB2KbiKKJwBE4SGoDBQ1O94RjW4asrCjQL4i8Fhbw==", + "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.29.2", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.29.2.tgz", + "integrity": "sha512-EdIUW3B2vLuHmv7urfzMI/h2fmlnOQBk1xlsDxkN1tCWKjNFjfLhGxYk8C8mzpSfr+A6jFFIi8fU6LbQGsRWjA==", + "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", @@ -9269,12 +9029,54 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "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.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "license": "MIT" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "license": "MIT" + }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", "license": "MIT" }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "license": "MIT" + }, "node_modules/log-symbols": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", @@ -9381,29 +9183,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", @@ -9518,6 +9297,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/mini-svg-data-uri": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/mini-svg-data-uri/-/mini-svg-data-uri-1.4.4.tgz", + "integrity": "sha512-r9deDe9p5FJUPZAk3A59wGH7Ii9YrjjWw0jmw/liSbHl2CHiyXj6FcDXDu2K3TjVAXqiJdaw3xxwlZZr9E6nHg==", + "license": "MIT", + "bin": { + "mini-svg-data-uri": "cli.js" + } + }, "node_modules/minimatch": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", @@ -9583,17 +9371,6 @@ "url": "https://github.com/sponsors/raouldeheer" } }, - "node_modules/mz": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", - "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", - "license": "MIT", - "dependencies": { - "any-promise": "^1.0.0", - "object-assign": "^4.0.1", - "thenify-all": "^1.0.0" - } - }, "node_modules/nanoid": { "version": "3.3.8", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.8.tgz", @@ -9634,12 +9411,12 @@ } }, "node_modules/next": { - "version": "15.1.3", - "resolved": "https://registry.npmjs.org/next/-/next-15.1.3.tgz", - "integrity": "sha512-5igmb8N8AEhWDYzogcJvtcRDU6n4cMGtBklxKD4biYv4LXN8+awc/bbQ2IM2NQHdVPgJ6XumYXfo3hBtErg1DA==", + "version": "15.2.4", + "resolved": "https://registry.npmjs.org/next/-/next-15.2.4.tgz", + "integrity": "sha512-VwL+LAaPSxEkd3lU2xWbgEOtrM8oedmyhBqaVNmgKB+GvZlCy9rgaEc+y2on0wv+l0oSFqLtYD6dcC1eAedUaQ==", "license": "MIT", "dependencies": { - "@next/env": "15.1.3", + "@next/env": "15.2.4", "@swc/counter": "0.1.3", "@swc/helpers": "0.5.15", "busboy": "1.6.0", @@ -9654,14 +9431,14 @@ "node": "^18.18.0 || ^19.8.0 || >= 20.0.0" }, "optionalDependencies": { - "@next/swc-darwin-arm64": "15.1.3", - "@next/swc-darwin-x64": "15.1.3", - "@next/swc-linux-arm64-gnu": "15.1.3", - "@next/swc-linux-arm64-musl": "15.1.3", - "@next/swc-linux-x64-gnu": "15.1.3", - "@next/swc-linux-x64-musl": "15.1.3", - "@next/swc-win32-arm64-msvc": "15.1.3", - "@next/swc-win32-x64-msvc": "15.1.3", + "@next/swc-darwin-arm64": "15.2.4", + "@next/swc-darwin-x64": "15.2.4", + "@next/swc-linux-arm64-gnu": "15.2.4", + "@next/swc-linux-arm64-musl": "15.2.4", + "@next/swc-linux-x64-gnu": "15.2.4", + "@next/swc-linux-x64-musl": "15.2.4", + "@next/swc-win32-arm64-msvc": "15.2.4", + "@next/swc-win32-x64-msvc": "15.2.4", "sharp": "^0.33.5" }, "peerDependencies": { @@ -9795,13 +9572,6 @@ "url": "https://opencollective.com/node-fetch" } }, - "node_modules/node-releases": { - "version": "2.0.19", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz", - "integrity": "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==", - "dev": true, - "license": "MIT" - }, "node_modules/nodemailer": { "version": "6.9.16", "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.9.16.tgz", @@ -9815,6 +9585,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -12455,6 +12226,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/openapi3-ts": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/openapi3-ts/-/openapi3-ts-4.4.0.tgz", + "integrity": "sha512-9asTNB9IkKEzWMcHmVZE7Ts3kC9G7AFHfs8i7caD8HbI76gEjdkId4z/AkP83xdZsH7PLAnnbl47qZkXuxpArw==", + "license": "MIT", + "dependencies": { + "yaml": "^2.5.0" + } + }, "node_modules/optimist": { "version": "0.3.7", "resolved": "https://registry.npmjs.org/optimist/-/optimist-0.3.7.tgz", @@ -12539,26 +12319,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", @@ -12584,38 +12344,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", @@ -12632,195 +12360,6 @@ "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", - "integrity": "sha512-EmgqZOlf4Jurk/szW1iTsVISx25bKksVC5uttJDUloTgsAgIGReCpUUO1R24pBhu9ESJa47iv8NSf3yAfGv6jQ==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/oslo/node_modules/@node-rs/argon2-linux-x64-musl": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/@node-rs/argon2-linux-x64-musl/-/argon2-linux-x64-musl-1.7.0.tgz", - "integrity": "sha512-/o1efYCYIxjfuoRYyBTi2Iy+1iFfhqHCvvVsnjNSgO1xWiWrX0Rrt/xXW5Zsl7vS2Y+yu8PL8KFWRzZhaVxfKA==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "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", @@ -12992,24 +12531,6 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/pify": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", - "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/pirates": { - "version": "4.0.6", - "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.6.tgz", - "integrity": "sha512-saLsH7WeYYPiD25LDuLRRY/i+6HaPYr6G1OUlN39otzkSTxKnubR9RTxS3/Kk50s1g2JTgFwWQDQyplC5/SHZg==", - "license": "MIT", - "engines": { - "node": ">= 6" - } - }, "node_modules/plimit-lit": { "version": "1.6.1", "resolved": "https://registry.npmjs.org/plimit-lit/-/plimit-lit-1.6.1.tgz", @@ -13036,6 +12557,7 @@ "version": "8.5.1", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.1.tgz", "integrity": "sha512-6oz2beyjc5VMn/KV1pPw8fliQkhBXrVn1Z3TVyqZxU8kZpzEKhBdmCFqI6ZbmGtamQvQGuU1sgPTk8ZrXDD7jQ==", + "dev": true, "funding": [ { "type": "opencollective", @@ -13060,121 +12582,6 @@ "node": "^10 || ^12 || >=14" } }, - "node_modules/postcss-import": { - "version": "15.1.0", - "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", - "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", - "license": "MIT", - "dependencies": { - "postcss-value-parser": "^4.0.0", - "read-cache": "^1.0.0", - "resolve": "^1.1.7" - }, - "engines": { - "node": ">=14.0.0" - }, - "peerDependencies": { - "postcss": "^8.0.0" - } - }, - "node_modules/postcss-js": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.0.1.tgz", - "integrity": "sha512-dDLF8pEO191hJMtlHFPRa8xsizHaM82MLfNkUHdUtVEV3tgTp5oj+8qbEqYM57SLfc74KSbw//4SeJma2LRVIw==", - "license": "MIT", - "dependencies": { - "camelcase-css": "^2.0.1" - }, - "engines": { - "node": "^12 || ^14 || >= 16" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - "peerDependencies": { - "postcss": "^8.4.21" - } - }, - "node_modules/postcss-load-config": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-4.0.2.tgz", - "integrity": "sha512-bSVhyJGL00wMVoPUzAVAnbEoWyqRxkjv64tUl427SKnPrENtq6hJwUojroMz2VB+Q1edmi4IfrAPpami5VVgMQ==", - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "lilconfig": "^3.0.0", - "yaml": "^2.3.4" - }, - "engines": { - "node": ">= 14" - }, - "peerDependencies": { - "postcss": ">=8.0.9", - "ts-node": ">=9.0.0" - }, - "peerDependenciesMeta": { - "postcss": { - "optional": true - }, - "ts-node": { - "optional": true - } - } - }, - "node_modules/postcss-nested": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", - "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "postcss-selector-parser": "^6.1.1" - }, - "engines": { - "node": ">=12.0" - }, - "peerDependencies": { - "postcss": "^8.2.14" - } - }, - "node_modules/postcss-selector-parser": { - "version": "6.1.2", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", - "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", - "license": "MIT", - "dependencies": { - "cssesc": "^3.0.0", - "util-deprecate": "^1.0.2" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/postcss-value-parser": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", - "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", - "license": "MIT" - }, "node_modules/prebuild-install": { "version": "7.1.3", "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", @@ -13211,9 +12618,9 @@ } }, "node_modules/prettier": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.3.3.tgz", - "integrity": "sha512-i2tDNA0O5IrMO757lfrdQZCc2jPNDVntV0m/+4whiDfWaTKfMNgR7Qz0NAeGz/nRqF4m5/6CLzbP4/liHt12Ew==", + "version": "3.5.3", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.5.3.tgz", + "integrity": "sha512-QQtaxnoDJeAkDvDKWCLiwIXkTgRhwYDEQCghU9Z6q03iyek/rxRh/2lC3HB7P8sWT2xC/y5JDctPLBIGzHKbhw==", "license": "MIT", "bin": { "prettier": "bin/prettier.cjs" @@ -13226,9 +12633,9 @@ } }, "node_modules/prismjs": { - "version": "1.29.0", - "resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.29.0.tgz", - "integrity": "sha512-Kx/1w86q/epKcmte75LNrEoT+lX8pBpavuAbvJWRXar7Hz8jrtF+e3vY751p0R8H9HdArwaCTNDDzHg/ScJK1Q==", + "version": "1.30.0", + "resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.30.0.tgz", + "integrity": "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==", "license": "MIT", "engines": { "node": ">=6" @@ -13430,26 +12837,26 @@ "license": "0BSD" }, "node_modules/react-email": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/react-email/-/react-email-3.0.4.tgz", - "integrity": "sha512-nXdo9P3V+qYSW6m5yN3XpFGhHb/bflX86m0EDQEqDIgayprj6InmBJoBnMSIyC5EP4tPtoAljlclJns4lJG/MQ==", + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/react-email/-/react-email-4.0.6.tgz", + "integrity": "sha512-RzMDZCRd2JFFkGljhBWNWGH2ti4Qnhcx03nR1uPW1vNBptqDJx/fxSJqzCDYEEpTkWPaEe2unHM4CdzRAI7awg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/core": "7.24.5", "@babel/parser": "7.24.5", + "@babel/traverse": "7.25.6", "chalk": "4.1.2", - "chokidar": "^4.0.1", + "chokidar": "4.0.3", "commander": "11.1.0", "debounce": "2.0.0", - "esbuild": "0.19.11", + "esbuild": "0.25.0", "glob": "10.3.4", "log-symbols": "4.1.0", "mime-types": "2.1.35", - "next": "15.0.4", + "next": "15.2.4", "normalize-path": "3.0.0", "ora": "5.4.1", - "socket.io": "4.8.0" + "socket.io": "4.8.1" }, "bin": { "email": "dist/cli/index.js" @@ -13459,9 +12866,9 @@ } }, "node_modules/react-email/node_modules/@esbuild/aix-ppc64": { - "version": "0.19.11", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.19.11.tgz", - "integrity": "sha512-FnzU0LyE3ySQk7UntJO4+qIiQgI7KoODnZg5xzXIrFJlKd2P2gwHsHY4927xj9y5PJmJSzULiUCWmv7iWnNa7g==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.0.tgz", + "integrity": "sha512-O7vun9Sf8DFjH2UtqK8Ku3LkquL9SZL8OLY1T5NZkA34+wG3OQF7cl4Ql8vdNzM6fzBbYfLaiRLIOZ+2FOCgBQ==", "cpu": [ "ppc64" ], @@ -13472,13 +12879,13 @@ "aix" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/react-email/node_modules/@esbuild/android-arm": { - "version": "0.19.11", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.19.11.tgz", - "integrity": "sha512-5OVapq0ClabvKvQ58Bws8+wkLCV+Rxg7tUVbo9xu034Nm536QTII4YzhaFriQ7rMrorfnFKUsArD2lqKbFY4vw==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.0.tgz", + "integrity": "sha512-PTyWCYYiU0+1eJKmw21lWtC+d08JDZPQ5g+kFyxP0V+es6VPPSUhM6zk8iImp2jbV6GwjX4pap0JFbUQN65X1g==", "cpu": [ "arm" ], @@ -13489,13 +12896,13 @@ "android" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/react-email/node_modules/@esbuild/android-arm64": { - "version": "0.19.11", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.19.11.tgz", - "integrity": "sha512-aiu7K/5JnLj//KOnOfEZ0D90obUkRzDMyqd/wNAUQ34m4YUPVhRZpnqKV9uqDGxT7cToSDnIHsGooyIczu9T+Q==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.0.tgz", + "integrity": "sha512-grvv8WncGjDSyUBjN9yHXNt+cq0snxXbDxy5pJtzMKGmmpPxeAmAhWxXI+01lU5rwZomDgD3kJwulEnhTRUd6g==", "cpu": [ "arm64" ], @@ -13506,13 +12913,13 @@ "android" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/react-email/node_modules/@esbuild/android-x64": { - "version": "0.19.11", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.19.11.tgz", - "integrity": "sha512-eccxjlfGw43WYoY9QgB82SgGgDbibcqyDTlk3l3C0jOVHKxrjdc9CTwDUQd0vkvYg5um0OH+GpxYvp39r+IPOg==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.0.tgz", + "integrity": "sha512-m/ix7SfKG5buCnxasr52+LI78SQ+wgdENi9CqyCXwjVR2X4Jkz+BpC3le3AoBPYTC9NHklwngVXvbJ9/Akhrfg==", "cpu": [ "x64" ], @@ -13523,13 +12930,13 @@ "android" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/react-email/node_modules/@esbuild/darwin-arm64": { - "version": "0.19.11", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.19.11.tgz", - "integrity": "sha512-ETp87DRWuSt9KdDVkqSoKoLFHYTrkyz2+65fj9nfXsaV3bMhTCjtQfw3y+um88vGRKRiF7erPrh/ZuIdLUIVxQ==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.0.tgz", + "integrity": "sha512-mVwdUb5SRkPayVadIOI78K7aAnPamoeFR2bT5nszFUZ9P8UpK4ratOdYbZZXYSqPKMHfS1wdHCJk1P1EZpRdvw==", "cpu": [ "arm64" ], @@ -13540,13 +12947,13 @@ "darwin" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/react-email/node_modules/@esbuild/darwin-x64": { - "version": "0.19.11", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.19.11.tgz", - "integrity": "sha512-fkFUiS6IUK9WYUO/+22omwetaSNl5/A8giXvQlcinLIjVkxwTLSktbF5f/kJMftM2MJp9+fXqZ5ezS7+SALp4g==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.0.tgz", + "integrity": "sha512-DgDaYsPWFTS4S3nWpFcMn/33ZZwAAeAFKNHNa1QN0rI4pUjgqf0f7ONmXf6d22tqTY+H9FNdgeaAa+YIFUn2Rg==", "cpu": [ "x64" ], @@ -13557,13 +12964,13 @@ "darwin" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/react-email/node_modules/@esbuild/freebsd-arm64": { - "version": "0.19.11", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.19.11.tgz", - "integrity": "sha512-lhoSp5K6bxKRNdXUtHoNc5HhbXVCS8V0iZmDvyWvYq9S5WSfTIHU2UGjcGt7UeS6iEYp9eeymIl5mJBn0yiuxA==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.0.tgz", + "integrity": "sha512-VN4ocxy6dxefN1MepBx/iD1dH5K8qNtNe227I0mnTRjry8tj5MRk4zprLEdG8WPyAPb93/e4pSgi1SoHdgOa4w==", "cpu": [ "arm64" ], @@ -13574,13 +12981,13 @@ "freebsd" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/react-email/node_modules/@esbuild/freebsd-x64": { - "version": "0.19.11", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.19.11.tgz", - "integrity": "sha512-JkUqn44AffGXitVI6/AbQdoYAq0TEullFdqcMY/PCUZ36xJ9ZJRtQabzMA+Vi7r78+25ZIBosLTOKnUXBSi1Kw==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.0.tgz", + "integrity": "sha512-mrSgt7lCh07FY+hDD1TxiTyIHyttn6vnjesnPoVDNmDfOmggTLXRv8Id5fNZey1gl/V2dyVK1VXXqVsQIiAk+A==", "cpu": [ "x64" ], @@ -13591,13 +12998,13 @@ "freebsd" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/react-email/node_modules/@esbuild/linux-arm": { - "version": "0.19.11", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.19.11.tgz", - "integrity": "sha512-3CRkr9+vCV2XJbjwgzjPtO8T0SZUmRZla+UL1jw+XqHZPkPgZiyWvbDvl9rqAN8Zl7qJF0O/9ycMtjU67HN9/Q==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.0.tgz", + "integrity": "sha512-vkB3IYj2IDo3g9xX7HqhPYxVkNQe8qTK55fraQyTzTX/fxaDtXiEnavv9geOsonh2Fd2RMB+i5cbhu2zMNWJwg==", "cpu": [ "arm" ], @@ -13608,13 +13015,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/react-email/node_modules/@esbuild/linux-arm64": { - "version": "0.19.11", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.19.11.tgz", - "integrity": "sha512-LneLg3ypEeveBSMuoa0kwMpCGmpu8XQUh+mL8XXwoYZ6Be2qBnVtcDI5azSvh7vioMDhoJFZzp9GWp9IWpYoUg==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.0.tgz", + "integrity": "sha512-9QAQjTWNDM/Vk2bgBl17yWuZxZNQIF0OUUuPZRKoDtqF2k4EtYbpyiG5/Dk7nqeK6kIJWPYldkOcBqjXjrUlmg==", "cpu": [ "arm64" ], @@ -13625,13 +13032,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/react-email/node_modules/@esbuild/linux-ia32": { - "version": "0.19.11", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.19.11.tgz", - "integrity": "sha512-caHy++CsD8Bgq2V5CodbJjFPEiDPq8JJmBdeyZ8GWVQMjRD0sU548nNdwPNvKjVpamYYVL40AORekgfIubwHoA==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.0.tgz", + "integrity": "sha512-43ET5bHbphBegyeqLb7I1eYn2P/JYGNmzzdidq/w0T8E2SsYL1U6un2NFROFRg1JZLTzdCoRomg8Rvf9M6W6Gg==", "cpu": [ "ia32" ], @@ -13642,13 +13049,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/react-email/node_modules/@esbuild/linux-loong64": { - "version": "0.19.11", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.19.11.tgz", - "integrity": "sha512-ppZSSLVpPrwHccvC6nQVZaSHlFsvCQyjnvirnVjbKSHuE5N24Yl8F3UwYUUR1UEPaFObGD2tSvVKbvR+uT1Nrg==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.0.tgz", + "integrity": "sha512-fC95c/xyNFueMhClxJmeRIj2yrSMdDfmqJnyOY4ZqsALkDrrKJfIg5NTMSzVBr5YW1jf+l7/cndBfP3MSDpoHw==", "cpu": [ "loong64" ], @@ -13659,13 +13066,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/react-email/node_modules/@esbuild/linux-mips64el": { - "version": "0.19.11", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.19.11.tgz", - "integrity": "sha512-B5x9j0OgjG+v1dF2DkH34lr+7Gmv0kzX6/V0afF41FkPMMqaQ77pH7CrhWeR22aEeHKaeZVtZ6yFwlxOKPVFyg==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.0.tgz", + "integrity": "sha512-nkAMFju7KDW73T1DdH7glcyIptm95a7Le8irTQNO/qtkoyypZAnjchQgooFUDQhNAy4iu08N79W4T4pMBwhPwQ==", "cpu": [ "mips64el" ], @@ -13676,13 +13083,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/react-email/node_modules/@esbuild/linux-ppc64": { - "version": "0.19.11", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.19.11.tgz", - "integrity": "sha512-MHrZYLeCG8vXblMetWyttkdVRjQlQUb/oMgBNurVEnhj4YWOr4G5lmBfZjHYQHHN0g6yDmCAQRR8MUHldvvRDA==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.0.tgz", + "integrity": "sha512-NhyOejdhRGS8Iwv+KKR2zTq2PpysF9XqY+Zk77vQHqNbo/PwZCzB5/h7VGuREZm1fixhs4Q/qWRSi5zmAiO4Fw==", "cpu": [ "ppc64" ], @@ -13693,13 +13100,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/react-email/node_modules/@esbuild/linux-riscv64": { - "version": "0.19.11", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.19.11.tgz", - "integrity": "sha512-f3DY++t94uVg141dozDu4CCUkYW+09rWtaWfnb3bqe4w5NqmZd6nPVBm+qbz7WaHZCoqXqHz5p6CM6qv3qnSSQ==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.0.tgz", + "integrity": "sha512-5S/rbP5OY+GHLC5qXp1y/Mx//e92L1YDqkiBbO9TQOvuFXM+iDqUNG5XopAnXoRH3FjIUDkeGcY1cgNvnXp/kA==", "cpu": [ "riscv64" ], @@ -13710,13 +13117,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/react-email/node_modules/@esbuild/linux-s390x": { - "version": "0.19.11", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.19.11.tgz", - "integrity": "sha512-A5xdUoyWJHMMlcSMcPGVLzYzpcY8QP1RtYzX5/bS4dvjBGVxdhuiYyFwp7z74ocV7WDc0n1harxmpq2ePOjI0Q==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.0.tgz", + "integrity": "sha512-XM2BFsEBz0Fw37V0zU4CXfcfuACMrppsMFKdYY2WuTS3yi8O1nFOhil/xhKTmE1nPmVyvQJjJivgDT+xh8pXJA==", "cpu": [ "s390x" ], @@ -13727,13 +13134,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/react-email/node_modules/@esbuild/linux-x64": { - "version": "0.19.11", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.19.11.tgz", - "integrity": "sha512-grbyMlVCvJSfxFQUndw5mCtWs5LO1gUlwP4CDi4iJBbVpZcqLVT29FxgGuBJGSzyOxotFG4LoO5X+M1350zmPA==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.0.tgz", + "integrity": "sha512-9yl91rHw/cpwMCNytUDxwj2XjFpxML0y9HAOH9pNVQDpQrBxHy01Dx+vaMu0N1CKa/RzBD2hB4u//nfc+Sd3Cw==", "cpu": [ "x64" ], @@ -13744,13 +13151,30 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" + } + }, + "node_modules/react-email/node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.0.tgz", + "integrity": "sha512-RuG4PSMPFfrkH6UwCAqBzauBWTygTvb1nxWasEJooGSJ/NwRw7b2HOwyRTQIU97Hq37l3npXoZGYMy3b3xYvPw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" } }, "node_modules/react-email/node_modules/@esbuild/netbsd-x64": { - "version": "0.19.11", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.19.11.tgz", - "integrity": "sha512-13jvrQZJc3P230OhU8xgwUnDeuC/9egsjTkXN49b3GcS5BKvJqZn86aGM8W9pd14Kd+u7HuFBMVtrNGhh6fHEQ==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.0.tgz", + "integrity": "sha512-jl+qisSB5jk01N5f7sPCsBENCOlPiS/xptD5yxOx2oqQfyourJwIKLRA2yqWdifj3owQZCL2sn6o08dBzZGQzA==", "cpu": [ "x64" ], @@ -13761,13 +13185,30 @@ "netbsd" ], "engines": { - "node": ">=12" + "node": ">=18" + } + }, + "node_modules/react-email/node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.0.tgz", + "integrity": "sha512-21sUNbq2r84YE+SJDfaQRvdgznTD8Xc0oc3p3iW/a1EVWeNj/SdUCbm5U0itZPQYRuRTW20fPMWMpcrciH2EJw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" } }, "node_modules/react-email/node_modules/@esbuild/openbsd-x64": { - "version": "0.19.11", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.19.11.tgz", - "integrity": "sha512-ysyOGZuTp6SNKPE11INDUeFVVQFrhcNDVUgSQVDzqsqX38DjhPEPATpid04LCoUr2WXhQTEZ8ct/EgJCUDpyNw==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.0.tgz", + "integrity": "sha512-2gwwriSMPcCFRlPlKx3zLQhfN/2WjJ2NSlg5TKLQOJdV0mSxIcYNTMhk3H3ulL/cak+Xj0lY1Ym9ysDV1igceg==", "cpu": [ "x64" ], @@ -13778,13 +13219,13 @@ "openbsd" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/react-email/node_modules/@esbuild/sunos-x64": { - "version": "0.19.11", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.19.11.tgz", - "integrity": "sha512-Hf+Sad9nVwvtxy4DXCZQqLpgmRTQqyFyhT3bZ4F2XlJCjxGmRFF0Shwn9rzhOYRB61w9VMXUkxlBy56dk9JJiQ==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.0.tgz", + "integrity": "sha512-bxI7ThgLzPrPz484/S9jLlvUAHYMzy6I0XiU1ZMeAEOBcS0VePBFxh1JjTQt3Xiat5b6Oh4x7UC7IwKQKIJRIg==", "cpu": [ "x64" ], @@ -13795,13 +13236,13 @@ "sunos" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/react-email/node_modules/@esbuild/win32-arm64": { - "version": "0.19.11", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.19.11.tgz", - "integrity": "sha512-0P58Sbi0LctOMOQbpEOvOL44Ne0sqbS0XWHMvvrg6NE5jQ1xguCSSw9jQeUk2lfrXYsKDdOe6K+oZiwKPilYPQ==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.0.tgz", + "integrity": "sha512-ZUAc2YK6JW89xTbXvftxdnYy3m4iHIkDtK3CLce8wg8M2L+YZhIvO1DKpxrd0Yr59AeNNkTiic9YLf6FTtXWMw==", "cpu": [ "arm64" ], @@ -13812,13 +13253,13 @@ "win32" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/react-email/node_modules/@esbuild/win32-ia32": { - "version": "0.19.11", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.19.11.tgz", - "integrity": "sha512-6YOrWS+sDJDmshdBIQU+Uoyh7pQKrdykdefC1avn76ss5c+RN6gut3LZA4E2cH5xUEp5/cA0+YxRaVtRAb0xBg==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.0.tgz", + "integrity": "sha512-eSNxISBu8XweVEWG31/JzjkIGbGIJN/TrRoiSVZwZ6pkC6VX4Im/WV2cz559/TXLcYbcrDN8JtKgd9DJVIo8GA==", "cpu": [ "ia32" ], @@ -13829,13 +13270,13 @@ "win32" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/react-email/node_modules/@esbuild/win32-x64": { - "version": "0.19.11", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.19.11.tgz", - "integrity": "sha512-vfkhltrjCAb603XaFhqhAF4LGDi2M4OrCRrFusyQ+iTLQ/o60QQXxc9cZC/FFpihBI9N1Grn6SMKVJ4KP7Fuiw==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.0.tgz", + "integrity": "sha512-ZENoHJBxA20C2zFzh6AI4fT6RraMzjYw4xKWemRTRmRVtN9c5DcH9r/f2ihEkMjOW5eGgrwCslG/+Y/3bL+DHQ==", "cpu": [ "x64" ], @@ -13846,160 +13287,7 @@ "win32" ], "engines": { - "node": ">=12" - } - }, - "node_modules/react-email/node_modules/@next/env": { - "version": "15.0.4", - "resolved": "https://registry.npmjs.org/@next/env/-/env-15.0.4.tgz", - "integrity": "sha512-WNRvtgnRVDD4oM8gbUcRc27IAhaL4eXQ/2ovGbgLnPGUvdyDr8UdXP4Q/IBDdAdojnD2eScryIDirv0YUCjUVw==", - "dev": true, - "license": "MIT" - }, - "node_modules/react-email/node_modules/@next/swc-darwin-arm64": { - "version": "15.0.4", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.0.4.tgz", - "integrity": "sha512-QecQXPD0yRHxSXWL5Ff80nD+A56sUXZG9koUsjWJwA2Z0ZgVQfuy7gd0/otjxoOovPVHR2eVEvPMHbtZP+pf9w==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/react-email/node_modules/@next/swc-darwin-x64": { - "version": "15.0.4", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.0.4.tgz", - "integrity": "sha512-pb7Bye3y1Og3PlCtnz2oO4z+/b3pH2/HSYkLbL0hbVuTGil7fPen8/3pyyLjdiTLcFJ+ymeU3bck5hd4IPFFCA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/react-email/node_modules/@next/swc-linux-arm64-gnu": { - "version": "15.0.4", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.0.4.tgz", - "integrity": "sha512-12oSaBFjGpB227VHzoXF3gJoK2SlVGmFJMaBJSu5rbpaoT5OjP5OuCLuR9/jnyBF1BAWMs/boa6mLMoJPRriMA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/react-email/node_modules/@next/swc-linux-arm64-musl": { - "version": "15.0.4", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.0.4.tgz", - "integrity": "sha512-QARO88fR/a+wg+OFC3dGytJVVviiYFEyjc/Zzkjn/HevUuJ7qGUUAUYy5PGVWY1YgTzeRYz78akQrVQ8r+sMjw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/react-email/node_modules/@next/swc-linux-x64-gnu": { - "version": "15.0.4", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.0.4.tgz", - "integrity": "sha512-Z50b0gvYiUU1vLzfAMiChV8Y+6u/T2mdfpXPHraqpypP7yIT2UV9YBBhcwYkxujmCvGEcRTVWOj3EP7XW/wUnw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/react-email/node_modules/@next/swc-linux-x64-musl": { - "version": "15.0.4", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.0.4.tgz", - "integrity": "sha512-7H9C4FAsrTAbA/ENzvFWsVytqRYhaJYKa2B3fyQcv96TkOGVMcvyS6s+sj4jZlacxxTcn7ygaMXUPkEk7b78zw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/react-email/node_modules/@next/swc-win32-arm64-msvc": { - "version": "15.0.4", - "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.0.4.tgz", - "integrity": "sha512-Z/v3WV5xRaeWlgJzN9r4PydWD8sXV35ywc28W63i37G2jnUgScA4OOgS8hQdiXLxE3gqfSuHTicUhr7931OXPQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/react-email/node_modules/@next/swc-win32-x64-msvc": { - "version": "15.0.4", - "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.0.4.tgz", - "integrity": "sha512-NGLchGruagh8lQpDr98bHLyWJXOBSmkEAfK980OiNBa7vNm6PsNoPvzTfstT78WyOeMRQphEQ455rggd7Eo+Dw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/react-email/node_modules/@swc/helpers": { - "version": "0.5.13", - "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.13.tgz", - "integrity": "sha512-UoKGxQ3r5kYI9dALKJapMmuK+1zWM/H17Z1+iwnNmzcJRnfFuevZs375TA5rW31pu4BS4NoSy1fRsexDXfWn5w==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.4.0" + "node": ">=18" } }, "node_modules/react-email/node_modules/brace-expansion": { @@ -14013,9 +13301,9 @@ } }, "node_modules/react-email/node_modules/esbuild": { - "version": "0.19.11", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.19.11.tgz", - "integrity": "sha512-HJ96Hev2hX/6i5cDVwcqiJBBtuo9+FeIJOtZ9W1kA5M6AMJRHUZlpYZ1/SbEwtO0ioNAW8rUooVpC/WehY2SfA==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.0.tgz", + "integrity": "sha512-BXq5mqc8ltbaN34cDqWuYKyNhX8D/Z0J1xdtdQ8UcIIIyJyz+ZMKUt58tF3SrZ85jcfN/PZYhjR5uDQAYNVbuw==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -14023,32 +13311,34 @@ "esbuild": "bin/esbuild" }, "engines": { - "node": ">=12" + "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.19.11", - "@esbuild/android-arm": "0.19.11", - "@esbuild/android-arm64": "0.19.11", - "@esbuild/android-x64": "0.19.11", - "@esbuild/darwin-arm64": "0.19.11", - "@esbuild/darwin-x64": "0.19.11", - "@esbuild/freebsd-arm64": "0.19.11", - "@esbuild/freebsd-x64": "0.19.11", - "@esbuild/linux-arm": "0.19.11", - "@esbuild/linux-arm64": "0.19.11", - "@esbuild/linux-ia32": "0.19.11", - "@esbuild/linux-loong64": "0.19.11", - "@esbuild/linux-mips64el": "0.19.11", - "@esbuild/linux-ppc64": "0.19.11", - "@esbuild/linux-riscv64": "0.19.11", - "@esbuild/linux-s390x": "0.19.11", - "@esbuild/linux-x64": "0.19.11", - "@esbuild/netbsd-x64": "0.19.11", - "@esbuild/openbsd-x64": "0.19.11", - "@esbuild/sunos-x64": "0.19.11", - "@esbuild/win32-arm64": "0.19.11", - "@esbuild/win32-ia32": "0.19.11", - "@esbuild/win32-x64": "0.19.11" + "@esbuild/aix-ppc64": "0.25.0", + "@esbuild/android-arm": "0.25.0", + "@esbuild/android-arm64": "0.25.0", + "@esbuild/android-x64": "0.25.0", + "@esbuild/darwin-arm64": "0.25.0", + "@esbuild/darwin-x64": "0.25.0", + "@esbuild/freebsd-arm64": "0.25.0", + "@esbuild/freebsd-x64": "0.25.0", + "@esbuild/linux-arm": "0.25.0", + "@esbuild/linux-arm64": "0.25.0", + "@esbuild/linux-ia32": "0.25.0", + "@esbuild/linux-loong64": "0.25.0", + "@esbuild/linux-mips64el": "0.25.0", + "@esbuild/linux-ppc64": "0.25.0", + "@esbuild/linux-riscv64": "0.25.0", + "@esbuild/linux-s390x": "0.25.0", + "@esbuild/linux-x64": "0.25.0", + "@esbuild/netbsd-arm64": "0.25.0", + "@esbuild/netbsd-x64": "0.25.0", + "@esbuild/openbsd-arm64": "0.25.0", + "@esbuild/openbsd-x64": "0.25.0", + "@esbuild/sunos-x64": "0.25.0", + "@esbuild/win32-arm64": "0.25.0", + "@esbuild/win32-ia32": "0.25.0", + "@esbuild/win32-x64": "0.25.0" } }, "node_modules/react-email/node_modules/glob": { @@ -14116,61 +13406,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/react-email/node_modules/next": { - "version": "15.0.4", - "resolved": "https://registry.npmjs.org/next/-/next-15.0.4.tgz", - "integrity": "sha512-nuy8FH6M1FG0lktGotamQDCXhh5hZ19Vo0ht1AOIQWrYJLP598TIUagKtvJrfJ5AGwB/WmDqkKaKhMpVifvGPA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@next/env": "15.0.4", - "@swc/counter": "0.1.3", - "@swc/helpers": "0.5.13", - "busboy": "1.6.0", - "caniuse-lite": "^1.0.30001579", - "postcss": "8.4.31", - "styled-jsx": "5.1.6" - }, - "bin": { - "next": "dist/bin/next" - }, - "engines": { - "node": "^18.18.0 || ^19.8.0 || >= 20.0.0" - }, - "optionalDependencies": { - "@next/swc-darwin-arm64": "15.0.4", - "@next/swc-darwin-x64": "15.0.4", - "@next/swc-linux-arm64-gnu": "15.0.4", - "@next/swc-linux-arm64-musl": "15.0.4", - "@next/swc-linux-x64-gnu": "15.0.4", - "@next/swc-linux-x64-musl": "15.0.4", - "@next/swc-win32-arm64-msvc": "15.0.4", - "@next/swc-win32-x64-msvc": "15.0.4", - "sharp": "^0.33.5" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.1.0", - "@playwright/test": "^1.41.2", - "babel-plugin-react-compiler": "*", - "react": "^18.2.0 || 19.0.0-rc-66855b96-20241106 || ^19.0.0", - "react-dom": "^18.2.0 || 19.0.0-rc-66855b96-20241106 || ^19.0.0", - "sass": "^1.3.0" - }, - "peerDependenciesMeta": { - "@opentelemetry/api": { - "optional": true - }, - "@playwright/test": { - "optional": true - }, - "babel-plugin-react-compiler": { - "optional": true - }, - "sass": { - "optional": true - } - } - }, "node_modules/react-email/node_modules/path-scurry": { "version": "1.11.1", "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", @@ -14188,35 +13423,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/react-email/node_modules/postcss": { - "version": "8.4.31", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", - "integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/postcss" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "nanoid": "^3.3.6", - "picocolors": "^1.0.0", - "source-map-js": "^1.0.2" - }, - "engines": { - "node": "^10 || ^12 || >=14" - } - }, "node_modules/react-hook-form": { "version": "7.54.2", "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.54.2.tgz", @@ -14332,15 +13538,6 @@ } } }, - "node_modules/read-cache": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", - "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", - "license": "MIT", - "dependencies": { - "pify": "^2.3.0" - } - }, "node_modules/readable-stream": { "version": "3.6.2", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", @@ -14813,6 +14010,19 @@ "node": ">=8" } }, + "node_modules/shell-quote": { + "version": "1.8.2", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.2.tgz", + "integrity": "sha512-AzqKpGKjrj7EM6rKVQEPpB288oCfnrEIuyoT9cyF4nmGa7V8Zk6f7RRqYisX8X9m+Q7bd632aZW4ky7EhbQztA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/side-channel": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", @@ -14957,9 +14167,9 @@ } }, "node_modules/socket.io": { - "version": "4.8.0", - "resolved": "https://registry.npmjs.org/socket.io/-/socket.io-4.8.0.tgz", - "integrity": "sha512-8U6BEgGjQOfGz3HHTYaC/L1GaxDCJ/KM0XTkJly0EhZ5U/du9uNEZy4ZgYzEzIqlx2CMm25CrCqr1ck899eLNA==", + "version": "4.8.1", + "resolved": "https://registry.npmjs.org/socket.io/-/socket.io-4.8.1.tgz", + "integrity": "sha512-oZ7iUCxph8WYRHHcjBEc9unw3adt5CmSNlppj/5Q4k2RIrhl8Z5yY2Xr4j9zj0+wzVZ0bxmYoGSzKJnRl6A4yg==", "dev": true, "license": "MIT", "dependencies": { @@ -15404,118 +14614,6 @@ } } }, - "node_modules/sucrase": { - "version": "3.35.0", - "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.0.tgz", - "integrity": "sha512-8EbVDiu9iN/nESwxeSxDKe0dunta1GOlHufmSSXxMD2z2/tMZpDMpvXQGsc+ajGo8y2uYUmixaSRUc/QPoQ0GA==", - "license": "MIT", - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.2", - "commander": "^4.0.0", - "glob": "^10.3.10", - "lines-and-columns": "^1.1.6", - "mz": "^2.7.0", - "pirates": "^4.0.1", - "ts-interface-checker": "^0.1.9" - }, - "bin": { - "sucrase": "bin/sucrase", - "sucrase-node": "bin/sucrase-node" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - } - }, - "node_modules/sucrase/node_modules/brace-expansion": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", - "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/sucrase/node_modules/commander": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", - "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", - "license": "MIT", - "engines": { - "node": ">= 6" - } - }, - "node_modules/sucrase/node_modules/glob": { - "version": "10.4.5", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", - "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", - "license": "ISC", - "dependencies": { - "foreground-child": "^3.1.0", - "jackspeak": "^3.1.2", - "minimatch": "^9.0.4", - "minipass": "^7.1.2", - "package-json-from-dist": "^1.0.0", - "path-scurry": "^1.11.1" - }, - "bin": { - "glob": "dist/esm/bin.mjs" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/sucrase/node_modules/jackspeak": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", - "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", - "license": "BlueOak-1.0.0", - "dependencies": { - "@isaacs/cliui": "^8.0.2" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - }, - "optionalDependencies": { - "@pkgjs/parseargs": "^0.11.0" - } - }, - "node_modules/sucrase/node_modules/lru-cache": { - "version": "10.4.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", - "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", - "license": "ISC" - }, - "node_modules/sucrase/node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/sucrase/node_modules/path-scurry": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", - "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", - "license": "BlueOak-1.0.0", - "dependencies": { - "lru-cache": "^10.2.0", - "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" - }, - "engines": { - "node": ">=16 || 14 >=14.18" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -15540,6 +14638,30 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/swagger-ui-dist": { + "version": "5.21.0", + "resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.21.0.tgz", + "integrity": "sha512-E0K3AB6HvQd8yQNSMR7eE5bk+323AUxjtCz/4ZNKiahOlPhPJxqn3UPIGs00cyY/dhrTDJ61L7C/a8u6zhGrZg==", + "license": "Apache-2.0", + "dependencies": { + "@scarf/scarf": "=1.4.0" + } + }, + "node_modules/swagger-ui-express": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/swagger-ui-express/-/swagger-ui-express-5.0.1.tgz", + "integrity": "sha512-SrNU3RiBGTLLmFU8GIJdOdanJTl4TOmT27tt3bWWHppqYmAZ6IDuEuBvMU6nZq0zLEe6b/1rACXCgLZqO6ZfrA==", + "license": "MIT", + "dependencies": { + "swagger-ui-dist": ">=5.0.0" + }, + "engines": { + "node": ">= v0.10.32" + }, + "peerDependencies": { + "express": ">=4.0.0 || >=5.0.0-beta" + } + }, "node_modules/tailwind-merge": { "version": "2.6.0", "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-2.6.0.tgz", @@ -15551,138 +14673,10 @@ } }, "node_modules/tailwindcss": { - "version": "3.4.17", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.17.tgz", - "integrity": "sha512-w33E2aCvSDP0tW9RZuNXadXlkHXqFzSkQew/aIa2i/Sj8fThxwovwlXHSPXTbAHwEIhBFXAedUhP2tueAKP8Og==", - "license": "MIT", - "dependencies": { - "@alloc/quick-lru": "^5.2.0", - "arg": "^5.0.2", - "chokidar": "^3.6.0", - "didyoumean": "^1.2.2", - "dlv": "^1.1.3", - "fast-glob": "^3.3.2", - "glob-parent": "^6.0.2", - "is-glob": "^4.0.3", - "jiti": "^1.21.6", - "lilconfig": "^3.1.3", - "micromatch": "^4.0.8", - "normalize-path": "^3.0.0", - "object-hash": "^3.0.0", - "picocolors": "^1.1.1", - "postcss": "^8.4.47", - "postcss-import": "^15.1.0", - "postcss-js": "^4.0.1", - "postcss-load-config": "^4.0.2", - "postcss-nested": "^6.2.0", - "postcss-selector-parser": "^6.1.2", - "resolve": "^1.22.8", - "sucrase": "^3.35.0" - }, - "bin": { - "tailwind": "lib/cli.js", - "tailwindcss": "lib/cli.js" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/tailwindcss-animate": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/tailwindcss-animate/-/tailwindcss-animate-1.0.7.tgz", - "integrity": "sha512-bl6mpH3T7I3UFxuvDEXLxy/VuFxBk5bbzplh7tXI68mwMokNYd1t9qPBHlnyTwfa4JGC4zP516I1hYYtQ/vspA==", - "license": "MIT", - "peerDependencies": { - "tailwindcss": ">=3.0.0 || insiders" - } - }, - "node_modules/tailwindcss/node_modules/chokidar": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", - "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", - "license": "MIT", - "dependencies": { - "anymatch": "~3.1.2", - "braces": "~3.0.2", - "glob-parent": "~5.1.2", - "is-binary-path": "~2.1.0", - "is-glob": "~4.0.1", - "normalize-path": "~3.0.0", - "readdirp": "~3.6.0" - }, - "engines": { - "node": ">= 8.10.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - }, - "optionalDependencies": { - "fsevents": "~2.3.2" - } - }, - "node_modules/tailwindcss/node_modules/chokidar/node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.1" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/tailwindcss/node_modules/fast-glob": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", - "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", - "license": "MIT", - "dependencies": { - "@nodelib/fs.stat": "^2.0.2", - "@nodelib/fs.walk": "^1.2.3", - "glob-parent": "^5.1.2", - "merge2": "^1.3.0", - "micromatch": "^4.0.8" - }, - "engines": { - "node": ">=8.6.0" - } - }, - "node_modules/tailwindcss/node_modules/fast-glob/node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.1" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/tailwindcss/node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", - "license": "MIT", - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/tailwindcss/node_modules/readdirp": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", - "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", - "license": "MIT", - "dependencies": { - "picomatch": "^2.2.1" - }, - "engines": { - "node": ">=8.10.0" - } + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.4.tgz", + "integrity": "sha512-1ZIUqtPITFbv/DxRmDr5/agPqJwF69d24m9qmM1939TJehgY539CtzeZRjbLt5G6fSy/7YqqYsfvoTEw9xUI2A==", + "license": "MIT" }, "node_modules/tapable": { "version": "2.2.1", @@ -15727,27 +14721,6 @@ "integrity": "sha512-uuVGNWzgJ4yhRaNSiubPY7OjISw4sw4E5Uv0wbjp+OzcbmVU/rsT8ujgcXJhn9ypzsgr5vlzpPqP+MBBKcGvbg==", "license": "MIT" }, - "node_modules/thenify": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", - "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", - "license": "MIT", - "dependencies": { - "any-promise": "^1.0.0" - } - }, - "node_modules/thenify-all": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", - "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", - "license": "MIT", - "dependencies": { - "thenify": ">= 3.1.0 < 4" - }, - "engines": { - "node": ">=0.8" - } - }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -15790,12 +14763,6 @@ "typescript": ">=4.8.4" } }, - "node_modules/ts-interface-checker": { - "version": "0.1.13", - "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", - "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", - "license": "Apache-2.0" - }, "node_modules/tsc-alias": { "version": "1.8.10", "resolved": "https://registry.npmjs.org/tsc-alias/-/tsc-alias-1.8.10.tgz", @@ -15918,14 +14885,23 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "license": "0BSD" }, + "node_modules/tsscmp": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/tsscmp/-/tsscmp-1.0.6.tgz", + "integrity": "sha512-LxhtAkPDTkVCMQjt2h6eBVY28KCjikZqZfMcC15YBeNjkgUpdCfBu5HoiOTDu86v6smE8yOjyEktJ8hlbANHQA==", + "license": "MIT", + "engines": { + "node": ">=0.6.x" + } + }, "node_modules/tsx": { - "version": "4.19.2", - "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.19.2.tgz", - "integrity": "sha512-pOUl6Vo2LUq/bSa8S5q7b91cgNSjctn9ugq/+Mvow99qW6x/UZYwzxy/3NmqoT66eHYfCVvFvACC58UBPFf28g==", + "version": "4.19.3", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.19.3.tgz", + "integrity": "sha512-4H8vUNGNjQ4V2EOoGw005+c+dGuPSnhpPBPHBtsZdGZBk/iJb4kguGlPWaZTZ3q5nMtFOEsY0nRDlh9PJyd6SQ==", "dev": true, "license": "MIT", "dependencies": { - "esbuild": "~0.23.0", + "esbuild": "~0.25.0", "get-tsconfig": "^4.7.5" }, "bin": { @@ -15938,454 +14914,6 @@ "fsevents": "~2.3.3" } }, - "node_modules/tsx/node_modules/@esbuild/aix-ppc64": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.23.1.tgz", - "integrity": "sha512-6VhYk1diRqrhBAqpJEdjASR/+WVRtfjpqKuNw11cLiaWpAT/Uu+nokB+UJnevzy/P9C/ty6AOe0dwueMrGh/iQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/android-arm": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.23.1.tgz", - "integrity": "sha512-uz6/tEy2IFm9RYOyvKl88zdzZfwEfKZmnX9Cj1BHjeSGNuGLuMD1kR8y5bteYmwqKm1tj8m4cb/aKEorr6fHWQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/android-arm64": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.23.1.tgz", - "integrity": "sha512-xw50ipykXcLstLeWH7WRdQuysJqejuAGPd30vd1i5zSyKK3WE+ijzHmLKxdiCMtH1pHz78rOg0BKSYOSB/2Khw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/android-x64": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.23.1.tgz", - "integrity": "sha512-nlN9B69St9BwUoB+jkyU090bru8L0NA3yFvAd7k8dNsVH8bi9a8cUAUSEcEEgTp2z3dbEDGJGfP6VUnkQnlReg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/darwin-arm64": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.23.1.tgz", - "integrity": "sha512-YsS2e3Wtgnw7Wq53XXBLcV6JhRsEq8hkfg91ESVadIrzr9wO6jJDMZnCQbHm1Guc5t/CdDiFSSfWP58FNuvT3Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/darwin-x64": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.23.1.tgz", - "integrity": "sha512-aClqdgTDVPSEGgoCS8QDG37Gu8yc9lTHNAQlsztQ6ENetKEO//b8y31MMu2ZaPbn4kVsIABzVLXYLhCGekGDqw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/freebsd-arm64": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.23.1.tgz", - "integrity": "sha512-h1k6yS8/pN/NHlMl5+v4XPfikhJulk4G+tKGFIOwURBSFzE8bixw1ebjluLOjfwtLqY0kewfjLSrO6tN2MgIhA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/freebsd-x64": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.23.1.tgz", - "integrity": "sha512-lK1eJeyk1ZX8UklqFd/3A60UuZ/6UVfGT2LuGo3Wp4/z7eRTRYY+0xOu2kpClP+vMTi9wKOfXi2vjUpO1Ro76g==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/linux-arm": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.23.1.tgz", - "integrity": "sha512-CXXkzgn+dXAPs3WBwE+Kvnrf4WECwBdfjfeYHpMeVxWE0EceB6vhWGShs6wi0IYEqMSIzdOF1XjQ/Mkm5d7ZdQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/linux-arm64": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.23.1.tgz", - "integrity": "sha512-/93bf2yxencYDnItMYV/v116zff6UyTjo4EtEQjUBeGiVpMmffDNUyD9UN2zV+V3LRV3/on4xdZ26NKzn6754g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/linux-ia32": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.23.1.tgz", - "integrity": "sha512-VTN4EuOHwXEkXzX5nTvVY4s7E/Krz7COC8xkftbbKRYAl96vPiUssGkeMELQMOnLOJ8k3BY1+ZY52tttZnHcXQ==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/linux-loong64": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.23.1.tgz", - "integrity": "sha512-Vx09LzEoBa5zDnieH8LSMRToj7ir/Jeq0Gu6qJ/1GcBq9GkfoEAoXvLiW1U9J1qE/Y/Oyaq33w5p2ZWrNNHNEw==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/linux-mips64el": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.23.1.tgz", - "integrity": "sha512-nrFzzMQ7W4WRLNUOU5dlWAqa6yVeI0P78WKGUo7lg2HShq/yx+UYkeNSE0SSfSure0SqgnsxPvmAUu/vu0E+3Q==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/linux-ppc64": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.23.1.tgz", - "integrity": "sha512-dKN8fgVqd0vUIjxuJI6P/9SSSe/mB9rvA98CSH2sJnlZ/OCZWO1DJvxj8jvKTfYUdGfcq2dDxoKaC6bHuTlgcw==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/linux-riscv64": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.23.1.tgz", - "integrity": "sha512-5AV4Pzp80fhHL83JM6LoA6pTQVWgB1HovMBsLQ9OZWLDqVY8MVobBXNSmAJi//Csh6tcY7e7Lny2Hg1tElMjIA==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/linux-s390x": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.23.1.tgz", - "integrity": "sha512-9ygs73tuFCe6f6m/Tb+9LtYxWR4c9yg7zjt2cYkjDbDpV/xVn+68cQxMXCjUpYwEkze2RcU/rMnfIXNRFmSoDw==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/linux-x64": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.23.1.tgz", - "integrity": "sha512-EV6+ovTsEXCPAp58g2dD68LxoP/wK5pRvgy0J/HxPGB009omFPv3Yet0HiaqvrIrgPTBuC6wCH1LTOY91EO5hQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/netbsd-x64": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.23.1.tgz", - "integrity": "sha512-aevEkCNu7KlPRpYLjwmdcuNz6bDFiE7Z8XC4CPqExjTvrHugh28QzUXVOZtiYghciKUacNktqxdpymplil1beA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/openbsd-arm64": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.23.1.tgz", - "integrity": "sha512-3x37szhLexNA4bXhLrCC/LImN/YtWis6WXr1VESlfVtVeoFJBRINPJ3f0a/6LV8zpikqoUg4hyXw0sFBt5Cr+Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/openbsd-x64": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.23.1.tgz", - "integrity": "sha512-aY2gMmKmPhxfU+0EdnN+XNtGbjfQgwZj43k8G3fyrDM/UdZww6xrWxmDkuz2eCZchqVeABjV5BpildOrUbBTqA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/sunos-x64": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.23.1.tgz", - "integrity": "sha512-RBRT2gqEl0IKQABT4XTj78tpk9v7ehp+mazn2HbUeZl1YMdaGAQqhapjGTCe7uw7y0frDi4gS0uHzhvpFuI1sA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/win32-arm64": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.23.1.tgz", - "integrity": "sha512-4O+gPR5rEBe2FpKOVyiJ7wNDPA8nGzDuJ6gN4okSA1gEOYZ67N8JPk58tkWtdtPeLz7lBnY6I5L3jdsr3S+A6A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/win32-ia32": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.23.1.tgz", - "integrity": "sha512-BcaL0Vn6QwCwre3Y717nVHZbAa4UBEigzFm6VdsVdT/MbZ38xoj1X9HPkZhbmaBGUD1W8vxAfffbDe8bA6AKnQ==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/win32-x64": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.23.1.tgz", - "integrity": "sha512-BHpFFeslkWrXWyUPnbKm+xYYVYruCinGcftSBaa8zoF9hZO4BcSCFUvHVTtzpIY6YzUnYtuEhZ+C9iEXjxnasg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/esbuild": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.23.1.tgz", - "integrity": "sha512-VVNz/9Sa0bs5SELtn3f7qhJCDPCF5oMEl5cO9/SSinpE9hbPVvxbd572HH5AKiP7WD8INO53GgfDDhRjkylHEg==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.23.1", - "@esbuild/android-arm": "0.23.1", - "@esbuild/android-arm64": "0.23.1", - "@esbuild/android-x64": "0.23.1", - "@esbuild/darwin-arm64": "0.23.1", - "@esbuild/darwin-x64": "0.23.1", - "@esbuild/freebsd-arm64": "0.23.1", - "@esbuild/freebsd-x64": "0.23.1", - "@esbuild/linux-arm": "0.23.1", - "@esbuild/linux-arm64": "0.23.1", - "@esbuild/linux-ia32": "0.23.1", - "@esbuild/linux-loong64": "0.23.1", - "@esbuild/linux-mips64el": "0.23.1", - "@esbuild/linux-ppc64": "0.23.1", - "@esbuild/linux-riscv64": "0.23.1", - "@esbuild/linux-s390x": "0.23.1", - "@esbuild/linux-x64": "0.23.1", - "@esbuild/netbsd-x64": "0.23.1", - "@esbuild/openbsd-arm64": "0.23.1", - "@esbuild/openbsd-x64": "0.23.1", - "@esbuild/sunos-x64": "0.23.1", - "@esbuild/win32-arm64": "0.23.1", - "@esbuild/win32-ia32": "0.23.1", - "@esbuild/win32-x64": "0.23.1" - } - }, "node_modules/tunnel-agent": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", @@ -16398,6 +14926,15 @@ "node": "*" } }, + "node_modules/tw-animate-css": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/tw-animate-css/-/tw-animate-css-1.2.8.tgz", + "integrity": "sha512-AxSnYRvyFnAiZCUndS3zQZhNfV/B77ZhJ+O7d3K6wfg/jKJY+yv6ahuyXwnyaYA9UdLqnpCwhTRv9pPTBnPR2g==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Wombosvideo" + } + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -16544,37 +15081,6 @@ "node": ">= 0.8" } }, - "node_modules/update-browserslist-db": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.2.tgz", - "integrity": "sha512-PPypAm5qvlD7XMZC3BujecnaOxwhrtoFR+Dqkk5Aa/6DssiH0ibKoketaj9w8LP7Bont1rYeoV5plxD7RTEPRg==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/browserslist" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "escalade": "^3.2.0", - "picocolors": "^1.1.1" - }, - "bin": { - "update-browserslist-db": "cli.js" - }, - "peerDependencies": { - "browserslist": ">= 4.21.0" - } - }, "node_modules/uri-js": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", @@ -16651,6 +15157,19 @@ "node": ">= 0.4.0" } }, + "node_modules/uuid": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz", + "integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/esm/bin/uuid" + } + }, "node_modules/vary": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", @@ -16989,17 +15508,10 @@ "node": ">=10" } }, - "node_modules/yallist": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", - "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", - "dev": true, - "license": "ISC" - }, "node_modules/yaml": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.7.0.tgz", - "integrity": "sha512-+hSoy/QHluxmC9kCIJyL/uyFmLmc+e5CFR5Wa+bpIhIj85LVb9ZH2nVnqrHoSvKogwODv0ClqZkmiSSaIH5LTA==", + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.7.1.tgz", + "integrity": "sha512-10ULxpnOCQXxJvBgxsn9ptjq6uviG/htZKk9veJGhlqn3w/DxQ631zFF+nlQXLwmImeS5amR2dl2U8sg6U9jsQ==", "license": "ISC", "bin": { "yaml": "bin.mjs" diff --git a/package.json b/package.json index 08cb73aa..f2ce2cd4 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "email": "email dev --dir server/emails/templates --port 3005" }, "dependencies": { + "@asteasolutions/zod-to-openapi": "^7.3.0", "@hookform/resolvers": "3.9.1", "@node-rs/argon2": "2.0.2", "@oslojs/crypto": "1.0.1", @@ -32,6 +33,7 @@ "@radix-ui/react-icons": "1.3.2", "@radix-ui/react-label": "2.1.1", "@radix-ui/react-popover": "1.1.4", + "@radix-ui/react-progress": "^1.1.4", "@radix-ui/react-radio-group": "1.2.2", "@radix-ui/react-select": "2.1.4", "@radix-ui/react-separator": "1.1.1", @@ -39,16 +41,23 @@ "@radix-ui/react-switch": "1.1.2", "@radix-ui/react-tabs": "1.1.2", "@radix-ui/react-toast": "1.2.4", - "@react-email/components": "0.0.31", + "@react-email/components": "0.0.36", + "@react-email/render": "^1.0.6", "@react-email/tailwind": "1.0.4", + "@tailwindcss/forms": "^0.5.10", "@tanstack/react-table": "8.20.6", - "axios": "1.7.9", + "arctic": "^3.6.0", + "axios": "1.8.4", "better-sqlite3": "11.7.0", + "canvas-confetti": "1.9.3", "class-variance-authority": "0.7.1", "clsx": "2.1.1", "cmdk": "1.0.4", + "cookie": "^1.0.2", "cookie-parser": "1.4.7", + "cookies": "^0.9.1", "cors": "2.8.5", + "crypto-js": "^4.2.0", "drizzle-orm": "0.38.3", "eslint": "9.17.0", "eslint-config-next": "15.1.3", @@ -59,10 +68,12 @@ "http-errors": "2.0.0", "i": "^0.3.7", "input-otp": "1.4.1", + "jmespath": "^0.16.0", "js-yaml": "4.1.0", + "jsonwebtoken": "^9.0.2", "lucide-react": "0.469.0", "moment": "2.30.1", - "next": "15.1.3", + "next": "15.2.4", "next-themes": "0.4.4", "node-cache": "5.1.2", "node-fetch": "3.3.2", @@ -77,8 +88,10 @@ "react-icons": "^5.5.0", "rebuild": "0.1.2", "semver": "7.6.3", + "swagger-ui-express": "^5.0.1", "tailwind-merge": "2.6.0", - "tailwindcss-animate": "1.0.7", + "tw-animate-css": "^1.2.5", + "uuid": "^11.1.0", "vaul": "1.1.2", "winston": "3.17.0", "winston-daily-rotate-file": "5.0.0", @@ -89,26 +102,31 @@ "devDependencies": { "@dotenvx/dotenvx": "1.32.0", "@esbuild-plugins/tsconfig-paths": "0.1.2", + "@tailwindcss/postcss": "^4.1.3", "@types/better-sqlite3": "7.6.12", "@types/cookie-parser": "1.4.8", "@types/cors": "2.8.17", + "@types/crypto-js": "^4.2.2", "@types/express": "5.0.0", + "@types/jmespath": "^0.15.2", "@types/js-yaml": "4.0.9", + "@types/jsonwebtoken": "^9.0.9", "@types/node": "^22", "@types/nodemailer": "6.4.17", - "@types/react": "19.0.2", - "@types/react-dom": "19.0.2", + "@types/react": "19.1.1", + "@types/react-dom": "19.1.2", "@types/semver": "7.5.8", + "@types/swagger-ui-express": "^4.1.8", "@types/ws": "8.5.13", "@types/yargs": "17.0.33", - "drizzle-kit": "0.30.1", - "esbuild": "0.24.2", - "esbuild-node-externals": "1.16.0", + "drizzle-kit": "0.30.6", + "esbuild": "0.25.2", + "esbuild-node-externals": "1.18.0", "postcss": "^8", - "react-email": "3.0.4", - "tailwindcss": "^3.4.17", + "react-email": "4.0.6", + "tailwindcss": "^4.1.4", "tsc-alias": "1.8.10", - "tsx": "4.19.2", + "tsx": "4.19.3", "typescript": "^5", "yargs": "17.7.2" }, diff --git a/postcss.config.mjs b/postcss.config.mjs index 6f943477..8dde23ef 100644 --- a/postcss.config.mjs +++ b/postcss.config.mjs @@ -1,7 +1,7 @@ /** @type {import('postcss-load-config').Config} */ const config = { plugins: { - tailwindcss: {}, + '@tailwindcss/postcss': {}, }, }; diff --git a/public/screenshots/collage.png b/public/screenshots/collage.png index 74fe6deb..c791e7ea 100644 Binary files a/public/screenshots/collage.png and b/public/screenshots/collage.png differ diff --git a/public/screenshots/hero.png b/public/screenshots/hero.png new file mode 100644 index 00000000..4e321ee1 Binary files /dev/null and b/public/screenshots/hero.png differ diff --git a/public/screenshots/resources.png b/public/screenshots/resources.png deleted file mode 100644 index 2ee2c6e2..00000000 Binary files a/public/screenshots/resources.png and /dev/null differ diff --git a/public/screenshots/sites.png b/public/screenshots/sites.png deleted file mode 100644 index aa7294f5..00000000 Binary files a/public/screenshots/sites.png and /dev/null differ diff --git a/server/auth/actions.ts b/server/auth/actions.ts index dc56ea94..e83031a1 100644 --- a/server/auth/actions.ts +++ b/server/auth/actions.ts @@ -6,6 +6,9 @@ import createHttpError from "http-errors"; import HttpCode from "@server/types/HttpCode"; export enum ActionsEnum { + createOrgUser = "createOrgUser", + listOrgs = "listOrgs", + listUserOrgs = "listUserOrgs", createOrg = "createOrg", // deleteOrg = "deleteOrg", getOrg = "getOrg", @@ -32,6 +35,8 @@ export enum ActionsEnum { listRoles = "listRoles", updateRole = "updateRole", inviteUser = "inviteUser", + listInvitations = "listInvitations", + removeInvitation = "removeInvitation", removeUser = "removeUser", listUsers = "listUsers", listSiteRoles = "listSiteRoles", @@ -64,6 +69,23 @@ export enum ActionsEnum { updateResourceRule = "updateResourceRule", listOrgDomains = "listOrgDomains", createNewt = "createNewt", + createIdp = "createIdp", + updateIdp = "updateIdp", + deleteIdp = "deleteIdp", + listIdps = "listIdps", + getIdp = "getIdp", + createIdpOrg = "createIdpOrg", + deleteIdpOrg = "deleteIdpOrg", + listIdpOrgs = "listIdpOrgs", + updateIdpOrg = "updateIdpOrg", + checkOrgId = "checkOrgId", + createApiKey = "createApiKey", + deleteApiKey = "deleteApiKey", + setApiKeyActions = "setApiKeyActions", + setApiKeyOrgs = "setApiKeyOrgs", + listApiKeyActions = "listApiKeyActions", + listApiKeys = "listApiKeys", + getApiKey = "getApiKey" } export async function checkUserActionPermission( diff --git a/server/db/schemas/hostMeta.ts b/server/db/schemas/hostMeta.ts new file mode 100644 index 00000000..e69de29b diff --git a/server/db/schemas/schema.ts b/server/db/schemas/schema.ts index 77872a02..ebbc0ce3 100644 --- a/server/db/schemas/schema.ts +++ b/server/db/schemas/schema.ts @@ -77,7 +77,12 @@ export const resources = sqliteTable("resources", { applyRules: integer("applyRules", { mode: "boolean" }) .notNull() .default(false), - enabled: integer("enabled", { mode: "boolean" }).notNull().default(true) + enabled: integer("enabled", { mode: "boolean" }).notNull().default(true), + stickySession: integer("stickySession", { mode: "boolean" }) + .notNull() + .default(false), + tlsServerName: text("tlsServerName"), + setHostHeader: text("setHostHeader") }); export const targets = sqliteTable("targets", { @@ -106,8 +111,14 @@ export const exitNodes = sqliteTable("exitNodes", { export const users = sqliteTable("user", { userId: text("id").primaryKey(), - email: text("email").notNull().unique(), - passwordHash: text("passwordHash").notNull(), + email: text("email"), + username: text("username").notNull(), + name: text("name"), + type: text("type").notNull(), // "internal", "oidc" + idpId: integer("idpId").references(() => idp.idpId, { + onDelete: "cascade" + }), + passwordHash: text("passwordHash"), twoFactorEnabled: integer("twoFactorEnabled", { mode: "boolean" }) .notNull() .default(false), @@ -415,6 +426,89 @@ export const supporterKey = sqliteTable("supporterKey", { valid: integer("valid", { mode: "boolean" }).notNull().default(false) }); +// Identity Providers +export const idp = sqliteTable("idp", { + idpId: integer("idpId").primaryKey({ autoIncrement: true }), + name: text("name").notNull(), + type: text("type").notNull(), + defaultRoleMapping: text("defaultRoleMapping"), + defaultOrgMapping: text("defaultOrgMapping"), + autoProvision: integer("autoProvision", { + mode: "boolean" + }) + .notNull() + .default(false) +}); + +// Identity Provider OAuth Configuration +export const idpOidcConfig = sqliteTable("idpOidcConfig", { + idpOauthConfigId: integer("idpOauthConfigId").primaryKey({ + autoIncrement: true + }), + idpId: integer("idpId") + .notNull() + .references(() => idp.idpId, { onDelete: "cascade" }), + clientId: text("clientId").notNull(), + clientSecret: text("clientSecret").notNull(), + authUrl: text("authUrl").notNull(), + tokenUrl: text("tokenUrl").notNull(), + identifierPath: text("identifierPath").notNull(), + emailPath: text("emailPath"), + namePath: text("namePath"), + scopes: text("scopes").notNull() +}); + +export const licenseKey = sqliteTable("licenseKey", { + licenseKeyId: text("licenseKeyId").primaryKey().notNull(), + instanceId: text("instanceId").notNull(), + token: text("token").notNull() +}); + +export const hostMeta = sqliteTable("hostMeta", { + hostMetaId: text("hostMetaId").primaryKey().notNull(), + createdAt: integer("createdAt").notNull() +}); + +export const apiKeys = sqliteTable("apiKeys", { + apiKeyId: text("apiKeyId").primaryKey(), + name: text("name").notNull(), + apiKeyHash: text("apiKeyHash").notNull(), + lastChars: text("lastChars").notNull(), + createdAt: text("dateCreated").notNull(), + isRoot: integer("isRoot", { mode: "boolean" }).notNull().default(false) +}); + +export const apiKeyActions = sqliteTable("apiKeyActions", { + apiKeyId: text("apiKeyId") + .notNull() + .references(() => apiKeys.apiKeyId, { onDelete: "cascade" }), + actionId: text("actionId") + .notNull() + .references(() => actions.actionId, { onDelete: "cascade" }) +}); + +export const apiKeyOrg = sqliteTable("apiKeyOrg", { + apiKeyId: text("apiKeyId") + .notNull() + .references(() => apiKeys.apiKeyId, { onDelete: "cascade" }), + orgId: text("orgId") + .references(() => orgs.orgId, { + onDelete: "cascade" + }) + .notNull() +}); + +export const idpOrg = sqliteTable("idpOrg", { + idpId: integer("idpId") + .notNull() + .references(() => idp.idpId, { onDelete: "cascade" }), + orgId: text("orgId") + .notNull() + .references(() => orgs.orgId, { onDelete: "cascade" }), + roleMapping: text("roleMapping"), + orgMapping: text("orgMapping") +}); + export type Org = InferSelectModel; export type User = InferSelectModel; export type Site = InferSelectModel; @@ -450,3 +544,7 @@ export type VersionMigration = InferSelectModel; export type ResourceRule = InferSelectModel; export type Domain = InferSelectModel; export type SupporterKey = InferSelectModel; +export type Idp = InferSelectModel; +export type ApiKey = InferSelectModel; +export type ApiKeyAction = InferSelectModel; +export type ApiKeyOrg = InferSelectModel; diff --git a/server/extendZod.ts b/server/extendZod.ts new file mode 100644 index 00000000..513992e8 --- /dev/null +++ b/server/extendZod.ts @@ -0,0 +1,6 @@ +import { extendZodWithOpenApi } from "@asteasolutions/zod-to-openapi"; +import { z } from "zod"; + +extendZodWithOpenApi(z); + +export default function extendZod() {} diff --git a/server/index.ts b/server/index.ts index b3dc0443..4c16caaa 100644 --- a/server/index.ts +++ b/server/index.ts @@ -1,8 +1,12 @@ +import "./extendZod.ts"; + import { runSetupFunctions } from "./setup"; import { createApiServer } from "./apiServer"; import { createNextServer } from "./nextServer"; import { createInternalServer } from "./internalServer"; -import { Session, User, UserOrg } from "./db/schemas/schema"; +import { ApiKey, ApiKeyOrg, Session, User, UserOrg } from "./db/schemas"; +import { createIntegrationApiServer } from "./integrationApiServer"; +import license from "./license/license.js"; async function startServers() { await runSetupFunctions(); @@ -12,10 +16,16 @@ async function startServers() { const internalServer = createInternalServer(); const nextServer = await createNextServer(); + let integrationServer; + if (await license.isUnlocked()) { + integrationServer = createIntegrationApiServer(); + } + return { apiServer, nextServer, internalServer, + integrationServer }; } @@ -23,9 +33,11 @@ async function startServers() { declare global { namespace Express { interface Request { + apiKey?: ApiKey; user?: User; session?: Session; userOrg?: UserOrg; + apiKeyOrg?: ApiKeyOrg; userOrgRoleId?: number; userOrgId?: string; userOrgIds?: string[]; diff --git a/server/integrationApiServer.ts b/server/integrationApiServer.ts new file mode 100644 index 00000000..ff5dca51 --- /dev/null +++ b/server/integrationApiServer.ts @@ -0,0 +1,110 @@ +// This file is licensed under the Fossorial Commercial License. +// Unauthorized use, copying, modification, or distribution is strictly prohibited. +// +// Copyright (c) 2025 Fossorial LLC. All rights reserved. + +import express from "express"; +import cors from "cors"; +import cookieParser from "cookie-parser"; +import config from "@server/lib/config"; +import logger from "@server/logger"; +import { + errorHandlerMiddleware, + notFoundMiddleware, + verifyValidLicense +} from "@server/middlewares"; +import { authenticated, unauthenticated } from "@server/routers/integration"; +import { logIncomingMiddleware } from "./middlewares/logIncoming"; +import helmet from "helmet"; +import swaggerUi from "swagger-ui-express"; +import { OpenApiGeneratorV3 } from "@asteasolutions/zod-to-openapi"; +import { registry } from "./openApi"; + +const dev = process.env.ENVIRONMENT !== "prod"; +const externalPort = config.getRawConfig().server.integration_port; + +export function createIntegrationApiServer() { + const apiServer = express(); + + apiServer.use(verifyValidLicense); + + if (config.getRawConfig().server.trust_proxy) { + apiServer.set("trust proxy", 1); + } + + apiServer.use(cors()); + + if (!dev) { + apiServer.use(helmet()); + } + + apiServer.use(cookieParser()); + apiServer.use(express.json()); + + apiServer.use( + "/v1/docs", + swaggerUi.serve, + swaggerUi.setup(getOpenApiDocumentation()) + ); + + // API routes + const prefix = `/v1`; + apiServer.use(logIncomingMiddleware); + apiServer.use(prefix, unauthenticated); + apiServer.use(prefix, authenticated); + + // Error handling + apiServer.use(notFoundMiddleware); + apiServer.use(errorHandlerMiddleware); + + // Create HTTP server + const httpServer = apiServer.listen(externalPort, (err?: any) => { + if (err) throw err; + logger.info( + `Integration API server is running on http://localhost:${externalPort}` + ); + }); + + return httpServer; +} + +function getOpenApiDocumentation() { + const bearerAuth = registry.registerComponent( + "securitySchemes", + "Bearer Auth", + { + type: "http", + scheme: "bearer" + } + ); + + for (const def of registry.definitions) { + if (def.type === "route") { + def.route.security = [ + { + [bearerAuth.name]: [] + } + ]; + } + } + + registry.registerPath({ + method: "get", + path: "/", + description: "Health check", + tags: [], + request: {}, + responses: {} + }); + + const generator = new OpenApiGeneratorV3(registry.definitions); + + return generator.generateDocument({ + openapi: "3.0.0", + info: { + version: "v1", + title: "Pangolin Integration API" + }, + servers: [{ url: "/v1" }] + }); +} diff --git a/server/lib/config.ts b/server/lib/config.ts index f6f4c447..a19b4a2a 100644 --- a/server/lib/config.ts +++ b/server/lib/config.ts @@ -12,8 +12,8 @@ import { passwordSchema } from "@server/auth/passwordSchema"; import stoi from "./stoi"; import db from "@server/db"; import { SupporterKey, supporterKey } from "@server/db/schemas"; -import { suppressDeprecationWarnings } from "moment"; import { eq } from "drizzle-orm"; +import { license } from "@server/license/license"; const portSchema = z.number().positive().gt(0).lte(65535); @@ -60,6 +60,10 @@ const configSchema = z.object({ } ), server: z.object({ + integration_port: portSchema + .optional() + .transform(stoi) + .pipe(portSchema.optional()), external_port: portSchema.optional().transform(stoi).pipe(portSchema), internal_port: portSchema.optional().transform(stoi).pipe(portSchema), next_port: portSchema.optional().transform(stoi).pipe(portSchema), @@ -91,7 +95,12 @@ const configSchema = z.object({ credentials: z.boolean().optional() }) .optional(), - trust_proxy: z.boolean().optional().default(true) + trust_proxy: z.boolean().optional().default(true), + secret: z + .string() + .optional() + .transform(getEnvOrYaml("SERVER_SECRET")) + .pipe(z.string().min(8)) }), traefik: z.object({ http_entrypoint: z.string(), @@ -255,13 +264,20 @@ export class Config { : "false"; process.env.DASHBOARD_URL = parsedConfig.data.app.dashboard_url; - if (!this.isDev) { - this.checkSupporterKey(); - } + license.setServerSecret(parsedConfig.data.server.secret); + + this.checkKeyStatus(); this.rawConfig = parsedConfig.data; } + private async checkKeyStatus() { + const licenseStatus = await license.check(); + if (!licenseStatus.isHostLicensed) { + this.checkSupporterKey(); + } + } + public getRawConfig() { return this.rawConfig; } @@ -307,7 +323,7 @@ export class Config { try { const response = await fetch( - "https://api.dev.fossorial.io/api/v1/license/validate", + "https://api.fossorial.io/api/v1/license/validate", { method: "POST", headers: { diff --git a/server/lib/consts.ts b/server/lib/consts.ts index 7c9892bf..94d2716e 100644 --- a/server/lib/consts.ts +++ b/server/lib/consts.ts @@ -2,7 +2,7 @@ import path from "path"; import { fileURLToPath } from "url"; // This is a placeholder value replaced by the build process -export const APP_VERSION = "1.2.0"; +export const APP_VERSION = "1.3.0"; export const __FILENAME = fileURLToPath(import.meta.url); export const __DIRNAME = path.dirname(__FILENAME); diff --git a/server/lib/crypto.ts b/server/lib/crypto.ts new file mode 100644 index 00000000..bd7df85a --- /dev/null +++ b/server/lib/crypto.ts @@ -0,0 +1,12 @@ +import CryptoJS from "crypto-js"; + +export function encrypt(value: string, key: string): string { + const ciphertext = CryptoJS.AES.encrypt(value, key).toString(); + return ciphertext; +} + +export function decrypt(encryptedValue: string, key: string): string { + const bytes = CryptoJS.AES.decrypt(encryptedValue, key); + const originalText = bytes.toString(CryptoJS.enc.Utf8); + return originalText; +} diff --git a/server/lib/idp/generateRedirectUrl.ts b/server/lib/idp/generateRedirectUrl.ts new file mode 100644 index 00000000..4eea973e --- /dev/null +++ b/server/lib/idp/generateRedirectUrl.ts @@ -0,0 +1,8 @@ +import config from "@server/lib/config"; + +export function generateOidcRedirectUrl(idpId: number) { + const dashboardUrl = config.getRawConfig().app.dashboard_url; + const redirectPath = `/auth/idp/${idpId}/oidc/callback`; + const redirectUrl = new URL(redirectPath, dashboardUrl).toString(); + return redirectUrl; +} diff --git a/server/lib/schemas.ts b/server/lib/schemas.ts index f4b7daf3..cf1b40c8 100644 --- a/server/lib/schemas.ts +++ b/server/lib/schemas.ts @@ -9,3 +9,10 @@ export const subdomainSchema = z .min(1, "Subdomain must be at least 1 character long") .transform((val) => val.toLowerCase()); +export const tlsNameSchema = z + .string() + .regex( + /^(?!:\/\/)([a-zA-Z0-9-_]+\.)*[a-zA-Z0-9-_]+$|^$/, + "Invalid subdomain format" + ) + .transform((val) => val.toLowerCase()); \ No newline at end of file diff --git a/server/license/license.ts b/server/license/license.ts new file mode 100644 index 00000000..e97b8f50 --- /dev/null +++ b/server/license/license.ts @@ -0,0 +1,493 @@ +// This file is licensed under the Fossorial Commercial License. +// Unauthorized use, copying, modification, or distribution is strictly prohibited. +// +// Copyright (c) 2025 Fossorial LLC. All rights reserved. + +import db from "@server/db"; +import { hostMeta, licenseKey, sites } from "@server/db/schemas"; +import logger from "@server/logger"; +import NodeCache from "node-cache"; +import { validateJWT } from "./licenseJwt"; +import { count, eq } from "drizzle-orm"; +import moment from "moment"; +import { setHostMeta } from "@server/setup/setHostMeta"; +import { encrypt, decrypt } from "@server/lib/crypto"; + +const keyTypes = ["HOST", "SITES"] as const; +type KeyType = (typeof keyTypes)[number]; + +const keyTiers = ["PROFESSIONAL", "ENTERPRISE"] as const; +type KeyTier = (typeof keyTiers)[number]; + +export type LicenseStatus = { + isHostLicensed: boolean; // Are there any license keys? + isLicenseValid: boolean; // Is the license key valid? + hostId: string; // Host ID + maxSites?: number; + usedSites?: number; + tier?: KeyTier; +}; + +export type LicenseKeyCache = { + licenseKey: string; + licenseKeyEncrypted: string; + valid: boolean; + iat?: Date; + type?: KeyType; + tier?: KeyTier; + numSites?: number; +}; + +type ActivateLicenseKeyAPIResponse = { + data: { + instanceId: string; + }; + success: boolean; + error: string; + message: string; + status: number; +}; + +type ValidateLicenseAPIResponse = { + data: { + licenseKeys: { + [key: string]: string; + }; + }; + success: boolean; + error: string; + message: string; + status: number; +}; + +type TokenPayload = { + valid: boolean; + type: KeyType; + tier: KeyTier; + quantity: number; + terminateAt: string; // ISO + iat: number; // Issued at +}; + +export class License { + private phoneHomeInterval = 6 * 60 * 60; // 6 hours = 6 * 60 * 60 = 21600 seconds + private validationServerUrl = + "https://api.fossorial.io/api/v1/license/professional/validate"; + private activationServerUrl = + "https://api.fossorial.io/api/v1/license/professional/activate"; + + private statusCache = new NodeCache({ stdTTL: this.phoneHomeInterval }); + private licenseKeyCache = new NodeCache(); + + private ephemeralKey!: string; + private statusKey = "status"; + private serverSecret!: string; + + private publicKey = `-----BEGIN PUBLIC KEY----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAx9RKc8cw+G8r7h/xeozF +FNkRDggQfYO6Ae+EWHGujZ9WYAZ10spLh9F/zoLhhr3XhsjpoRXwMfgNuO5HstWf +CYM20I0l7EUUMWEyWd4tZLd+5XQ4jY5xWOCWyFJAGQSp7flcRmxdfde+l+xg9eKl +apbY84aVp09/GqM96hCS+CsQZrhohu/aOqYVB/eAhF01qsbmiZ7Y3WtdhTldveYt +h4mZWGmjf8d/aEgePf/tk1gp0BUxf+Ae5yqoAqU+6aiFbjJ7q1kgxc18PWFGfE9y +zSk+OZk887N5ThQ52154+oOUCMMR2Y3t5OH1hVZod51vuY2u5LsQXsf+87PwB91y +LQIDAQAB +-----END PUBLIC KEY-----`; + + constructor(private hostId: string) { + this.ephemeralKey = Buffer.from( + JSON.stringify({ ts: new Date().toISOString() }) + ).toString("base64"); + + setInterval( + async () => { + await this.check(); + }, + 1000 * 60 * 60 + ); // 1 hour = 60 * 60 = 3600 seconds + } + + public listKeys(): LicenseKeyCache[] { + const keys = this.licenseKeyCache.keys(); + return keys.map((key) => { + return this.licenseKeyCache.get(key)!; + }); + } + + public setServerSecret(secret: string) { + this.serverSecret = secret; + } + + public async forceRecheck() { + this.statusCache.flushAll(); + this.licenseKeyCache.flushAll(); + + return await this.check(); + } + + public async isUnlocked(): Promise { + const status = await this.check(); + if (status.isHostLicensed) { + if (status.isLicenseValid) { + return true; + } + } + return false; + } + + public async check(): Promise { + // Set used sites + const [siteCount] = await db + .select({ + value: count() + }) + .from(sites); + + const status: LicenseStatus = { + hostId: this.hostId, + isHostLicensed: true, + isLicenseValid: false, + maxSites: undefined, + usedSites: siteCount.value + }; + + try { + if (this.statusCache.has(this.statusKey)) { + const res = this.statusCache.get("status") as LicenseStatus; + res.usedSites = status.usedSites; + return res; + } + + // Invalidate all + this.licenseKeyCache.flushAll(); + + const allKeysRes = await db.select().from(licenseKey); + + if (allKeysRes.length === 0) { + status.isHostLicensed = false; + return status; + } + + let foundHostKey = false; + // Validate stored license keys + for (const key of allKeysRes) { + try { + // Decrypt the license key and token + const decryptedKey = decrypt( + key.licenseKeyId, + this.serverSecret + ); + const decryptedToken = decrypt( + key.token, + this.serverSecret + ); + + const payload = validateJWT( + decryptedToken, + this.publicKey + ); + + this.licenseKeyCache.set(decryptedKey, { + licenseKey: decryptedKey, + licenseKeyEncrypted: key.licenseKeyId, + valid: payload.valid, + type: payload.type, + tier: payload.tier, + numSites: payload.quantity, + iat: new Date(payload.iat * 1000) + }); + + if (payload.type === "HOST") { + foundHostKey = true; + } + } catch (e) { + logger.error( + `Error validating license key: ${key.licenseKeyId}` + ); + logger.error(e); + + this.licenseKeyCache.set( + key.licenseKeyId, + { + licenseKey: key.licenseKeyId, + licenseKeyEncrypted: key.licenseKeyId, + valid: false + } + ); + } + } + + if (!foundHostKey && allKeysRes.length) { + logger.debug("No host license key found"); + status.isHostLicensed = false; + } + + const keys = allKeysRes.map((key) => ({ + licenseKey: decrypt(key.licenseKeyId, this.serverSecret), + instanceId: decrypt(key.instanceId, this.serverSecret) + })); + + let apiResponse: ValidateLicenseAPIResponse | undefined; + try { + // Phone home to validate license keys + apiResponse = await this.phoneHome(keys); + + if (!apiResponse?.success) { + throw new Error(apiResponse?.error); + } + } catch (e) { + logger.error("Error communicating with license server:"); + logger.error(e); + } + + logger.debug("Validate response", apiResponse); + + // Check and update all license keys with server response + for (const key of keys) { + try { + const cached = this.licenseKeyCache.get( + key.licenseKey + )!; + const licenseKeyRes = + apiResponse?.data?.licenseKeys[key.licenseKey]; + + if (!apiResponse || !licenseKeyRes) { + logger.debug( + `No response from server for license key: ${key.licenseKey}` + ); + if (cached.iat) { + const exp = moment(cached.iat) + .add(7, "days") + .toDate(); + if (exp > new Date()) { + logger.debug( + `Using cached license key: ${key.licenseKey}, valid ${cached.valid}` + ); + continue; + } + } + + logger.debug( + `Can't trust license key: ${key.licenseKey}` + ); + cached.valid = false; + this.licenseKeyCache.set( + key.licenseKey, + cached + ); + continue; + } + + const payload = validateJWT( + licenseKeyRes, + this.publicKey + ); + cached.valid = payload.valid; + cached.type = payload.type; + cached.tier = payload.tier; + cached.numSites = payload.quantity; + cached.iat = new Date(payload.iat * 1000); + + // Encrypt the updated token before storing + const encryptedKey = encrypt( + key.licenseKey, + this.serverSecret + ); + const encryptedToken = encrypt( + licenseKeyRes, + this.serverSecret + ); + + await db + .update(licenseKey) + .set({ + token: encryptedToken + }) + .where(eq(licenseKey.licenseKeyId, encryptedKey)); + + this.licenseKeyCache.set( + key.licenseKey, + cached + ); + } catch (e) { + logger.error(`Error validating license key: ${key}`); + logger.error(e); + } + } + + // Compute host status + for (const key of keys) { + const cached = this.licenseKeyCache.get( + key.licenseKey + )!; + + logger.debug("Checking key", cached); + + if (cached.type === "HOST") { + status.isLicenseValid = cached.valid; + status.tier = cached.tier; + } + + if (!cached.valid) { + continue; + } + + if (!status.maxSites) { + status.maxSites = 0; + } + + status.maxSites += cached.numSites || 0; + } + } catch (error) { + logger.error("Error checking license status:"); + logger.error(error); + } + + this.statusCache.set(this.statusKey, status); + return status; + } + + public async activateLicenseKey(key: string) { + // Encrypt the license key before storing + const encryptedKey = encrypt(key, this.serverSecret); + + const [existingKey] = await db + .select() + .from(licenseKey) + .where(eq(licenseKey.licenseKeyId, encryptedKey)) + .limit(1); + + if (existingKey) { + throw new Error("License key already exists"); + } + + let instanceId: string | undefined; + try { + // Call activate + const apiResponse = await fetch(this.activationServerUrl, { + method: "POST", + headers: { + "Content-Type": "application/json" + }, + body: JSON.stringify({ + licenseKey: key, + instanceName: this.hostId + }) + }); + + const data = await apiResponse.json(); + + if (!data.success) { + throw new Error(`${data.message || data.error}`); + } + + const response = data as ActivateLicenseKeyAPIResponse; + + if (!response.data) { + throw new Error("No response from server"); + } + + if (!response.data.instanceId) { + throw new Error("No instance ID in response"); + } + + instanceId = response.data.instanceId; + } catch (error) { + throw Error(`Error activating license key: ${error}`); + } + + // Phone home to validate license key + const keys = [ + { + licenseKey: key, + instanceId: instanceId! + } + ]; + + let validateResponse: ValidateLicenseAPIResponse; + try { + validateResponse = await this.phoneHome(keys); + + if (!validateResponse) { + throw new Error("No response from server"); + } + + if (!validateResponse.success) { + throw new Error(validateResponse.error); + } + + // Validate the license key + const licenseKeyRes = validateResponse.data.licenseKeys[key]; + if (!licenseKeyRes) { + throw new Error("Invalid license key"); + } + + const payload = validateJWT( + licenseKeyRes, + this.publicKey + ); + + if (!payload.valid) { + throw new Error("Invalid license key"); + } + + const encryptedToken = encrypt(licenseKeyRes, this.serverSecret); + // Encrypt the instanceId before storing + const encryptedInstanceId = encrypt(instanceId!, this.serverSecret); + + // Store the license key in the database + await db.insert(licenseKey).values({ + licenseKeyId: encryptedKey, + token: encryptedToken, + instanceId: encryptedInstanceId + }); + } catch (error) { + throw Error(`Error validating license key: ${error}`); + } + + // Invalidate the cache and re-compute the status + return await this.forceRecheck(); + } + + private async phoneHome( + keys: { + licenseKey: string; + instanceId: string; + }[] + ): Promise { + // Decrypt the instanceIds before sending to the server + const decryptedKeys = keys.map((key) => ({ + licenseKey: key.licenseKey, + instanceId: key.instanceId + ? decrypt(key.instanceId, this.serverSecret) + : key.instanceId + })); + + const response = await fetch(this.validationServerUrl, { + method: "POST", + headers: { + "Content-Type": "application/json" + }, + body: JSON.stringify({ + licenseKeys: decryptedKeys, + ephemeralKey: this.ephemeralKey, + instanceName: this.hostId + }) + }); + + const data = await response.json(); + + return data as ValidateLicenseAPIResponse; + } +} + +await setHostMeta(); + +const [info] = await db.select().from(hostMeta).limit(1); + +if (!info) { + throw new Error("Host information not found"); +} + +export const license = new License(info.hostMetaId); + +export default license; diff --git a/server/license/licenseJwt.ts b/server/license/licenseJwt.ts new file mode 100644 index 00000000..ed7f4a0a --- /dev/null +++ b/server/license/licenseJwt.ts @@ -0,0 +1,114 @@ +// This file is licensed under the Fossorial Commercial License. +// Unauthorized use, copying, modification, or distribution is strictly prohibited. +// +// Copyright (c) 2025 Fossorial LLC. All rights reserved. + +import * as crypto from "crypto"; + +/** + * Validates a JWT using a public key + * @param token - The JWT to validate + * @param publicKey - The public key used for verification (PEM format) + * @returns The decoded payload if validation succeeds, throws an error otherwise + */ +function validateJWT( + token: string, + publicKey: string +): Payload { + // Split the JWT into its three parts + const parts = token.split("."); + if (parts.length !== 3) { + throw new Error("Invalid JWT format"); + } + + const [encodedHeader, encodedPayload, signature] = parts; + + // Decode the header to get the algorithm + const header = JSON.parse(Buffer.from(encodedHeader, "base64").toString()); + const algorithm = header.alg; + + // Verify the signature + const signatureInput = `${encodedHeader}.${encodedPayload}`; + const isValid = verify(signatureInput, signature, publicKey, algorithm); + + if (!isValid) { + throw new Error("Invalid signature"); + } + + // Decode the payload + const payload = JSON.parse( + Buffer.from(encodedPayload, "base64").toString() + ); + + // Check if the token has expired + const now = Math.floor(Date.now() / 1000); + if (payload.exp && payload.exp < now) { + throw new Error("Token has expired"); + } + + return payload; +} + +/** + * Verifies the signature of a JWT + */ +function verify( + input: string, + signature: string, + publicKey: string, + algorithm: string +): boolean { + let verifyAlgorithm: string; + + // Map JWT algorithm name to Node.js crypto algorithm name + switch (algorithm) { + case "RS256": + verifyAlgorithm = "RSA-SHA256"; + break; + case "RS384": + verifyAlgorithm = "RSA-SHA384"; + break; + case "RS512": + verifyAlgorithm = "RSA-SHA512"; + break; + case "ES256": + verifyAlgorithm = "SHA256"; + break; + case "ES384": + verifyAlgorithm = "SHA384"; + break; + case "ES512": + verifyAlgorithm = "SHA512"; + break; + default: + throw new Error(`Unsupported algorithm: ${algorithm}`); + } + + // Convert base64url signature to standard base64 + const base64Signature = base64URLToBase64(signature); + + // Verify the signature + const verifier = crypto.createVerify(verifyAlgorithm); + verifier.update(input); + return verifier.verify(publicKey, base64Signature, "base64"); +} + +/** + * Converts base64url format to standard base64 + */ +function base64URLToBase64(base64url: string): string { + // Add padding if needed + let base64 = base64url.replace(/-/g, "+").replace(/_/g, "/"); + + const pad = base64.length % 4; + if (pad) { + if (pad === 1) { + throw new Error("Invalid base64url string"); + } + base64 += "=".repeat(4 - pad); + } + + return base64; +} + +export { validateJWT }; diff --git a/server/middlewares/index.ts b/server/middlewares/index.ts index b02f5b18..03d6f3bb 100644 --- a/server/middlewares/index.ts +++ b/server/middlewares/index.ts @@ -14,4 +14,9 @@ export * from "./verifyAdmin"; export * from "./verifySetResourceUsers"; export * from "./verifyUserInRole"; export * from "./verifyAccessTokenAccess"; -export * from "./verifyUserIsServerAdmin"; \ No newline at end of file +export * from "./verifyUserIsServerAdmin"; +export * from "./verifyIsLoggedInUser"; +export * from "./integration"; +export * from "./verifyValidLicense"; +export * from "./verifyUserHasAction"; +export * from "./verifyApiKeyAccess"; diff --git a/server/middlewares/integration/index.ts b/server/middlewares/integration/index.ts new file mode 100644 index 00000000..c16e1294 --- /dev/null +++ b/server/middlewares/integration/index.ts @@ -0,0 +1,17 @@ +// This file is licensed under the Fossorial Commercial License. +// Unauthorized use, copying, modification, or distribution is strictly prohibited. +// +// Copyright (c) 2025 Fossorial LLC. All rights reserved. + +export * from "./verifyApiKey"; +export * from "./verifyApiKeyOrgAccess"; +export * from "./verifyApiKeyHasAction"; +export * from "./verifyApiKeySiteAccess"; +export * from "./verifyApiKeyResourceAccess"; +export * from "./verifyApiKeyTargetAccess"; +export * from "./verifyApiKeyRoleAccess"; +export * from "./verifyApiKeyUserAccess"; +export * from "./verifyApiKeySetResourceUsers"; +export * from "./verifyAccessTokenAccess"; +export * from "./verifyApiKeyIsRoot"; +export * from "./verifyApiKeyApiKeyAccess"; diff --git a/server/middlewares/integration/verifyAccessTokenAccess.ts b/server/middlewares/integration/verifyAccessTokenAccess.ts new file mode 100644 index 00000000..82badcd4 --- /dev/null +++ b/server/middlewares/integration/verifyAccessTokenAccess.ts @@ -0,0 +1,115 @@ +// This file is licensed under the Fossorial Commercial License. +// Unauthorized use, copying, modification, or distribution is strictly prohibited. +// +// Copyright (c) 2025 Fossorial LLC. All rights reserved. + +import { Request, Response, NextFunction } from "express"; +import { db } from "@server/db"; +import { resourceAccessToken, resources, apiKeyOrg } from "@server/db/schemas"; +import { and, eq } from "drizzle-orm"; +import createHttpError from "http-errors"; +import HttpCode from "@server/types/HttpCode"; + +export async function verifyApiKeyAccessTokenAccess( + req: Request, + res: Response, + next: NextFunction +) { + try { + const apiKey = req.apiKey; + const accessTokenId = req.params.accessTokenId; + + if (!apiKey) { + return next( + createHttpError(HttpCode.UNAUTHORIZED, "Key not authenticated") + ); + } + + const [accessToken] = await db + .select() + .from(resourceAccessToken) + .where(eq(resourceAccessToken.accessTokenId, accessTokenId)) + .limit(1); + + if (!accessToken) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `Access token with ID ${accessTokenId} not found` + ) + ); + } + + const resourceId = accessToken.resourceId; + + if (!resourceId) { + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + `Access token with ID ${accessTokenId} does not have a resource ID` + ) + ); + } + + const [resource] = await db + .select() + .from(resources) + .where(eq(resources.resourceId, resourceId)) + .limit(1); + + if (!resource) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `Resource with ID ${resourceId} not found` + ) + ); + } + + if (!resource.orgId) { + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + `Resource with ID ${resourceId} does not have an organization ID` + ) + ); + } + + // Verify that the API key is linked to the resource's organization + if (!req.apiKeyOrg) { + const apiKeyOrgResult = await db + .select() + .from(apiKeyOrg) + .where( + and( + eq(apiKeyOrg.apiKeyId, apiKey.apiKeyId), + eq(apiKeyOrg.orgId, resource.orgId) + ) + ) + .limit(1); + + if (apiKeyOrgResult.length > 0) { + req.apiKeyOrg = apiKeyOrgResult[0]; + } + } + + if (!req.apiKeyOrg) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "Key does not have access to this organization" + ) + ); + } + + + return next(); + } catch (e) { + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Error verifying access token access" + ) + ); + } +} diff --git a/server/middlewares/integration/verifyApiKey.ts b/server/middlewares/integration/verifyApiKey.ts new file mode 100644 index 00000000..39fc3de6 --- /dev/null +++ b/server/middlewares/integration/verifyApiKey.ts @@ -0,0 +1,65 @@ +// This file is licensed under the Fossorial Commercial License. +// Unauthorized use, copying, modification, or distribution is strictly prohibited. +// +// Copyright (c) 2025 Fossorial LLC. All rights reserved. + +import { verifyPassword } from "@server/auth/password"; +import db from "@server/db"; +import { apiKeys } from "@server/db/schemas"; +import logger from "@server/logger"; +import HttpCode from "@server/types/HttpCode"; +import { eq } from "drizzle-orm"; +import { Request, Response, NextFunction } from "express"; +import createHttpError from "http-errors"; + +export async function verifyApiKey( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const authHeader = req.headers["authorization"]; + + if (!authHeader || !authHeader.startsWith("Bearer ")) { + return next( + createHttpError(HttpCode.UNAUTHORIZED, "API key required") + ); + } + + const key = authHeader.split(" ")[1]; // Get the token part after "Bearer" + const [apiKeyId, apiKeySecret] = key.split("."); + + const [apiKey] = await db + .select() + .from(apiKeys) + .where(eq(apiKeys.apiKeyId, apiKeyId)) + .limit(1); + + if (!apiKey) { + return next( + createHttpError(HttpCode.UNAUTHORIZED, "Invalid API key") + ); + } + + const secretHash = apiKey.apiKeyHash; + const valid = await verifyPassword(apiKeySecret, secretHash); + + if (!valid) { + return next( + createHttpError(HttpCode.UNAUTHORIZED, "Invalid API key") + ); + } + + req.apiKey = apiKey; + + return next(); + } catch (error) { + logger.error(error); + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "An error occurred checking API key" + ) + ); + } +} diff --git a/server/middlewares/integration/verifyApiKeyApiKeyAccess.ts b/server/middlewares/integration/verifyApiKeyApiKeyAccess.ts new file mode 100644 index 00000000..aedc60c1 --- /dev/null +++ b/server/middlewares/integration/verifyApiKeyApiKeyAccess.ts @@ -0,0 +1,86 @@ +// This file is licensed under the Fossorial Commercial License. +// Unauthorized use, copying, modification, or distribution is strictly prohibited. +// +// Copyright (c) 2025 Fossorial LLC. All rights reserved. + +import { Request, Response, NextFunction } from "express"; +import { db } from "@server/db"; +import { apiKeys, apiKeyOrg } from "@server/db/schemas"; +import { and, eq, or } from "drizzle-orm"; +import createHttpError from "http-errors"; +import HttpCode from "@server/types/HttpCode"; + +export async function verifyApiKeyApiKeyAccess( + req: Request, + res: Response, + next: NextFunction +) { + try { + const {apiKey: callerApiKey } = req; + + const apiKeyId = + req.params.apiKeyId || req.body.apiKeyId || req.query.apiKeyId; + const orgId = req.params.orgId; + + if (!callerApiKey) { + return next( + createHttpError(HttpCode.UNAUTHORIZED, "Key not authenticated") + ); + } + + if (!orgId) { + return next( + createHttpError(HttpCode.BAD_REQUEST, "Invalid organization ID") + ); + } + + if (!apiKeyId) { + return next( + createHttpError(HttpCode.BAD_REQUEST, "Invalid key ID") + ); + } + + const [callerApiKeyOrg] = await db + .select() + .from(apiKeyOrg) + .where( + and(eq(apiKeys.apiKeyId, callerApiKey.apiKeyId), eq(apiKeyOrg.orgId, orgId)) + ) + .limit(1); + + if (!callerApiKeyOrg) { + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + `API key with ID ${apiKeyId} does not have an organization ID` + ) + ); + } + + const [otherApiKeyOrg] = await db + .select() + .from(apiKeyOrg) + .where( + and(eq(apiKeys.apiKeyId, apiKeyId), eq(apiKeyOrg.orgId, orgId)) + ) + .limit(1); + + if (!otherApiKeyOrg) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + `API key with ID ${apiKeyId} does not have access to organization with ID ${orgId}` + ) + ); + } + + return next(); + } catch (error) { + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Error verifying key access" + ) + ); + } +} diff --git a/server/middlewares/integration/verifyApiKeyHasAction.ts b/server/middlewares/integration/verifyApiKeyHasAction.ts new file mode 100644 index 00000000..0326c465 --- /dev/null +++ b/server/middlewares/integration/verifyApiKeyHasAction.ts @@ -0,0 +1,61 @@ +// This file is licensed under the Fossorial Commercial License. +// Unauthorized use, copying, modification, or distribution is strictly prohibited. +// +// Copyright (c) 2025 Fossorial LLC. All rights reserved. + +import { Request, Response, NextFunction } from "express"; +import createHttpError from "http-errors"; +import HttpCode from "@server/types/HttpCode"; +import logger from "@server/logger"; +import { ActionsEnum } from "@server/auth/actions"; +import db from "@server/db"; +import { apiKeyActions } from "@server/db/schemas"; +import { and, eq } from "drizzle-orm"; + +export function verifyApiKeyHasAction(action: ActionsEnum) { + return async function ( + req: Request, + res: Response, + next: NextFunction + ): Promise { + try { + if (!req.apiKey) { + return next( + createHttpError( + HttpCode.UNAUTHORIZED, + "API Key not authenticated" + ) + ); + } + + const [actionRes] = await db + .select() + .from(apiKeyActions) + .where( + and( + eq(apiKeyActions.apiKeyId, req.apiKey.apiKeyId), + eq(apiKeyActions.actionId, action) + ) + ); + + if (!actionRes) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "Key does not have permission perform this action" + ) + ); + } + + return next(); + } catch (error) { + logger.error("Error verifying key action access:", error); + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Error verifying key action access" + ) + ); + } + }; +} diff --git a/server/middlewares/integration/verifyApiKeyIsRoot.ts b/server/middlewares/integration/verifyApiKeyIsRoot.ts new file mode 100644 index 00000000..35cd0faf --- /dev/null +++ b/server/middlewares/integration/verifyApiKeyIsRoot.ts @@ -0,0 +1,44 @@ +// This file is licensed under the Fossorial Commercial License. +// Unauthorized use, copying, modification, or distribution is strictly prohibited. +// +// Copyright (c) 2025 Fossorial LLC. All rights reserved. + +import logger from "@server/logger"; +import HttpCode from "@server/types/HttpCode"; +import { Request, Response, NextFunction } from "express"; +import createHttpError from "http-errors"; + +export async function verifyApiKeyIsRoot( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const { apiKey } = req; + + if (!apiKey) { + return next( + createHttpError(HttpCode.UNAUTHORIZED, "Key not authenticated") + ); + } + + if (!apiKey.isRoot) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "Key does not have root access" + ) + ); + } + + return next(); + } catch (error) { + logger.error(error); + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "An error occurred checking API key" + ) + ); + } +} diff --git a/server/middlewares/integration/verifyApiKeyOrgAccess.ts b/server/middlewares/integration/verifyApiKeyOrgAccess.ts new file mode 100644 index 00000000..e1e1e0d4 --- /dev/null +++ b/server/middlewares/integration/verifyApiKeyOrgAccess.ts @@ -0,0 +1,66 @@ +// This file is licensed under the Fossorial Commercial License. +// Unauthorized use, copying, modification, or distribution is strictly prohibited. +// +// Copyright (c) 2025 Fossorial LLC. All rights reserved. + +import { Request, Response, NextFunction } from "express"; +import { db } from "@server/db"; +import { apiKeyOrg } from "@server/db/schemas"; +import { and, eq } from "drizzle-orm"; +import createHttpError from "http-errors"; +import HttpCode from "@server/types/HttpCode"; +import logger from "@server/logger"; + +export async function verifyApiKeyOrgAccess( + req: Request, + res: Response, + next: NextFunction +) { + try { + const apiKeyId = req.apiKey?.apiKeyId; + const orgId = req.params.orgId; + + if (!apiKeyId) { + return next( + createHttpError(HttpCode.UNAUTHORIZED, "Key not authenticated") + ); + } + + if (!orgId) { + return next( + createHttpError(HttpCode.BAD_REQUEST, "Invalid organization ID") + ); + } + + if (!req.apiKeyOrg) { + const apiKeyOrgRes = await db + .select() + .from(apiKeyOrg) + .where( + and( + eq(apiKeyOrg.apiKeyId, apiKeyId), + eq(apiKeyOrg.orgId, orgId) + ) + ); + req.apiKeyOrg = apiKeyOrgRes[0]; + } + + if (!req.apiKeyOrg) { + next( + createHttpError( + HttpCode.FORBIDDEN, + "Key does not have access to this organization" + ) + ); + } + + return next(); + } catch (e) { + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Error verifying organization access" + ) + ); + } +} diff --git a/server/middlewares/integration/verifyApiKeyResourceAccess.ts b/server/middlewares/integration/verifyApiKeyResourceAccess.ts new file mode 100644 index 00000000..49180b59 --- /dev/null +++ b/server/middlewares/integration/verifyApiKeyResourceAccess.ts @@ -0,0 +1,90 @@ +// This file is licensed under the Fossorial Commercial License. +// Unauthorized use, copying, modification, or distribution is strictly prohibited. +// +// Copyright (c) 2025 Fossorial LLC. All rights reserved. + +import { Request, Response, NextFunction } from "express"; +import { db } from "@server/db"; +import { resources, apiKeyOrg } from "@server/db/schemas"; +import { eq, and } from "drizzle-orm"; +import createHttpError from "http-errors"; +import HttpCode from "@server/types/HttpCode"; + +export async function verifyApiKeyResourceAccess( + req: Request, + res: Response, + next: NextFunction +) { + const apiKey = req.apiKey; + const resourceId = + req.params.resourceId || req.body.resourceId || req.query.resourceId; + + if (!apiKey) { + return next( + createHttpError(HttpCode.UNAUTHORIZED, "Key not authenticated") + ); + } + + try { + // Retrieve the resource + const [resource] = await db + .select() + .from(resources) + .where(eq(resources.resourceId, resourceId)) + .limit(1); + + if (!resource) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `Resource with ID ${resourceId} not found` + ) + ); + } + + if (!resource.orgId) { + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + `Resource with ID ${resourceId} does not have an organization ID` + ) + ); + } + + // Verify that the API key is linked to the resource's organization + if (!req.apiKeyOrg) { + const apiKeyOrgResult = await db + .select() + .from(apiKeyOrg) + .where( + and( + eq(apiKeyOrg.apiKeyId, apiKey.apiKeyId), + eq(apiKeyOrg.orgId, resource.orgId) + ) + ) + .limit(1); + + if (apiKeyOrgResult.length > 0) { + req.apiKeyOrg = apiKeyOrgResult[0]; + } + } + + if (!req.apiKeyOrg) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "Key does not have access to this organization" + ) + ); + } + + return next(); + } catch (error) { + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Error verifying resource access" + ) + ); + } +} diff --git a/server/middlewares/integration/verifyApiKeyRoleAccess.ts b/server/middlewares/integration/verifyApiKeyRoleAccess.ts new file mode 100644 index 00000000..a7abf9a6 --- /dev/null +++ b/server/middlewares/integration/verifyApiKeyRoleAccess.ts @@ -0,0 +1,132 @@ +// This file is licensed under the Fossorial Commercial License. +// Unauthorized use, copying, modification, or distribution is strictly prohibited. +// +// Copyright (c) 2025 Fossorial LLC. All rights reserved. + +import { Request, Response, NextFunction } from "express"; +import { db } from "@server/db"; +import { roles, apiKeyOrg } from "@server/db/schemas"; +import { and, eq, inArray } from "drizzle-orm"; +import createHttpError from "http-errors"; +import HttpCode from "@server/types/HttpCode"; +import logger from "@server/logger"; + +export async function verifyApiKeyRoleAccess( + req: Request, + res: Response, + next: NextFunction +) { + try { + const apiKey = req.apiKey; + const singleRoleId = parseInt( + req.params.roleId || req.body.roleId || req.query.roleId + ); + + if (!apiKey) { + return next( + createHttpError(HttpCode.UNAUTHORIZED, "Key not authenticated") + ); + } + + const { roleIds } = req.body; + const allRoleIds = + roleIds || (isNaN(singleRoleId) ? [] : [singleRoleId]); + + if (allRoleIds.length === 0) { + return next(); + } + + const rolesData = await db + .select() + .from(roles) + .where(inArray(roles.roleId, allRoleIds)); + + if (rolesData.length !== allRoleIds.length) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + "One or more roles not found" + ) + ); + } + + const orgIds = new Set(rolesData.map((role) => role.orgId)); + + for (const role of rolesData) { + const apiKeyOrgAccess = await db + .select() + .from(apiKeyOrg) + .where( + and( + eq(apiKeyOrg.apiKeyId, apiKey.apiKeyId), + eq(apiKeyOrg.orgId, role.orgId!) + ) + ) + .limit(1); + + if (apiKeyOrgAccess.length === 0) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + `Key does not have access to organization for role ID ${role.roleId}` + ) + ); + } + } + + if (orgIds.size > 1) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "Roles must belong to the same organization" + ) + ); + } + + const orgId = orgIds.values().next().value; + + if (!orgId) { + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Roles do not have an organization ID" + ) + ); + } + + if (!req.apiKeyOrg) { + // Retrieve the API key's organization link if not already set + const apiKeyOrgRes = await db + .select() + .from(apiKeyOrg) + .where( + and( + eq(apiKeyOrg.apiKeyId, apiKey.apiKeyId), + eq(apiKeyOrg.orgId, orgId) + ) + ) + .limit(1); + + if (apiKeyOrgRes.length === 0) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "Key does not have access to this organization" + ) + ); + } + + req.apiKeyOrg = apiKeyOrgRes[0]; + } + + return next(); + } catch (error) { + logger.error("Error verifying role access:", error); + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Error verifying role access" + ) + ); + } +} diff --git a/server/middlewares/integration/verifyApiKeySetResourceUsers.ts b/server/middlewares/integration/verifyApiKeySetResourceUsers.ts new file mode 100644 index 00000000..d43021ba --- /dev/null +++ b/server/middlewares/integration/verifyApiKeySetResourceUsers.ts @@ -0,0 +1,74 @@ +// This file is licensed under the Fossorial Commercial License. +// Unauthorized use, copying, modification, or distribution is strictly prohibited. +// +// Copyright (c) 2025 Fossorial LLC. All rights reserved. + +import { Request, Response, NextFunction } from "express"; +import { db } from "@server/db"; +import { userOrgs } from "@server/db/schemas"; +import { and, eq, inArray } from "drizzle-orm"; +import createHttpError from "http-errors"; +import HttpCode from "@server/types/HttpCode"; + +export async function verifyApiKeySetResourceUsers( + req: Request, + res: Response, + next: NextFunction +) { + const apiKey = req.apiKey; + const userIds = req.body.userIds; + + if (!apiKey) { + return next( + createHttpError(HttpCode.UNAUTHORIZED, "Key not authenticated") + ); + } + + if (!req.apiKeyOrg) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "Key does not have access to this organization" + ) + ); + } + + if (!userIds) { + return next(createHttpError(HttpCode.BAD_REQUEST, "Invalid user IDs")); + } + + if (userIds.length === 0) { + return next(); + } + + try { + const orgId = req.apiKeyOrg.orgId; + const userOrgsData = await db + .select() + .from(userOrgs) + .where( + and( + inArray(userOrgs.userId, userIds), + eq(userOrgs.orgId, orgId) + ) + ); + + if (userOrgsData.length !== userIds.length) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "Key does not have access to one or more specified users" + ) + ); + } + + return next(); + } catch (error) { + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Error checking if key has access to the specified users" + ) + ); + } +} diff --git a/server/middlewares/integration/verifyApiKeySiteAccess.ts b/server/middlewares/integration/verifyApiKeySiteAccess.ts new file mode 100644 index 00000000..7d10ddee --- /dev/null +++ b/server/middlewares/integration/verifyApiKeySiteAccess.ts @@ -0,0 +1,94 @@ +// This file is licensed under the Fossorial Commercial License. +// Unauthorized use, copying, modification, or distribution is strictly prohibited. +// +// Copyright (c) 2025 Fossorial LLC. All rights reserved. + +import { Request, Response, NextFunction } from "express"; +import { db } from "@server/db"; +import { + sites, + apiKeyOrg +} from "@server/db/schemas"; +import { and, eq, or } from "drizzle-orm"; +import createHttpError from "http-errors"; +import HttpCode from "@server/types/HttpCode"; + +export async function verifyApiKeySiteAccess( + req: Request, + res: Response, + next: NextFunction +) { + try { + const apiKey = req.apiKey; + const siteId = parseInt( + req.params.siteId || req.body.siteId || req.query.siteId + ); + + if (!apiKey) { + return next( + createHttpError(HttpCode.UNAUTHORIZED, "Key not authenticated") + ); + } + + if (isNaN(siteId)) { + return next( + createHttpError(HttpCode.BAD_REQUEST, "Invalid site ID") + ); + } + + const site = await db + .select() + .from(sites) + .where(eq(sites.siteId, siteId)) + .limit(1); + + if (site.length === 0) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `Site with ID ${siteId} not found` + ) + ); + } + + if (!site[0].orgId) { + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + `Site with ID ${siteId} does not have an organization ID` + ) + ); + } + + if (!req.apiKeyOrg) { + const apiKeyOrgRes = await db + .select() + .from(apiKeyOrg) + .where( + and( + eq(apiKeyOrg.apiKeyId, apiKey.apiKeyId), + eq(apiKeyOrg.orgId, site[0].orgId) + ) + ); + req.apiKeyOrg = apiKeyOrgRes[0]; + } + + if (!req.apiKeyOrg) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "Key does not have access to this organization" + ) + ); + } + + return next(); + } catch (error) { + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Error verifying site access" + ) + ); + } +} diff --git a/server/middlewares/integration/verifyApiKeyTargetAccess.ts b/server/middlewares/integration/verifyApiKeyTargetAccess.ts new file mode 100644 index 00000000..bd6e5bc0 --- /dev/null +++ b/server/middlewares/integration/verifyApiKeyTargetAccess.ts @@ -0,0 +1,117 @@ +// This file is licensed under the Fossorial Commercial License. +// Unauthorized use, copying, modification, or distribution is strictly prohibited. +// +// Copyright (c) 2025 Fossorial LLC. All rights reserved. + +import { Request, Response, NextFunction } from "express"; +import { db } from "@server/db"; +import { resources, targets, apiKeyOrg } from "@server/db/schemas"; +import { and, eq } from "drizzle-orm"; +import createHttpError from "http-errors"; +import HttpCode from "@server/types/HttpCode"; + +export async function verifyApiKeyTargetAccess( + req: Request, + res: Response, + next: NextFunction +) { + try { + const apiKey = req.apiKey; + const targetId = parseInt(req.params.targetId); + + if (!apiKey) { + return next( + createHttpError(HttpCode.UNAUTHORIZED, "Key not authenticated") + ); + } + + if (isNaN(targetId)) { + return next( + createHttpError(HttpCode.BAD_REQUEST, "Invalid target ID") + ); + } + + const [target] = await db + .select() + .from(targets) + .where(eq(targets.targetId, targetId)) + .limit(1); + + if (!target) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `Target with ID ${targetId} not found` + ) + ); + } + + const resourceId = target.resourceId; + if (!resourceId) { + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + `Target with ID ${targetId} does not have a resource ID` + ) + ); + } + + const [resource] = await db + .select() + .from(resources) + .where(eq(resources.resourceId, resourceId)) + .limit(1); + + if (!resource) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `Resource with ID ${resourceId} not found` + ) + ); + } + + if (!resource.orgId) { + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + `Resource with ID ${resourceId} does not have an organization ID` + ) + ); + } + + if (!req.apiKeyOrg) { + const apiKeyOrgResult = await db + .select() + .from(apiKeyOrg) + .where( + and( + eq(apiKeyOrg.apiKeyId, apiKey.apiKeyId), + eq(apiKeyOrg.orgId, resource.orgId) + ) + ) + .limit(1); + if (apiKeyOrgResult.length > 0) { + req.apiKeyOrg = apiKeyOrgResult[0]; + } + } + + if (!req.apiKeyOrg) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "Key does not have access to this organization" + ) + ); + } + + return next(); + } catch (error) { + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Error verifying target access" + ) + ); + } +} diff --git a/server/middlewares/integration/verifyApiKeyUserAccess.ts b/server/middlewares/integration/verifyApiKeyUserAccess.ts new file mode 100644 index 00000000..e1b5d3d3 --- /dev/null +++ b/server/middlewares/integration/verifyApiKeyUserAccess.ts @@ -0,0 +1,72 @@ +// This file is licensed under the Fossorial Commercial License. +// Unauthorized use, copying, modification, or distribution is strictly prohibited. +// +// Copyright (c) 2025 Fossorial LLC. All rights reserved. + +import { Request, Response, NextFunction } from "express"; +import { db } from "@server/db"; +import { userOrgs } from "@server/db/schemas"; +import { and, eq } from "drizzle-orm"; +import createHttpError from "http-errors"; +import HttpCode from "@server/types/HttpCode"; + +export async function verifyApiKeyUserAccess( + req: Request, + res: Response, + next: NextFunction +) { + try { + const apiKey = req.apiKey; + const reqUserId = + req.params.userId || req.body.userId || req.query.userId; + + if (!apiKey) { + return next( + createHttpError(HttpCode.UNAUTHORIZED, "Key not authenticated") + ); + } + + if (!reqUserId) { + return next( + createHttpError(HttpCode.BAD_REQUEST, "Invalid user ID") + ); + } + + if (!req.apiKeyOrg || !req.apiKeyOrg.orgId) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "Key does not have organization access" + ) + ); + } + + const orgId = req.apiKeyOrg.orgId; + + const [userOrgRecord] = await db + .select() + .from(userOrgs) + .where( + and(eq(userOrgs.userId, reqUserId), eq(userOrgs.orgId, orgId)) + ) + .limit(1); + + if (!userOrgRecord) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "Key does not have access to this user" + ) + ); + } + + return next(); + } catch (error) { + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Error checking if key has access to this user" + ) + ); + } +} diff --git a/server/middlewares/verifyApiKeyAccess.ts b/server/middlewares/verifyApiKeyAccess.ts new file mode 100644 index 00000000..0bba8f4b --- /dev/null +++ b/server/middlewares/verifyApiKeyAccess.ts @@ -0,0 +1,104 @@ +// This file is licensed under the Fossorial Commercial License. +// Unauthorized use, copying, modification, or distribution is strictly prohibited. +// +// Copyright (c) 2025 Fossorial LLC. All rights reserved. + +import { Request, Response, NextFunction } from "express"; +import { db } from "@server/db"; +import { userOrgs, apiKeys, apiKeyOrg } from "@server/db/schemas"; +import { and, eq, or } from "drizzle-orm"; +import createHttpError from "http-errors"; +import HttpCode from "@server/types/HttpCode"; + +export async function verifyApiKeyAccess( + req: Request, + res: Response, + next: NextFunction +) { + try { + const userId = req.user!.userId; + const apiKeyId = + req.params.apiKeyId || 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 (!apiKeyId) { + return next( + createHttpError(HttpCode.BAD_REQUEST, "Invalid key ID") + ); + } + + const [apiKey] = await db + .select() + .from(apiKeys) + .innerJoin(apiKeyOrg, eq(apiKeys.apiKeyId, apiKeyOrg.apiKeyId)) + .where( + and(eq(apiKeys.apiKeyId, apiKeyId), eq(apiKeyOrg.orgId, orgId)) + ) + .limit(1); + + if (!apiKey.apiKeys) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `API key with ID ${apiKeyId} not found` + ) + ); + } + + if (!apiKeyOrg.orgId) { + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + `API key with ID ${apiKeyId} does not have an organization ID` + ) + ); + } + + 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 key access" + ) + ); + } +} diff --git a/server/middlewares/verifyIsLoggedInUser.ts b/server/middlewares/verifyIsLoggedInUser.ts new file mode 100644 index 00000000..bee066b7 --- /dev/null +++ b/server/middlewares/verifyIsLoggedInUser.ts @@ -0,0 +1,44 @@ +import { Request, Response, NextFunction } from "express"; +import createHttpError from "http-errors"; +import HttpCode from "@server/types/HttpCode"; + +export async function verifyIsLoggedInUser( + req: Request, + res: Response, + next: NextFunction +) { + try { + const userId = req.user!.userId; + const reqUserId = + req.params.userId || req.body.userId || req.query.userId; + + if (!userId) { + return next( + createHttpError(HttpCode.UNAUTHORIZED, "User not authenticated") + ); + } + + // allow server admins to access any user + if (req.user?.serverAdmin) { + return next(); + } + + if (reqUserId !== userId) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "User only has access to their own account" + ) + ); + } + + return next(); + } catch (error) { + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Error checking if user has access to this user" + ) + ); + } +} diff --git a/server/middlewares/verifyValidLicense.ts b/server/middlewares/verifyValidLicense.ts new file mode 100644 index 00000000..7f4de34a --- /dev/null +++ b/server/middlewares/verifyValidLicense.ts @@ -0,0 +1,33 @@ +// This file is licensed under the Fossorial Commercial License. +// Unauthorized use, copying, modification, or distribution is strictly prohibited. +// +// Copyright (c) 2025 Fossorial LLC. All rights reserved. + +import { Request, Response, NextFunction } from "express"; +import createHttpError from "http-errors"; +import HttpCode from "@server/types/HttpCode"; +import license from "@server/license/license"; + +export async function verifyValidLicense( + req: Request, + res: Response, + next: NextFunction +) { + try { + const unlocked = await license.isUnlocked(); + if (!unlocked) { + return next( + createHttpError(HttpCode.FORBIDDEN, "License is not valid") + ); + } + + return next(); + } catch (e) { + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Error verifying license" + ) + ); + } +} diff --git a/server/openApi.ts b/server/openApi.ts new file mode 100644 index 00000000..4df6cbdd --- /dev/null +++ b/server/openApi.ts @@ -0,0 +1,18 @@ +import { OpenAPIRegistry } from "@asteasolutions/zod-to-openapi"; + +export const registry = new OpenAPIRegistry(); + +export enum OpenAPITags { + Site = "Site", + Org = "Organization", + Resource = "Resource", + Role = "Role", + User = "User", + Invitation = "Invitation", + Target = "Target", + Rule = "Rule", + AccessToken = "Access Token", + Idp = "Identity Provider", + Client = "Client", + ApiKey = "API Key" +} diff --git a/server/routers/accessToken/deleteAccessToken.ts b/server/routers/accessToken/deleteAccessToken.ts index b7aa83d1..783c5fc8 100644 --- a/server/routers/accessToken/deleteAccessToken.ts +++ b/server/routers/accessToken/deleteAccessToken.ts @@ -8,6 +8,7 @@ import { fromError } from "zod-validation-error"; import { resourceAccessToken } from "@server/db/schemas"; import { and, eq } from "drizzle-orm"; import db from "@server/db"; +import { OpenAPITags, registry } from "@server/openApi"; const deleteAccessTokenParamsSchema = z .object({ @@ -15,6 +16,17 @@ const deleteAccessTokenParamsSchema = z }) .strict(); +registry.registerPath({ + method: "delete", + path: "/access-token/{accessTokenId}", + description: "Delete a access token.", + tags: [OpenAPITags.AccessToken], + request: { + params: deleteAccessTokenParamsSchema + }, + responses: {} +}); + export async function deleteAccessToken( req: Request, res: Response, diff --git a/server/routers/accessToken/generateAccessToken.ts b/server/routers/accessToken/generateAccessToken.ts index bb67387f..738c230e 100644 --- a/server/routers/accessToken/generateAccessToken.ts +++ b/server/routers/accessToken/generateAccessToken.ts @@ -22,6 +22,7 @@ import { createDate, TimeSpan } from "oslo"; import { hashPassword } from "@server/auth/password"; import { encodeHexLowerCase } from "@oslojs/encoding"; import { sha256 } from "@oslojs/crypto/sha2"; +import { OpenAPITags, registry } from "@server/openApi"; export const generateAccessTokenBodySchema = z .object({ @@ -45,6 +46,24 @@ export type GenerateAccessTokenResponse = Omit< "tokenHash" > & { accessToken: string }; +registry.registerPath({ + method: "post", + path: "/resource/{resourceId}/access-token", + description: "Generate a new access token for a resource.", + tags: [OpenAPITags.Resource, OpenAPITags.AccessToken], + request: { + params: generateAccssTokenParamsSchema, + body: { + content: { + "application/json": { + schema: generateAccessTokenBodySchema + } + } + } + }, + responses: {} +}); + export async function generateAccessToken( req: Request, res: Response, diff --git a/server/routers/accessToken/listAccessTokens.ts b/server/routers/accessToken/listAccessTokens.ts index a6dcff6c..07ef9aa3 100644 --- a/server/routers/accessToken/listAccessTokens.ts +++ b/server/routers/accessToken/listAccessTokens.ts @@ -15,6 +15,7 @@ import { sql, eq, or, inArray, and, count, isNull, lt, gt } from "drizzle-orm"; import logger from "@server/logger"; import stoi from "@server/lib/stoi"; import { fromZodError } from "zod-validation-error"; +import { OpenAPITags, registry } from "@server/openApi"; const listAccessTokensParamsSchema = z .object({ @@ -73,10 +74,7 @@ function queryAccessTokens( resources, eq(resourceAccessToken.resourceId, resources.resourceId) ) - .leftJoin( - sites, - eq(resources.resourceId, sites.siteId) - ) + .leftJoin(sites, eq(resources.resourceId, sites.siteId)) .where( and( inArray( @@ -98,10 +96,7 @@ function queryAccessTokens( resources, eq(resourceAccessToken.resourceId, resources.resourceId) ) - .leftJoin( - sites, - eq(resources.resourceId, sites.siteId) - ) + .leftJoin(sites, eq(resources.resourceId, sites.siteId)) .where( and( inArray( @@ -123,6 +118,34 @@ export type ListAccessTokensResponse = { pagination: { total: number; limit: number; offset: number }; }; +registry.registerPath({ + method: "get", + path: "/org/{orgId}/access-tokens", + description: "List all access tokens in an organization.", + tags: [OpenAPITags.Org, OpenAPITags.AccessToken], + request: { + params: z.object({ + orgId: z.string() + }), + query: listAccessTokensSchema + }, + responses: {} +}); + +registry.registerPath({ + method: "get", + path: "/resource/{resourceId}/access-tokens", + description: "List all access tokens in an organization.", + tags: [OpenAPITags.Resource, OpenAPITags.AccessToken], + request: { + params: z.object({ + resourceId: z.number() + }), + query: listAccessTokensSchema + }, + responses: {} +}); + export async function listAccessTokens( req: Request, res: Response, @@ -149,9 +172,20 @@ export async function listAccessTokens( ) ); } - const { orgId, resourceId } = parsedParams.data; + const { resourceId } = parsedParams.data; - if (orgId && orgId !== req.userOrgId) { + const orgId = + parsedParams.data.orgId || + req.userOrg?.orgId || + req.apiKeyOrg?.orgId; + + if (!orgId) { + return next( + createHttpError(HttpCode.BAD_REQUEST, "Invalid organization ID") + ); + } + + if (req.user && orgId && orgId !== req.userOrgId) { return next( createHttpError( HttpCode.FORBIDDEN, @@ -160,21 +194,29 @@ export async function listAccessTokens( ); } - const accessibleResources = await db - .select({ - resourceId: sql`COALESCE(${userResources.resourceId}, ${roleResources.resourceId})` - }) - .from(userResources) - .fullJoin( - roleResources, - eq(userResources.resourceId, roleResources.resourceId) - ) - .where( - or( - eq(userResources.userId, req.user!.userId), - eq(roleResources.roleId, req.userOrgRoleId!) + let accessibleResources; + if (req.user) { + accessibleResources = await db + .select({ + resourceId: sql`COALESCE(${userResources.resourceId}, ${roleResources.resourceId})` + }) + .from(userResources) + .fullJoin( + roleResources, + eq(userResources.resourceId, roleResources.resourceId) ) - ); + .where( + or( + eq(userResources.userId, req.user!.userId), + eq(roleResources.roleId, req.userOrgRoleId!) + ) + ); + } else { + accessibleResources = await db + .select({ resourceId: resources.resourceId }) + .from(resources) + .where(eq(resources.orgId, orgId)); + } const accessibleResourceIds = accessibleResources.map( (resource) => resource.resourceId diff --git a/server/routers/apiKeys/createOrgApiKey.ts b/server/routers/apiKeys/createOrgApiKey.ts new file mode 100644 index 00000000..2fb9fd20 --- /dev/null +++ b/server/routers/apiKeys/createOrgApiKey.ts @@ -0,0 +1,133 @@ +// This file is licensed under the Fossorial Commercial License. +// Unauthorized use, copying, modification, or distribution is strictly prohibited. +// +// Copyright (c) 2025 Fossorial LLC. All rights reserved. + +import { NextFunction, Request, Response } from "express"; +import db from "@server/db"; +import HttpCode from "@server/types/HttpCode"; +import { z } from "zod"; +import { apiKeyOrg, apiKeys } from "@server/db/schemas"; +import { fromError } from "zod-validation-error"; +import createHttpError from "http-errors"; +import response from "@server/lib/response"; +import moment from "moment"; +import { + generateId, + generateIdFromEntropySize +} from "@server/auth/sessions/app"; +import logger from "@server/logger"; +import { hashPassword } from "@server/auth/password"; +import { OpenAPITags, registry } from "@server/openApi"; + +const paramsSchema = z.object({ + orgId: z.string().nonempty() +}); + +const bodySchema = z.object({ + name: z.string().min(1).max(255) +}); + +export type CreateOrgApiKeyBody = z.infer; + +export type CreateOrgApiKeyResponse = { + apiKeyId: string; + name: string; + apiKey: string; + lastChars: string; + createdAt: string; +}; + +registry.registerPath({ + method: "put", + path: "/org/{orgId}/api-key", + description: "Create a new API key scoped to the organization.", + tags: [OpenAPITags.Org, OpenAPITags.ApiKey], + request: { + params: paramsSchema, + body: { + content: { + "application/json": { + schema: bodySchema + } + } + } + }, + responses: {} +}); + +export async function createOrgApiKey( + req: Request, + res: Response, + next: NextFunction +): Promise { + const parsedParams = paramsSchema.safeParse(req.params); + + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const parsedBody = bodySchema.safeParse(req.body); + + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + + const { orgId } = parsedParams.data; + const { name } = parsedBody.data; + + const apiKeyId = generateId(15); + const apiKey = generateIdFromEntropySize(25); + const apiKeyHash = await hashPassword(apiKey); + const lastChars = apiKey.slice(-4); + const createdAt = moment().toISOString(); + + await db.transaction(async (trx) => { + await trx.insert(apiKeys).values({ + name, + apiKeyId, + apiKeyHash, + createdAt, + lastChars + }); + + await trx.insert(apiKeyOrg).values({ + apiKeyId, + orgId + }); + }); + + try { + return response(res, { + data: { + apiKeyId, + apiKey, + name, + lastChars, + createdAt + }, + success: true, + error: false, + message: "API key created", + status: HttpCode.CREATED + }); + } catch (e) { + logger.error(e); + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Failed to create API key" + ) + ); + } +} diff --git a/server/routers/apiKeys/createRootApiKey.ts b/server/routers/apiKeys/createRootApiKey.ts new file mode 100644 index 00000000..775ae576 --- /dev/null +++ b/server/routers/apiKeys/createRootApiKey.ts @@ -0,0 +1,105 @@ +// This file is licensed under the Fossorial Commercial License. +// Unauthorized use, copying, modification, or distribution is strictly prohibited. +// +// Copyright (c) 2025 Fossorial LLC. All rights reserved. + +import { NextFunction, Request, Response } from "express"; +import db from "@server/db"; +import HttpCode from "@server/types/HttpCode"; +import { z } from "zod"; +import { apiKeyOrg, apiKeys, orgs } from "@server/db/schemas"; +import { fromError } from "zod-validation-error"; +import createHttpError from "http-errors"; +import response from "@server/lib/response"; +import moment from "moment"; +import { + generateId, + generateIdFromEntropySize +} from "@server/auth/sessions/app"; +import logger from "@server/logger"; +import { hashPassword } from "@server/auth/password"; + +const bodySchema = z + .object({ + name: z.string().min(1).max(255) + }) + .strict(); + +export type CreateRootApiKeyBody = z.infer; + +export type CreateRootApiKeyResponse = { + apiKeyId: string; + name: string; + apiKey: string; + lastChars: string; + createdAt: string; +}; + +export async function createRootApiKey( + req: Request, + res: Response, + next: NextFunction +): Promise { + const parsedBody = bodySchema.safeParse(req.body); + + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + + const { name } = parsedBody.data; + + const apiKeyId = generateId(15); + const apiKey = generateIdFromEntropySize(25); + const apiKeyHash = await hashPassword(apiKey); + const lastChars = apiKey.slice(-4); + const createdAt = moment().toISOString(); + + await db.transaction(async (trx) => { + await trx.insert(apiKeys).values({ + apiKeyId, + name, + apiKeyHash, + createdAt, + lastChars, + isRoot: true + }); + + const allOrgs = await trx.select().from(orgs); + + for (const org of allOrgs) { + await trx.insert(apiKeyOrg).values({ + apiKeyId, + orgId: org.orgId + }); + } + }); + + try { + return response(res, { + data: { + apiKeyId, + name, + apiKey, + lastChars, + createdAt + }, + success: true, + error: false, + message: "API key created", + status: HttpCode.CREATED + }); + } catch (e) { + logger.error(e); + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Failed to create API key" + ) + ); + } +} diff --git a/server/routers/apiKeys/deleteApiKey.ts b/server/routers/apiKeys/deleteApiKey.ts new file mode 100644 index 00000000..2af4ae23 --- /dev/null +++ b/server/routers/apiKeys/deleteApiKey.ts @@ -0,0 +1,81 @@ +// This file is licensed under the Fossorial Commercial License. +// Unauthorized use, copying, modification, or distribution is strictly prohibited. +// +// Copyright (c) 2025 Fossorial LLC. All rights reserved. + +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db } from "@server/db"; +import { apiKeys } from "@server/db/schemas"; +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 paramsSchema = z.object({ + apiKeyId: z.string().nonempty() +}); + +registry.registerPath({ + method: "delete", + path: "/org/{orgId}/api-key/{apiKeyId}", + description: "Delete an API key.", + tags: [OpenAPITags.Org, OpenAPITags.ApiKey], + request: { + params: paramsSchema + }, + responses: {} +}); + +export async function deleteApiKey( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = paramsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { apiKeyId } = parsedParams.data; + + const [apiKey] = await db + .select() + .from(apiKeys) + .where(eq(apiKeys.apiKeyId, apiKeyId)) + .limit(1); + + if (!apiKey) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `API Key with ID ${apiKeyId} not found` + ) + ); + } + + await db.delete(apiKeys).where(eq(apiKeys.apiKeyId, apiKeyId)); + + return response(res, { + data: null, + success: true, + error: false, + message: "API key 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/apiKeys/deleteOrgApiKey.ts b/server/routers/apiKeys/deleteOrgApiKey.ts new file mode 100644 index 00000000..1834c82c --- /dev/null +++ b/server/routers/apiKeys/deleteOrgApiKey.ts @@ -0,0 +1,104 @@ +// This file is licensed under the Fossorial Commercial License. +// Unauthorized use, copying, modification, or distribution is strictly prohibited. +// +// Copyright (c) 2025 Fossorial LLC. All rights reserved. + +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db } from "@server/db"; +import { apiKeyOrg, apiKeys } from "@server/db/schemas"; +import { and, 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"; + +const paramsSchema = z.object({ + apiKeyId: z.string().nonempty(), + orgId: z.string().nonempty() +}); + +export async function deleteOrgApiKey( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = paramsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { apiKeyId, orgId } = parsedParams.data; + + const [apiKey] = await db + .select() + .from(apiKeys) + .where(eq(apiKeys.apiKeyId, apiKeyId)) + .innerJoin( + apiKeyOrg, + and( + eq(apiKeys.apiKeyId, apiKeyOrg.apiKeyId), + eq(apiKeyOrg.orgId, orgId) + ) + ) + .limit(1); + + if (!apiKey) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `API Key with ID ${apiKeyId} not found` + ) + ); + } + + if (apiKey.apiKeys.isRoot) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "Cannot delete root API key" + ) + ); + } + + await db.transaction(async (trx) => { + await trx + .delete(apiKeyOrg) + .where( + and( + eq(apiKeyOrg.apiKeyId, apiKeyId), + eq(apiKeyOrg.orgId, orgId) + ) + ); + + const apiKeyOrgs = await db + .select() + .from(apiKeyOrg) + .where(eq(apiKeyOrg.apiKeyId, apiKeyId)); + + if (apiKeyOrgs.length === 0) { + await trx.delete(apiKeys).where(eq(apiKeys.apiKeyId, apiKeyId)); + } + }); + + return response(res, { + data: null, + success: true, + error: false, + message: "API removed from organization", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/apiKeys/getApiKey.ts b/server/routers/apiKeys/getApiKey.ts new file mode 100644 index 00000000..bd495bdd --- /dev/null +++ b/server/routers/apiKeys/getApiKey.ts @@ -0,0 +1,81 @@ +// This file is licensed under the Fossorial Commercial License. +// Unauthorized use, copying, modification, or distribution is strictly prohibited. +// +// Copyright (c) 2025 Fossorial LLC. All rights reserved. + +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db } from "@server/db"; +import { apiKeys } from "@server/db/schemas"; +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"; + +const paramsSchema = z.object({ + apiKeyId: z.string().nonempty() +}); + +async function query(apiKeyId: string) { + return await db + .select({ + apiKeyId: apiKeys.apiKeyId, + lastChars: apiKeys.lastChars, + createdAt: apiKeys.createdAt, + isRoot: apiKeys.isRoot, + name: apiKeys.name + }) + .from(apiKeys) + .where(eq(apiKeys.apiKeyId, apiKeyId)) + .limit(1); +} + +export type GetApiKeyResponse = NonNullable< + Awaited>[0] +>; + +export async function getApiKey( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = paramsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { apiKeyId } = parsedParams.data; + + const [apiKey] = await query(apiKeyId); + + if (!apiKey) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `API Key with ID ${apiKeyId} not found` + ) + ); + } + + return response(res, { + data: apiKey, + success: true, + error: false, + message: "API key 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/apiKeys/index.ts b/server/routers/apiKeys/index.ts new file mode 100644 index 00000000..84d4ee68 --- /dev/null +++ b/server/routers/apiKeys/index.ts @@ -0,0 +1,16 @@ +// This file is licensed under the Fossorial Commercial License. +// Unauthorized use, copying, modification, or distribution is strictly prohibited. +// +// Copyright (c) 2025 Fossorial LLC. All rights reserved. + +export * from "./createRootApiKey"; +export * from "./deleteApiKey"; +export * from "./getApiKey"; +export * from "./listApiKeyActions"; +export * from "./listOrgApiKeys"; +export * from "./listApiKeyActions"; +export * from "./listRootApiKeys"; +export * from "./setApiKeyActions"; +export * from "./setApiKeyOrgs"; +export * from "./createOrgApiKey"; +export * from "./deleteOrgApiKey"; diff --git a/server/routers/apiKeys/listApiKeyActions.ts b/server/routers/apiKeys/listApiKeyActions.ts new file mode 100644 index 00000000..0cf694a0 --- /dev/null +++ b/server/routers/apiKeys/listApiKeyActions.ts @@ -0,0 +1,118 @@ +// This file is licensed under the Fossorial Commercial License. +// Unauthorized use, copying, modification, or distribution is strictly prohibited. +// +// Copyright (c) 2025 Fossorial LLC. All rights reserved. + +import { db } from "@server/db"; +import { actions, apiKeyActions, apiKeyOrg, apiKeys } from "@server/db/schemas"; +import logger from "@server/logger"; +import HttpCode from "@server/types/HttpCode"; +import response from "@server/lib/response"; +import { NextFunction, Request, Response } from "express"; +import createHttpError from "http-errors"; +import { z } from "zod"; +import { fromError } from "zod-validation-error"; +import { eq } from "drizzle-orm"; +import { OpenAPITags, registry } from "@server/openApi"; + +const paramsSchema = z.object({ + apiKeyId: z.string().nonempty() +}); + +const querySchema = 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 queryActions(apiKeyId: string) { + return db + .select({ + actionId: actions.actionId + }) + .from(apiKeyActions) + .where(eq(apiKeyActions.apiKeyId, apiKeyId)) + .innerJoin(actions, eq(actions.actionId, apiKeyActions.actionId)); +} + +export type ListApiKeyActionsResponse = { + actions: Awaited>; + pagination: { total: number; limit: number; offset: number }; +}; + +registry.registerPath({ + method: "get", + path: "/org/{orgId}/api-key/{apiKeyId}/actions", + description: + "List all actions set for an API key.", + tags: [OpenAPITags.Org, OpenAPITags.ApiKey], + request: { + params: paramsSchema, + query: querySchema + }, + responses: {} +}); + +export async function listApiKeyActions( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedQuery = querySchema.safeParse(req.query); + if (!parsedQuery.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedQuery.error) + ) + ); + } + + const parsedParams = paramsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error) + ) + ); + } + + const { limit, offset } = parsedQuery.data; + const { apiKeyId } = parsedParams.data; + + const baseQuery = queryActions(apiKeyId); + + const actionsList = await baseQuery.limit(limit).offset(offset); + + return response(res, { + data: { + actions: actionsList, + pagination: { + total: actionsList.length, + limit, + offset + } + }, + success: true, + error: false, + message: "API keys 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/apiKeys/listOrgApiKeys.ts b/server/routers/apiKeys/listOrgApiKeys.ts new file mode 100644 index 00000000..a0169074 --- /dev/null +++ b/server/routers/apiKeys/listOrgApiKeys.ts @@ -0,0 +1,121 @@ +// This file is licensed under the Fossorial Commercial License. +// Unauthorized use, copying, modification, or distribution is strictly prohibited. +// +// Copyright (c) 2025 Fossorial LLC. All rights reserved. + +import { db } from "@server/db"; +import { apiKeyOrg, apiKeys } from "@server/db/schemas"; +import logger from "@server/logger"; +import HttpCode from "@server/types/HttpCode"; +import response from "@server/lib/response"; +import { NextFunction, Request, Response } from "express"; +import createHttpError from "http-errors"; +import { z } from "zod"; +import { fromError } from "zod-validation-error"; +import { eq, and } from "drizzle-orm"; +import { OpenAPITags, registry } from "@server/openApi"; + +const querySchema = 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()) +}); + +const paramsSchema = z.object({ + orgId: z.string() +}); + +function queryApiKeys(orgId: string) { + return db + .select({ + apiKeyId: apiKeys.apiKeyId, + orgId: apiKeyOrg.orgId, + lastChars: apiKeys.lastChars, + createdAt: apiKeys.createdAt, + name: apiKeys.name + }) + .from(apiKeyOrg) + .where(and(eq(apiKeyOrg.orgId, orgId), eq(apiKeys.isRoot, false))) + .innerJoin(apiKeys, eq(apiKeys.apiKeyId, apiKeyOrg.apiKeyId)); +} + +export type ListOrgApiKeysResponse = { + apiKeys: Awaited>; + pagination: { total: number; limit: number; offset: number }; +}; + +registry.registerPath({ + method: "get", + path: "/org/{orgId}/api-keys", + description: "List all API keys for an organization", + tags: [OpenAPITags.Org, OpenAPITags.ApiKey], + request: { + params: paramsSchema, + query: querySchema + }, + responses: {} +}); + +export async function listOrgApiKeys( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedQuery = querySchema.safeParse(req.query); + if (!parsedQuery.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedQuery.error) + ) + ); + } + + const parsedParams = paramsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error) + ) + ); + } + + const { limit, offset } = parsedQuery.data; + const { orgId } = parsedParams.data; + + const baseQuery = queryApiKeys(orgId); + + const apiKeysList = await baseQuery.limit(limit).offset(offset); + + return response(res, { + data: { + apiKeys: apiKeysList, + pagination: { + total: apiKeysList.length, + limit, + offset + } + }, + success: true, + error: false, + message: "API keys 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/apiKeys/listRootApiKeys.ts b/server/routers/apiKeys/listRootApiKeys.ts new file mode 100644 index 00000000..7feca733 --- /dev/null +++ b/server/routers/apiKeys/listRootApiKeys.ts @@ -0,0 +1,90 @@ +// This file is licensed under the Fossorial Commercial License. +// Unauthorized use, copying, modification, or distribution is strictly prohibited. +// +// Copyright (c) 2025 Fossorial LLC. All rights reserved. + +import { db } from "@server/db"; +import { apiKeys } from "@server/db/schemas"; +import logger from "@server/logger"; +import HttpCode from "@server/types/HttpCode"; +import response from "@server/lib/response"; +import { NextFunction, Request, Response } from "express"; +import createHttpError from "http-errors"; +import { z } from "zod"; +import { fromError } from "zod-validation-error"; +import { eq } from "drizzle-orm"; + +const querySchema = 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 queryApiKeys() { + return db + .select({ + apiKeyId: apiKeys.apiKeyId, + lastChars: apiKeys.lastChars, + createdAt: apiKeys.createdAt, + name: apiKeys.name + }) + .from(apiKeys) + .where(eq(apiKeys.isRoot, true)); +} + +export type ListRootApiKeysResponse = { + apiKeys: Awaited>; + pagination: { total: number; limit: number; offset: number }; +}; + +export async function listRootApiKeys( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedQuery = querySchema.safeParse(req.query); + if (!parsedQuery.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedQuery.error) + ) + ); + } + const { limit, offset } = parsedQuery.data; + + const baseQuery = queryApiKeys(); + + const apiKeysList = await baseQuery.limit(limit).offset(offset); + + return response(res, { + data: { + apiKeys: apiKeysList, + pagination: { + total: apiKeysList.length, + limit, + offset + } + }, + success: true, + error: false, + message: "API keys 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/apiKeys/setApiKeyActions.ts b/server/routers/apiKeys/setApiKeyActions.ts new file mode 100644 index 00000000..187dd114 --- /dev/null +++ b/server/routers/apiKeys/setApiKeyActions.ts @@ -0,0 +1,141 @@ +// This file is licensed under the Fossorial Commercial License. +// Unauthorized use, copying, modification, or distribution is strictly prohibited. +// +// Copyright (c) 2025 Fossorial LLC. All rights reserved. + +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db } from "@server/db"; +import { actions, apiKeyActions } from "@server/db/schemas"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; +import { eq, and, inArray } from "drizzle-orm"; +import { OpenAPITags, registry } from "@server/openApi"; + +const bodySchema = z + .object({ + actionIds: z + .array(z.string().nonempty()) + .transform((v) => Array.from(new Set(v))) + }) + .strict(); + +const paramsSchema = z.object({ + apiKeyId: z.string().nonempty() +}); + +registry.registerPath({ + method: "post", + path: "/org/{orgId}/api-key/{apiKeyId}/actions", + description: + "Set actions for an API key. This will replace any existing actions.", + tags: [OpenAPITags.Org, OpenAPITags.ApiKey], + request: { + params: paramsSchema, + body: { + content: { + "application/json": { + schema: bodySchema + } + } + } + }, + responses: {} +}); + +export async function setApiKeyActions( + 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 { actionIds: newActionIds } = parsedBody.data; + + const parsedParams = paramsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { apiKeyId } = parsedParams.data; + + const actionsExist = await db + .select() + .from(actions) + .where(inArray(actions.actionId, newActionIds)); + + if (actionsExist.length !== newActionIds.length) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "One or more actions do not exist" + ) + ); + } + + await db.transaction(async (trx) => { + const existingActions = await trx + .select() + .from(apiKeyActions) + .where(eq(apiKeyActions.apiKeyId, apiKeyId)); + + const existingActionIds = existingActions.map((a) => a.actionId); + + const actionIdsToAdd = newActionIds.filter( + (id) => !existingActionIds.includes(id) + ); + const actionIdsToRemove = existingActionIds.filter( + (id) => !newActionIds.includes(id) + ); + + if (actionIdsToRemove.length > 0) { + await trx + .delete(apiKeyActions) + .where( + and( + eq(apiKeyActions.apiKeyId, apiKeyId), + inArray(apiKeyActions.actionId, actionIdsToRemove) + ) + ); + } + + if (actionIdsToAdd.length > 0) { + const insertValues = actionIdsToAdd.map((actionId) => ({ + apiKeyId, + actionId + })); + await trx.insert(apiKeyActions).values(insertValues); + } + }); + + return response(res, { + data: {}, + success: true, + error: false, + message: "API key actions 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/apiKeys/setApiKeyOrgs.ts b/server/routers/apiKeys/setApiKeyOrgs.ts new file mode 100644 index 00000000..ee0611d3 --- /dev/null +++ b/server/routers/apiKeys/setApiKeyOrgs.ts @@ -0,0 +1,122 @@ +// This file is licensed under the Fossorial Commercial License. +// Unauthorized use, copying, modification, or distribution is strictly prohibited. +// +// Copyright (c) 2025 Fossorial LLC. All rights reserved. + +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db } from "@server/db"; +import { apiKeyOrg, orgs } from "@server/db/schemas"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; +import { eq, and, inArray } from "drizzle-orm"; + +const bodySchema = z + .object({ + orgIds: z + .array(z.string().nonempty()) + .transform((v) => Array.from(new Set(v))) + }) + .strict(); + +const paramsSchema = z.object({ + apiKeyId: z.string().nonempty() +}); + +export async function setApiKeyOrgs( + 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 { orgIds: newOrgIds } = parsedBody.data; + + const parsedParams = paramsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { apiKeyId } = parsedParams.data; + + // make sure all orgs exist + const allOrgs = await db + .select() + .from(orgs) + .where(inArray(orgs.orgId, newOrgIds)); + + if (allOrgs.length !== newOrgIds.length) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "One or more orgs do not exist" + ) + ); + } + + await db.transaction(async (trx) => { + const existingOrgs = await trx + .select({ orgId: apiKeyOrg.orgId }) + .from(apiKeyOrg) + .where(eq(apiKeyOrg.apiKeyId, apiKeyId)); + + const existingOrgIds = existingOrgs.map((a) => a.orgId); + + const orgIdsToAdd = newOrgIds.filter( + (id) => !existingOrgIds.includes(id) + ); + const orgIdsToRemove = existingOrgIds.filter( + (id) => !newOrgIds.includes(id) + ); + + if (orgIdsToRemove.length > 0) { + await trx + .delete(apiKeyOrg) + .where( + and( + eq(apiKeyOrg.apiKeyId, apiKeyId), + inArray(apiKeyOrg.orgId, orgIdsToRemove) + ) + ); + } + + if (orgIdsToAdd.length > 0) { + const insertValues = orgIdsToAdd.map((orgId) => ({ + apiKeyId, + orgId + })); + await trx.insert(apiKeyOrg).values(insertValues); + } + + return response(res, { + data: {}, + success: true, + error: false, + message: "API key orgs 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/auth/changePassword.ts b/server/routers/auth/changePassword.ts index 3be9ef2e..3b1e4c2f 100644 --- a/server/routers/auth/changePassword.ts +++ b/server/routers/auth/changePassword.ts @@ -16,6 +16,7 @@ import logger from "@server/logger"; import { unauthorized } from "@server/auth/unauthorizedResponse"; import { invalidateAllSessions } from "@server/auth/sessions/app"; import { passwordSchema } from "@server/auth/passwordSchema"; +import { UserType } from "@server/types/UserTypes"; export const changePasswordBody = z .object({ @@ -50,6 +51,15 @@ export async function changePassword( const { newPassword, oldPassword, code } = parsedBody.data; const user = req.user as User; + if (user.type !== UserType.Internal) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Two-factor authentication is not supported for external users" + ) + ); + } + try { if (newPassword === oldPassword) { return next( @@ -62,7 +72,7 @@ export async function changePassword( const validPassword = await verifyPassword( oldPassword, - user.passwordHash + user.passwordHash! ); if (!validPassword) { return next(unauthorized()); diff --git a/server/routers/auth/disable2fa.ts b/server/routers/auth/disable2fa.ts index 45644461..b10dd9b2 100644 --- a/server/routers/auth/disable2fa.ts +++ b/server/routers/auth/disable2fa.ts @@ -14,6 +14,7 @@ import { sendEmail } from "@server/emails"; import TwoFactorAuthNotification from "@server/emails/templates/TwoFactorAuthNotification"; import config from "@server/lib/config"; import { unauthorized } from "@server/auth/unauthorizedResponse"; +import { UserType } from "@server/types/UserTypes"; export const disable2faBody = z .object({ @@ -47,8 +48,17 @@ export async function disable2fa( const { password, code } = parsedBody.data; const user = req.user as User; + if (user.type !== UserType.Internal) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Two-factor authentication is not supported for external users" + ) + ); + } + try { - const validPassword = await verifyPassword(password, user.passwordHash); + const validPassword = await verifyPassword(password, user.passwordHash!); if (!validPassword) { return next(unauthorized()); } @@ -99,11 +109,11 @@ export async function disable2fa( sendEmail( TwoFactorAuthNotification({ - email: user.email, + email: user.email!, // email is not null because we are checking user.type enabled: false }), { - to: user.email, + to: user.email!, from: config.getRawConfig().email?.no_reply, subject: "Two-factor authentication disabled" } diff --git a/server/routers/auth/login.ts b/server/routers/auth/login.ts index aa4f0d53..eda637fa 100644 --- a/server/routers/auth/login.ts +++ b/server/routers/auth/login.ts @@ -7,7 +7,7 @@ import db from "@server/db"; import { users } from "@server/db/schemas"; import HttpCode from "@server/types/HttpCode"; import response from "@server/lib/response"; -import { eq } from "drizzle-orm"; +import { eq, and } from "drizzle-orm"; import { NextFunction, Request, Response } from "express"; import createHttpError from "http-errors"; import { z } from "zod"; @@ -17,6 +17,7 @@ import config from "@server/lib/config"; import logger from "@server/logger"; import { verifyPassword } from "@server/auth/password"; import { verifySession } from "@server/auth/sessions/verifySession"; +import { UserType } from "@server/types/UserTypes"; export const loginBodySchema = z .object({ @@ -69,7 +70,9 @@ export async function login( const existingUserRes = await db .select() .from(users) - .where(eq(users.email, email)); + .where( + and(eq(users.type, UserType.Internal), eq(users.email, email)) + ); if (!existingUserRes || !existingUserRes.length) { if (config.getRawConfig().app.log_failed_attempts) { logger.info( @@ -88,7 +91,7 @@ export async function login( const validPassword = await verifyPassword( password, - existingUser.passwordHash + existingUser.passwordHash! ); if (!validPassword) { if (config.getRawConfig().app.log_failed_attempts) { diff --git a/server/routers/auth/requestEmailVerificationCode.ts b/server/routers/auth/requestEmailVerificationCode.ts index 47747a95..0cc8825c 100644 --- a/server/routers/auth/requestEmailVerificationCode.ts +++ b/server/routers/auth/requestEmailVerificationCode.ts @@ -6,6 +6,7 @@ import { User } from "@server/db/schemas"; import { sendEmailVerificationCode } from "../../auth/sendEmailVerificationCode"; import config from "@server/lib/config"; import logger from "@server/logger"; +import { UserType } from "@server/types/UserTypes"; export type RequestEmailVerificationCodeResponse = { codeSent: boolean; @@ -28,6 +29,15 @@ export async function requestEmailVerificationCode( try { const user = req.user as User; + if (user.type !== UserType.Internal) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Email verification is not supported for external users" + ) + ); + } + if (user.emailVerified) { return next( createHttpError( @@ -37,7 +47,7 @@ export async function requestEmailVerificationCode( ); } - await sendEmailVerificationCode(user.email, user.userId); + await sendEmailVerificationCode(user.email!, user.userId); return response(res, { data: { diff --git a/server/routers/auth/requestPasswordReset.ts b/server/routers/auth/requestPasswordReset.ts index 20a6511a..087352f0 100644 --- a/server/routers/auth/requestPasswordReset.ts +++ b/server/routers/auth/requestPasswordReset.ts @@ -74,7 +74,7 @@ export async function requestPasswordReset( await trx.insert(passwordResetTokens).values({ userId: existingUser[0].userId, - email: existingUser[0].email, + email: existingUser[0].email!, tokenHash, expiresAt: createDate(new TimeSpan(2, "h")).getTime() }); diff --git a/server/routers/auth/requestTotpSecret.ts b/server/routers/auth/requestTotpSecret.ts index c60904ce..a4f8bc4a 100644 --- a/server/routers/auth/requestTotpSecret.ts +++ b/server/routers/auth/requestTotpSecret.ts @@ -12,6 +12,7 @@ import { createTOTPKeyURI } from "oslo/otp"; import logger from "@server/logger"; import { verifyPassword } from "@server/auth/password"; import { unauthorized } from "@server/auth/unauthorizedResponse"; +import { UserType } from "@server/types/UserTypes"; export const requestTotpSecretBody = z .object({ @@ -46,8 +47,17 @@ export async function requestTotpSecret( const user = req.user as User; + if (user.type !== UserType.Internal) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Two-factor authentication is not supported for external users" + ) + ); + } + try { - const validPassword = await verifyPassword(password, user.passwordHash); + const validPassword = await verifyPassword(password, user.passwordHash!); if (!validPassword) { return next(unauthorized()); } @@ -63,7 +73,7 @@ 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 833850ce..564a1378 100644 --- a/server/routers/auth/signup.ts +++ b/server/routers/auth/signup.ts @@ -8,7 +8,7 @@ import createHttpError from "http-errors"; import response from "@server/lib/response"; import { SqliteError } from "better-sqlite3"; import { sendEmailVerificationCode } from "../../auth/sendEmailVerificationCode"; -import { eq } from "drizzle-orm"; +import { eq, and } from "drizzle-orm"; import moment from "moment"; import { createSession, @@ -21,6 +21,7 @@ import logger from "@server/logger"; import { hashPassword } from "@server/auth/password"; import { checkValidInvite } from "@server/auth/checkValidInvite"; import { passwordSchema } from "@server/auth/passwordSchema"; +import { UserType } from "@server/types/UserTypes"; export const signupBodySchema = z.object({ email: z @@ -110,7 +111,9 @@ export async function signup( const existing = await db .select() .from(users) - .where(eq(users.email, email)); + .where( + and(eq(users.email, email), eq(users.type, UserType.Internal)) + ); if (existing && existing.length > 0) { if (!config.getRawConfig().flags?.require_email_verification) { @@ -157,6 +160,8 @@ export async function signup( await db.insert(users).values({ userId: userId, + type: UserType.Internal, + username: email, email: email, passwordHash, dateCreated: moment().toISOString() diff --git a/server/routers/auth/verifyTotp.ts b/server/routers/auth/verifyTotp.ts index a349d79d..db4ec1a1 100644 --- a/server/routers/auth/verifyTotp.ts +++ b/server/routers/auth/verifyTotp.ts @@ -14,6 +14,7 @@ import logger from "@server/logger"; import { sendEmail } from "@server/emails"; import TwoFactorAuthNotification from "@server/emails/templates/TwoFactorAuthNotification"; import config from "@server/lib/config"; +import { UserType } from "@server/types/UserTypes"; export const verifyTotpBody = z .object({ @@ -48,6 +49,15 @@ export async function verifyTotp( const user = req.user as User; + if (user.type !== UserType.Internal) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Two-factor authentication is not supported for external users" + ) + ); + } + if (user.twoFactorEnabled) { return next( createHttpError( @@ -111,11 +121,11 @@ export async function verifyTotp( sendEmail( TwoFactorAuthNotification({ - email: user.email, + email: user.email!, enabled: true }), { - to: user.email, + to: user.email!, from: config.getRawConfig().email?.no_reply, subject: "Two-factor authentication enabled" } diff --git a/server/routers/domain/listDomains.ts b/server/routers/domain/listDomains.ts index a1cbbb3f..c525e1d8 100644 --- a/server/routers/domain/listDomains.ts +++ b/server/routers/domain/listDomains.ts @@ -8,6 +8,7 @@ import createHttpError from "http-errors"; import { eq, sql } from "drizzle-orm"; import logger from "@server/logger"; import { fromError } from "zod-validation-error"; +import { OpenAPITags, registry } from "@server/openApi"; const listDomainsParamsSchema = z .object({ @@ -51,6 +52,20 @@ export type ListDomainsResponse = { pagination: { total: number; limit: number; offset: number }; }; +registry.registerPath({ + method: "get", + path: "/org/{orgId}/domains", + description: "List all domains for a organization.", + tags: [OpenAPITags.Org], + request: { + params: z.object({ + orgId: z.string() + }), + query: listDomainsSchema + }, + responses: {} +}); + export async function listDomains( req: Request, res: Response, diff --git a/server/routers/external.ts b/server/routers/external.ts index 91a21995..d631c377 100644 --- a/server/routers/external.ts +++ b/server/routers/external.ts @@ -10,6 +10,9 @@ import * as auth from "./auth"; import * as role from "./role"; import * as supporterKey from "./supporterKey"; import * as accessToken from "./accessToken"; +import * as idp from "./idp"; +import * as license from "./license"; +import * as apiKeys from "./apiKeys"; import HttpCode from "@server/types/HttpCode"; import { verifyAccessTokenAccess, @@ -24,7 +27,10 @@ import { verifySetResourceUsers, verifyUserAccess, getUserOrgs, - verifyUserIsServerAdmin + verifyUserIsServerAdmin, + verifyIsLoggedInUser, + verifyApiKeyAccess, + verifyValidLicense } from "@server/middlewares"; import { verifyUserHasAction } from "../middlewares/verifyUserHasAction"; import { ActionsEnum } from "@server/auth/actions"; @@ -46,7 +52,10 @@ authenticated.use(verifySessionUserMiddleware); authenticated.get("/org/checkId", org.checkId); authenticated.put("/org", getUserOrgs, org.createOrg); -authenticated.get("/orgs", getUserOrgs, org.listOrgs); // TODO we need to check the orgs here + +authenticated.get("/orgs", verifyUserIsServerAdmin, org.listOrgs); +authenticated.get("/user/:userId/orgs", verifyIsLoggedInUser, org.listUserOrgs); + authenticated.get( "/org/:orgId", verifyOrgAccess, @@ -143,6 +152,20 @@ authenticated.get( domain.listDomains ); +authenticated.get( + "/org/:orgId/invitations", + verifyOrgAccess, + verifyUserHasAction(ActionsEnum.listInvitations), + user.listInvitations +); + +authenticated.delete( + "/org/:orgId/invitations/:inviteId", + verifyOrgAccess, + verifyUserHasAction(ActionsEnum.removeInvitation), + user.removeInvitation +); + authenticated.post( "/org/:orgId/create-invite", verifyOrgAccess, @@ -429,7 +452,15 @@ authenticated.delete( user.adminRemoveUser ); +authenticated.put( + "/org/:orgId/user", + verifyOrgAccess, + verifyUserHasAction(ActionsEnum.createOrgUser), + user.createOrgUser +); + authenticated.get("/org/:orgId/user/:userId", verifyOrgAccess, user.getOrgUser); + authenticated.get( "/org/:orgId/users", verifyOrgAccess, @@ -479,6 +510,174 @@ authenticated.delete( // createNewt // ); +authenticated.put( + "/idp/oidc", + verifyUserIsServerAdmin, + // verifyUserHasAction(ActionsEnum.createIdp), + idp.createOidcIdp +); + +authenticated.post( + "/idp/:idpId/oidc", + verifyUserIsServerAdmin, + idp.updateOidcIdp +); + +authenticated.delete("/idp/:idpId", verifyUserIsServerAdmin, idp.deleteIdp); + +authenticated.get("/idp", verifyUserIsServerAdmin, idp.listIdps); + +authenticated.get("/idp/:idpId", verifyUserIsServerAdmin, idp.getIdp); + +authenticated.put( + "/idp/:idpId/org/:orgId", + verifyValidLicense, + verifyUserIsServerAdmin, + idp.createIdpOrgPolicy +); + +authenticated.post( + "/idp/:idpId/org/:orgId", + verifyValidLicense, + verifyUserIsServerAdmin, + idp.updateIdpOrgPolicy +); + +authenticated.delete( + "/idp/:idpId/org/:orgId", + verifyValidLicense, + verifyUserIsServerAdmin, + idp.deleteIdpOrgPolicy +); + +authenticated.get( + "/idp/:idpId/org", + verifyValidLicense, + verifyUserIsServerAdmin, + idp.listIdpOrgPolicies +); + +authenticated.get("/idp", idp.listIdps); // anyone can see this; it's just a list of idp names and ids +authenticated.get("/idp/:idpId", verifyUserIsServerAdmin, idp.getIdp); + +authenticated.post( + "/license/activate", + verifyUserIsServerAdmin, + license.activateLicense +); + +authenticated.get( + "/license/keys", + verifyUserIsServerAdmin, + license.listLicenseKeys +); + +authenticated.delete( + "/license/:licenseKey", + verifyUserIsServerAdmin, + license.deleteLicenseKey +); + +authenticated.post( + "/license/recheck", + verifyUserIsServerAdmin, + license.recheckStatus +); + +authenticated.get( + `/api-key/:apiKeyId`, + verifyValidLicense, + verifyUserIsServerAdmin, + apiKeys.getApiKey +); + +authenticated.put( + `/api-key`, + verifyValidLicense, + verifyUserIsServerAdmin, + apiKeys.createRootApiKey +); + +authenticated.delete( + `/api-key/:apiKeyId`, + verifyValidLicense, + verifyUserIsServerAdmin, + apiKeys.deleteApiKey +); + +authenticated.get( + `/api-keys`, + verifyValidLicense, + verifyUserIsServerAdmin, + apiKeys.listRootApiKeys +); + +authenticated.get( + `/api-key/:apiKeyId/actions`, + verifyValidLicense, + verifyUserIsServerAdmin, + apiKeys.listApiKeyActions +); + +authenticated.post( + `/api-key/:apiKeyId/actions`, + verifyValidLicense, + verifyUserIsServerAdmin, + apiKeys.setApiKeyActions +); + +authenticated.get( + `/org/:orgId/api-keys`, + verifyValidLicense, + verifyOrgAccess, + verifyUserHasAction(ActionsEnum.listApiKeys), + apiKeys.listOrgApiKeys +); + +authenticated.post( + `/org/:orgId/api-key/:apiKeyId/actions`, + verifyValidLicense, + verifyOrgAccess, + verifyApiKeyAccess, + verifyUserHasAction(ActionsEnum.setApiKeyActions), + apiKeys.setApiKeyActions +); + +authenticated.get( + `/org/:orgId/api-key/:apiKeyId/actions`, + verifyValidLicense, + verifyOrgAccess, + verifyApiKeyAccess, + verifyUserHasAction(ActionsEnum.listApiKeyActions), + apiKeys.listApiKeyActions +); + +authenticated.put( + `/org/:orgId/api-key`, + verifyValidLicense, + verifyOrgAccess, + verifyUserHasAction(ActionsEnum.createApiKey), + apiKeys.createOrgApiKey +); + +authenticated.delete( + `/org/:orgId/api-key/:apiKeyId`, + verifyValidLicense, + verifyOrgAccess, + verifyApiKeyAccess, + verifyUserHasAction(ActionsEnum.deleteApiKey), + apiKeys.deleteOrgApiKey +); + +authenticated.get( + `/org/:orgId/api-key/:apiKeyId`, + verifyValidLicense, + verifyOrgAccess, + verifyApiKeyAccess, + verifyUserHasAction(ActionsEnum.getApiKey), + apiKeys.getApiKey +); + // Auth routes export const authRouter = Router(); unauthenticated.use("/auth", authRouter); @@ -567,7 +766,8 @@ authRouter.post( resource.authWithAccessToken ); -authRouter.post( - "/access-token", - resource.authWithAccessToken -); +authRouter.post("/access-token", resource.authWithAccessToken); + +authRouter.post("/idp/:idpId/oidc/generate-url", idp.generateOidcUrl); + +authRouter.post("/idp/:idpId/oidc/validate-callback", idp.validateOidcCallback); diff --git a/server/routers/idp/createIdpOrgPolicy.ts b/server/routers/idp/createIdpOrgPolicy.ts new file mode 100644 index 00000000..ae5acce4 --- /dev/null +++ b/server/routers/idp/createIdpOrgPolicy.ts @@ -0,0 +1,129 @@ +// This file is licensed under the Fossorial Commercial License. +// Unauthorized use, copying, modification, or distribution is strictly prohibited. +// +// Copyright (c) 2025 Fossorial LLC. All rights reserved. + +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db } from "@server/db"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; +import { OpenAPITags, registry } from "@server/openApi"; +import config from "@server/lib/config"; +import { eq, and } from "drizzle-orm"; +import { idp, idpOrg } from "@server/db/schemas"; + +const paramsSchema = z + .object({ + idpId: z.coerce.number(), + orgId: z.string() + }) + .strict(); + +const bodySchema = z + .object({ + roleMapping: z.string().optional(), + orgMapping: z.string().optional() + }) + .strict(); + +export type CreateIdpOrgPolicyResponse = {}; + +registry.registerPath({ + method: "put", + path: "/idp/{idpId}/org/{orgId}", + description: "Create an IDP policy for an existing IDP on an organization.", + tags: [OpenAPITags.Idp], + request: { + params: paramsSchema, + body: { + content: { + "application/json": { + schema: bodySchema + } + } + } + }, + responses: {} +}); + +export async function createIdpOrgPolicy( + 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 { idpId, orgId } = parsedParams.data; + const { roleMapping, orgMapping } = parsedBody.data; + + const [existing] = await db + .select() + .from(idp) + .leftJoin( + idpOrg, + and(eq(idpOrg.orgId, orgId), eq(idpOrg.idpId, idpId)) + ) + .where(eq(idp.idpId, idpId)); + + if (!existing?.idp) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "An IDP with this ID does not exist." + ) + ); + } + + if (existing.idpOrg) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "An IDP org policy already exists." + ) + ); + } + + await db.insert(idpOrg).values({ + idpId, + orgId, + roleMapping, + orgMapping + }); + + return response(res, { + data: {}, + success: true, + error: false, + message: "Idp 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/idp/createOidcIdp.ts b/server/routers/idp/createOidcIdp.ts new file mode 100644 index 00000000..d663afef --- /dev/null +++ b/server/routers/idp/createOidcIdp.ts @@ -0,0 +1,137 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db } from "@server/db"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; +import { OpenAPITags, registry } from "@server/openApi"; +import { idp, idpOidcConfig, idpOrg, orgs } from "@server/db/schemas"; +import { generateOidcRedirectUrl } from "@server/lib/idp/generateRedirectUrl"; +import { encrypt } from "@server/lib/crypto"; +import config from "@server/lib/config"; +import license from "@server/license/license"; + +const paramsSchema = z.object({}).strict(); + +const bodySchema = z + .object({ + name: z.string().nonempty(), + clientId: z.string().nonempty(), + clientSecret: z.string().nonempty(), + authUrl: z.string().url(), + tokenUrl: z.string().url(), + identifierPath: z.string().nonempty(), + emailPath: z.string().optional(), + namePath: z.string().optional(), + scopes: z.string().nonempty(), + autoProvision: z.boolean().optional() + }) + .strict(); + +export type CreateIdpResponse = { + idpId: number; + redirectUrl: string; +}; + +registry.registerPath({ + method: "put", + path: "/idp/oidc", + description: "Create an OIDC IdP.", + tags: [OpenAPITags.Idp], + request: { + body: { + content: { + "application/json": { + schema: bodySchema + } + } + } + }, + responses: {} +}); + +export async function createOidcIdp( + 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() + ) + ); + } + + let { + clientId, + clientSecret, + authUrl, + tokenUrl, + scopes, + identifierPath, + emailPath, + namePath, + name, + autoProvision + } = parsedBody.data; + + if (!(await license.isUnlocked())) { + autoProvision = false; + } + + const key = config.getRawConfig().server.secret; + + const encryptedSecret = encrypt(clientSecret, key); + const encryptedClientId = encrypt(clientId, key); + + let idpId: number | undefined; + await db.transaction(async (trx) => { + const [idpRes] = await trx + .insert(idp) + .values({ + name, + autoProvision, + type: "oidc" + }) + .returning(); + + idpId = idpRes.idpId; + + await trx.insert(idpOidcConfig).values({ + idpId: idpRes.idpId, + clientId: encryptedClientId, + clientSecret: encryptedSecret, + authUrl, + tokenUrl, + scopes, + identifierPath, + emailPath, + namePath + }); + }); + + const redirectUrl = generateOidcRedirectUrl(idpId as number); + + return response(res, { + data: { + idpId: idpId as number, + redirectUrl + }, + success: true, + error: false, + message: "Idp 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/idp/deleteIdp.ts b/server/routers/idp/deleteIdp.ts new file mode 100644 index 00000000..ac84c4f7 --- /dev/null +++ b/server/routers/idp/deleteIdp.ts @@ -0,0 +1,94 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db } from "@server/db"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; +import { idp, idpOidcConfig, idpOrg } from "@server/db/schemas"; +import { eq } from "drizzle-orm"; +import { OpenAPITags, registry } from "@server/openApi"; + +const paramsSchema = z + .object({ + idpId: z.coerce.number() + }) + .strict(); + +registry.registerPath({ + method: "delete", + path: "/idp/{idpId}", + description: "Delete IDP.", + tags: [OpenAPITags.Idp], + request: { + params: paramsSchema + }, + responses: {} +}); + +export async function deleteIdp( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = paramsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { idpId } = parsedParams.data; + + // Check if IDP exists + const [existingIdp] = await db + .select() + .from(idp) + .where(eq(idp.idpId, idpId)); + + if (!existingIdp) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + "IdP not found" + ) + ); + } + + // Delete the IDP and its related records in a transaction + await db.transaction(async (trx) => { + // Delete OIDC config if it exists + await trx + .delete(idpOidcConfig) + .where(eq(idpOidcConfig.idpId, idpId)); + + // Delete IDP-org mappings + await trx + .delete(idpOrg) + .where(eq(idpOrg.idpId, idpId)); + + // Delete the IDP itself + await trx + .delete(idp) + .where(eq(idp.idpId, idpId)); + }); + + return response(res, { + data: null, + success: true, + error: false, + message: "IdP 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/idp/deleteIdpOrgPolicy.ts b/server/routers/idp/deleteIdpOrgPolicy.ts new file mode 100644 index 00000000..5c41c958 --- /dev/null +++ b/server/routers/idp/deleteIdpOrgPolicy.ts @@ -0,0 +1,95 @@ +// This file is licensed under the Fossorial Commercial License. +// Unauthorized use, copying, modification, or distribution is strictly prohibited. +// +// Copyright (c) 2025 Fossorial LLC. All rights reserved. + +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db } from "@server/db"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; +import { idp, idpOrg } from "@server/db/schemas"; +import { eq, and } from "drizzle-orm"; +import { OpenAPITags, registry } from "@server/openApi"; + +const paramsSchema = z + .object({ + idpId: z.coerce.number(), + orgId: z.string() + }) + .strict(); + +registry.registerPath({ + method: "delete", + path: "/idp/{idpId}/org/{orgId}", + description: "Create an OIDC IdP for an organization.", + tags: [OpenAPITags.Idp], + request: { + params: paramsSchema + }, + responses: {} +}); + +export async function deleteIdpOrgPolicy( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = paramsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { idpId, orgId } = parsedParams.data; + + const [existing] = await db + .select() + .from(idp) + .leftJoin(idpOrg, eq(idpOrg.orgId, orgId)) + .where(and(eq(idp.idpId, idpId), eq(idpOrg.orgId, orgId))); + + if (!existing.idp) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "An IDP with this ID does not exist." + ) + ); + } + + if (!existing.idpOrg) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "A policy for this IDP and org does not exist." + ) + ); + } + + await db + .delete(idpOrg) + .where(and(eq(idpOrg.idpId, idpId), eq(idpOrg.orgId, orgId))); + + return response(res, { + data: null, + success: true, + error: false, + message: "Policy 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/idp/generateOidcUrl.ts b/server/routers/idp/generateOidcUrl.ts new file mode 100644 index 00000000..4a62cac2 --- /dev/null +++ b/server/routers/idp/generateOidcUrl.ts @@ -0,0 +1,153 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db } from "@server/db"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; +import { idp, idpOidcConfig, idpOrg } from "@server/db/schemas"; +import { and, eq } from "drizzle-orm"; +import * as arctic from "arctic"; +import { generateOidcRedirectUrl } from "@server/lib/idp/generateRedirectUrl"; +import cookie from "cookie"; +import jsonwebtoken from "jsonwebtoken"; +import config from "@server/lib/config"; +import { decrypt } from "@server/lib/crypto"; + +const paramsSchema = z + .object({ + idpId: z.coerce.number() + }) + .strict(); + +const bodySchema = z + .object({ + redirectUrl: z.string() + }) + .strict(); + +const ensureTrailingSlash = (url: string): string => { + return url.endsWith('/') ? url : `${url}/`; +}; + +export type GenerateOidcUrlResponse = { + redirectUrl: string; +}; + +export async function generateOidcUrl( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = paramsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { idpId } = parsedParams.data; + + const parsedBody = bodySchema.safeParse(req.body); + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + + const { redirectUrl: postAuthRedirectUrl } = parsedBody.data; + + const [existingIdp] = await db + .select() + .from(idp) + .innerJoin(idpOidcConfig, eq(idpOidcConfig.idpId, idp.idpId)) + .where(and(eq(idp.type, "oidc"), eq(idp.idpId, idpId))); + + if (!existingIdp) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "IdP not found for the organization" + ) + ); + } + + const parsedScopes = existingIdp.idpOidcConfig.scopes + .split(" ") + .map((scope) => { + return scope.trim(); + }) + .filter((scope) => { + return scope.length > 0; + }); + + const key = config.getRawConfig().server.secret; + + const decryptedClientId = decrypt( + existingIdp.idpOidcConfig.clientId, + key + ); + const decryptedClientSecret = decrypt( + existingIdp.idpOidcConfig.clientSecret, + key + ); + + const redirectUrl = generateOidcRedirectUrl(idpId); + const client = new arctic.OAuth2Client( + decryptedClientId, + decryptedClientSecret, + redirectUrl + ); + + const codeVerifier = arctic.generateCodeVerifier(); + const state = arctic.generateState(); + const url = client.createAuthorizationURLWithPKCE( + ensureTrailingSlash(existingIdp.idpOidcConfig.authUrl), + state, + arctic.CodeChallengeMethod.S256, + codeVerifier, + parsedScopes + ); + logger.debug("Generated OIDC URL", { url }); + + const stateJwt = jsonwebtoken.sign( + { + redirectUrl: postAuthRedirectUrl, // TODO: validate that this is safe + state, + codeVerifier + }, + config.getRawConfig().server.secret + ); + + res.cookie("p_oidc_state", stateJwt, { + path: "/", + httpOnly: true, + secure: req.protocol === "https", + expires: new Date(Date.now() + 60 * 10 * 1000), + sameSite: "lax" + }); + + return response(res, { + data: { + redirectUrl: url.toString() + }, + success: true, + error: false, + message: "Idp auth url generated", + status: HttpCode.CREATED + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/idp/getIdp.ts b/server/routers/idp/getIdp.ts new file mode 100644 index 00000000..794daade --- /dev/null +++ b/server/routers/idp/getIdp.ts @@ -0,0 +1,97 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db } from "@server/db"; +import { idp, idpOidcConfig } from "@server/db/schemas"; +import { eq } from "drizzle-orm"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; +import { OpenAPITags, registry } from "@server/openApi"; +import config from "@server/lib/config"; +import { decrypt } from "@server/lib/crypto"; + +const paramsSchema = z + .object({ + idpId: z.coerce.number() + }) + .strict(); + +async function query(idpId: number) { + const [res] = await db + .select() + .from(idp) + .where(eq(idp.idpId, idpId)) + .leftJoin(idpOidcConfig, eq(idpOidcConfig.idpId, idp.idpId)) + .limit(1); + return res; +} + +export type GetIdpResponse = NonNullable>>; + +registry.registerPath({ + method: "get", + path: "/idp/{idpId}", + description: "Get an IDP by its IDP ID.", + tags: [OpenAPITags.Idp], + request: { + params: paramsSchema + }, + responses: {} +}); + +export async function getIdp( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = paramsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { idpId } = parsedParams.data; + + const idpRes = await query(idpId); + + if (!idpRes) { + return next(createHttpError(HttpCode.NOT_FOUND, "Idp not found")); + } + + const key = config.getRawConfig().server.secret; + + if (idpRes.idp.type === "oidc") { + const clientSecret = idpRes.idpOidcConfig!.clientSecret; + const clientId = idpRes.idpOidcConfig!.clientId; + + idpRes.idpOidcConfig!.clientSecret = decrypt( + clientSecret, + key + ); + idpRes.idpOidcConfig!.clientId = decrypt( + clientId, + key + ); + } + + return response(res, { + data: idpRes, + success: true, + error: false, + message: "Idp 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/idp/index.ts b/server/routers/idp/index.ts new file mode 100644 index 00000000..f0dcf02e --- /dev/null +++ b/server/routers/idp/index.ts @@ -0,0 +1,11 @@ +export * from "./createOidcIdp"; +export * from "./updateOidcIdp"; +export * from "./deleteIdp"; +export * from "./listIdps"; +export * from "./generateOidcUrl"; +export * from "./validateOidcCallback"; +export * from "./getIdp"; +export * from "./createIdpOrgPolicy"; +export * from "./deleteIdpOrgPolicy"; +export * from "./listIdpOrgPolicies"; +export * from "./updateIdpOrgPolicy"; diff --git a/server/routers/idp/listIdpOrgPolicies.ts b/server/routers/idp/listIdpOrgPolicies.ts new file mode 100644 index 00000000..9ff9c97a --- /dev/null +++ b/server/routers/idp/listIdpOrgPolicies.ts @@ -0,0 +1,121 @@ +// This file is licensed under the Fossorial Commercial License. +// Unauthorized use, copying, modification, or distribution is strictly prohibited. +// +// Copyright (c) 2025 Fossorial LLC. All rights reserved. + +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db } from "@server/db"; +import { idpOrg } from "@server/db/schemas"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import { eq, sql } from "drizzle-orm"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; +import { OpenAPITags, registry } from "@server/openApi"; + +const paramsSchema = z.object({ + idpId: z.coerce.number() +}); + +const querySchema = z + .object({ + limit: z + .string() + .optional() + .default("1000") + .transform(Number) + .pipe(z.number().int().nonnegative()), + offset: z + .string() + .optional() + .default("0") + .transform(Number) + .pipe(z.number().int().nonnegative()) + }) + .strict(); + +async function query(idpId: number, limit: number, offset: number) { + const res = await db + .select() + .from(idpOrg) + .where(eq(idpOrg.idpId, idpId)) + .limit(limit) + .offset(offset); + return res; +} + +export type ListIdpOrgPoliciesResponse = { + policies: NonNullable>>; + pagination: { total: number; limit: number; offset: number }; +}; + +registry.registerPath({ + method: "get", + path: "/idp/{idpId}/org", + description: "List all org policies on an IDP.", + tags: [OpenAPITags.Idp], + request: { + params: paramsSchema, + query: querySchema + }, + responses: {} +}); + +export async function listIdpOrgPolicies( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = paramsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + const { idpId } = parsedParams.data; + + const parsedQuery = querySchema.safeParse(req.query); + if (!parsedQuery.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedQuery.error).toString() + ) + ); + } + const { limit, offset } = parsedQuery.data; + + const list = await query(idpId, limit, offset); + + const [{ count }] = await db + .select({ count: sql`count(*)` }) + .from(idpOrg) + .where(eq(idpOrg.idpId, idpId)); + + return response(res, { + data: { + policies: list, + pagination: { + total: count, + limit, + offset + } + }, + success: true, + error: false, + message: "Policies 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/idp/listIdps.ts b/server/routers/idp/listIdps.ts new file mode 100644 index 00000000..a723ee05 --- /dev/null +++ b/server/routers/idp/listIdps.ts @@ -0,0 +1,114 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db } from "@server/db"; +import { domains, idp, orgDomains, users, idpOrg } from "@server/db/schemas"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import { sql } from "drizzle-orm"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; +import { OpenAPITags, registry } from "@server/openApi"; + +const querySchema = z + .object({ + limit: z + .string() + .optional() + .default("1000") + .transform(Number) + .pipe(z.number().int().nonnegative()), + offset: z + .string() + .optional() + .default("0") + .transform(Number) + .pipe(z.number().int().nonnegative()) + }) + .strict(); + +async function query(limit: number, offset: number) { + const res = await db + .select({ + idpId: idp.idpId, + name: idp.name, + type: idp.type, + orgCount: sql`count(${idpOrg.orgId})` + }) + .from(idp) + .leftJoin(idpOrg, sql`${idp.idpId} = ${idpOrg.idpId}`) + .groupBy(idp.idpId) + .limit(limit) + .offset(offset); + return res; +} + +export type ListIdpsResponse = { + idps: Array<{ + idpId: number; + name: string; + type: string; + orgCount: number; + }>; + pagination: { + total: number; + limit: number; + offset: number; + }; +}; + +registry.registerPath({ + method: "get", + path: "/idp", + description: "List all IDP in the system.", + tags: [OpenAPITags.Idp], + request: { + query: querySchema + }, + responses: {} +}); + +export async function listIdps( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedQuery = querySchema.safeParse(req.query); + if (!parsedQuery.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedQuery.error).toString() + ) + ); + } + const { limit, offset } = parsedQuery.data; + + const list = await query(limit, offset); + + const [{ count }] = await db + .select({ count: sql`count(*)` }) + .from(idp); + + return response(res, { + data: { + idps: list, + pagination: { + total: count, + limit, + offset + } + }, + success: true, + error: false, + message: "Idps retrieved successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/idp/oidcAutoProvision.ts b/server/routers/idp/oidcAutoProvision.ts new file mode 100644 index 00000000..7861fc41 --- /dev/null +++ b/server/routers/idp/oidcAutoProvision.ts @@ -0,0 +1,233 @@ +// This file is licensed under the Fossorial Commercial License. +// Unauthorized use, copying, modification, or distribution is strictly prohibited. +// +// Copyright (c) 2025 Fossorial LLC. All rights reserved. + +import { + createSession, + generateId, + generateSessionToken, + serializeSessionCookie +} from "@server/auth/sessions/app"; +import db from "@server/db"; +import { Idp, idpOrg, orgs, roles, User, userOrgs, users } from "@server/db/schemas"; +import logger from "@server/logger"; +import { UserType } from "@server/types/UserTypes"; +import { eq, and, inArray } from "drizzle-orm"; +import jmespath from "jmespath"; +import { Request, Response } from "express"; + +export async function oidcAutoProvision({ + idp, + claims, + existingUser, + userIdentifier, + email, + name, + req, + res +}: { + idp: Idp; + claims: any; + existingUser?: User; + userIdentifier: string; + email?: string; + name?: string; + req: Request; + res: Response; +}) { + const allOrgs = await db.select().from(orgs); + + const defaultRoleMapping = idp.defaultRoleMapping; + const defaultOrgMapping = idp.defaultOrgMapping; + + let userOrgInfo: { orgId: string; roleId: number }[] = []; + for (const org of allOrgs) { + const [idpOrgRes] = await db + .select() + .from(idpOrg) + .where( + and(eq(idpOrg.idpId, idp.idpId), eq(idpOrg.orgId, org.orgId)) + ); + + let roleId: number | undefined = undefined; + + const orgMapping = idpOrgRes?.orgMapping || defaultOrgMapping; + const hydratedOrgMapping = hydrateOrgMapping(orgMapping, org.orgId); + + if (hydratedOrgMapping) { + logger.debug("Hydrated Org Mapping", { + hydratedOrgMapping + }); + const orgId = jmespath.search(claims, hydratedOrgMapping); + logger.debug("Extraced Org ID", { orgId }); + if (orgId !== true && orgId !== org.orgId) { + // user not allowed to access this org + continue; + } + } + + const roleMapping = idpOrgRes?.roleMapping || defaultRoleMapping; + if (roleMapping) { + logger.debug("Role Mapping", { roleMapping }); + const roleName = jmespath.search(claims, roleMapping); + + if (!roleName) { + logger.error("Role name not found in the ID token", { + roleName + }); + continue; + } + + const [roleRes] = await db + .select() + .from(roles) + .where( + and(eq(roles.orgId, org.orgId), eq(roles.name, roleName)) + ); + + if (!roleRes) { + logger.error("Role not found", { + orgId: org.orgId, + roleName + }); + continue; + } + + roleId = roleRes.roleId; + + userOrgInfo.push({ + orgId: org.orgId, + roleId + }); + } + } + + logger.debug("User org info", { userOrgInfo }); + + let existingUserId = existingUser?.userId; + + // sync the user with the orgs and roles + await db.transaction(async (trx) => { + let userId = existingUser?.userId; + + // create user if not exists + if (!existingUser) { + userId = generateId(15); + + await trx.insert(users).values({ + userId, + username: userIdentifier, + email: email || null, + name: name || null, + type: UserType.OIDC, + idpId: idp.idpId, + emailVerified: true, // OIDC users are always verified + dateCreated: new Date().toISOString() + }); + } else { + // set the name and email + await trx + .update(users) + .set({ + username: userIdentifier, + email: email || null, + name: name || null + }) + .where(eq(users.userId, userId!)); + } + + existingUserId = userId; + + // get all current user orgs + const currentUserOrgs = await trx + .select() + .from(userOrgs) + .where(eq(userOrgs.userId, userId!)); + + // Delete orgs that are no longer valid + const orgsToDelete = currentUserOrgs.filter( + (currentOrg) => + !userOrgInfo.some((newOrg) => newOrg.orgId === currentOrg.orgId) + ); + + if (orgsToDelete.length > 0) { + await trx.delete(userOrgs).where( + and( + eq(userOrgs.userId, userId!), + inArray( + userOrgs.orgId, + orgsToDelete.map((org) => org.orgId) + ) + ) + ); + } + + // Update roles for existing orgs where the role has changed + const orgsToUpdate = currentUserOrgs.filter((currentOrg) => { + const newOrg = userOrgInfo.find( + (newOrg) => newOrg.orgId === currentOrg.orgId + ); + return newOrg && newOrg.roleId !== currentOrg.roleId; + }); + + if (orgsToUpdate.length > 0) { + for (const org of orgsToUpdate) { + const newRole = userOrgInfo.find( + (newOrg) => newOrg.orgId === org.orgId + ); + if (newRole) { + await trx + .update(userOrgs) + .set({ roleId: newRole.roleId }) + .where( + and( + eq(userOrgs.userId, userId!), + eq(userOrgs.orgId, org.orgId) + ) + ); + } + } + } + + // Add new orgs that don't exist yet + const orgsToAdd = userOrgInfo.filter( + (newOrg) => + !currentUserOrgs.some( + (currentOrg) => currentOrg.orgId === newOrg.orgId + ) + ); + + if (orgsToAdd.length > 0) { + await trx.insert(userOrgs).values( + orgsToAdd.map((org) => ({ + userId: userId!, + orgId: org.orgId, + roleId: org.roleId, + dateCreated: new Date().toISOString() + })) + ); + } + }); + + const token = generateSessionToken(); + const sess = await createSession(token, existingUserId!); + const isSecure = req.protocol === "https"; + const cookie = serializeSessionCookie( + token, + isSecure, + new Date(sess.expiresAt) + ); + + res.appendHeader("Set-Cookie", cookie); +} + +function hydrateOrgMapping( + orgMapping: string | null, + orgId: string +): string | undefined { + if (!orgMapping) { + return undefined; + } + return orgMapping.split("{{orgId}}").join(orgId); +} diff --git a/server/routers/idp/updateIdpOrgPolicy.ts b/server/routers/idp/updateIdpOrgPolicy.ts new file mode 100644 index 00000000..6f8580ac --- /dev/null +++ b/server/routers/idp/updateIdpOrgPolicy.ts @@ -0,0 +1,131 @@ +// This file is licensed under the Fossorial Commercial License. +// Unauthorized use, copying, modification, or distribution is strictly prohibited. +// +// Copyright (c) 2025 Fossorial LLC. All rights reserved. + +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db } from "@server/db"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; +import { OpenAPITags, registry } from "@server/openApi"; +import { eq, and } from "drizzle-orm"; +import { idp, idpOrg } from "@server/db/schemas"; + +const paramsSchema = z + .object({ + idpId: z.coerce.number(), + orgId: z.string() + }) + .strict(); + +const bodySchema = z + .object({ + roleMapping: z.string().optional(), + orgMapping: z.string().optional() + }) + .strict(); + +export type UpdateIdpOrgPolicyResponse = {}; + +registry.registerPath({ + method: "post", + path: "/idp/{idpId}/org/{orgId}", + description: "Update an IDP org policy.", + tags: [OpenAPITags.Idp], + request: { + params: paramsSchema, + body: { + content: { + "application/json": { + schema: bodySchema + } + } + } + }, + responses: {} +}); + +export async function updateIdpOrgPolicy( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = paramsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const parsedBody = bodySchema.safeParse(req.body); + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + + const { idpId, orgId } = parsedParams.data; + const { roleMapping, orgMapping } = parsedBody.data; + + // Check if IDP and policy exist + const [existing] = await db + .select() + .from(idp) + .leftJoin( + idpOrg, + and(eq(idpOrg.orgId, orgId), eq(idpOrg.idpId, idpId)) + ) + .where(eq(idp.idpId, idpId)); + + if (!existing?.idp) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "An IDP with this ID does not exist." + ) + ); + } + + if (!existing.idpOrg) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "A policy for this IDP and org does not exist." + ) + ); + } + + // Update the policy + await db + .update(idpOrg) + .set({ + roleMapping, + orgMapping + }) + .where(and(eq(idpOrg.idpId, idpId), eq(idpOrg.orgId, orgId))); + + return response(res, { + data: {}, + success: true, + error: false, + message: "Policy 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/idp/updateOidcIdp.ts b/server/routers/idp/updateOidcIdp.ts new file mode 100644 index 00000000..d24e319e --- /dev/null +++ b/server/routers/idp/updateOidcIdp.ts @@ -0,0 +1,189 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db } from "@server/db"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; +import { OpenAPITags, registry } from "@server/openApi"; +import { idp, idpOidcConfig } from "@server/db/schemas"; +import { eq } from "drizzle-orm"; +import { encrypt } from "@server/lib/crypto"; +import config from "@server/lib/config"; +import license from "@server/license/license"; + +const paramsSchema = z + .object({ + idpId: z.coerce.number() + }) + .strict(); + +const bodySchema = z + .object({ + name: z.string().optional(), + clientId: z.string().optional(), + clientSecret: z.string().optional(), + authUrl: z.string().optional(), + tokenUrl: z.string().optional(), + identifierPath: z.string().optional(), + emailPath: z.string().optional(), + namePath: z.string().optional(), + scopes: z.string().optional(), + autoProvision: z.boolean().optional(), + defaultRoleMapping: z.string().optional(), + defaultOrgMapping: z.string().optional() + }) + .strict(); + +export type UpdateIdpResponse = { + idpId: number; +}; + +registry.registerPath({ + method: "post", + path: "/idp/{idpId}/oidc", + description: "Update an OIDC IdP.", + tags: [OpenAPITags.Idp], + request: { + params: paramsSchema, + body: { + content: { + "application/json": { + schema: bodySchema + } + } + } + }, + responses: {} +}); + +export async function updateOidcIdp( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = paramsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const parsedBody = bodySchema.safeParse(req.body); + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + + const { idpId } = parsedParams.data; + let { + clientId, + clientSecret, + authUrl, + tokenUrl, + scopes, + identifierPath, + emailPath, + namePath, + name, + autoProvision, + defaultRoleMapping, + defaultOrgMapping + } = parsedBody.data; + + if (!(await license.isUnlocked())) { + autoProvision = false; + } + + // Check if IDP exists and is of type OIDC + const [existingIdp] = await db + .select() + .from(idp) + .where(eq(idp.idpId, idpId)); + + if (!existingIdp) { + return next(createHttpError(HttpCode.NOT_FOUND, "IdP not found")); + } + + if (existingIdp.type !== "oidc") { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "IdP is not an OIDC provider" + ) + ); + } + + const key = config.getRawConfig().server.secret; + const encryptedSecret = clientSecret + ? encrypt(clientSecret, key) + : undefined; + const encryptedClientId = clientId ? encrypt(clientId, key) : undefined; + + await db.transaction(async (trx) => { + const idpData = { + name, + autoProvision, + defaultRoleMapping, + defaultOrgMapping + }; + + // only update if at least one key is not undefined + let keysToUpdate = Object.keys(idpData).filter( + (key) => idpData[key as keyof typeof idpData] !== undefined + ); + + if (keysToUpdate.length > 0) { + await trx.update(idp).set(idpData).where(eq(idp.idpId, idpId)); + } + + const configData = { + clientId: encryptedClientId, + clientSecret: encryptedSecret, + authUrl, + tokenUrl, + scopes, + identifierPath, + emailPath, + namePath + }; + + keysToUpdate = Object.keys(configData).filter( + (key) => + configData[key as keyof typeof configData] !== undefined + ); + + if (keysToUpdate.length > 0) { + // Update OIDC config + await trx + .update(idpOidcConfig) + .set(configData) + .where(eq(idpOidcConfig.idpId, idpId)); + } + }); + + return response(res, { + data: { + idpId + }, + success: true, + error: false, + message: "IdP 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/idp/validateOidcCallback.ts b/server/routers/idp/validateOidcCallback.ts new file mode 100644 index 00000000..1624616e --- /dev/null +++ b/server/routers/idp/validateOidcCallback.ts @@ -0,0 +1,278 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db } from "@server/db"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; +import { idp, idpOidcConfig, users } from "@server/db/schemas"; +import { and, eq, inArray } from "drizzle-orm"; +import * as arctic from "arctic"; +import { generateOidcRedirectUrl } from "@server/lib/idp/generateRedirectUrl"; +import jmespath from "jmespath"; +import jsonwebtoken from "jsonwebtoken"; +import config from "@server/lib/config"; +import { + createSession, + generateSessionToken, + serializeSessionCookie +} from "@server/auth/sessions/app"; +import { decrypt } from "@server/lib/crypto"; +import { oidcAutoProvision } from "./oidcAutoProvision"; +import license from "@server/license/license"; + +const ensureTrailingSlash = (url: string): string => { + return url.endsWith("/") ? url : `${url}/`; +}; + +const paramsSchema = z + .object({ + idpId: z.coerce.number() + }) + .strict(); + +const bodySchema = z.object({ + code: z.string().nonempty(), + state: z.string().nonempty(), + storedState: z.string().nonempty() +}); + +export type ValidateOidcUrlCallbackResponse = { + redirectUrl: string; +}; + +export async function validateOidcCallback( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = paramsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { idpId } = parsedParams.data; + + const parsedBody = bodySchema.safeParse(req.body); + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + + const { storedState, code, state: expectedState } = parsedBody.data; + + const [existingIdp] = await db + .select() + .from(idp) + .innerJoin(idpOidcConfig, eq(idpOidcConfig.idpId, idp.idpId)) + .where(and(eq(idp.type, "oidc"), eq(idp.idpId, idpId))); + + if (!existingIdp) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "IdP not found for the organization" + ) + ); + } + + const key = config.getRawConfig().server.secret; + + const decryptedClientId = decrypt( + existingIdp.idpOidcConfig.clientId, + key + ); + const decryptedClientSecret = decrypt( + existingIdp.idpOidcConfig.clientSecret, + key + ); + + const redirectUrl = generateOidcRedirectUrl(existingIdp.idp.idpId); + const client = new arctic.OAuth2Client( + decryptedClientId, + decryptedClientSecret, + redirectUrl + ); + + const statePayload = jsonwebtoken.verify( + storedState, + config.getRawConfig().server.secret, + function (err, decoded) { + if (err) { + logger.error("Error verifying state JWT", { err }); + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Invalid state JWT" + ) + ); + } + return decoded; + } + ); + + const stateObj = z + .object({ + redirectUrl: z.string(), + state: z.string(), + codeVerifier: z.string() + }) + .safeParse(statePayload); + + if (!stateObj.success) { + logger.error("Error parsing state JWT"); + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(stateObj.error).toString() + ) + ); + } + + const { + codeVerifier, + state, + redirectUrl: postAuthRedirectUrl + } = stateObj.data; + + if (state !== expectedState) { + logger.error("State mismatch", { expectedState, state }); + return next( + createHttpError(HttpCode.BAD_REQUEST, "State mismatch") + ); + } + + const tokens = await client.validateAuthorizationCode( + ensureTrailingSlash(existingIdp.idpOidcConfig.tokenUrl), + code, + codeVerifier + ); + + const idToken = tokens.idToken(); + const claims = arctic.decodeIdToken(idToken); + + const userIdentifier = jmespath.search( + claims, + existingIdp.idpOidcConfig.identifierPath + ); + + if (!userIdentifier) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "User identifier not found in the ID token" + ) + ); + } + + logger.debug("User identifier", { userIdentifier }); + + let email = null; + let name = null; + try { + if (existingIdp.idpOidcConfig.emailPath) { + email = jmespath.search( + claims, + existingIdp.idpOidcConfig.emailPath + ); + } + + if (existingIdp.idpOidcConfig.namePath) { + name = jmespath.search( + claims, + existingIdp.idpOidcConfig.namePath || "" + ); + } + } catch (error) {} + + logger.debug("User email", { email }); + logger.debug("User name", { name }); + + const [existingUser] = await db + .select() + .from(users) + .where( + and( + eq(users.username, userIdentifier), + eq(users.idpId, existingIdp.idp.idpId) + ) + ); + + if (existingIdp.idp.autoProvision) { + if (!(await license.isUnlocked())) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "Auto-provisioning is not available" + ) + ); + } + await oidcAutoProvision({ + idp: existingIdp.idp, + userIdentifier, + email, + name, + claims, + existingUser, + req, + res + }); + + return response(res, { + data: { + redirectUrl: postAuthRedirectUrl + }, + success: true, + error: false, + message: "OIDC callback validated successfully", + status: HttpCode.CREATED + }); + } else { + if (!existingUser) { + return next( + createHttpError( + HttpCode.UNAUTHORIZED, + "User not provisioned in the system" + ) + ); + } + + const token = generateSessionToken(); + const sess = await createSession(token, existingUser.userId); + const isSecure = req.protocol === "https"; + const cookie = serializeSessionCookie( + token, + isSecure, + new Date(sess.expiresAt) + ); + + res.appendHeader("Set-Cookie", cookie); + + return response(res, { + data: { + redirectUrl: postAuthRedirectUrl + }, + success: true, + error: false, + message: "OIDC callback validated successfully", + status: HttpCode.CREATED + }); + } + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/integration.ts b/server/routers/integration.ts new file mode 100644 index 00000000..40ab9aa9 --- /dev/null +++ b/server/routers/integration.ts @@ -0,0 +1,499 @@ +// This file is licensed under the Fossorial Commercial License. +// Unauthorized use, copying, modification, or distribution is strictly prohibited. +// +// Copyright (c) 2025 Fossorial LLC. All rights reserved. + +import * as site from "./site"; +import * as org from "./org"; +import * as resource from "./resource"; +import * as domain from "./domain"; +import * as target from "./target"; +import * as user from "./user"; +import * as role from "./role"; +// import * as client from "./client"; +import * as accessToken from "./accessToken"; +import * as apiKeys from "./apiKeys"; +import * as idp from "./idp"; +import { + verifyApiKey, + verifyApiKeyOrgAccess, + verifyApiKeyHasAction, + verifyApiKeySiteAccess, + verifyApiKeyResourceAccess, + verifyApiKeyTargetAccess, + verifyApiKeyRoleAccess, + verifyApiKeyUserAccess, + verifyApiKeySetResourceUsers, + verifyApiKeyAccessTokenAccess, + verifyApiKeyIsRoot +} from "@server/middlewares"; +import HttpCode from "@server/types/HttpCode"; +import { Router } from "express"; +import { ActionsEnum } from "@server/auth/actions"; + +export const unauthenticated = Router(); + +unauthenticated.get("/", (_, res) => { + res.status(HttpCode.OK).json({ message: "Healthy" }); +}); + +export const authenticated = Router(); +authenticated.use(verifyApiKey); + +authenticated.get( + "/org/checkId", + verifyApiKeyIsRoot, + verifyApiKeyHasAction(ActionsEnum.checkOrgId), + org.checkId +); + +authenticated.put( + "/org", + verifyApiKeyIsRoot, + verifyApiKeyHasAction(ActionsEnum.createOrg), + org.createOrg +); + +authenticated.get( + "/orgs", + verifyApiKeyIsRoot, + verifyApiKeyHasAction(ActionsEnum.listOrgs), + org.listOrgs +); // TODO we need to check the orgs here + +authenticated.get( + "/org/:orgId", + verifyApiKeyOrgAccess, + verifyApiKeyHasAction(ActionsEnum.getOrg), + org.getOrg +); + +authenticated.post( + "/org/:orgId", + verifyApiKeyOrgAccess, + verifyApiKeyHasAction(ActionsEnum.updateOrg), + org.updateOrg +); + +authenticated.delete( + "/org/:orgId", + verifyApiKeyIsRoot, + verifyApiKeyHasAction(ActionsEnum.deleteOrg), + org.deleteOrg +); + +authenticated.put( + "/org/:orgId/site", + verifyApiKeyOrgAccess, + verifyApiKeyHasAction(ActionsEnum.createSite), + site.createSite +); + +authenticated.get( + "/org/:orgId/sites", + verifyApiKeyOrgAccess, + verifyApiKeyHasAction(ActionsEnum.listSites), + site.listSites +); + +authenticated.get( + "/org/:orgId/site/:niceId", + verifyApiKeyOrgAccess, + verifyApiKeyHasAction(ActionsEnum.getSite), + site.getSite +); + +authenticated.get( + "/org/:orgId/pick-site-defaults", + verifyApiKeyOrgAccess, + verifyApiKeyHasAction(ActionsEnum.createSite), + site.pickSiteDefaults +); + +authenticated.get( + "/site/:siteId", + verifyApiKeySiteAccess, + verifyApiKeyHasAction(ActionsEnum.getSite), + site.getSite +); + +authenticated.post( + "/site/:siteId", + verifyApiKeySiteAccess, + verifyApiKeyHasAction(ActionsEnum.updateSite), + site.updateSite +); + +authenticated.delete( + "/site/:siteId", + verifyApiKeySiteAccess, + verifyApiKeyHasAction(ActionsEnum.deleteSite), + site.deleteSite +); + +authenticated.put( + "/org/:orgId/site/:siteId/resource", + verifyApiKeyOrgAccess, + verifyApiKeyHasAction(ActionsEnum.createResource), + resource.createResource +); + +authenticated.get( + "/site/:siteId/resources", + verifyApiKeySiteAccess, + verifyApiKeyHasAction(ActionsEnum.listResources), + resource.listResources +); + +authenticated.get( + "/org/:orgId/resources", + verifyApiKeyOrgAccess, + verifyApiKeyHasAction(ActionsEnum.listResources), + resource.listResources +); + +authenticated.get( + "/org/:orgId/domains", + verifyApiKeyOrgAccess, + verifyApiKeyHasAction(ActionsEnum.listOrgDomains), + domain.listDomains +); + +authenticated.post( + "/org/:orgId/create-invite", + verifyApiKeyOrgAccess, + verifyApiKeyHasAction(ActionsEnum.inviteUser), + user.inviteUser +); + +authenticated.get( + "/resource/:resourceId/roles", + verifyApiKeyResourceAccess, + verifyApiKeyHasAction(ActionsEnum.listResourceRoles), + resource.listResourceRoles +); + +authenticated.get( + "/resource/:resourceId/users", + verifyApiKeyResourceAccess, + verifyApiKeyHasAction(ActionsEnum.listResourceUsers), + resource.listResourceUsers +); + +authenticated.get( + "/resource/:resourceId", + verifyApiKeyResourceAccess, + verifyApiKeyHasAction(ActionsEnum.getResource), + resource.getResource +); + +authenticated.post( + "/resource/:resourceId", + verifyApiKeyResourceAccess, + verifyApiKeyHasAction(ActionsEnum.updateResource), + resource.updateResource +); + +authenticated.delete( + "/resource/:resourceId", + verifyApiKeyResourceAccess, + verifyApiKeyHasAction(ActionsEnum.deleteResource), + resource.deleteResource +); + +authenticated.put( + "/resource/:resourceId/target", + verifyApiKeyResourceAccess, + verifyApiKeyHasAction(ActionsEnum.createTarget), + target.createTarget +); + +authenticated.get( + "/resource/:resourceId/targets", + verifyApiKeyResourceAccess, + verifyApiKeyHasAction(ActionsEnum.listTargets), + target.listTargets +); + +authenticated.put( + "/resource/:resourceId/rule", + verifyApiKeyResourceAccess, + verifyApiKeyHasAction(ActionsEnum.createResourceRule), + resource.createResourceRule +); + +authenticated.get( + "/resource/:resourceId/rules", + verifyApiKeyResourceAccess, + verifyApiKeyHasAction(ActionsEnum.listResourceRules), + resource.listResourceRules +); + +authenticated.post( + "/resource/:resourceId/rule/:ruleId", + verifyApiKeyResourceAccess, + verifyApiKeyHasAction(ActionsEnum.updateResourceRule), + resource.updateResourceRule +); + +authenticated.delete( + "/resource/:resourceId/rule/:ruleId", + verifyApiKeyResourceAccess, + verifyApiKeyHasAction(ActionsEnum.deleteResourceRule), + resource.deleteResourceRule +); + +authenticated.get( + "/target/:targetId", + verifyApiKeyTargetAccess, + verifyApiKeyHasAction(ActionsEnum.getTarget), + target.getTarget +); + +authenticated.post( + "/target/:targetId", + verifyApiKeyTargetAccess, + verifyApiKeyHasAction(ActionsEnum.updateTarget), + target.updateTarget +); + +authenticated.delete( + "/target/:targetId", + verifyApiKeyTargetAccess, + verifyApiKeyHasAction(ActionsEnum.deleteTarget), + target.deleteTarget +); + +authenticated.put( + "/org/:orgId/role", + verifyApiKeyOrgAccess, + verifyApiKeyHasAction(ActionsEnum.createRole), + role.createRole +); + +authenticated.get( + "/org/:orgId/roles", + verifyApiKeyOrgAccess, + verifyApiKeyHasAction(ActionsEnum.listRoles), + role.listRoles +); + +authenticated.delete( + "/role/:roleId", + verifyApiKeyRoleAccess, + verifyApiKeyHasAction(ActionsEnum.deleteRole), + role.deleteRole +); + +authenticated.post( + "/role/:roleId/add/:userId", + verifyApiKeyRoleAccess, + verifyApiKeyUserAccess, + verifyApiKeyHasAction(ActionsEnum.addUserRole), + user.addUserRole +); + +authenticated.post( + "/resource/:resourceId/roles", + verifyApiKeyResourceAccess, + verifyApiKeyRoleAccess, + verifyApiKeyHasAction(ActionsEnum.setResourceRoles), + resource.setResourceRoles +); + +authenticated.post( + "/resource/:resourceId/users", + verifyApiKeyResourceAccess, + verifyApiKeySetResourceUsers, + verifyApiKeyHasAction(ActionsEnum.setResourceUsers), + resource.setResourceUsers +); + +authenticated.post( + `/resource/:resourceId/password`, + verifyApiKeyResourceAccess, + verifyApiKeyHasAction(ActionsEnum.setResourcePassword), + resource.setResourcePassword +); + +authenticated.post( + `/resource/:resourceId/pincode`, + verifyApiKeyResourceAccess, + verifyApiKeyHasAction(ActionsEnum.setResourcePincode), + resource.setResourcePincode +); + +authenticated.post( + `/resource/:resourceId/whitelist`, + verifyApiKeyResourceAccess, + verifyApiKeyHasAction(ActionsEnum.setResourceWhitelist), + resource.setResourceWhitelist +); + +authenticated.get( + `/resource/:resourceId/whitelist`, + verifyApiKeyResourceAccess, + verifyApiKeyHasAction(ActionsEnum.getResourceWhitelist), + resource.getResourceWhitelist +); + +authenticated.post( + `/resource/:resourceId/transfer`, + verifyApiKeyResourceAccess, + verifyApiKeyHasAction(ActionsEnum.updateResource), + resource.transferResource +); + +authenticated.post( + `/resource/:resourceId/access-token`, + verifyApiKeyResourceAccess, + verifyApiKeyHasAction(ActionsEnum.generateAccessToken), + accessToken.generateAccessToken +); + +authenticated.delete( + `/access-token/:accessTokenId`, + verifyApiKeyAccessTokenAccess, + verifyApiKeyHasAction(ActionsEnum.deleteAcessToken), + accessToken.deleteAccessToken +); + +authenticated.get( + `/org/:orgId/access-tokens`, + verifyApiKeyOrgAccess, + verifyApiKeyHasAction(ActionsEnum.listAccessTokens), + accessToken.listAccessTokens +); + +authenticated.get( + `/resource/:resourceId/access-tokens`, + verifyApiKeyResourceAccess, + verifyApiKeyHasAction(ActionsEnum.listAccessTokens), + accessToken.listAccessTokens +); + +authenticated.get( + "/org/:orgId/user/:userId", + verifyApiKeyOrgAccess, + verifyApiKeyHasAction(ActionsEnum.getOrgUser), + user.getOrgUser +); + +authenticated.get( + "/org/:orgId/users", + verifyApiKeyOrgAccess, + verifyApiKeyHasAction(ActionsEnum.listUsers), + user.listUsers +); + +authenticated.delete( + "/org/:orgId/user/:userId", + verifyApiKeyOrgAccess, + verifyApiKeyUserAccess, + verifyApiKeyHasAction(ActionsEnum.removeUser), + user.removeUserOrg +); + +// authenticated.put( +// "/newt", +// verifyApiKeyHasAction(ActionsEnum.createNewt), +// newt.createNewt +// ); + +authenticated.get( + `/org/:orgId/api-keys`, + verifyApiKeyIsRoot, + verifyApiKeyHasAction(ActionsEnum.listApiKeys), + apiKeys.listOrgApiKeys +); + +authenticated.post( + `/org/:orgId/api-key/:apiKeyId/actions`, + verifyApiKeyIsRoot, + verifyApiKeyHasAction(ActionsEnum.setApiKeyActions), + apiKeys.setApiKeyActions +); + +authenticated.get( + `/org/:orgId/api-key/:apiKeyId/actions`, + verifyApiKeyIsRoot, + verifyApiKeyHasAction(ActionsEnum.listApiKeyActions), + apiKeys.listApiKeyActions +); + +authenticated.put( + `/org/:orgId/api-key`, + verifyApiKeyIsRoot, + verifyApiKeyHasAction(ActionsEnum.createApiKey), + apiKeys.createOrgApiKey +); + +authenticated.delete( + `/org/:orgId/api-key/:apiKeyId`, + verifyApiKeyIsRoot, + verifyApiKeyHasAction(ActionsEnum.deleteApiKey), + apiKeys.deleteApiKey +); + +authenticated.put( + "/idp/oidc", + verifyApiKeyIsRoot, + verifyApiKeyHasAction(ActionsEnum.createIdp), + idp.createOidcIdp +); + +authenticated.post( + "/idp/:idpId/oidc", + verifyApiKeyIsRoot, + verifyApiKeyHasAction(ActionsEnum.updateIdp), + idp.updateOidcIdp +); + +authenticated.delete( + "/idp/:idpId", + verifyApiKeyIsRoot, + verifyApiKeyHasAction(ActionsEnum.deleteIdp), + idp.deleteIdp +); + +authenticated.get( + "/idp", + verifyApiKeyIsRoot, + verifyApiKeyHasAction(ActionsEnum.listIdps), + idp.listIdps +); + +authenticated.get( + "/idp/:idpId", + verifyApiKeyIsRoot, + verifyApiKeyHasAction(ActionsEnum.getIdp), + idp.getIdp +); + +authenticated.put( + "/idp/:idpId/org/:orgId", + verifyApiKeyIsRoot, + verifyApiKeyHasAction(ActionsEnum.createIdpOrg), + idp.createIdpOrgPolicy +); + +authenticated.post( + "/idp/:idpId/org/:orgId", + verifyApiKeyIsRoot, + verifyApiKeyHasAction(ActionsEnum.updateIdpOrg), + idp.updateIdpOrgPolicy +); + +authenticated.delete( + "/idp/:idpId/org/:orgId", + verifyApiKeyIsRoot, + verifyApiKeyHasAction(ActionsEnum.deleteIdpOrg), + idp.deleteIdpOrgPolicy +); + +authenticated.get( + "/idp/:idpId/org", + verifyApiKeyIsRoot, + verifyApiKeyHasAction(ActionsEnum.listIdpOrgs), + idp.listIdpOrgPolicies +); diff --git a/server/routers/internal.ts b/server/routers/internal.ts index aaa955e6..eee72e9e 100644 --- a/server/routers/internal.ts +++ b/server/routers/internal.ts @@ -5,6 +5,7 @@ import * as resource from "./resource"; import * as badger from "./badger"; import * as auth from "@server/routers/auth"; import * as supporterKey from "@server/routers/supporterKey"; +import * as license from "@server/routers/license"; import HttpCode from "@server/types/HttpCode"; import { verifyResourceAccess, @@ -37,6 +38,11 @@ internalRouter.get( supporterKey.isSupporterKeyVisible ); +internalRouter.get( + `/license/status`, + license.getLicenseStatus +); + // Gerbil routes const gerbilRouter = Router(); internalRouter.use("/gerbil", gerbilRouter); diff --git a/server/routers/license/activateLicense.ts b/server/routers/license/activateLicense.ts new file mode 100644 index 00000000..da2b76c4 --- /dev/null +++ b/server/routers/license/activateLicense.ts @@ -0,0 +1,62 @@ +// This file is licensed under the Fossorial Commercial License. +// Unauthorized use, copying, modification, or distribution is strictly prohibited. +// +// Copyright (c) 2025 Fossorial LLC. All rights reserved. + +import { Request, Response, NextFunction } from "express"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { response as sendResponse } from "@server/lib"; +import license, { LicenseStatus } from "@server/license/license"; +import { z } from "zod"; +import { fromError } from "zod-validation-error"; + +const bodySchema = z + .object({ + licenseKey: z.string().min(1).max(255) + }) + .strict(); + +export type ActivateLicenseStatus = LicenseStatus; + +export async function activateLicense( + 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 { licenseKey } = parsedBody.data; + + try { + const status = await license.activateLicenseKey(licenseKey); + return sendResponse(res, { + data: status, + success: true, + error: false, + message: "License key activated successfully", + status: HttpCode.OK + }); + } catch (e) { + logger.error(e); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, `${e}`) + ); + } + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/license/deleteLicenseKey.ts b/server/routers/license/deleteLicenseKey.ts new file mode 100644 index 00000000..bea7f9ad --- /dev/null +++ b/server/routers/license/deleteLicenseKey.ts @@ -0,0 +1,78 @@ +// This file is licensed under the Fossorial Commercial License. +// Unauthorized use, copying, modification, or distribution is strictly prohibited. +// +// Copyright (c) 2025 Fossorial LLC. All rights reserved. + +import { Request, Response, NextFunction } from "express"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { response as sendResponse } from "@server/lib"; +import { z } from "zod"; +import { fromError } from "zod-validation-error"; +import db from "@server/db"; +import { eq } from "drizzle-orm"; +import { licenseKey } from "@server/db/schemas"; +import license, { LicenseStatus } from "@server/license/license"; +import { encrypt } from "@server/lib/crypto"; +import config from "@server/lib/config"; + +const paramsSchema = z + .object({ + licenseKey: z.string().min(1).max(255) + }) + .strict(); + +export type DeleteLicenseKeyResponse = LicenseStatus; + +export async function deleteLicenseKey( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = paramsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { licenseKey: key } = parsedParams.data; + + const [existing] = await db + .select() + .from(licenseKey) + .where(eq(licenseKey.licenseKeyId, key)) + .limit(1); + + if (!existing) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `License key ${key} not found` + ) + ); + } + + await db.delete(licenseKey).where(eq(licenseKey.licenseKeyId, key)); + + const status = await license.forceRecheck(); + + return sendResponse(res, { + data: status, + success: true, + error: false, + message: "License key 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/license/getLicenseStatus.ts b/server/routers/license/getLicenseStatus.ts new file mode 100644 index 00000000..a4e4151a --- /dev/null +++ b/server/routers/license/getLicenseStatus.ts @@ -0,0 +1,36 @@ +// This file is licensed under the Fossorial Commercial License. +// Unauthorized use, copying, modification, or distribution is strictly prohibited. +// +// Copyright (c) 2025 Fossorial LLC. All rights reserved. + +import { Request, Response, NextFunction } from "express"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { response as sendResponse } from "@server/lib"; +import license, { LicenseStatus } from "@server/license/license"; + +export type GetLicenseStatusResponse = LicenseStatus; + +export async function getLicenseStatus( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const status = await license.check(); + + return sendResponse(res, { + data: status, + success: true, + error: false, + message: "Got status", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/license/index.ts b/server/routers/license/index.ts new file mode 100644 index 00000000..6c848c2a --- /dev/null +++ b/server/routers/license/index.ts @@ -0,0 +1,10 @@ +// This file is licensed under the Fossorial Commercial License. +// Unauthorized use, copying, modification, or distribution is strictly prohibited. +// +// Copyright (c) 2025 Fossorial LLC. All rights reserved. + +export * from "./getLicenseStatus"; +export * from "./activateLicense"; +export * from "./listLicenseKeys"; +export * from "./deleteLicenseKey"; +export * from "./recheckStatus"; diff --git a/server/routers/license/listLicenseKeys.ts b/server/routers/license/listLicenseKeys.ts new file mode 100644 index 00000000..12a19564 --- /dev/null +++ b/server/routers/license/listLicenseKeys.ts @@ -0,0 +1,36 @@ +// This file is licensed under the Fossorial Commercial License. +// Unauthorized use, copying, modification, or distribution is strictly prohibited. +// +// Copyright (c) 2025 Fossorial LLC. All rights reserved. + +import { Request, Response, NextFunction } from "express"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { response as sendResponse } from "@server/lib"; +import license, { LicenseKeyCache } from "@server/license/license"; + +export type ListLicenseKeysResponse = LicenseKeyCache[]; + +export async function listLicenseKeys( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const keys = license.listKeys(); + + return sendResponse(res, { + data: keys, + success: true, + error: false, + message: "Successfully retrieved license keys", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/license/recheckStatus.ts b/server/routers/license/recheckStatus.ts new file mode 100644 index 00000000..5f0bd949 --- /dev/null +++ b/server/routers/license/recheckStatus.ts @@ -0,0 +1,42 @@ +// This file is licensed under the Fossorial Commercial License. +// Unauthorized use, copying, modification, or distribution is strictly prohibited. +// +// Copyright (c) 2025 Fossorial LLC. All rights reserved. + +import { Request, Response, NextFunction } from "express"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { response as sendResponse } from "@server/lib"; +import license, { LicenseStatus } from "@server/license/license"; + +export type RecheckStatusResponse = LicenseStatus; + +export async function recheckStatus( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + try { + const status = await license.forceRecheck(); + return sendResponse(res, { + data: status, + success: true, + error: false, + message: "License status rechecked successfully", + status: HttpCode.OK + }); + } catch (e) { + logger.error(e); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, `${e}`) + ); + } + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/newt/createNewt.ts b/server/routers/newt/createNewt.ts index 25f4bb31..02517db5 100644 --- a/server/routers/newt/createNewt.ts +++ b/server/routers/newt/createNewt.ts @@ -49,7 +49,7 @@ export async function createNewt( const { newtId, secret } = parsedBody.data; - if (!req.userOrgRoleId) { + if (req.user && !req.userOrgRoleId) { return next( createHttpError(HttpCode.FORBIDDEN, "User does not have a role") ); diff --git a/server/routers/org/createOrg.ts b/server/routers/org/createOrg.ts index d264eac8..60ff5558 100644 --- a/server/routers/org/createOrg.ts +++ b/server/routers/org/createOrg.ts @@ -3,13 +3,16 @@ import { z } from "zod"; import { db } from "@server/db"; import { eq } from "drizzle-orm"; import { + apiKeyOrg, + apiKeys, domains, Org, orgDomains, orgs, roleActions, roles, - userOrgs + userOrgs, + users } from "@server/db/schemas"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; @@ -19,6 +22,7 @@ import { createAdminRole } from "@server/setup/ensureActions"; import config from "@server/lib/config"; import { fromError } from "zod-validation-error"; import { defaultRoleAllowedActions } from "../role"; +import { OpenAPITags, registry } from "@server/openApi"; const createOrgSchema = z .object({ @@ -29,6 +33,23 @@ const createOrgSchema = z // const MAX_ORGS = 5; +registry.registerPath({ + method: "put", + path: "/org", + description: "Create a new organization", + tags: [OpenAPITags.Org], + request: { + body: { + content: { + "application/json": { + schema: createOrgSchema + } + } + } + }, + responses: {} +}); + export async function createOrg( req: Request, res: Response, @@ -37,7 +58,7 @@ export async function createOrg( try { // should this be in a middleware? if (config.getRawConfig().flags?.disable_user_create_org) { - if (!req.user?.serverAdmin) { + if (req.user && !req.user?.serverAdmin) { return next( createHttpError( HttpCode.FORBIDDEN, @@ -125,12 +146,33 @@ export async function createOrg( })) ); - await trx.insert(userOrgs).values({ - userId: req.user!.userId, - orgId: newOrg[0].orgId, - roleId: roleId, - isOwner: true - }); + if (req.user) { + await trx.insert(userOrgs).values({ + userId: req.user!.userId, + orgId: newOrg[0].orgId, + roleId: roleId, + isOwner: true + }); + } else { + // if org created by root api key, set the server admin as the owner + const [serverAdmin] = await trx + .select() + .from(users) + .where(eq(users.serverAdmin, true)); + + if (!serverAdmin) { + error = "Server admin not found"; + trx.rollback(); + return; + } + + await trx.insert(userOrgs).values({ + userId: serverAdmin.userId, + orgId: newOrg[0].orgId, + roleId: roleId, + isOwner: true + }); + } const memberRole = await trx .insert(roles) @@ -148,6 +190,18 @@ export async function createOrg( orgId })) ); + + const rootApiKeys = await trx + .select() + .from(apiKeys) + .where(eq(apiKeys.isRoot, true)); + + for (const apiKey of rootApiKeys) { + await trx.insert(apiKeyOrg).values({ + apiKeyId: apiKey.apiKeyId, + orgId: newOrg[0].orgId + }); + } }); if (!org) { diff --git a/server/routers/org/deleteOrg.ts b/server/routers/org/deleteOrg.ts index 5ffdd739..030588c5 100644 --- a/server/routers/org/deleteOrg.ts +++ b/server/routers/org/deleteOrg.ts @@ -17,6 +17,7 @@ import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import { sendToClient } from "../ws"; import { deletePeer } from "../gerbil/peers"; +import { OpenAPITags, registry } from "@server/openApi"; const deleteOrgSchema = z .object({ @@ -26,6 +27,17 @@ const deleteOrgSchema = z export type DeleteOrgResponse = {}; +registry.registerPath({ + method: "delete", + path: "/org/{orgId}", + description: "Delete an organization", + tags: [OpenAPITags.Org], + request: { + params: deleteOrgSchema + }, + responses: {} +}); + export async function deleteOrg( req: Request, res: Response, diff --git a/server/routers/org/getOrg.ts b/server/routers/org/getOrg.ts index 21a6fa2a..c112ab7a 100644 --- a/server/routers/org/getOrg.ts +++ b/server/routers/org/getOrg.ts @@ -8,6 +8,7 @@ import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; import logger from "@server/logger"; import { fromZodError } from "zod-validation-error"; +import { OpenAPITags, registry } from "@server/openApi"; const getOrgSchema = z .object({ @@ -19,6 +20,17 @@ export type GetOrgResponse = { org: Org; }; +registry.registerPath({ + method: "get", + path: "/org/{orgId}", + description: "Get an organization", + tags: [OpenAPITags.Org], + request: { + params: getOrgSchema + }, + responses: {} +}); + export async function getOrg( req: Request, res: Response, diff --git a/server/routers/org/index.ts b/server/routers/org/index.ts index 04ff1362..5623823d 100644 --- a/server/routers/org/index.ts +++ b/server/routers/org/index.ts @@ -2,6 +2,7 @@ export * from "./getOrg"; export * from "./createOrg"; export * from "./deleteOrg"; export * from "./updateOrg"; -export * from "./listOrgs"; +export * from "./listUserOrgs"; export * from "./checkId"; export * from "./getOrgOverview"; +export * from "./listOrgs"; diff --git a/server/routers/org/listOrgs.ts b/server/routers/org/listOrgs.ts index 106c58e4..27114104 100644 --- a/server/routers/org/listOrgs.ts +++ b/server/routers/org/listOrgs.ts @@ -1,13 +1,14 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db } from "@server/db"; -import { Org, orgs } from "@server/db/schemas"; +import { Org, orgs, userOrgs } from "@server/db/schemas"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; -import { sql, inArray } from "drizzle-orm"; +import { sql, inArray, eq } from "drizzle-orm"; import logger from "@server/logger"; import { fromZodError } from "zod-validation-error"; +import { OpenAPITags, registry } from "@server/openApi"; const listOrgsSchema = z.object({ limit: z @@ -21,7 +22,18 @@ const listOrgsSchema = z.object({ .optional() .default("0") .transform(Number) - .pipe(z.number().int().nonnegative()), + .pipe(z.number().int().nonnegative()) +}); + +registry.registerPath({ + method: "get", + path: "/orgs", + description: "List all organizations in the system.", + tags: [OpenAPITags.Org], + request: { + query: listOrgsSchema + }, + responses: {} }); export type ListOrgsResponse = { @@ -47,37 +59,15 @@ export async function listOrgs( const { limit, offset } = parsedQuery.data; - // Use the userOrgs passed from the middleware - const userOrgIds = req.userOrgIds; - - if (!userOrgIds || userOrgIds.length === 0) { - return response(res, { - data: { - orgs: [], - pagination: { - total: 0, - limit, - offset, - }, - }, - success: true, - error: false, - message: "No organizations found for the user", - status: HttpCode.OK, - }); - } - const organizations = await db .select() .from(orgs) - .where(inArray(orgs.orgId, userOrgIds)) .limit(limit) .offset(offset); const totalCountResult = await db .select({ count: sql`cast(count(*) as integer)` }) - .from(orgs) - .where(inArray(orgs.orgId, userOrgIds)); + .from(orgs); const totalCount = totalCountResult[0].count; return response(res, { @@ -86,13 +76,13 @@ export async function listOrgs( pagination: { total: totalCount, limit, - offset, - }, + offset + } }, success: true, error: false, message: "Organizations retrieved successfully", - status: HttpCode.OK, + status: HttpCode.OK }); } catch (error) { logger.error(error); diff --git a/server/routers/org/listUserOrgs.ts b/server/routers/org/listUserOrgs.ts new file mode 100644 index 00000000..fa33d2cb --- /dev/null +++ b/server/routers/org/listUserOrgs.ts @@ -0,0 +1,141 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db } from "@server/db"; +import { Org, orgs, userOrgs } from "@server/db/schemas"; +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 logger from "@server/logger"; +import { fromZodError } from "zod-validation-error"; +import { OpenAPITags, registry } from "@server/openApi"; + +const listOrgsParamsSchema = z.object({ + userId: z.string() +}); + +const listOrgsSchema = 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()) +}); + +// registry.registerPath({ +// method: "get", +// path: "/user/{userId}/orgs", +// description: "List all organizations for a user.", +// tags: [OpenAPITags.Org, OpenAPITags.User], +// request: { +// query: listOrgsSchema +// }, +// responses: {} +// }); + +export type ListUserOrgsResponse = { + orgs: Org[]; + pagination: { total: number; limit: number; offset: number }; +}; + +export async function listUserOrgs( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedQuery = listOrgsSchema.safeParse(req.query); + if (!parsedQuery.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromZodError(parsedQuery.error) + ) + ); + } + + const { limit, offset } = parsedQuery.data; + + const parsedParams = listOrgsParamsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromZodError(parsedParams.error) + ) + ); + } + + const { userId } = parsedParams.data; + + const userOrganizations = await db + .select({ + orgId: userOrgs.orgId, + roleId: userOrgs.roleId + }) + .from(userOrgs) + .where(eq(userOrgs.userId, userId)); + + const userOrgIds = userOrganizations.map((org) => org.orgId); + + if (!userOrgIds || userOrgIds.length === 0) { + return response(res, { + data: { + orgs: [], + pagination: { + total: 0, + limit, + offset + } + }, + success: true, + error: false, + message: "No organizations found for the user", + status: HttpCode.OK + }); + } + + const organizations = await db + .select() + .from(orgs) + .where(inArray(orgs.orgId, userOrgIds)) + .limit(limit) + .offset(offset); + + const totalCountResult = await db + .select({ count: sql`cast(count(*) as integer)` }) + .from(orgs) + .where(inArray(orgs.orgId, userOrgIds)); + const totalCount = totalCountResult[0].count; + + return response(res, { + data: { + orgs: organizations, + pagination: { + total: totalCount, + limit, + offset + } + }, + success: true, + error: false, + message: "Organizations 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/org/updateOrg.ts b/server/routers/org/updateOrg.ts index 2c4a4cf0..0f0aa89a 100644 --- a/server/routers/org/updateOrg.ts +++ b/server/routers/org/updateOrg.ts @@ -8,6 +8,7 @@ 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 updateOrgParamsSchema = z .object({ @@ -17,7 +18,7 @@ const updateOrgParamsSchema = z const updateOrgBodySchema = z .object({ - name: z.string().min(1).max(255).optional(), + name: z.string().min(1).max(255).optional() // domain: z.string().min(1).max(255).optional(), }) .strict() @@ -25,6 +26,24 @@ const updateOrgBodySchema = z message: "At least one field must be provided for update" }); +registry.registerPath({ + method: "post", + path: "/org/{orgId}", + description: "Update an organization", + tags: [OpenAPITags.Org], + request: { + params: updateOrgParamsSchema, + body: { + content: { + "application/json": { + schema: updateOrgBodySchema + } + } + } + }, + responses: {} +}); + export async function updateOrg( req: Request, res: Response, diff --git a/server/routers/resource/createResource.ts b/server/routers/resource/createResource.ts index d4001de2..e899530b 100644 --- a/server/routers/resource/createResource.ts +++ b/server/routers/resource/createResource.ts @@ -20,6 +20,7 @@ import { fromError } from "zod-validation-error"; import logger from "@server/logger"; import { subdomainSchema } from "@server/lib/schemas"; import config from "@server/lib/config"; +import { OpenAPITags, registry } from "@server/openApi"; const createResourceParamsSchema = z .object({ @@ -90,6 +91,26 @@ const createRawResourceSchema = z export type CreateResourceResponse = Resource; +registry.registerPath({ + method: "put", + path: "/org/{orgId}/site/{siteId}/resource", + description: "Create a resource.", + tags: [OpenAPITags.Org, OpenAPITags.Resource], + request: { + params: createResourceParamsSchema, + body: { + content: { + "application/json": { + schema: createHttpResourceSchema.or( + createRawResourceSchema + ) + } + } + } + }, + responses: {} +}); + export async function createResource( req: Request, res: Response, @@ -109,7 +130,7 @@ export async function createResource( const { siteId, orgId } = parsedParams.data; - if (!req.userOrgRoleId) { + if (req.user && !req.userOrgRoleId) { return next( createHttpError(HttpCode.FORBIDDEN, "User does not have a role") ); @@ -264,7 +285,7 @@ async function createHttpResource( resourceId: newResource[0].resourceId }); - if (req.userOrgRoleId != adminRole[0].roleId) { + if (req.user && req.userOrgRoleId != adminRole[0].roleId) { // make sure the user can access the resource await trx.insert(userResources).values({ userId: req.user?.userId!, diff --git a/server/routers/resource/createResourceRule.ts b/server/routers/resource/createResourceRule.ts index d52f294f..b52713d1 100644 --- a/server/routers/resource/createResourceRule.ts +++ b/server/routers/resource/createResourceRule.ts @@ -13,6 +13,7 @@ import { isValidIP, isValidUrlGlobPattern } from "@server/lib/validators"; +import { OpenAPITags, registry } from "@server/openApi"; const createResourceRuleSchema = z .object({ @@ -33,6 +34,24 @@ const createResourceRuleParamsSchema = z }) .strict(); +registry.registerPath({ + method: "put", + path: "/resource/{resourceId}/rule", + description: "Create a resource rule.", + tags: [OpenAPITags.Resource, OpenAPITags.Rule], + request: { + params: createResourceRuleParamsSchema, + body: { + content: { + "application/json": { + schema: createResourceRuleSchema + } + } + } + }, + responses: {} +}); + export async function createResourceRule( req: Request, res: Response, diff --git a/server/routers/resource/deleteResource.ts b/server/routers/resource/deleteResource.ts index f1d2f206..8b58f688 100644 --- a/server/routers/resource/deleteResource.ts +++ b/server/routers/resource/deleteResource.ts @@ -11,6 +11,7 @@ import { fromError } from "zod-validation-error"; import { addPeer } from "../gerbil/peers"; import { removeTargets } from "../newt/targets"; import { getAllowedIps } from "../target/helpers"; +import { OpenAPITags, registry } from "@server/openApi"; // Define Zod schema for request parameters validation const deleteResourceSchema = z @@ -22,6 +23,17 @@ const deleteResourceSchema = z }) .strict(); +registry.registerPath({ + method: "delete", + path: "/resource/{resourceId}", + description: "Delete a resource.", + tags: [OpenAPITags.Resource], + request: { + params: deleteResourceSchema + }, + responses: {} +}); + export async function deleteResource( req: Request, res: Response, @@ -88,7 +100,11 @@ export async function deleteResource( .where(eq(newts.siteId, site.siteId)) .limit(1); - removeTargets(newt.newtId, targetsToBeRemoved, deletedResource.protocol); + removeTargets( + newt.newtId, + targetsToBeRemoved, + deletedResource.protocol + ); } } diff --git a/server/routers/resource/deleteResourceRule.ts b/server/routers/resource/deleteResourceRule.ts index 7583c311..573825b0 100644 --- a/server/routers/resource/deleteResourceRule.ts +++ b/server/routers/resource/deleteResourceRule.ts @@ -8,13 +8,11 @@ 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 deleteResourceRuleSchema = z .object({ - ruleId: z - .string() - .transform(Number) - .pipe(z.number().int().positive()), + ruleId: z.string().transform(Number).pipe(z.number().int().positive()), resourceId: z .string() .transform(Number) @@ -22,6 +20,17 @@ const deleteResourceRuleSchema = z }) .strict(); +registry.registerPath({ + method: "delete", + path: "/resource/{resourceId}/rule/{ruleId}", + description: "Delete a resource rule.", + tags: [OpenAPITags.Resource, OpenAPITags.Rule], + request: { + params: deleteResourceRuleSchema + }, + responses: {} +}); + export async function deleteResourceRule( req: Request, res: Response, diff --git a/server/routers/resource/getResource.ts b/server/routers/resource/getResource.ts index 4fa3acac..ae3c87d3 100644 --- a/server/routers/resource/getResource.ts +++ b/server/routers/resource/getResource.ts @@ -8,6 +8,7 @@ import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; import { fromError } from "zod-validation-error"; import logger from "@server/logger"; +import { OpenAPITags, registry } from "@server/openApi"; const getResourceSchema = z .object({ @@ -22,6 +23,17 @@ export type GetResourceResponse = Resource & { siteName: string; }; +registry.registerPath({ + method: "get", + path: "/resource/{resourceId}", + description: "Get a resource.", + tags: [OpenAPITags.Resource], + request: { + params: getResourceSchema + }, + responses: {} +}); + export async function getResource( req: Request, res: Response, diff --git a/server/routers/resource/getResourceWhitelist.ts b/server/routers/resource/getResourceWhitelist.ts index b99decd3..321fd331 100644 --- a/server/routers/resource/getResourceWhitelist.ts +++ b/server/routers/resource/getResourceWhitelist.ts @@ -8,6 +8,7 @@ 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 getResourceWhitelistSchema = z .object({ @@ -31,6 +32,17 @@ export type GetResourceWhitelistResponse = { whitelist: NonNullable>>; }; +registry.registerPath({ + method: "get", + path: "/resource/{resourceId}/whitelist", + description: "Get the whitelist of emails for a specific resource.", + tags: [OpenAPITags.Resource], + request: { + params: getResourceWhitelistSchema + }, + responses: {} +}); + export async function getResourceWhitelist( req: Request, res: Response, diff --git a/server/routers/resource/listResourceRoles.ts b/server/routers/resource/listResourceRoles.ts index 8b80568f..c173cacb 100644 --- a/server/routers/resource/listResourceRoles.ts +++ b/server/routers/resource/listResourceRoles.ts @@ -8,6 +8,7 @@ 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 listResourceRolesSchema = z .object({ @@ -35,6 +36,17 @@ export type ListResourceRolesResponse = { roles: NonNullable>>; }; +registry.registerPath({ + method: "get", + path: "/resource/{resourceId}/roles", + description: "List all roles for a resource.", + tags: [OpenAPITags.Resource, OpenAPITags.Role], + request: { + params: listResourceRolesSchema + }, + responses: {} +}); + export async function listResourceRoles( req: Request, res: Response, diff --git a/server/routers/resource/listResourceRules.ts b/server/routers/resource/listResourceRules.ts index 6a9fe0bc..f0a0d84c 100644 --- a/server/routers/resource/listResourceRules.ts +++ b/server/routers/resource/listResourceRules.ts @@ -8,6 +8,7 @@ import createHttpError from "http-errors"; import { z } from "zod"; import { fromError } from "zod-validation-error"; import logger from "@server/logger"; +import { OpenAPITags, registry } from "@server/openApi"; const listResourceRulesParamsSchema = z .object({ @@ -56,6 +57,18 @@ export type ListResourceRulesResponse = { pagination: { total: number; limit: number; offset: number }; }; +registry.registerPath({ + method: "get", + path: "/resource/{resourceId}/rules", + description: "List rules for a resource.", + tags: [OpenAPITags.Resource, OpenAPITags.Rule], + request: { + params: listResourceRulesParamsSchema, + query: listResourceRulesSchema + }, + responses: {} +}); + export async function listResourceRules( req: Request, res: Response, diff --git a/server/routers/resource/listResourceUsers.ts b/server/routers/resource/listResourceUsers.ts index 6ca79748..4699ec8b 100644 --- a/server/routers/resource/listResourceUsers.ts +++ b/server/routers/resource/listResourceUsers.ts @@ -1,13 +1,14 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db } from "@server/db"; -import { userResources, users } from "@server/db/schemas"; // Assuming these are the correct tables +import { idp, userResources, users } from "@server/db/schemas"; // Assuming these are the correct tables 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 listResourceUsersSchema = z .object({ @@ -22,10 +23,15 @@ async function queryUsers(resourceId: number) { return await db .select({ userId: userResources.userId, + username: users.username, + type: users.type, + idpName: idp.name, + idpId: users.idpId, email: users.email }) .from(userResources) .innerJoin(users, eq(userResources.userId, users.userId)) + .leftJoin(idp, eq(users.idpId, idp.idpId)) .where(eq(userResources.resourceId, resourceId)); } @@ -33,6 +39,17 @@ export type ListResourceUsersResponse = { users: NonNullable>>; }; +registry.registerPath({ + method: "get", + path: "/resource/{resourceId}/users", + description: "List all users for a resource.", + tags: [OpenAPITags.Resource, OpenAPITags.User], + request: { + params: listResourceUsersSchema + }, + responses: {} +}); + export async function listResourceUsers( req: Request, res: Response, diff --git a/server/routers/resource/listResources.ts b/server/routers/resource/listResources.ts index 1dba4119..9af24740 100644 --- a/server/routers/resource/listResources.ts +++ b/server/routers/resource/listResources.ts @@ -16,6 +16,7 @@ import { sql, eq, or, inArray, and, count } from "drizzle-orm"; import logger from "@server/logger"; import stoi from "@server/lib/stoi"; import { fromZodError } from "zod-validation-error"; +import { OpenAPITags, registry } from "@server/openApi"; const listResourcesParamsSchema = z .object({ @@ -128,6 +129,34 @@ export type ListResourcesResponse = { pagination: { total: number; limit: number; offset: number }; }; +registry.registerPath({ + method: "get", + path: "/site/{siteId}/resources", + description: "List resources for a site.", + tags: [OpenAPITags.Site, OpenAPITags.Resource], + request: { + params: z.object({ + siteId: z.number() + }), + query: listResourcesSchema + }, + responses: {} +}); + +registry.registerPath({ + method: "get", + path: "/org/{orgId}/resources", + description: "List resources for an organization.", + tags: [OpenAPITags.Org, OpenAPITags.Resource], + request: { + params: z.object({ + orgId: z.string() + }), + query: listResourcesSchema + }, + responses: {} +}); + export async function listResources( req: Request, res: Response, @@ -154,9 +183,17 @@ export async function listResources( ) ); } - const { siteId, orgId } = parsedParams.data; + const { siteId } = parsedParams.data; - if (orgId && orgId !== req.userOrgId) { + const orgId = parsedParams.data.orgId || req.userOrg?.orgId || req.apiKeyOrg?.orgId; + + if (!orgId) { + return next( + createHttpError(HttpCode.BAD_REQUEST, "Invalid organization ID") + ); + } + + if (req.user && orgId && orgId !== req.userOrgId) { return next( createHttpError( HttpCode.FORBIDDEN, @@ -165,7 +202,9 @@ export async function listResources( ); } - const accessibleResources = await db + let accessibleResources; + if (req.user) { + accessibleResources = await db .select({ resourceId: sql`COALESCE(${userResources.resourceId}, ${roleResources.resourceId})` }) @@ -180,6 +219,11 @@ export async function listResources( eq(roleResources.roleId, req.userOrgRoleId!) ) ); + } else { + accessibleResources = await db.select({ + resourceId: resources.resourceId + }).from(resources).where(eq(resources.orgId, orgId)); + } const accessibleResourceIds = accessibleResources.map( (resource) => resource.resourceId diff --git a/server/routers/resource/setResourcePassword.ts b/server/routers/resource/setResourcePassword.ts index 77628807..29eb89cb 100644 --- a/server/routers/resource/setResourcePassword.ts +++ b/server/routers/resource/setResourcePassword.ts @@ -10,6 +10,7 @@ import { hash } from "@node-rs/argon2"; import { response } from "@server/lib"; import logger from "@server/logger"; import { hashPassword } from "@server/auth/password"; +import { OpenAPITags, registry } from "@server/openApi"; const setResourceAuthMethodsParamsSchema = z.object({ resourceId: z.string().transform(Number).pipe(z.number().int().positive()) @@ -21,6 +22,25 @@ const setResourceAuthMethodsBodySchema = z }) .strict(); +registry.registerPath({ + method: "post", + path: "/resource/{resourceId}/password", + description: + "Set the password for a resource. Setting the password to null will remove it.", + tags: [OpenAPITags.Resource], + request: { + params: setResourceAuthMethodsParamsSchema, + body: { + content: { + "application/json": { + schema: setResourceAuthMethodsBodySchema + } + } + } + }, + responses: {} +}); + export async function setResourcePassword( req: Request, res: Response, diff --git a/server/routers/resource/setResourcePincode.ts b/server/routers/resource/setResourcePincode.ts index 8530b90c..2a1b7c1f 100644 --- a/server/routers/resource/setResourcePincode.ts +++ b/server/routers/resource/setResourcePincode.ts @@ -11,9 +11,10 @@ import { response } from "@server/lib"; import stoi from "@server/lib/stoi"; import logger from "@server/logger"; import { hashPassword } from "@server/auth/password"; +import { OpenAPITags, registry } from "@server/openApi"; const setResourceAuthMethodsParamsSchema = z.object({ - resourceId: z.string().transform(Number).pipe(z.number().int().positive()), + resourceId: z.string().transform(Number).pipe(z.number().int().positive()) }); const setResourceAuthMethodsBodySchema = z @@ -21,25 +22,44 @@ const setResourceAuthMethodsBodySchema = z pincode: z .string() .regex(/^\d{6}$/) - .or(z.null()), + .or(z.null()) }) .strict(); +registry.registerPath({ + method: "post", + path: "/resource/{resourceId}/pincode", + description: + "Set the PIN code for a resource. Setting the PIN code to null will remove it.", + tags: [OpenAPITags.Resource], + request: { + params: setResourceAuthMethodsParamsSchema, + body: { + content: { + "application/json": { + schema: setResourceAuthMethodsBodySchema + } + } + } + }, + responses: {} +}); + export async function setResourcePincode( req: Request, res: Response, - next: NextFunction, + next: NextFunction ): Promise { try { const parsedParams = setResourceAuthMethodsParamsSchema.safeParse( - req.params, + req.params ); if (!parsedParams.success) { return next( createHttpError( HttpCode.BAD_REQUEST, - fromError(parsedParams.error).toString(), - ), + fromError(parsedParams.error).toString() + ) ); } @@ -48,8 +68,8 @@ export async function setResourcePincode( return next( createHttpError( HttpCode.BAD_REQUEST, - fromError(parsedBody.error).toString(), - ), + fromError(parsedBody.error).toString() + ) ); } @@ -75,15 +95,12 @@ export async function setResourcePincode( success: true, error: false, message: "Resource PIN code set successfully", - status: HttpCode.CREATED, + status: HttpCode.CREATED }); } catch (error) { logger.error(error); return next( - createHttpError( - HttpCode.INTERNAL_SERVER_ERROR, - "An error occurred", - ), + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") ); } } diff --git a/server/routers/resource/setResourceRoles.ts b/server/routers/resource/setResourceRoles.ts index 3f09e765..0f0b3df2 100644 --- a/server/routers/resource/setResourceRoles.ts +++ b/server/routers/resource/setResourceRoles.ts @@ -1,13 +1,14 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db } from "@server/db"; -import { roleResources, roles } from "@server/db/schemas"; +import { apiKeys, roleResources, roles } from "@server/db/schemas"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import { eq, and, ne } from "drizzle-orm"; +import { OpenAPITags, registry } from "@server/openApi"; const setResourceRolesBodySchema = z .object({ @@ -24,6 +25,25 @@ const setResourceRolesParamsSchema = z }) .strict(); +registry.registerPath({ + method: "post", + path: "/resource/{resourceId}/roles", + description: + "Set roles for a resource. This will replace all existing roles.", + tags: [OpenAPITags.Resource, OpenAPITags.Role], + request: { + params: setResourceRolesParamsSchema, + body: { + content: { + "application/json": { + schema: setResourceRolesBodySchema + } + } + } + }, + responses: {} +}); + export async function setResourceRoles( req: Request, res: Response, @@ -54,6 +74,17 @@ export async function setResourceRoles( const { resourceId } = parsedParams.data; + const orgId = req.userOrg?.orgId || req.apiKeyOrg?.orgId; + + if (!orgId) { + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Organization not found" + ) + ); + } + // get this org's admin role const adminRole = await db .select() @@ -61,7 +92,7 @@ export async function setResourceRoles( .where( and( eq(roles.name, "Admin"), - eq(roles.orgId, req.userOrg!.orgId) + eq(roles.orgId, orgId) ) ) .limit(1); @@ -116,3 +147,4 @@ export async function setResourceRoles( ); } } + diff --git a/server/routers/resource/setResourceUsers.ts b/server/routers/resource/setResourceUsers.ts index 8878fd7d..3080ae45 100644 --- a/server/routers/resource/setResourceUsers.ts +++ b/server/routers/resource/setResourceUsers.ts @@ -8,6 +8,7 @@ import createHttpError from "http-errors"; import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import { eq } from "drizzle-orm"; +import { OpenAPITags, registry } from "@server/openApi"; const setUserResourcesBodySchema = z .object({ @@ -24,6 +25,25 @@ const setUserResourcesParamsSchema = z }) .strict(); +registry.registerPath({ + method: "post", + path: "/resource/{resourceId}/users", + description: + "Set users for a resource. This will replace all existing users.", + tags: [OpenAPITags.Resource, OpenAPITags.User], + request: { + params: setUserResourcesParamsSchema, + body: { + content: { + "application/json": { + schema: setUserResourcesBodySchema + } + } + } + }, + responses: {} +}); + export async function setResourceUsers( req: Request, res: Response, diff --git a/server/routers/resource/setResourceWhitelist.ts b/server/routers/resource/setResourceWhitelist.ts index 390c2c29..ceec816c 100644 --- a/server/routers/resource/setResourceWhitelist.ts +++ b/server/routers/resource/setResourceWhitelist.ts @@ -8,6 +8,7 @@ import createHttpError from "http-errors"; import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import { and, eq } from "drizzle-orm"; +import { OpenAPITags, registry } from "@server/openApi"; const setResourceWhitelistBodySchema = z .object({ @@ -37,6 +38,25 @@ const setResourceWhitelistParamsSchema = z }) .strict(); +registry.registerPath({ + method: "post", + path: "/resource/{resourceId}/whitelist", + description: + "Set email whitelist for a resource. This will replace all existing emails.", + tags: [OpenAPITags.Resource], + request: { + params: setResourceWhitelistParamsSchema, + body: { + content: { + "application/json": { + schema: setResourceWhitelistBodySchema + } + } + } + }, + responses: {} +}); + export async function setResourceWhitelist( req: Request, res: Response, diff --git a/server/routers/resource/transferResource.ts b/server/routers/resource/transferResource.ts index 17c1dcbe..9b21abb2 100644 --- a/server/routers/resource/transferResource.ts +++ b/server/routers/resource/transferResource.ts @@ -11,6 +11,7 @@ import { fromError } from "zod-validation-error"; import { addPeer } from "../gerbil/peers"; import { addTargets, removeTargets } from "../newt/targets"; import { getAllowedIps } from "../target/helpers"; +import { OpenAPITags, registry } from "@server/openApi"; const transferResourceParamsSchema = z .object({ @@ -27,6 +28,25 @@ const transferResourceBodySchema = z }) .strict(); +registry.registerPath({ + method: "post", + path: "/resource/{resourceId}/transfer", + description: + "Transfer a resource to a different site. This will also transfer the targets associated with the resource.", + tags: [OpenAPITags.Resource], + request: { + params: transferResourceParamsSchema, + body: { + content: { + "application/json": { + schema: transferResourceBodySchema + } + } + } + }, + responses: {} +}); + export async function transferResource( req: Request, res: Response, diff --git a/server/routers/resource/updateResource.ts b/server/routers/resource/updateResource.ts index 121b34ed..a857e103 100644 --- a/server/routers/resource/updateResource.ts +++ b/server/routers/resource/updateResource.ts @@ -16,7 +16,10 @@ import createHttpError from "http-errors"; import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import config from "@server/lib/config"; +import { tlsNameSchema } from "@server/lib/schemas"; import { subdomainSchema } from "@server/lib/schemas"; +import { registry } from "@server/openApi"; +import { OpenAPITags } from "@server/openApi"; const updateResourceParamsSchema = z .object({ @@ -40,7 +43,10 @@ const updateHttpResourceBodySchema = z isBaseDomain: z.boolean().optional(), applyRules: z.boolean().optional(), domainId: z.string().optional(), - enabled: z.boolean().optional() + enabled: z.boolean().optional(), + stickySession: z.boolean().optional(), + tlsServerName: z.string().nullable().optional(), + setHostHeader: z.string().nullable().optional() }) .strict() .refine((data) => Object.keys(data).length > 0, { @@ -67,6 +73,30 @@ const updateHttpResourceBodySchema = z { message: "Base domain resources are not allowed" } + ) + .refine( + (data) => { + if (data.tlsServerName) { + return tlsNameSchema.safeParse(data.tlsServerName).success; + } + return true; + }, + { + message: + "Invalid TLS Server Name. Use domain name format, or save empty to remove the TLS Server Name." + } + ) + .refine( + (data) => { + if (data.setHostHeader) { + return tlsNameSchema.safeParse(data.setHostHeader).success; + } + return true; + }, + { + message: + "Invalid custom Host Header value. Use domain name format, or save empty to unset custom Host Header." + } ); export type UpdateResourceResponse = Resource; @@ -75,6 +105,7 @@ const updateRawResourceBodySchema = z .object({ name: z.string().min(1).max(255).optional(), proxyPort: z.number().int().min(1).max(65535).optional(), + stickySession: z.boolean().optional(), enabled: z.boolean().optional() }) .strict() @@ -93,6 +124,26 @@ const updateRawResourceBodySchema = z { message: "Cannot update proxyPort" } ); +registry.registerPath({ + method: "post", + path: "/resource/{resourceId}", + description: "Update a resource.", + tags: [OpenAPITags.Resource], + request: { + params: updateResourceParamsSchema, + body: { + content: { + "application/json": { + schema: updateHttpResourceBodySchema.and( + updateRawResourceBodySchema + ) + } + } + } + }, + responses: {} +}); + export async function updateResource( req: Request, res: Response, @@ -255,7 +306,22 @@ async function updateHttpResource( const updatedResource = await db .update(resources) - .set(updatePayload) + .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 || null, + setHostHeader: updatePayload.setHostHeader || null, + fullDomain: updatePayload.fullDomain + }) .where(eq(resources.resourceId, resource.resourceId)) .returning(); diff --git a/server/routers/resource/updateResourceRule.ts b/server/routers/resource/updateResourceRule.ts index 9070d451..9a953500 100644 --- a/server/routers/resource/updateResourceRule.ts +++ b/server/routers/resource/updateResourceRule.ts @@ -13,6 +13,7 @@ import { isValidIP, isValidUrlGlobPattern } from "@server/lib/validators"; +import { OpenAPITags, registry } from "@server/openApi"; // Define Zod schema for request parameters validation const updateResourceRuleParamsSchema = z @@ -39,6 +40,24 @@ const updateResourceRuleSchema = z message: "At least one field must be provided for update" }); +registry.registerPath({ + method: "post", + path: "/resource/{resourceId}/rule/{ruleId}", + description: "Update a resource rule.", + tags: [OpenAPITags.Resource, OpenAPITags.Rule], + request: { + params: updateResourceRuleParamsSchema, + body: { + content: { + "application/json": { + schema: updateResourceRuleSchema + } + } + } + }, + responses: {} +}); + export async function updateResourceRule( req: Request, res: Response, diff --git a/server/routers/role/createRole.ts b/server/routers/role/createRole.ts index b9bdc37e..3bc363f6 100644 --- a/server/routers/role/createRole.ts +++ b/server/routers/role/createRole.ts @@ -9,6 +9,7 @@ import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import { ActionsEnum } from "@server/auth/actions"; import { eq, and } from "drizzle-orm"; +import { OpenAPITags, registry } from "@server/openApi"; const createRoleParamsSchema = z .object({ @@ -33,6 +34,24 @@ export type CreateRoleBody = z.infer; export type CreateRoleResponse = Role; +registry.registerPath({ + method: "put", + path: "/org/{orgId}/role", + description: "Create a role.", + tags: [OpenAPITags.Org, OpenAPITags.Role], + request: { + params: createRoleParamsSchema, + body: { + content: { + "application/json": { + schema: createRoleSchema + } + } + } + }, + responses: {} +}); + export async function createRole( req: Request, res: Response, diff --git a/server/routers/role/deleteRole.ts b/server/routers/role/deleteRole.ts index 0e709ff2..a89428d5 100644 --- a/server/routers/role/deleteRole.ts +++ b/server/routers/role/deleteRole.ts @@ -8,6 +8,7 @@ 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 deleteRoleSchema = z .object({ @@ -21,6 +22,24 @@ const deelteRoleBodySchema = z }) .strict(); +registry.registerPath({ + method: "delete", + path: "/role/{roleId}", + description: "Delete a role.", + tags: [OpenAPITags.Role], + request: { + params: deleteRoleSchema, + body: { + content: { + "application/json": { + schema: deelteRoleBodySchema + } + } + } + }, + responses: {} +}); + export async function deleteRole( req: Request, res: Response, diff --git a/server/routers/role/listRoles.ts b/server/routers/role/listRoles.ts index 2740484a..73834b53 100644 --- a/server/routers/role/listRoles.ts +++ b/server/routers/role/listRoles.ts @@ -9,6 +9,7 @@ import { sql, eq } from "drizzle-orm"; import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import stoi from "@server/lib/stoi"; +import { OpenAPITags, registry } from "@server/openApi"; const listRolesParamsSchema = z .object({ @@ -57,6 +58,18 @@ export type ListRolesResponse = { }; }; +registry.registerPath({ + method: "get", + path: "/orgs/{orgId}/roles", + description: "List roles.", + tags: [OpenAPITags.Org, OpenAPITags.Role], + request: { + params: listRolesParamsSchema, + query: listRolesSchema + }, + responses: {} +}); + export async function listRoles( req: Request, res: Response, diff --git a/server/routers/site/createSite.ts b/server/routers/site/createSite.ts index 8d6d0014..87eaa954 100644 --- a/server/routers/site/createSite.ts +++ b/server/routers/site/createSite.ts @@ -1,7 +1,7 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db } from "@server/db"; -import { roles, userSites, sites, roleSites, Site } from "@server/db/schemas"; +import { roles, userSites, sites, roleSites, Site, orgs } from "@server/db/schemas"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; @@ -10,9 +10,9 @@ import { eq, and } from "drizzle-orm"; import { getUniqueSiteName } from "@server/db/names"; import { addPeer } from "../gerbil/peers"; import { fromError } from "zod-validation-error"; -import { hash } from "@node-rs/argon2"; import { newts } from "@server/db/schemas"; import moment from "moment"; +import { OpenAPITags, registry } from "@server/openApi"; import { hashPassword } from "@server/auth/password"; const createSiteParamsSchema = z @@ -35,7 +35,7 @@ const createSiteSchema = z subnet: z.string().optional(), newtId: z.string().optional(), secret: z.string().optional(), - type: z.string() + type: z.enum(["newt", "wireguard", "local"]) }) .strict(); @@ -43,6 +43,24 @@ export type CreateSiteBody = z.infer; export type CreateSiteResponse = Site; +registry.registerPath({ + method: "put", + path: "/org/{orgId}/site", + description: "Create a new site.", + tags: [OpenAPITags.Site, OpenAPITags.Org], + request: { + params: createSiteParamsSchema, + body: { + content: { + "application/json": { + schema: createSiteSchema + } + } + } + }, + responses: {} +}); + export async function createSite( req: Request, res: Response, @@ -59,8 +77,15 @@ export async function createSite( ); } - const { name, type, exitNodeId, pubKey, subnet, newtId, secret } = - parsedBody.data; + const { + name, + type, + exitNodeId, + pubKey, + subnet, + newtId, + secret + } = parsedBody.data; const parsedParams = createSiteParamsSchema.safeParse(req.params); if (!parsedParams.success) { @@ -74,12 +99,23 @@ export async function createSite( const { orgId } = parsedParams.data; - if (!req.userOrgRoleId) { + if (req.user && !req.userOrgRoleId) { return next( createHttpError(HttpCode.FORBIDDEN, "User does not have a role") ); } + 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` + ) + ); + } + const niceId = await getUniqueSiteName(orgId); await db.transaction(async (trx) => { @@ -140,7 +176,7 @@ export async function createSite( siteId: newSite.siteId }); - if (req.userOrgRoleId != adminRole[0].roleId) { + if (req.user && req.userOrgRoleId != adminRole[0].roleId) { // make sure the user can access the site trx.insert(userSites).values({ userId: req.user?.userId!, diff --git a/server/routers/site/deleteSite.ts b/server/routers/site/deleteSite.ts index 1c237efe..667ab5c8 100644 --- a/server/routers/site/deleteSite.ts +++ b/server/routers/site/deleteSite.ts @@ -10,6 +10,7 @@ import logger from "@server/logger"; import { deletePeer } from "../gerbil/peers"; import { fromError } from "zod-validation-error"; import { sendToClient } from "../ws"; +import { OpenAPITags, registry } from "@server/openApi"; const deleteSiteSchema = z .object({ @@ -17,6 +18,17 @@ const deleteSiteSchema = z }) .strict(); +registry.registerPath({ + method: "delete", + path: "/site/{siteId}", + description: "Delete a site and all its associated data.", + tags: [OpenAPITags.Site], + request: { + params: deleteSiteSchema + }, + responses: {} +}); + export async function deleteSite( req: Request, res: Response, diff --git a/server/routers/site/getSite.ts b/server/routers/site/getSite.ts index 98cd6ef4..4baa85cc 100644 --- a/server/routers/site/getSite.ts +++ b/server/routers/site/getSite.ts @@ -9,6 +9,7 @@ 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 getSiteSchema = z .object({ @@ -43,6 +44,34 @@ async function query(siteId?: number, niceId?: string, orgId?: string) { export type GetSiteResponse = NonNullable>>; +registry.registerPath({ + method: "get", + path: "/org/{orgId}/site/{niceId}", + description: + "Get a site by orgId and niceId. NiceId is a readable ID for the site and unique on a per org basis.", + tags: [OpenAPITags.Org, OpenAPITags.Site], + request: { + params: z.object({ + orgId: z.string(), + niceId: z.string() + }) + }, + responses: {} +}); + +registry.registerPath({ + method: "get", + path: "/site/{siteId}", + description: "Get a site by siteId.", + tags: [OpenAPITags.Site], + request: { + params: z.object({ + siteId: z.number() + }) + }, + responses: {} +}); + export async function getSite( req: Request, res: Response, diff --git a/server/routers/site/listSites.ts b/server/routers/site/listSites.ts index c40b7fe7..1b8791ca 100644 --- a/server/routers/site/listSites.ts +++ b/server/routers/site/listSites.ts @@ -8,6 +8,7 @@ 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 listSitesParamsSchema = z .object({ @@ -59,6 +60,18 @@ export type ListSitesResponse = { pagination: { total: number; limit: number; offset: number }; }; +registry.registerPath({ + method: "get", + path: "/org/{orgId}/sites", + description: "List all sites in an organization", + tags: [OpenAPITags.Org, OpenAPITags.Site], + request: { + params: listSitesParamsSchema, + query: listSitesSchema + }, + responses: {} +}); + export async function listSites( req: Request, res: Response, @@ -87,7 +100,7 @@ export async function listSites( } const { orgId } = parsedParams.data; - if (orgId && orgId !== req.userOrgId) { + if (req.user && orgId && orgId !== req.userOrgId) { return next( createHttpError( HttpCode.FORBIDDEN, @@ -96,18 +109,26 @@ export async function listSites( ); } - const accessibleSites = await db - .select({ - siteId: sql`COALESCE(${userSites.siteId}, ${roleSites.siteId})` - }) - .from(userSites) - .fullJoin(roleSites, eq(userSites.siteId, roleSites.siteId)) - .where( - or( - eq(userSites.userId, req.user!.userId), - eq(roleSites.roleId, req.userOrgRoleId!) - ) - ); + let accessibleSites; + if (req.user) { + accessibleSites = await db + .select({ + siteId: sql`COALESCE(${userSites.siteId}, ${roleSites.siteId})` + }) + .from(userSites) + .fullJoin(roleSites, eq(userSites.siteId, roleSites.siteId)) + .where( + or( + eq(userSites.userId, req.user!.userId), + eq(roleSites.roleId, req.userOrgRoleId!) + ) + ); + } else { + accessibleSites = await db + .select({ siteId: sites.siteId }) + .from(sites) + .where(eq(sites.orgId, orgId)); + } const accessibleSiteIds = accessibleSites.map((site) => site.siteId); const baseQuery = querySites(orgId, accessibleSiteIds); diff --git a/server/routers/site/pickSiteDefaults.ts b/server/routers/site/pickSiteDefaults.ts index 38f2ec9c..92b93e3c 100644 --- a/server/routers/site/pickSiteDefaults.ts +++ b/server/routers/site/pickSiteDefaults.ts @@ -9,6 +9,8 @@ import logger from "@server/logger"; import { findNextAvailableCidr } from "@server/lib/ip"; import { generateId } from "@server/auth/sessions/app"; import config from "@server/lib/config"; +import { OpenAPITags, registry } from "@server/openApi"; +import { z } from "zod"; export type PickSiteDefaultsResponse = { exitNodeId: number; @@ -22,6 +24,20 @@ export type PickSiteDefaultsResponse = { newtSecret: string; }; +registry.registerPath({ + method: "get", + path: "/org/{orgId}/pick-site-defaults", + description: + "Return pre-requisite data for creating a site, such as the exit node, subnet, Newt credentials, etc.", + tags: [OpenAPITags.Org, OpenAPITags.Site], + request: { + params: z.object({ + orgId: z.string() + }) + }, + responses: {} +}); + export async function pickSiteDefaults( req: Request, res: Response, @@ -45,7 +61,7 @@ export async function pickSiteDefaults( // list all of the sites on that exit node const sitesQuery = await db .select({ - subnet: sites.subnet, + subnet: sites.subnet }) .from(sites) .where(eq(sites.exitNodeId, exitNode.exitNodeId)); @@ -53,8 +69,17 @@ export async function pickSiteDefaults( // TODO: we need to lock this subnet for some time so someone else does not take it let subnets = sitesQuery.map((site) => site.subnet); // exclude the exit node address by replacing after the / with a site block size - subnets.push(exitNode.address.replace(/\/\d+$/, `/${config.getRawConfig().gerbil.site_block_size}`)); - const newSubnet = findNextAvailableCidr(subnets, config.getRawConfig().gerbil.site_block_size, exitNode.address); + subnets.push( + exitNode.address.replace( + /\/\d+$/, + `/${config.getRawConfig().gerbil.site_block_size}` + ) + ); + const newSubnet = findNextAvailableCidr( + subnets, + config.getRawConfig().gerbil.site_block_size, + exitNode.address + ); if (!newSubnet) { return next( createHttpError( @@ -77,12 +102,12 @@ export async function pickSiteDefaults( endpoint: exitNode.endpoint, subnet: newSubnet, newtId, - newtSecret: secret, + newtSecret: secret }, success: true, error: false, message: "Organization retrieved successfully", - status: HttpCode.OK, + status: HttpCode.OK }); } catch (error) { logger.error(error); diff --git a/server/routers/site/updateSite.ts b/server/routers/site/updateSite.ts index 2309fa3d..43cd848a 100644 --- a/server/routers/site/updateSite.ts +++ b/server/routers/site/updateSite.ts @@ -8,6 +8,7 @@ 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 updateSiteParamsSchema = z .object({ @@ -35,6 +36,25 @@ const updateSiteBodySchema = z message: "At least one field must be provided for update" }); +registry.registerPath({ + method: "post", + path: "/site/{siteId}", + description: + "Update a site.", + tags: [OpenAPITags.Site], + request: { + params: updateSiteParamsSchema, + body: { + content: { + "application/json": { + schema: updateSiteBodySchema + } + } + } + }, + responses: {} +}); + export async function updateSite( req: Request, res: Response, diff --git a/server/routers/supporterKey/isSupporterKeyVisible.ts b/server/routers/supporterKey/isSupporterKeyVisible.ts index 3eab2ac8..15e313de 100644 --- a/server/routers/supporterKey/isSupporterKeyVisible.ts +++ b/server/routers/supporterKey/isSupporterKeyVisible.ts @@ -7,6 +7,7 @@ import config from "@server/lib/config"; import db from "@server/db"; import { count } from "drizzle-orm"; import { users } from "@server/db/schemas"; +import license from "@server/license/license"; export type IsSupporterKeyVisibleResponse = { visible: boolean; @@ -26,6 +27,12 @@ export async function isSupporterKeyVisible( let visible = !hidden && key?.valid !== true; + const licenseStatus = await license.check(); + + if (licenseStatus.isLicenseValid) { + visible = false; + } + if (key?.tier === "Limited Supporter") { const [numUsers] = await db.select({ count: count() }).from(users); diff --git a/server/routers/supporterKey/validateSupporterKey.ts b/server/routers/supporterKey/validateSupporterKey.ts index 0f023ea6..fadcdc39 100644 --- a/server/routers/supporterKey/validateSupporterKey.ts +++ b/server/routers/supporterKey/validateSupporterKey.ts @@ -44,7 +44,7 @@ export async function validateSupporterKey( const { githubUsername, key } = parsedBody.data; const response = await fetch( - "https://api.dev.fossorial.io/api/v1/license/validate", + "https://api.fossorial.io/api/v1/license/validate", { method: "POST", headers: { diff --git a/server/routers/target/createTarget.ts b/server/routers/target/createTarget.ts index 86e9ff63..810ee409 100644 --- a/server/routers/target/createTarget.ts +++ b/server/routers/target/createTarget.ts @@ -13,6 +13,7 @@ import { addTargets } from "../newt/targets"; import { eq } from "drizzle-orm"; import { pickPort } from "./helpers"; import { isTargetValid } from "@server/lib/validators"; +import { OpenAPITags, registry } from "@server/openApi"; const createTargetParamsSchema = z .object({ @@ -34,6 +35,24 @@ const createTargetSchema = z export type CreateTargetResponse = Target; +registry.registerPath({ + method: "put", + path: "/resource/{resourceId}/target", + description: "Create a target for a resource.", + tags: [OpenAPITags.Resource, OpenAPITags.Target], + request: { + params: createTargetParamsSchema, + body: { + content: { + "application/json": { + schema: createTargetSchema + } + } + } + }, + responses: {} +}); + export async function createTarget( req: Request, res: Response, diff --git a/server/routers/target/deleteTarget.ts b/server/routers/target/deleteTarget.ts index 57534bd3..979740dd 100644 --- a/server/routers/target/deleteTarget.ts +++ b/server/routers/target/deleteTarget.ts @@ -11,6 +11,7 @@ import { addPeer } from "../gerbil/peers"; import { fromError } from "zod-validation-error"; import { removeTargets } from "../newt/targets"; import { getAllowedIps } from "./helpers"; +import { OpenAPITags, registry } from "@server/openApi"; const deleteTargetSchema = z .object({ @@ -18,6 +19,17 @@ const deleteTargetSchema = z }) .strict(); +registry.registerPath({ + method: "delete", + path: "/target/{targetId}", + description: "Delete a target.", + tags: [OpenAPITags.Target], + request: { + params: deleteTargetSchema + }, + responses: {} +}); + export async function deleteTarget( req: Request, res: Response, diff --git a/server/routers/target/getTarget.ts b/server/routers/target/getTarget.ts index 69ade88f..a268629c 100644 --- a/server/routers/target/getTarget.ts +++ b/server/routers/target/getTarget.ts @@ -8,6 +8,7 @@ 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 getTargetSchema = z .object({ @@ -15,6 +16,17 @@ const getTargetSchema = z }) .strict(); +registry.registerPath({ + method: "get", + path: "/target/{targetId}", + description: "Get a target.", + tags: [OpenAPITags.Target], + request: { + params: getTargetSchema + }, + responses: {} +}); + export async function getTarget( req: Request, res: Response, diff --git a/server/routers/target/listTargets.ts b/server/routers/target/listTargets.ts index 154787a0..3d4c573b 100644 --- a/server/routers/target/listTargets.ts +++ b/server/routers/target/listTargets.ts @@ -8,6 +8,7 @@ import createHttpError from "http-errors"; import { z } from "zod"; import { fromError } from "zod-validation-error"; import logger from "@server/logger"; +import { OpenAPITags, registry } from "@server/openApi"; const listTargetsParamsSchema = z .object({ @@ -56,6 +57,18 @@ export type ListTargetsResponse = { pagination: { total: number; limit: number; offset: number }; }; +registry.registerPath({ + method: "get", + path: "/resource/{resourceId}/targets", + description: "List targets for a resource.", + tags: [OpenAPITags.Resource, OpenAPITags.Target], + request: { + params: listTargetsParamsSchema, + query: listTargetsSchema + }, + responses: {} +}); + export async function listTargets( req: Request, res: Response, diff --git a/server/routers/target/updateTarget.ts b/server/routers/target/updateTarget.ts index e675034d..284b1a31 100644 --- a/server/routers/target/updateTarget.ts +++ b/server/routers/target/updateTarget.ts @@ -12,6 +12,7 @@ import { addPeer } from "../gerbil/peers"; import { addTargets } from "../newt/targets"; import { pickPort } from "./helpers"; import { isTargetValid } from "@server/lib/validators"; +import { OpenAPITags, registry } from "@server/openApi"; const updateTargetParamsSchema = z .object({ @@ -31,6 +32,24 @@ const updateTargetBodySchema = z message: "At least one field must be provided for update" }); +registry.registerPath({ + method: "post", + path: "/target/{targetId}", + description: "Update a target.", + tags: [OpenAPITags.Target], + request: { + params: updateTargetParamsSchema, + body: { + content: { + "application/json": { + schema: updateTargetBodySchema + } + } + } + }, + responses: {} +}); + export async function updateTarget( req: Request, res: Response, diff --git a/server/routers/traefik/getTraefikConfig.ts b/server/routers/traefik/getTraefikConfig.ts index 17e385ed..2fd656ba 100644 --- a/server/routers/traefik/getTraefikConfig.ts +++ b/server/routers/traefik/getTraefikConfig.ts @@ -40,7 +40,10 @@ export async function traefikConfigProvider( org: { orgId: orgs.orgId }, - enabled: resources.enabled + enabled: resources.enabled, + stickySession: resources.stickySession, + tlsServerName: resources.tlsServerName, + setHostHeader: resources.setHostHeader }) .from(resources) .innerJoin(sites, eq(sites.siteId, resources.siteId)) @@ -102,7 +105,10 @@ export async function traefikConfigProvider( [badgerMiddlewareName]: { apiBaseUrl: new URL( "/api/v1", - `http://${config.getRawConfig().server.internal_hostname}:${ + `http://${ + config.getRawConfig().server + .internal_hostname + }:${ config.getRawConfig().server .internal_port }` @@ -139,6 +145,8 @@ export async function traefikConfigProvider( const routerName = `${resource.resourceId}-router`; const serviceName = `${resource.resourceId}-service`; const fullDomain = `${resource.fullDomain}`; + const transportName = `${resource.resourceId}-transport`; + const hostHeaderMiddlewareName = `${resource.resourceId}-host-header-middleware`; if (!resource.enabled) { continue; @@ -275,9 +283,57 @@ export async function traefikConfigProvider( url: `${target.method}://${ip}:${target.internalPort}` }; } - }) + }), + ...(resource.stickySession + ? { + sticky: { + cookie: { + name: "p_sticky", // TODO: make this configurable via config.yml like other cookies + secure: resource.ssl, + httpOnly: true + } + } + } + : {}) } }; + + // Add the serversTransport if TLS server name is provided + if (resource.tlsServerName) { + if (!config_output.http.serversTransports) { + config_output.http.serversTransports = {}; + } + config_output.http.serversTransports![transportName] = { + serverName: resource.tlsServerName, + //unfortunately the following needs to be set. traefik doesn't merge the default serverTransport settings + // 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; + } + + // Add the host header middleware + if (resource.setHostHeader) { + if (!config_output.http.middlewares) { + config_output.http.middlewares = {}; + } + 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, + hostHeaderMiddlewareName + ]; + } + } else { // Non-HTTP (TCP/UDP) configuration const protocol = resource.protocol.toLowerCase(); @@ -335,7 +391,17 @@ export async function traefikConfigProvider( address: `${ip}:${target.internalPort}` }; } - }) + }), + ...(resource.stickySession + ? { + sticky: { + ipStrategy: { + depth: 0, + sourcePort: true + } + } + } + : {}) } }; } diff --git a/server/routers/user/addUserRole.ts b/server/routers/user/addUserRole.ts index 22527d09..c0ac31bc 100644 --- a/server/routers/user/addUserRole.ts +++ b/server/routers/user/addUserRole.ts @@ -9,6 +9,7 @@ import createHttpError from "http-errors"; import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import stoi from "@server/lib/stoi"; +import { OpenAPITags, registry } from "@server/openApi"; const addUserRoleParamsSchema = z .object({ @@ -19,6 +20,17 @@ const addUserRoleParamsSchema = z export type AddUserRoleResponse = z.infer; +registry.registerPath({ + method: "post", + path: "/role/{roleId}/add/{userId}", + description: "Add a role to a user.", + tags: [OpenAPITags.Role, OpenAPITags.User], + request: { + params: addUserRoleParamsSchema + }, + responses: {} +}); + export async function addUserRole( req: Request, res: Response, @@ -37,7 +49,7 @@ export async function addUserRole( const { userId, roleId } = parsedParams.data; - if (!req.userOrg) { + if (req.user && !req.userOrg) { return next( createHttpError( HttpCode.FORBIDDEN, @@ -46,7 +58,13 @@ export async function addUserRole( ); } - const orgId = req.userOrg.orgId; + const orgId = req.userOrg?.orgId || req.apiKeyOrg?.orgId; + + if (!orgId) { + return next( + createHttpError(HttpCode.BAD_REQUEST, "Invalid organization ID") + ); + } const existingUser = await db .select() diff --git a/server/routers/user/adminListUsers.ts b/server/routers/user/adminListUsers.ts index 2d95756d..6de12be9 100644 --- a/server/routers/user/adminListUsers.ts +++ b/server/routers/user/adminListUsers.ts @@ -6,7 +6,7 @@ import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; import { sql, eq } from "drizzle-orm"; import logger from "@server/logger"; -import { users } from "@server/db/schemas"; +import { idp, users } from "@server/db/schemas"; import { fromZodError } from "zod-validation-error"; const listUsersSchema = z @@ -31,10 +31,16 @@ async function queryUsers(limit: number, offset: number) { .select({ id: users.userId, email: users.email, + username: users.username, + name: users.name, dateCreated: users.dateCreated, - serverAdmin: users.serverAdmin + serverAdmin: users.serverAdmin, + type: users.type, + idpName: idp.name, + idpId: users.idpId }) .from(users) + .leftJoin(idp, eq(users.idpId, idp.idpId)) .where(eq(users.serverAdmin, false)) .limit(limit) .offset(offset); diff --git a/server/routers/user/createOrgUser.ts b/server/routers/user/createOrgUser.ts new file mode 100644 index 00000000..a198db5d --- /dev/null +++ b/server/routers/user/createOrgUser.ts @@ -0,0 +1,215 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; +import { OpenAPITags, registry } from "@server/openApi"; +import db from "@server/db"; +import { and, eq } from "drizzle-orm"; +import { idp, idpOidcConfig, roles, userOrgs, users } from "@server/db/schemas"; +import { generateId } from "@server/auth/sessions/app"; + +const paramsSchema = z + .object({ + orgId: z.string().nonempty() + }) + .strict(); + +const bodySchema = z + .object({ + email: z + .string() + .optional() + .refine((data) => { + if (data) { + return z.string().email().safeParse(data).success; + } + return true; + }), + username: z.string().nonempty(), + name: z.string().optional(), + type: z.enum(["internal", "oidc"]).optional(), + idpId: z.number().optional(), + roleId: z.number() + }) + .strict(); + +export type CreateOrgUserResponse = {}; + +registry.registerPath({ + method: "put", + path: "/org/{orgId}/user", + description: "Create an organization user.", + tags: [OpenAPITags.User, OpenAPITags.Org], + request: { + params: paramsSchema, + body: { + content: { + "application/json": { + schema: bodySchema + } + } + } + }, + responses: {} +}); + +export async function createOrgUser( + 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 { username, email, name, type, idpId, roleId } = parsedBody.data; + + const [role] = await db + .select() + .from(roles) + .where(eq(roles.roleId, roleId)); + + if (!role) { + return next( + createHttpError(HttpCode.BAD_REQUEST, "Role ID not found") + ); + } + + if (type === "internal") { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Internal users are not supported yet" + ) + ); + } else if (type === "oidc") { + if (!idpId) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "IDP ID is required for OIDC users" + ) + ); + } + + const [idpRes] = await db + .select() + .from(idp) + .innerJoin(idpOidcConfig, eq(idp.idpId, idpOidcConfig.idpId)) + .where(eq(idp.idpId, idpId)); + + if (!idpRes) { + return next( + createHttpError(HttpCode.BAD_REQUEST, "IDP ID not found") + ); + } + + if (idpRes.idp.type !== "oidc") { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "IDP ID is not of type OIDC" + ) + ); + } + + const [existingUser] = await db + .select() + .from(users) + .where(eq(users.username, username)); + + if (existingUser) { + const [existingOrgUser] = await db + .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 db + .insert(userOrgs) + .values({ + orgId, + userId: existingUser.userId, + roleId: role.roleId + }) + .returning(); + } else { + const userId = generateId(15); + + 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") + ); + } + + return response(res, { + data: {}, + success: true, + error: false, + message: "Org user 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/user/getOrgUser.ts b/server/routers/user/getOrgUser.ts index fd4defe3..6ebd33c0 100644 --- a/server/routers/user/getOrgUser.ts +++ b/server/routers/user/getOrgUser.ts @@ -9,6 +9,7 @@ import createHttpError from "http-errors"; import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import { ActionsEnum, checkUserActionPermission } from "@server/auth/actions"; +import { OpenAPITags, registry } from "@server/openApi"; async function queryUser(orgId: string, userId: string) { const [user] = await db @@ -16,6 +17,9 @@ async function queryUser(orgId: string, userId: string) { orgId: userOrgs.orgId, userId: users.userId, email: users.email, + username: users.username, + name: users.name, + type: users.type, roleId: userOrgs.roleId, roleName: roles.name, isOwner: userOrgs.isOwner, @@ -40,6 +44,17 @@ const getOrgUserParamsSchema = z }) .strict(); +registry.registerPath({ + method: "get", + path: "/org/{orgId}/user/{userId}", + description: "Get a user in an organization.", + tags: [OpenAPITags.Org, OpenAPITags.User], + request: { + params: getOrgUserParamsSchema + }, + responses: {} +}); + export async function getOrgUser( req: Request, res: Response, @@ -91,7 +106,7 @@ export async function getOrgUser( ); } - if (user.userId !== req.userOrg.userId) { + if (req.user && user.userId !== req.userOrg.userId) { const hasPermission = await checkUserActionPermission( ActionsEnum.getOrgUser, req diff --git a/server/routers/user/getUser.ts b/server/routers/user/getUser.ts index 31c7d8a5..2f80be90 100644 --- a/server/routers/user/getUser.ts +++ b/server/routers/user/getUser.ts @@ -1,7 +1,7 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db } from "@server/db"; -import { users } from "@server/db/schemas"; +import { idp, users } from "@server/db/schemas"; import { eq } from "drizzle-orm"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; @@ -13,11 +13,17 @@ async function queryUser(userId: string) { .select({ userId: users.userId, email: users.email, + username: users.username, + name: users.name, + type: users.type, twoFactorEnabled: users.twoFactorEnabled, emailVerified: users.emailVerified, - serverAdmin: users.serverAdmin + serverAdmin: users.serverAdmin, + idpName: idp.name, + idpId: users.idpId }) .from(users) + .leftJoin(idp, eq(users.idpId, idp.idpId)) .where(eq(users.userId, userId)) .limit(1); return user; diff --git a/server/routers/user/index.ts b/server/routers/user/index.ts index 5311fc93..49278c14 100644 --- a/server/routers/user/index.ts +++ b/server/routers/user/index.ts @@ -7,3 +7,6 @@ export * from "./acceptInvite"; export * from "./getOrgUser"; export * from "./adminListUsers"; export * from "./adminRemoveUser"; +export * from "./listInvitations"; +export * from "./removeInvitation"; +export * from "./createOrgUser"; diff --git a/server/routers/user/inviteUser.ts b/server/routers/user/inviteUser.ts index 078c1ab7..042942ab 100644 --- a/server/routers/user/inviteUser.ts +++ b/server/routers/user/inviteUser.ts @@ -1,3 +1,4 @@ +import NodeCache from "node-cache"; import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db } from "@server/db"; @@ -14,6 +15,10 @@ import { hashPassword } from "@server/auth/password"; import { fromError } from "zod-validation-error"; import { sendEmail } from "@server/emails"; import SendInviteLink from "@server/emails/templates/SendInviteLink"; +import { OpenAPITags, registry } from "@server/openApi"; +import { UserType } from "@server/types/UserTypes"; + +const regenerateTracker = new NodeCache({ stdTTL: 3600, checkperiod: 600 }); const inviteUserParamsSchema = z .object({ @@ -29,7 +34,8 @@ const inviteUserBodySchema = z .transform((v) => v.toLowerCase()), roleId: z.number(), validHours: z.number().gt(0).lte(168), - sendEmail: z.boolean().optional() + sendEmail: z.boolean().optional(), + regenerate: z.boolean().optional() }) .strict(); @@ -40,7 +46,23 @@ export type InviteUserResponse = { expiresAt: number; }; -const inviteTracker: Record = {}; +registry.registerPath({ + method: "post", + path: "/org/{orgId}/create-invite", + description: "Invite a user to join an organization.", + tags: [OpenAPITags.Org], + request: { + params: inviteUserParamsSchema, + body: { + content: { + "application/json": { + schema: inviteUserBodySchema + } + } + } + }, + responses: {} +}); export async function inviteUser( req: Request, @@ -73,31 +95,11 @@ export async function inviteUser( email, validHours, roleId, - sendEmail: doEmail + sendEmail: doEmail, + regenerate } = parsedBody.data; - const currentTime = Date.now(); - const oneHourAgo = currentTime - 3600000; - - if (!inviteTracker[email]) { - inviteTracker[email] = { timestamps: [] }; - } - - inviteTracker[email].timestamps = inviteTracker[ - email - ].timestamps.filter((timestamp) => timestamp > oneHourAgo); // TODO: this could cause memory increase over time if the object is never deleted - - if (inviteTracker[email].timestamps.length >= 3) { - return next( - createHttpError( - HttpCode.TOO_MANY_REQUESTS, - "User has already been invited 3 times in the last hour" - ) - ); - } - - inviteTracker[email].timestamps.push(currentTime); - + // Check if the organization exists const org = await db .select() .from(orgs) @@ -109,21 +111,115 @@ export async function inviteUser( ); } + // Check if the user already exists in the `users` table const existingUser = await db .select() .from(users) .innerJoin(userOrgs, eq(users.userId, userOrgs.userId)) - .where(eq(users.email, email)) + .where( + and( + eq(users.email, email), + eq(userOrgs.orgId, orgId), + eq(users.type, UserType.Internal) + ) + ) .limit(1); - if (existingUser.length && existingUser[0].userOrgs?.orgId === orgId) { + + if (existingUser.length) { return next( createHttpError( - HttpCode.BAD_REQUEST, - "User is already a member of this organization" + HttpCode.CONFLICT, + "This user is already a member of the organization." ) ); } + // Check if an invitation already exists + const existingInvite = await db + .select() + .from(userInvites) + .where( + and(eq(userInvites.email, email), eq(userInvites.orgId, orgId)) + ) + .limit(1); + + if (existingInvite.length && !regenerate) { + return next( + createHttpError( + HttpCode.CONFLICT, + "An invitation for this user already exists." + ) + ); + } + + if (existingInvite.length) { + const attempts = regenerateTracker.get(email) || 0; + if (attempts >= 3) { + return next( + createHttpError( + HttpCode.TOO_MANY_REQUESTS, + "You have exceeded the limit of 3 regenerations per hour." + ) + ); + } + + regenerateTracker.set(email, attempts + 1); + + const inviteId = existingInvite[0].inviteId; // Retrieve the original inviteId + const token = generateRandomString( + 32, + alphabet("a-z", "A-Z", "0-9") + ); + const expiresAt = createDate( + new TimeSpan(validHours, "h") + ).getTime(); + const tokenHash = await hashPassword(token); + + await db + .update(userInvites) + .set({ + tokenHash, + expiresAt + }) + .where( + and( + eq(userInvites.email, email), + eq(userInvites.orgId, orgId) + ) + ); + + const inviteLink = `${config.getRawConfig().app.dashboard_url}/invite?token=${inviteId}-${token}`; + + if (doEmail) { + await sendEmail( + SendInviteLink({ + email, + inviteLink, + expiresInDays: (validHours / 24).toString(), + orgName: org[0].name || orgId, + inviterName: req.user?.email || req.user?.username + }), + { + to: email, + from: config.getNoReplyEmail(), + subject: "Your invitation has been regenerated" + } + ); + } + + return response(res, { + data: { + inviteLink, + expiresAt + }, + success: true, + error: false, + message: "Invitation regenerated successfully", + status: HttpCode.OK + }); + } + + // Create a new invite if none exists const inviteId = generateRandomString( 10, alphabet("a-z", "A-Z", "0-9") @@ -134,17 +230,6 @@ export async function inviteUser( const tokenHash = await hashPassword(token); await db.transaction(async (trx) => { - // delete any existing invites for this email - await trx - .delete(userInvites) - .where( - and( - eq(userInvites.email, email), - eq(userInvites.orgId, orgId) - ) - ) - .execute(); - await trx.insert(userInvites).values({ inviteId, orgId, @@ -164,12 +249,12 @@ export async function inviteUser( inviteLink, expiresInDays: (validHours / 24).toString(), orgName: org[0].name || orgId, - inviterName: req.user?.email + inviterName: req.user?.email || req.user?.username }), { to: email, from: config.getNoReplyEmail(), - subject: "You're invited to join a Fossorial organization" + subject: `You're invited to join ${org[0].name || orgId}` } ); } diff --git a/server/routers/user/listInvitations.ts b/server/routers/user/listInvitations.ts new file mode 100644 index 00000000..76e82db5 --- /dev/null +++ b/server/routers/user/listInvitations.ts @@ -0,0 +1,124 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db } from "@server/db"; +import { userInvites, roles } from "@server/db/schemas"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import { sql } from "drizzle-orm"; +import logger from "@server/logger"; +import { fromZodError } from "zod-validation-error"; +import { OpenAPITags, registry } from "@server/openApi"; + +const listInvitationsParamsSchema = z + .object({ + orgId: z.string() + }) + .strict(); + +const listInvitationsQuerySchema = z + .object({ + limit: z + .string() + .optional() + .default("1000") + .transform(Number) + .pipe(z.number().int().nonnegative()), + offset: z + .string() + .optional() + .default("0") + .transform(Number) + .pipe(z.number().int().nonnegative()) + }) + .strict(); + +async function queryInvitations(orgId: string, limit: number, offset: number) { + return await db + .select({ + inviteId: userInvites.inviteId, + email: userInvites.email, + expiresAt: userInvites.expiresAt, + roleId: userInvites.roleId, + roleName: roles.name + }) + .from(userInvites) + .leftJoin(roles, sql`${userInvites.roleId} = ${roles.roleId}`) + .where(sql`${userInvites.orgId} = ${orgId}`) + .limit(limit) + .offset(offset); +} + +export type ListInvitationsResponse = { + invitations: NonNullable>>; + pagination: { total: number; limit: number; offset: number }; +}; + +registry.registerPath({ + method: "get", + path: "/org/{orgId}/invitations", + description: "List invitations in an organization.", + tags: [OpenAPITags.Org, OpenAPITags.Invitation], + request: { + params: listInvitationsParamsSchema, + query: listInvitationsQuerySchema + }, + responses: {} +}); + +export async function listInvitations( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedQuery = listInvitationsQuerySchema.safeParse(req.query); + if (!parsedQuery.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromZodError(parsedQuery.error) + ) + ); + } + const { limit, offset } = parsedQuery.data; + + const parsedParams = listInvitationsParamsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromZodError(parsedParams.error) + ) + ); + } + const { orgId } = parsedParams.data; + + const invitations = await queryInvitations(orgId, limit, offset); + + const [{ count }] = await db + .select({ count: sql`count(*)` }) + .from(userInvites) + .where(sql`${userInvites.orgId} = ${orgId}`); + + return response(res, { + data: { + invitations, + pagination: { + total: count, + limit, + offset + } + }, + success: true, + error: false, + message: "Invitations 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/user/listUsers.ts b/server/routers/user/listUsers.ts index e4429597..fd2291d5 100644 --- a/server/routers/user/listUsers.ts +++ b/server/routers/user/listUsers.ts @@ -1,13 +1,15 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db } from "@server/db"; -import { roles, userOrgs, users } from "@server/db/schemas"; +import { idp, roles, userOrgs, users } from "@server/db/schemas"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; -import { sql } from "drizzle-orm"; +import { and, sql } from "drizzle-orm"; import logger from "@server/logger"; import { fromZodError } from "zod-validation-error"; +import { OpenAPITags, registry } from "@server/openApi"; +import { eq } from "drizzle-orm"; const listUsersParamsSchema = z .object({ @@ -40,14 +42,20 @@ async function queryUsers(orgId: string, limit: number, offset: number) { emailVerified: users.emailVerified, dateCreated: users.dateCreated, orgId: userOrgs.orgId, + username: users.username, + name: users.name, + type: users.type, roleId: userOrgs.roleId, roleName: roles.name, - isOwner: userOrgs.isOwner + isOwner: userOrgs.isOwner, + idpName: idp.name, + idpId: users.idpId }) .from(users) - .leftJoin(userOrgs, sql`${users.userId} = ${userOrgs.userId}`) - .leftJoin(roles, sql`${userOrgs.roleId} = ${roles.roleId}`) - .where(sql`${userOrgs.orgId} = ${orgId}`) + .leftJoin(userOrgs, eq(users.userId, userOrgs.userId)) + .leftJoin(roles, eq(userOrgs.roleId, roles.roleId)) + .leftJoin(idp, eq(users.idpId, idp.idpId)) + .where(eq(userOrgs.orgId, orgId)) .limit(limit) .offset(offset); } @@ -57,6 +65,18 @@ export type ListUsersResponse = { pagination: { total: number; limit: number; offset: number }; }; +registry.registerPath({ + method: "get", + path: "/org/{orgId}/users", + description: "List users in an organization.", + tags: [OpenAPITags.Org, OpenAPITags.User], + request: { + params: listUsersParamsSchema, + query: listUsersSchema + }, + responses: {} +}); + export async function listUsers( req: Request, res: Response, @@ -94,7 +114,8 @@ export async function listUsers( const [{ count }] = await db .select({ count: sql`count(*)` }) - .from(users); + .from(userOrgs) + .where(eq(userOrgs.orgId, orgId)); return response(res, { data: { diff --git a/server/routers/user/removeInvitation.ts b/server/routers/user/removeInvitation.ts new file mode 100644 index 00000000..c825df6d --- /dev/null +++ b/server/routers/user/removeInvitation.ts @@ -0,0 +1,69 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db } from "@server/db"; +import { userInvites } from "@server/db/schemas"; +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 { fromError } from "zod-validation-error"; + +const removeInvitationParamsSchema = z + .object({ + orgId: z.string(), + inviteId: z.string() + }) + .strict(); + +export async function removeInvitation( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = removeInvitationParamsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { orgId, inviteId } = parsedParams.data; + + const deletedInvitation = await db + .delete(userInvites) + .where( + and( + eq(userInvites.orgId, orgId), + eq(userInvites.inviteId, inviteId) + ) + ) + .returning(); + + if (deletedInvitation.length === 0) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `Invitation with ID ${inviteId} not found in organization ${orgId}` + ) + ); + } + + return response(res, { + data: null, + success: true, + error: false, + message: "Invitation removed successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/user/removeUserOrg.ts b/server/routers/user/removeUserOrg.ts index 6043989e..b344978c 100644 --- a/server/routers/user/removeUserOrg.ts +++ b/server/routers/user/removeUserOrg.ts @@ -8,6 +8,7 @@ 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 removeUserSchema = z .object({ @@ -16,6 +17,17 @@ const removeUserSchema = z }) .strict(); +registry.registerPath({ + method: "delete", + path: "/org/{orgId}/user/{userId}", + description: "Remove a user from an organization.", + tags: [OpenAPITags.Org, OpenAPITags.User], + request: { + params: removeUserSchema + }, + responses: {} +}); + export async function removeUserOrg( req: Request, res: Response, diff --git a/server/setup/migrations.ts b/server/setup/migrations.ts index 77248f62..753ed6a7 100644 --- a/server/setup/migrations.ts +++ b/server/setup/migrations.ts @@ -19,6 +19,8 @@ import m15 from "./scripts/1.0.0-beta15"; import m16 from "./scripts/1.0.0"; import m17 from "./scripts/1.1.0"; import m18 from "./scripts/1.2.0"; +import m19 from "./scripts/1.3.0"; +import { setHostMeta } from "./setHostMeta"; // THIS CANNOT IMPORT ANYTHING FROM THE SERVER // EXCEPT FOR THE DATABASE AND THE SCHEMA @@ -37,7 +39,8 @@ const migrations = [ { version: "1.0.0-beta.15", run: m15 }, { version: "1.0.0", run: m16 }, { version: "1.1.0", run: m17 }, - { version: "1.2.0", run: m18 } + { version: "1.2.0", run: m18 }, + { version: "1.3.0", run: m19 } // Add new migrations here as they are created ] as const; diff --git a/server/setup/scripts/1.3.0.ts b/server/setup/scripts/1.3.0.ts new file mode 100644 index 00000000..a75dc207 --- /dev/null +++ b/server/setup/scripts/1.3.0.ts @@ -0,0 +1,203 @@ +import Database from "better-sqlite3"; +import path from "path"; +import fs from "fs"; +import yaml from "js-yaml"; +import { encodeBase32LowerCaseNoPadding } from "@oslojs/encoding"; +import { APP_PATH, configFilePath1, configFilePath2 } from "@server/lib/consts"; + +const version = "1.3.0"; +const location = path.join(APP_PATH, "db", "db.sqlite"); + +export default async function migration() { + console.log(`Running setup script ${version}...`); + + const db = new Database(location); + + try { + db.pragma("foreign_keys = OFF"); + db.transaction(() => { + db.exec(` + CREATE TABLE 'apiKeyActions' ( + 'apiKeyId' text NOT NULL, + 'actionId' text NOT NULL, + FOREIGN KEY ('apiKeyId') REFERENCES 'apiKeys'('apiKeyId') ON UPDATE no action ON DELETE cascade, + FOREIGN KEY ('actionId') REFERENCES 'actions'('actionId') ON UPDATE no action ON DELETE cascade + ); + + CREATE TABLE 'apiKeyOrg' ( + 'apiKeyId' text NOT NULL, + 'orgId' text NOT NULL, + FOREIGN KEY ('apiKeyId') REFERENCES 'apiKeys'('apiKeyId') ON UPDATE no action ON DELETE cascade, + FOREIGN KEY ('orgId') REFERENCES 'orgs'('orgId') ON UPDATE no action ON DELETE cascade + ); + + CREATE TABLE 'apiKeys' ( + 'apiKeyId' text PRIMARY KEY NOT NULL, + 'name' text NOT NULL, + 'apiKeyHash' text NOT NULL, + 'lastChars' text NOT NULL, + 'dateCreated' text NOT NULL, + 'isRoot' integer DEFAULT false NOT NULL + ); + + CREATE TABLE 'hostMeta' ( + 'hostMetaId' text PRIMARY KEY NOT NULL, + 'createdAt' integer NOT NULL + ); + + CREATE TABLE 'idp' ( + 'idpId' integer PRIMARY KEY AUTOINCREMENT NOT NULL, + 'name' text NOT NULL, + 'type' text NOT NULL, + 'defaultRoleMapping' text, + 'defaultOrgMapping' text, + 'autoProvision' integer DEFAULT false NOT NULL + ); + + CREATE TABLE 'idpOidcConfig' ( + 'idpOauthConfigId' integer PRIMARY KEY AUTOINCREMENT NOT NULL, + 'idpId' integer NOT NULL, + 'clientId' text NOT NULL, + 'clientSecret' text NOT NULL, + 'authUrl' text NOT NULL, + 'tokenUrl' text NOT NULL, + 'identifierPath' text NOT NULL, + 'emailPath' text, + 'namePath' text, + 'scopes' text NOT NULL, + FOREIGN KEY ('idpId') REFERENCES 'idp'('idpId') ON UPDATE no action ON DELETE cascade + ); + + CREATE TABLE 'idpOrg' ( + 'idpId' integer NOT NULL, + 'orgId' text NOT NULL, + 'roleMapping' text, + 'orgMapping' text, + FOREIGN KEY ('idpId') REFERENCES 'idp'('idpId') ON UPDATE no action ON DELETE cascade, + FOREIGN KEY ('orgId') REFERENCES 'orgs'('orgId') ON UPDATE no action ON DELETE cascade + ); + + CREATE TABLE 'licenseKey' ( + 'licenseKeyId' text PRIMARY KEY NOT NULL, + 'instanceId' text NOT NULL, + 'token' text NOT NULL + ); + + CREATE TABLE '__new_user' ( + 'id' text PRIMARY KEY NOT NULL, + 'email' text, + 'username' text NOT NULL, + 'name' text, + 'type' text NOT NULL, + 'idpId' integer, + 'passwordHash' text, + 'twoFactorEnabled' integer DEFAULT false NOT NULL, + 'twoFactorSecret' text, + 'emailVerified' integer DEFAULT false NOT NULL, + 'dateCreated' text NOT NULL, + 'serverAdmin' integer DEFAULT false NOT NULL, + FOREIGN KEY ('idpId') REFERENCES 'idp'('idpId') ON UPDATE no action ON DELETE cascade + ); + + INSERT INTO '__new_user'( + "id", "email", "username", "name", "type", "idpId", "passwordHash", + "twoFactorEnabled", "twoFactorSecret", "emailVerified", "dateCreated", "serverAdmin" + ) + SELECT + "id", + "email", + COALESCE("email", 'unknown'), + NULL, + 'internal', + NULL, + "passwordHash", + "twoFactorEnabled", + "twoFactorSecret", + "emailVerified", + "dateCreated", + "serverAdmin" + FROM 'user'; + + DROP TABLE 'user'; + ALTER TABLE '__new_user' RENAME TO 'user'; + + ALTER TABLE 'resources' ADD 'stickySession' integer DEFAULT false NOT NULL; + ALTER TABLE 'resources' ADD 'tlsServerName' text; + ALTER TABLE 'resources' ADD 'setHostHeader' text; + + CREATE TABLE 'exitNodes_new' ( + 'exitNodeId' integer PRIMARY KEY AUTOINCREMENT NOT NULL, + 'name' text NOT NULL, + 'address' text NOT NULL, + 'endpoint' text NOT NULL, + 'publicKey' text NOT NULL, + 'listenPort' integer NOT NULL, + 'reachableAt' text + ); + + INSERT INTO 'exitNodes_new' ( + 'exitNodeId', 'name', 'address', 'endpoint', 'publicKey', 'listenPort', 'reachableAt' + ) + SELECT + exitNodeId, + name, + address, + endpoint, + pubicKey, + listenPort, + reachableAt + FROM exitNodes; + + DROP TABLE 'exitNodes'; + ALTER TABLE 'exitNodes_new' RENAME TO 'exitNodes'; + `); + })(); // <-- executes the transaction immediately + db.pragma("foreign_keys = ON"); + console.log(`Migrated database schema`); + } catch (e) { + console.log("Unable to migrate database schema"); + throw e; + } + + // Update config file + try { + const filePaths = [configFilePath1, configFilePath2]; + let filePath = ""; + for (const path of filePaths) { + if (fs.existsSync(path)) { + filePath = path; + break; + } + } + + if (!filePath) { + throw new Error( + `No config file found (expected config.yml or config.yaml).` + ); + } + + const fileContents = fs.readFileSync(filePath, "utf8"); + let rawConfig: any = yaml.load(fileContents); + + if (!rawConfig.server.secret) { + rawConfig.server.secret = generateIdFromEntropySize(32); + } + + const updatedYaml = yaml.dump(rawConfig); + fs.writeFileSync(filePath, updatedYaml, "utf8"); + + console.log(`Added new config option: server.secret`); + } catch (e) { + console.log( + `Unable to add new config option: server.secret. Please add it manually.` + ); + console.error(e); + } + + console.log(`${version} migration complete`); +} + +function generateIdFromEntropySize(size: number): string { + const buffer = crypto.getRandomValues(new Uint8Array(size)); + return encodeBase32LowerCaseNoPadding(buffer); +} diff --git a/server/setup/setHostMeta.ts b/server/setup/setHostMeta.ts new file mode 100644 index 00000000..2a5b16a5 --- /dev/null +++ b/server/setup/setHostMeta.ts @@ -0,0 +1,17 @@ +import db from "@server/db"; +import { hostMeta } from "@server/db/schemas"; +import { v4 as uuidv4 } from "uuid"; + +export async function setHostMeta() { + const [existing] = await db.select().from(hostMeta).limit(1); + + if (existing && existing.hostMetaId) { + return; + } + + const id = uuidv4(); + + await db + .insert(hostMeta) + .values({ hostMetaId: id, createdAt: new Date().getTime() }); +} diff --git a/server/setup/setupServerAdmin.ts b/server/setup/setupServerAdmin.ts index 6ec6784c..9a84852a 100644 --- a/server/setup/setupServerAdmin.ts +++ b/server/setup/setupServerAdmin.ts @@ -8,6 +8,7 @@ import { eq } from "drizzle-orm"; import moment from "moment"; import { fromError } from "zod-validation-error"; import { passwordSchema } from "@server/auth/passwordSchema"; +import { UserType } from "@server/types/UserTypes"; export async function setupServerAdmin() { const { @@ -34,7 +35,7 @@ export async function setupServerAdmin() { if (existing) { const passwordChanged = !(await verifyPassword( password, - existing.passwordHash + existing.passwordHash! )); if (passwordChanged) { @@ -65,6 +66,8 @@ export async function setupServerAdmin() { await db.insert(users).values({ userId: userId, email: email, + type: UserType.Internal, + username: email, passwordHash, dateCreated: moment().toISOString(), serverAdmin: true, diff --git a/server/types/UserTypes.ts b/server/types/UserTypes.ts new file mode 100644 index 00000000..954d84f9 --- /dev/null +++ b/server/types/UserTypes.ts @@ -0,0 +1,4 @@ +export enum UserType { + Internal = "internal", + OIDC = "oidc" +} diff --git a/src/app/[orgId]/page.tsx b/src/app/[orgId]/page.tsx index eaff09d1..5f91fb62 100644 --- a/src/app/[orgId]/page.tsx +++ b/src/app/[orgId]/page.tsx @@ -1,4 +1,3 @@ -import ProfileIcon from "@app/components/ProfileIcon"; import { verifySession } from "@app/lib/auth/verifySession"; import UserProvider from "@app/providers/UserProvider"; import { cache } from "react"; @@ -8,6 +7,9 @@ import { internal } from "@app/lib/api"; 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 = { params: Promise<{ orgId: string }>; @@ -20,6 +22,10 @@ export default async function OrgPage(props: OrgPageProps) { const getUser = cache(verifySession); const user = await getUser(); + if (!user) { + redirect("/"); + } + let redirectToSettings = false; let overview: GetOrgOverviewResponse | undefined; try { @@ -38,15 +44,23 @@ export default async function OrgPage(props: OrgPageProps) { redirect(`/${orgId}/settings`); } - return ( - <> -
- {user && ( - - - - )} + let orgs: ListUserOrgsResponse["orgs"] = []; + try { + const getOrgs = cache(async () => + internal.get>( + `/user/${user.userId}/orgs`, + await authCookieHeader() + ) + ); + const res = await getOrgs(); + if (res && res.data.data.orgs) { + orgs = res.data.data.orgs; + } + } catch (e) {} + return ( + + {overview && (
)} -
- + + ); } diff --git a/src/app/[orgId]/settings/access/AccessPageHeaderAndNav.tsx b/src/app/[orgId]/settings/access/AccessPageHeaderAndNav.tsx index 8c1053d6..a3053e7e 100644 --- a/src/app/[orgId]/settings/access/AccessPageHeaderAndNav.tsx +++ b/src/app/[orgId]/settings/access/AccessPageHeaderAndNav.tsx @@ -1,37 +1,45 @@ "use client"; +import { HorizontalTabs } from "@app/components/HorizontalTabs"; import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; -import { SidebarSettings } from "@app/components/SidebarSettings"; -type AccessPageHeaderAndNavProps = { +interface AccessPageHeaderAndNavProps { children: React.ReactNode; -}; + hasInvitations: boolean; +} export default function AccessPageHeaderAndNav({ children, + hasInvitations }: AccessPageHeaderAndNavProps) { - const sidebarNavItems = [ + const navItems = [ { title: "Users", - href: `/{orgId}/settings/access/users`, + href: `/{orgId}/settings/access/users` }, { title: "Roles", - href: `/{orgId}/settings/access/roles`, - }, + href: `/{orgId}/settings/access/roles` + } ]; + if (hasInvitations) { + navItems.push({ + title: "Invitations", + href: `/{orgId}/settings/access/invitations` + }); + } + return ( <> - + {children} - + ); } diff --git a/src/app/[orgId]/settings/access/invitations/InvitationsDataTable.tsx b/src/app/[orgId]/settings/access/invitations/InvitationsDataTable.tsx new file mode 100644 index 00000000..e2154b2d --- /dev/null +++ b/src/app/[orgId]/settings/access/invitations/InvitationsDataTable.tsx @@ -0,0 +1,26 @@ +"use client"; + +import { + ColumnDef, +} from "@tanstack/react-table"; +import { DataTable } from "@app/components/ui/data-table"; + +interface DataTableProps { + columns: ColumnDef[]; + data: TData[]; +} + +export function InvitationsDataTable({ + columns, + data +}: DataTableProps) { + return ( + + ); +} diff --git a/src/app/[orgId]/settings/access/invitations/InvitationsTable.tsx b/src/app/[orgId]/settings/access/invitations/InvitationsTable.tsx new file mode 100644 index 00000000..9618df14 --- /dev/null +++ b/src/app/[orgId]/settings/access/invitations/InvitationsTable.tsx @@ -0,0 +1,185 @@ +"use client"; + +import { ColumnDef } from "@tanstack/react-table"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger +} from "@app/components/ui/dropdown-menu"; +import { Button } from "@app/components/ui/button"; +import { MoreHorizontal } from "lucide-react"; +import { InvitationsDataTable } from "./InvitationsDataTable"; +import { useState } from "react"; +import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog"; +import RegenerateInvitationForm from "./RegenerateInvitationForm"; +import { useOrgContext } from "@app/hooks/useOrgContext"; +import { toast } from "@app/hooks/useToast"; +import { createApiClient } from "@app/lib/api"; +import { useEnvContext } from "@app/hooks/useEnvContext"; + +export type InvitationRow = { + id: string; + email: string; + expiresAt: string; + role: string; + roleId: number; +}; + +type InvitationsTableProps = { + invitations: InvitationRow[]; +}; + +export default function InvitationsTable({ + invitations: i +}: InvitationsTableProps) { + const [invitations, setInvitations] = useState(i); + const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); + const [isRegenerateModalOpen, setIsRegenerateModalOpen] = useState(false); + const [selectedInvitation, setSelectedInvitation] = + useState(null); + + const api = createApiClient(useEnvContext()); + const { org } = useOrgContext(); + + const columns: ColumnDef[] = [ + { + id: "dots", + cell: ({ row }) => { + const invitation = row.original; + return ( + + + + + + { + setIsRegenerateModalOpen(true); + setSelectedInvitation(invitation); + }} + > + Regenerate Invitation + + { + setIsDeleteModalOpen(true); + setSelectedInvitation(invitation); + }} + > + + Remove Invitation + + + + + ); + } + }, + { + accessorKey: "email", + header: "Email" + }, + { + accessorKey: "expiresAt", + header: "Expires At", + cell: ({ row }) => { + const expiresAt = new Date(row.original.expiresAt); + const isExpired = expiresAt < new Date(); + + return ( + + {expiresAt.toLocaleString()} + + ); + } + }, + { + accessorKey: "role", + header: "Role" + } + ]; + + async function removeInvitation() { + if (selectedInvitation) { + const res = await api + .delete( + `/org/${org?.org.orgId}/invitations/${selectedInvitation.id}` + ) + .catch((e) => { + toast({ + variant: "destructive", + title: "Failed to remove invitation", + description: + "An error occurred while removing the invitation." + }); + }); + + if (res && res.status === 200) { + toast({ + variant: "default", + title: "Invitation removed", + description: `The invitation for ${selectedInvitation.email} has been removed.` + }); + + setInvitations((prev) => + prev.filter( + (invitation) => invitation.id !== selectedInvitation.id + ) + ); + } + } + setIsDeleteModalOpen(false); + } + + return ( + <> + { + setIsDeleteModalOpen(val); + setSelectedInvitation(null); + }} + dialog={ +
+

+ Are you sure you want to remove the invitation for{" "} + {selectedInvitation?.email}? +

+

+ Once removed, this invitation will no longer be + valid. You can always re-invite the user later. +

+

+ To confirm, please type the email address of the + invitation below. +

+
+ } + buttonText="Confirm Remove Invitation" + onConfirm={removeInvitation} + string={selectedInvitation?.email ?? ""} + title="Remove Invitation" + /> + { + setInvitations((prev) => + prev.map((inv) => + inv.id === updatedInvitation.id + ? updatedInvitation + : inv + ) + ); + }} + /> + + + + ); +} diff --git a/src/app/[orgId]/settings/access/invitations/RegenerateInvitationForm.tsx b/src/app/[orgId]/settings/access/invitations/RegenerateInvitationForm.tsx new file mode 100644 index 00000000..a8acb791 --- /dev/null +++ b/src/app/[orgId]/settings/access/invitations/RegenerateInvitationForm.tsx @@ -0,0 +1,255 @@ +import { Button } from "@app/components/ui/button"; +import { + Credenza, + CredenzaBody, + CredenzaClose, + CredenzaContent, + CredenzaDescription, + CredenzaFooter, + CredenzaHeader, + CredenzaTitle +} from "@app/components/Credenza"; +import { useState, useEffect } from "react"; +import { createApiClient } from "@app/lib/api"; +import { useEnvContext } from "@app/hooks/useEnvContext"; +import { useOrgContext } from "@app/hooks/useOrgContext"; +import { toast } from "@app/hooks/useToast"; +import CopyTextBox from "@app/components/CopyTextBox"; +import { Checkbox } from "@app/components/ui/checkbox"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue +} from "@app/components/ui/select"; +import { Label } from "@app/components/ui/label"; + +type RegenerateInvitationFormProps = { + open: boolean; + setOpen: (open: boolean) => void; + invitation: { + id: string; + email: string; + roleId: number; + role: string; + } | null; + onRegenerate: (updatedInvitation: { + id: string; + email: string; + expiresAt: string; + role: string; + roleId: number; + }) => void; +}; + +export default function RegenerateInvitationForm({ + open, + setOpen, + invitation, + onRegenerate +}: RegenerateInvitationFormProps) { + const [loading, setLoading] = useState(false); + const [inviteLink, setInviteLink] = useState(null); + const [sendEmail, setSendEmail] = useState(true); + const [validHours, setValidHours] = useState(72); + const api = createApiClient(useEnvContext()); + const { org } = useOrgContext(); + + const validForOptions = [ + { hours: 24, name: "1 day" }, + { hours: 48, name: "2 days" }, + { hours: 72, name: "3 days" }, + { hours: 96, name: "4 days" }, + { hours: 120, name: "5 days" }, + { hours: 144, name: "6 days" }, + { hours: 168, name: "7 days" } + ]; + + useEffect(() => { + if (open) { + setSendEmail(true); + setValidHours(72); + } + }, [open]); + + async function handleRegenerate() { + if (!invitation) return; + + if (!org?.org.orgId) { + toast({ + variant: "destructive", + title: "Organization ID Missing", + description: + "Unable to regenerate invitation without an organization ID.", + duration: 5000 + }); + return; + } + + setLoading(true); + + try { + const res = await api.post(`/org/${org.org.orgId}/create-invite`, { + email: invitation.email, + roleId: invitation.roleId, + validHours, + sendEmail, + regenerate: true + }); + + if (res.status === 200) { + const link = res.data.data.inviteLink; + setInviteLink(link); + + if (sendEmail) { + toast({ + variant: "default", + title: "Invitation Regenerated", + description: `A new invitation has been sent to ${invitation.email}.`, + duration: 5000 + }); + } else { + toast({ + variant: "default", + title: "Invitation Regenerated", + description: `A new invitation has been generated for ${invitation.email}.`, + duration: 5000 + }); + } + + onRegenerate({ + id: invitation.id, + email: invitation.email, + expiresAt: res.data.data.expiresAt, + role: invitation.role, + roleId: invitation.roleId + }); + } + } catch (error: any) { + if (error.response?.status === 409) { + toast({ + variant: "destructive", + title: "Duplicate Invite", + description: "An invitation for this user already exists.", + duration: 5000 + }); + } else if (error.response?.status === 429) { + toast({ + variant: "destructive", + title: "Rate Limit Exceeded", + description: + "You have exceeded the limit of 3 regenerations per hour. Please try again later.", + duration: 5000 + }); + } else { + toast({ + variant: "destructive", + title: "Failed to Regenerate Invitation", + description: + "An error occurred while regenerating the invitation.", + duration: 5000 + }); + } + } finally { + setLoading(false); + } + } + + return ( + { + setOpen(isOpen); + if (!isOpen) { + setInviteLink(null); + } + }} + > + + + Regenerate Invitation + + Revoke previous invitation and create a new one + + + + {!inviteLink ? ( +
+

+ Are you sure you want to regenerate the + invitation for {invitation?.email}? This + will revoke the previous invitation. +

+
+ + setSendEmail(e as boolean) + } + /> + +
+
+ + +
+
+ ) : ( +
+

+ The invitation has been regenerated. The user + must access the link below to accept the + invitation. +

+ +
+ )} +
+ + {!inviteLink ? ( + <> + + + + + + ) : ( + + + + )} + +
+
+ ); +} diff --git a/src/app/[orgId]/settings/access/invitations/page.tsx b/src/app/[orgId]/settings/access/invitations/page.tsx new file mode 100644 index 00000000..9c8b5e11 --- /dev/null +++ b/src/app/[orgId]/settings/access/invitations/page.tsx @@ -0,0 +1,87 @@ +import { internal } from "@app/lib/api"; +import { authCookieHeader } from "@app/lib/api/cookies"; +import { AxiosResponse } from "axios"; +import InvitationsTable, { InvitationRow } from "./InvitationsTable"; +import { GetOrgResponse } from "@server/routers/org"; +import { cache } from "react"; +import OrgProvider from "@app/providers/OrgProvider"; +import UserProvider from "@app/providers/UserProvider"; +import { verifySession } from "@app/lib/auth/verifySession"; +import AccessPageHeaderAndNav from "../AccessPageHeaderAndNav"; +import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; + +type InvitationsPageProps = { + params: Promise<{ orgId: string }>; +}; + +export const dynamic = "force-dynamic"; + +export default async function InvitationsPage(props: InvitationsPageProps) { + const params = await props.params; + + const getUser = cache(verifySession); + const user = await getUser(); + + let invitations: { + inviteId: string; + email: string; + expiresAt: string; + roleId: number; + roleName?: string; + }[] = []; + let hasInvitations = false; + + const res = await internal + .get< + AxiosResponse<{ + invitations: typeof invitations; + pagination: { total: number }; + }> + >(`/org/${params.orgId}/invitations`, await authCookieHeader()) + .catch((e) => {}); + + if (res && res.status === 200) { + invitations = res.data.data.invitations; + hasInvitations = res.data.data.pagination.total > 0; + } + + let org: GetOrgResponse | null = null; + const getOrg = cache(async () => + internal + .get< + AxiosResponse + >(`/org/${params.orgId}`, await authCookieHeader()) + .catch((e) => { + console.error(e); + }) + ); + const orgRes = await getOrg(); + + if (orgRes && orgRes.status === 200) { + org = orgRes.data.data; + } + + const invitationRows: InvitationRow[] = invitations.map((invite) => { + return { + id: invite.inviteId, + email: invite.email, + expiresAt: new Date(Number(invite.expiresAt)).toISOString(), + role: invite.roleName || "Unknown Role", + roleId: invite.roleId + }; + }); + + return ( + <> + + + + + + + + ); +} diff --git a/src/app/[orgId]/settings/access/roles/RolesDataTable.tsx b/src/app/[orgId]/settings/access/roles/RolesDataTable.tsx index e4e0fb9c..93ddd1cc 100644 --- a/src/app/[orgId]/settings/access/roles/RolesDataTable.tsx +++ b/src/app/[orgId]/settings/access/roles/RolesDataTable.tsx @@ -2,149 +2,29 @@ import { ColumnDef, - flexRender, - getCoreRowModel, - useReactTable, - getPaginationRowModel, - SortingState, - getSortedRowModel, - ColumnFiltersState, - getFilteredRowModel } from "@tanstack/react-table"; -import { - Table, - TableBody, - TableCell, - TableContainer, - TableHead, - TableHeader, - TableRow -} from "@/components/ui/table"; -import { Button } from "@app/components/ui/button"; -import { useState } from "react"; -import { Input } from "@app/components/ui/input"; -import { Plus, Search } from "lucide-react"; -import { DataTablePagination } from "@app/components/DataTablePagination"; +import { DataTable } from "@app/components/ui/data-table"; interface DataTableProps { columns: ColumnDef[]; data: TData[]; - addRole?: () => void; + createRole?: () => void; } export function RolesDataTable({ - addRole, columns, - data + data, + createRole }: DataTableProps) { - const [sorting, setSorting] = useState([]); - const [columnFilters, setColumnFilters] = useState([]); - const [globalFilter, setGlobalFilter] = useState([]); - - const table = useReactTable({ - data, - columns, - getCoreRowModel: getCoreRowModel(), - getPaginationRowModel: getPaginationRowModel(), - onSortingChange: setSorting, - getSortedRowModel: getSortedRowModel(), - onColumnFiltersChange: setColumnFilters, - getFilteredRowModel: getFilteredRowModel(), - onGlobalFilterChange: setGlobalFilter, - initialState: { - pagination: { - pageSize: 20, - pageIndex: 0 - } - }, - state: { - sorting, - columnFilters, - globalFilter - } - }); - return ( -
-
-
- - table.setGlobalFilter(String(e.target.value)) - } - className="w-full pl-8" - /> - -
- -
- - - - {table.getHeaderGroups().map((headerGroup) => ( - - {headerGroup.headers.map((header) => { - return ( - - {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() - )} - - ))} - - )) - ) : ( - - - No roles. Create a role, then add users to - the it. - - - )} - -
-
-
- -
-
+ ); } diff --git a/src/app/[orgId]/settings/access/roles/RolesTable.tsx b/src/app/[orgId]/settings/access/roles/RolesTable.tsx index e8e98265..7ebcfbce 100644 --- a/src/app/[orgId]/settings/access/roles/RolesTable.tsx +++ b/src/app/[orgId]/settings/access/roles/RolesTable.tsx @@ -131,7 +131,7 @@ export default function UsersTable({ roles: r }: RolesTableProps) { { + createRole={() => { setIsCreateModalOpen(true); }} /> diff --git a/src/app/[orgId]/settings/access/roles/page.tsx b/src/app/[orgId]/settings/access/roles/page.tsx index b0915978..16fefd7d 100644 --- a/src/app/[orgId]/settings/access/roles/page.tsx +++ b/src/app/[orgId]/settings/access/roles/page.tsx @@ -8,6 +8,7 @@ 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"; type RolesPageProps = { params: Promise<{ orgId: string }>; @@ -19,6 +20,8 @@ export default async function RolesPage(props: RolesPageProps) { const params = await props.params; let roles: ListRolesResponse["roles"] = []; + let hasInvitations = false; + const res = await internal .get< AxiosResponse @@ -29,6 +32,21 @@ export default async function RolesPage(props: RolesPageProps) { roles = res.data.data.roles; } + const invitationsRes = await internal + .get< + AxiosResponse<{ + pagination: { total: number }; + }> + >( + `/org/${params.orgId}/invitations?limit=1&offset=0`, + await authCookieHeader() + ) + .catch((e) => {}); + + if (invitationsRes && invitationsRes.status === 200) { + hasInvitations = invitationsRes.data.data.pagination.total > 0; + } + let org: GetOrgResponse | null = null; const getOrg = cache(async () => internal @@ -47,11 +65,13 @@ export default async function RolesPage(props: RolesPageProps) { return ( <> - - - - - + + + + ); } diff --git a/src/app/[orgId]/settings/access/users/InviteUserForm.tsx b/src/app/[orgId]/settings/access/users/InviteUserForm.tsx deleted file mode 100644 index 0285123a..00000000 --- a/src/app/[orgId]/settings/access/users/InviteUserForm.tsx +++ /dev/null @@ -1,357 +0,0 @@ -"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 { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue -} from "@app/components/ui/select"; -import { toast } from "@app/hooks/useToast"; -import { zodResolver } from "@hookform/resolvers/zod"; -import { InviteUserBody, InviteUserResponse } from "@server/routers/user"; -import { AxiosResponse } from "axios"; -import { useEffect, useState } from "react"; -import { useForm } from "react-hook-form"; -import { z } from "zod"; -import CopyTextBox from "@app/components/CopyTextBox"; -import { - Credenza, - CredenzaBody, - CredenzaClose, - CredenzaContent, - CredenzaDescription, - CredenzaFooter, - CredenzaHeader, - CredenzaTitle -} from "@app/components/Credenza"; -import { useOrgContext } from "@app/hooks/useOrgContext"; -import { ListRolesResponse } from "@server/routers/role"; -import { formatAxiosError } from "@app/lib/api"; -import { createApiClient } from "@app/lib/api"; -import { useEnvContext } from "@app/hooks/useEnvContext"; -import { Checkbox } from "@app/components/ui/checkbox"; - -type InviteUserFormProps = { - open: boolean; - setOpen: (open: boolean) => void; -}; - -const formSchema = z.object({ - email: z.string().email({ message: "Invalid email address" }), - validForHours: z.string().min(1, { message: "Please select a duration" }), - roleId: z.string().min(1, { message: "Please select a role" }) -}); - -export default function InviteUserForm({ open, setOpen }: InviteUserFormProps) { - const { org } = useOrgContext(); - - const { env } = useEnvContext(); - - const api = createApiClient({ env }); - - const [inviteLink, setInviteLink] = useState(null); - const [loading, setLoading] = useState(false); - const [expiresInDays, setExpiresInDays] = useState(1); - - const [roles, setRoles] = useState<{ roleId: number; name: string }[]>([]); - - const [sendEmail, setSendEmail] = useState(env.email.emailEnabled); - - const validFor = [ - { hours: 24, name: "1 day" }, - { hours: 48, name: "2 days" }, - { hours: 72, name: "3 days" }, - { hours: 96, name: "4 days" }, - { hours: 120, name: "5 days" }, - { hours: 144, name: "6 days" }, - { hours: 168, name: "7 days" } - ]; - - const form = useForm>({ - resolver: zodResolver(formSchema), - defaultValues: { - email: "", - validForHours: "72", - roleId: "" - } - }); - - useEffect(() => { - if (!open) { - return; - } - - async function fetchRoles() { - const res = await api - .get< - AxiosResponse - >(`/org/${org?.org.orgId}/roles`) - .catch((e) => { - console.error(e); - toast({ - variant: "destructive", - title: "Failed to fetch roles", - description: formatAxiosError( - e, - "An error occurred while fetching the roles" - ) - }); - }); - - if (res?.status === 200) { - setRoles(res.data.data.roles); - // form.setValue( - // "roleId", - // res.data.data.roles[0].roleId.toString() - // ); - } - } - - fetchRoles(); - }, [open]); - - async function onSubmit(values: z.infer) { - setLoading(true); - - const res = await api - .post>( - `/org/${org?.org.orgId}/create-invite`, - { - email: values.email, - roleId: parseInt(values.roleId), - validHours: parseInt(values.validForHours), - sendEmail: sendEmail - } as InviteUserBody - ) - .catch((e) => { - toast({ - variant: "destructive", - title: "Failed to invite user", - description: formatAxiosError( - e, - "An error occurred while inviting the user" - ) - }); - }); - - if (res && res.status === 200) { - setInviteLink(res.data.data.inviteLink); - toast({ - variant: "default", - title: "User invited", - description: "The user has been successfully invited." - }); - - setExpiresInDays(parseInt(values.validForHours) / 24); - } - - setLoading(false); - } - - return ( - <> - { - setOpen(val); - setInviteLink(null); - setLoading(false); - setExpiresInDays(1); - form.reset(); - }} - > - - - Invite User - - Give new users access to your organization - - - -
- {!inviteLink && ( -
- - ( - - Email - - - - - - )} - /> - - {env.email.emailEnabled && ( -
- - setSendEmail( - e as boolean - ) - } - /> - -
- )} - - ( - - Role - - - - )} - /> - ( - - - Valid For - - - - - )} - /> - - - )} - - {inviteLink && ( -
- {sendEmail && ( -

- An email has been sent to the user - with the access link below. They - must access the link to accept the - invitation. -

- )} - {!sendEmail && ( -

- The user has been invited. They must - access the link below to accept the - invitation. -

- )} -

- The invite will expire in{" "} - - {expiresInDays}{" "} - {expiresInDays === 1 - ? "day" - : "days"} - - . -

- -
- )} -
-
- - - - - - -
-
- - ); -} diff --git a/src/app/[orgId]/settings/access/users/UsersDataTable.tsx b/src/app/[orgId]/settings/access/users/UsersDataTable.tsx index b47424ba..643d8641 100644 --- a/src/app/[orgId]/settings/access/users/UsersDataTable.tsx +++ b/src/app/[orgId]/settings/access/users/UsersDataTable.tsx @@ -2,29 +2,8 @@ import { ColumnDef, - flexRender, - getCoreRowModel, - useReactTable, - getPaginationRowModel, - SortingState, - getSortedRowModel, - ColumnFiltersState, - getFilteredRowModel } from "@tanstack/react-table"; -import { - Table, - TableBody, - TableCell, - TableContainer, - TableHead, - TableHeader, - TableRow -} from "@/components/ui/table"; -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 { DataTable } from "@app/components/ui/data-table"; interface DataTableProps { columns: ColumnDef[]; @@ -33,118 +12,19 @@ interface DataTableProps { } export function UsersDataTable({ - inviteUser, columns, - data + data, + inviteUser }: DataTableProps) { - const [sorting, setSorting] = useState([]); - const [columnFilters, setColumnFilters] = useState([]); - const [globalFilter, setGlobalFilter] = useState([]); - - const table = useReactTable({ - data, - columns, - getCoreRowModel: getCoreRowModel(), - getPaginationRowModel: getPaginationRowModel(), - onSortingChange: setSorting, - getSortedRowModel: getSortedRowModel(), - onColumnFiltersChange: setColumnFilters, - getFilteredRowModel: getFilteredRowModel(), - onGlobalFilterChange: setGlobalFilter, - initialState: { - pagination: { - pageSize: 20, - pageIndex: 0 - } - }, - state: { - sorting, - columnFilters, - globalFilter - } - }); - return ( -
-
-
- - table.setGlobalFilter(String(e.target.value)) - } - className="w-full pl-8" - /> - -
- -
- - - - {table.getHeaderGroups().map((headerGroup) => ( - - {headerGroup.headers.map((header) => { - return ( - - {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() - )} - - ))} - - )) - ) : ( - - - No Users. Invite one to share access to - resources. - - - )} - -
-
-
- -
-
+ ); } diff --git a/src/app/[orgId]/settings/access/users/UsersTable.tsx b/src/app/[orgId]/settings/access/users/UsersTable.tsx index 29529d66..8036cc84 100644 --- a/src/app/[orgId]/settings/access/users/UsersTable.tsx +++ b/src/app/[orgId]/settings/access/users/UsersTable.tsx @@ -11,7 +11,6 @@ import { Button } from "@app/components/ui/button"; import { ArrowRight, ArrowUpDown, Crown, MoreHorizontal } from "lucide-react"; import { UsersDataTable } from "./UsersDataTable"; import { useState } from "react"; -import InviteUserForm from "./InviteUserForm"; import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog"; import { useOrgContext } from "@app/hooks/useOrgContext"; import { toast } from "@app/hooks/useToast"; @@ -24,7 +23,13 @@ import { useUserContext } from "@app/hooks/useUserContext"; export type UserRow = { id: string; - email: string; + email: string | null; + displayUsername: string | null; + username: string; + name: string | null; + idpId: number | null; + idpName: string; + type: string; status: string; role: string; isOwner: boolean; @@ -35,16 +40,11 @@ type UsersTableProps = { }; export default function UsersTable({ users: u }: UsersTableProps) { - const [isInviteModalOpen, setIsInviteModalOpen] = useState(false); const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); const [selectedUser, setSelectedUser] = useState(null); - const [users, setUsers] = useState(u); - const router = useRouter(); - const api = createApiClient(useEnvContext()); - const { user, updateUser } = useUserContext(); const { org } = useOrgContext(); @@ -82,7 +82,8 @@ export default function UsersTable({ users: u }: UsersTableProps) { Manage User - {userRow.email !== user?.email && ( + {`${userRow.username}-${userRow.idpId}` !== + `${user?.username}-${userRow.idpId}` && ( { setIsDeleteModalOpen( @@ -108,7 +109,7 @@ export default function UsersTable({ users: u }: UsersTableProps) { } }, { - accessorKey: "email", + accessorKey: "displayUsername", header: ({ column }) => { return ( ); } }, { - accessorKey: "status", + accessorKey: "idpName", header: ({ column }) => { return ( ); @@ -185,7 +186,10 @@ export default function UsersTable({ users: u }: UsersTableProps) { - @@ -239,7 +243,12 @@ export default function UsersTable({ users: u }: UsersTableProps) {

Are you sure you want to remove{" "} - {selectedUser?.email} from the organization? + + {selectedUser?.email || + selectedUser?.name || + selectedUser?.username} + {" "} + from the organization?

@@ -250,27 +259,27 @@ export default function UsersTable({ users: u }: UsersTableProps) {

- To confirm, please type the email address of the - user below. + To confirm, please type the name of the of the user + below.

} buttonText="Confirm Remove User" onConfirm={removeUser} - string={selectedUser?.email ?? ""} + string={ + selectedUser?.email || + selectedUser?.name || + selectedUser?.username || + "" + } title="Remove User from Organization" /> - - { - setIsInviteModalOpen(true); + 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 135c47a3..342e8b7c 100644 --- a/src/app/[orgId]/settings/access/users/[userId]/layout.tsx +++ b/src/app/[orgId]/settings/access/users/[userId]/layout.tsx @@ -2,19 +2,19 @@ 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 { GetOrgUserResponse } from "@server/routers/user"; import OrgUserProvider from "@app/providers/OrgUserProvider"; +import { HorizontalTabs } from "@app/components/HorizontalTabs"; import { Breadcrumb, BreadcrumbItem, - BreadcrumbLink, BreadcrumbList, BreadcrumbPage, BreadcrumbSeparator -} from "@/components/ui/breadcrumb"; +} from "@app/components/ui/breadcrumb"; import Link from "next/link"; import { cache } from "react"; +import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; interface UserLayoutProps { children: React.ReactNode; @@ -40,7 +40,7 @@ export default async function UserLayoutProps(props: UserLayoutProps) { redirect(`/${params.orgId}/settings/sites`); } - const sidebarNavItems = [ + const navItems = [ { title: "Access Controls", href: "/{orgId}/settings/access/users/{userId}/access-controls" @@ -49,33 +49,14 @@ export default async function UserLayoutProps(props: UserLayoutProps) { return ( <> + -
- - - - Users - - - - {user.email} - - - -
- -
-

- User {user?.email} -

-

Manage user

-
- - + {children} - +
); diff --git a/src/app/[orgId]/settings/access/users/create/page.tsx b/src/app/[orgId]/settings/access/users/create/page.tsx new file mode 100644 index 00000000..c270b350 --- /dev/null +++ b/src/app/[orgId]/settings/access/users/create/page.tsx @@ -0,0 +1,793 @@ +"use client"; + +import { + SettingsContainer, + SettingsSection, + SettingsSectionBody, + SettingsSectionDescription, + SettingsSectionForm, + SettingsSectionHeader, + SettingsSectionTitle +} from "@app/components/Settings"; +import { StrategySelect } from "@app/components/StrategySelect"; +import HeaderTitle from "@app/components/SettingsSectionTitle"; +import { Button } from "@app/components/ui/button"; +import { useParams, useRouter } from "next/navigation"; +import { useState } from "react"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage +} from "@app/components/ui/form"; +import { Input } from "@app/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue +} from "@app/components/ui/select"; +import { toast } from "@app/hooks/useToast"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { InviteUserBody, InviteUserResponse } from "@server/routers/user"; +import { AxiosResponse } from "axios"; +import { useEffect } from "react"; +import { useForm } from "react-hook-form"; +import { z } from "zod"; +import CopyTextBox from "@app/components/CopyTextBox"; +import { useEnvContext } from "@app/hooks/useEnvContext"; +import { ListRolesResponse } from "@server/routers/role"; +import { formatAxiosError } from "@app/lib/api"; +import { createApiClient } from "@app/lib/api"; +import { Checkbox } from "@app/components/ui/checkbox"; +import { ListIdpsResponse } from "@server/routers/idp"; + +type UserType = "internal" | "oidc"; + +interface UserTypeOption { + id: UserType; + title: string; + description: string; +} + +interface IdpOption { + idpId: number; + name: string; + type: string; +} + +const internalFormSchema = z.object({ + email: z.string().email({ message: "Invalid email address" }), + validForHours: z.string().min(1, { message: "Please select a duration" }), + roleId: z.string().min(1, { message: "Please select a role" }) +}); + +const externalFormSchema = z.object({ + username: z.string().min(1, { message: "Username is required" }), + email: z + .string() + .email({ message: "Invalid email address" }) + .optional() + .or(z.literal("")), + name: z.string().optional(), + roleId: z.string().min(1, { message: "Please select a role" }), + idpId: z.string().min(1, { message: "Please select an identity provider" }) +}); + +const formatIdpType = (type: string) => { + switch (type.toLowerCase()) { + case "oidc": + return "Generic OAuth2/OIDC provider."; + default: + return type; + } +}; + +export default function Page() { + const { orgId } = useParams(); + const router = useRouter(); + const { env } = useEnvContext(); + const api = createApiClient({ env }); + + const [userType, setUserType] = useState("internal"); + const [inviteLink, setInviteLink] = useState(null); + const [loading, setLoading] = useState(false); + const [expiresInDays, setExpiresInDays] = useState(1); + const [roles, setRoles] = useState<{ roleId: number; name: string }[]>([]); + const [idps, setIdps] = useState([]); + const [sendEmail, setSendEmail] = useState(env.email.emailEnabled); + const [selectedIdp, setSelectedIdp] = useState(null); + const [dataLoaded, setDataLoaded] = useState(false); + + const validFor = [ + { hours: 24, name: "1 day" }, + { hours: 48, name: "2 days" }, + { hours: 72, name: "3 days" }, + { hours: 96, name: "4 days" }, + { hours: 120, name: "5 days" }, + { hours: 144, name: "6 days" }, + { hours: 168, name: "7 days" } + ]; + + const internalForm = useForm>({ + resolver: zodResolver(internalFormSchema), + defaultValues: { + email: "", + validForHours: "72", + roleId: "" + } + }); + + const externalForm = useForm>({ + resolver: zodResolver(externalFormSchema), + defaultValues: { + username: "", + email: "", + name: "", + roleId: "", + idpId: "" + } + }); + + useEffect(() => { + if (userType === "internal") { + setSendEmail(env.email.emailEnabled); + internalForm.reset(); + setInviteLink(null); + setExpiresInDays(1); + } else if (userType === "oidc") { + externalForm.reset(); + } + }, [userType, env.email.emailEnabled, internalForm, externalForm]); + + useEffect(() => { + if (!userType) { + return; + } + + async function fetchRoles() { + const res = await api + .get>(`/org/${orgId}/roles`) + .catch((e) => { + console.error(e); + toast({ + variant: "destructive", + title: "Failed to fetch roles", + description: formatAxiosError( + e, + "An error occurred while fetching the roles" + ) + }); + }); + + if (res?.status === 200) { + setRoles(res.data.data.roles); + if (userType === "internal") { + setDataLoaded(true); + } + } + } + + async function fetchIdps() { + const res = await api + .get>("/idp") + .catch((e) => { + console.error(e); + toast({ + variant: "destructive", + title: "Failed to fetch identity providers", + description: formatAxiosError( + e, + "An error occurred while fetching identity providers" + ) + }); + }); + + if (res?.status === 200) { + setIdps(res.data.data.idps); + setDataLoaded(true); + } + } + + setDataLoaded(false); + fetchRoles(); + if (userType !== "internal") { + fetchIdps(); + } + }, [userType]); + + async function onSubmitInternal( + values: z.infer + ) { + setLoading(true); + + const res = await api + .post>( + `/org/${orgId}/create-invite`, + { + email: values.email, + roleId: parseInt(values.roleId), + validHours: parseInt(values.validForHours), + sendEmail: sendEmail + } as InviteUserBody + ) + .catch((e) => { + if (e.response?.status === 409) { + toast({ + variant: "destructive", + title: "User Already Exists", + description: + "This user is already a member of the organization." + }); + } else { + toast({ + variant: "destructive", + title: "Failed to invite user", + description: formatAxiosError( + e, + "An error occurred while inviting the user" + ) + }); + } + }); + + if (res && res.status === 200) { + setInviteLink(res.data.data.inviteLink); + toast({ + variant: "default", + title: "User invited", + description: "The user has been successfully invited." + }); + + setExpiresInDays(parseInt(values.validForHours) / 24); + } + + setLoading(false); + } + + async function onSubmitExternal( + values: z.infer + ) { + setLoading(true); + + const res = await api + .put(`/org/${orgId}/user`, { + username: values.username, + email: values.email, + name: values.name, + type: "oidc", + idpId: parseInt(values.idpId), + roleId: parseInt(values.roleId) + }) + .catch((e) => { + toast({ + variant: "destructive", + title: "Failed to create user", + description: formatAxiosError( + e, + "An error occurred while creating the user" + ) + }); + }); + + if (res && res.status === 201) { + toast({ + variant: "default", + title: "User created", + description: "The user has been successfully created." + }); + router.push(`/${orgId}/settings/access/users`); + } + + setLoading(false); + } + + const userTypes: ReadonlyArray = [ + { + id: "internal", + title: "Internal User", + description: "Invite a user to join your organization directly." + }, + { + id: "oidc", + title: "External User", + description: "Create a user with an external identity provider." + } + ]; + + return ( + <> +
+ + +
+ +
+ + + + + User Type + + + Determine how you want to create the user + + + + { + setUserType(value as UserType); + if (value === "internal") { + internalForm.reset(); + } else if (value === "oidc") { + externalForm.reset(); + setSelectedIdp(null); + } + }} + cols={2} + /> + + + + {userType === "internal" && dataLoaded && ( + <> + + + + User Information + + + Enter the details for the new user + + + + +
+ + ( + + + Email + + + + + + + )} + /> + + {env.email.emailEnabled && ( +
+ + setSendEmail( + e as boolean + ) + } + /> + +
+ )} + + ( + + + Valid For + + + + + )} + /> + + ( + + + Role + + + + + )} + /> + + {inviteLink && ( +
+ {sendEmail && ( +

+ An email has + been sent to the + user with the + access link + below. They must + access the link + to accept the + invitation. +

+ )} + {!sendEmail && ( +

+ The user has + been invited. + They must access + the link below + to accept the + invitation. +

+ )} +

+ The invite will + expire in{" "} + + {expiresInDays}{" "} + {expiresInDays === + 1 + ? "day" + : "days"} + + . +

+ +
+ )} + + +
+
+
+ + )} + + {userType !== "internal" && dataLoaded && ( + <> + + + + Identity Provider + + + Select the identity provider for the + external user + + + + {idps.length === 0 ? ( +

+ No identity providers are + configured. Please configure an + identity provider before creating + external users. +

+ ) : ( +
+ ( + + ({ + id: idp.idpId.toString(), + title: idp.name, + description: + formatIdpType( + idp.type + ) + }) + )} + defaultValue={ + field.value + } + onChange={( + value + ) => { + field.onChange( + value + ); + const idp = + idps.find( + (idp) => + idp.idpId.toString() === + value + ); + setSelectedIdp( + idp || null + ); + }} + cols={3} + /> + + + )} + /> + + )} +
+
+ + {idps.length > 0 && ( + + + + User Information + + + Enter the details for the new user + + + + +
+ + ( + + + Username + + + + +

+ This must + match the + unique + username + that exists + in the + selected + identity + provider. +

+ +
+ )} + /> + + ( + + + Email + (Optional) + + + + + + + )} + /> + + ( + + + Name + (Optional) + + + + + + + )} + /> + + ( + + + Role + + + + + )} + /> + + +
+
+
+ )} + + )} +
+ +
+ + {userType && dataLoaded && ( + + )} +
+
+ + ); +} diff --git a/src/app/[orgId]/settings/access/users/page.tsx b/src/app/[orgId]/settings/access/users/page.tsx index 68832f0e..f82cfdb0 100644 --- a/src/app/[orgId]/settings/access/users/page.tsx +++ b/src/app/[orgId]/settings/access/users/page.tsx @@ -9,6 +9,7 @@ import OrgProvider from "@app/providers/OrgProvider"; import UserProvider from "@app/providers/UserProvider"; import { verifySession } from "@app/lib/auth/verifySession"; import AccessPageHeaderAndNav from "../AccessPageHeaderAndNav"; +import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; type UsersPageProps = { params: Promise<{ orgId: string }>; @@ -23,6 +24,8 @@ export default async function UsersPage(props: UsersPageProps) { const user = await getUser(); let users: ListUsersResponse["users"] = []; + let hasInvitations = false; + const res = await internal .get< AxiosResponse @@ -33,6 +36,21 @@ export default async function UsersPage(props: UsersPageProps) { users = res.data.data.users; } + const invitationsRes = await internal + .get< + AxiosResponse<{ + pagination: { total: number }; + }> + >( + `/org/${params.orgId}/invitations?limit=1&offset=0`, + await authCookieHeader() + ) + .catch((e) => {}); + + if (invitationsRes && invitationsRes.status === 200) { + hasInvitations = invitationsRes.data.data.pagination.total > 0; + } + let org: GetOrgResponse | null = null; const getOrg = cache(async () => internal @@ -52,7 +70,13 @@ export default async function UsersPage(props: UsersPageProps) { const userRows: UserRow[] = users.map((user) => { return { id: user.id, + username: user.username, + displayUsername: user.email || user.name || user.username, + name: user.name, email: user.email, + type: user.type, + idpId: user.idpId, + idpName: user.idpName || "Internal", status: "Confirmed", role: user.isOwner ? "Owner" : user.roleName || "Member", isOwner: user.isOwner || false @@ -61,13 +85,15 @@ export default async function UsersPage(props: UsersPageProps) { return ( <> - - - - - - - + + + + + + ); } diff --git a/src/app/[orgId]/settings/api-keys/OrgApiKeysDataTable.tsx b/src/app/[orgId]/settings/api-keys/OrgApiKeysDataTable.tsx new file mode 100644 index 00000000..69fe7176 --- /dev/null +++ b/src/app/[orgId]/settings/api-keys/OrgApiKeysDataTable.tsx @@ -0,0 +1,33 @@ +// This file is licensed under the Fossorial Commercial License. +// Unauthorized use, copying, modification, or distribution is strictly prohibited. +// +// Copyright (c) 2025 Fossorial LLC. All rights reserved. + +"use client"; + +import { DataTable } from "@app/components/ui/data-table"; +import { ColumnDef } from "@tanstack/react-table"; + +interface DataTableProps { + columns: ColumnDef[]; + data: TData[]; + addApiKey?: () => void; +} + +export function OrgApiKeysDataTable({ + addApiKey, + columns, + data +}: DataTableProps) { + return ( + + ); +} diff --git a/src/app/[orgId]/settings/api-keys/OrgApiKeysTable.tsx b/src/app/[orgId]/settings/api-keys/OrgApiKeysTable.tsx new file mode 100644 index 00000000..89e47842 --- /dev/null +++ b/src/app/[orgId]/settings/api-keys/OrgApiKeysTable.tsx @@ -0,0 +1,204 @@ +// This file is licensed under the Fossorial Commercial License. +// Unauthorized use, copying, modification, or distribution is strictly prohibited. +// +// Copyright (c) 2025 Fossorial LLC. All rights reserved. + +"use client"; + +import { ColumnDef } from "@tanstack/react-table"; +import { OrgApiKeysDataTable } from "./OrgApiKeysDataTable"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger +} from "@app/components/ui/dropdown-menu"; +import { Button } from "@app/components/ui/button"; +import { ArrowRight, ArrowUpDown, MoreHorizontal } from "lucide-react"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { useState } from "react"; +import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog"; +import { toast } from "@app/hooks/useToast"; +import { formatAxiosError } from "@app/lib/api"; +import { createApiClient } from "@app/lib/api"; +import { useEnvContext } from "@app/hooks/useEnvContext"; +import moment from "moment"; + +export type OrgApiKeyRow = { + id: string; + key: string; + name: string; + createdAt: string; +}; + +type OrgApiKeyTableProps = { + apiKeys: OrgApiKeyRow[]; + orgId: string; +}; + +export default function OrgApiKeysTable({ + apiKeys, + orgId +}: OrgApiKeyTableProps) { + const router = useRouter(); + + const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); + const [selected, setSelected] = useState(null); + const [rows, setRows] = useState(apiKeys); + + const api = createApiClient(useEnvContext()); + + const deleteSite = (apiKeyId: string) => { + api.delete(`/org/${orgId}/api-key/${apiKeyId}`) + .catch((e) => { + console.error("Error deleting API key", e); + toast({ + variant: "destructive", + title: "Error deleting API key", + description: formatAxiosError(e, "Error deleting API key") + }); + }) + .then(() => { + router.refresh(); + setIsDeleteModalOpen(false); + + const newRows = rows.filter((row) => row.id !== apiKeyId); + + setRows(newRows); + }); + }; + + const columns: ColumnDef[] = [ + { + id: "dots", + cell: ({ row }) => { + const apiKeyROw = row.original; + const router = useRouter(); + + return ( + + + + + + { + setSelected(apiKeyROw); + }} + > + View settings + + { + setSelected(apiKeyROw); + setIsDeleteModalOpen(true); + }} + > + Delete + + + + ); + } + }, + { + accessorKey: "name", + header: ({ column }) => { + return ( + + ); + } + }, + { + accessorKey: "key", + header: "Key", + cell: ({ row }) => { + const r = row.original; + return {r.key}; + } + }, + { + accessorKey: "createdAt", + header: "Created At", + cell: ({ row }) => { + const r = row.original; + return {moment(r.createdAt).format("lll")} ; + } + }, + { + id: "actions", + cell: ({ row }) => { + const r = row.original; + return ( +
+ + + +
+ ); + } + } + ]; + + return ( + <> + {selected && ( + { + setIsDeleteModalOpen(val); + setSelected(null); + }} + dialog={ +
+

+ Are you sure you want to remove the API key{" "} + {selected?.name || selected?.id} from the + organization? +

+ +

+ + Once removed, the API key will no longer be + able to be used. + +

+ +

+ To confirm, please type the name of the API key + below. +

+
+ } + buttonText="Confirm Delete API Key" + onConfirm={async () => deleteSite(selected!.id)} + string={selected.name} + title="Delete API Key" + /> + )} + + { + router.push(`/${orgId}/settings/api-keys/create`); + }} + /> + + ); +} diff --git a/src/app/[orgId]/settings/api-keys/[apiKeyId]/layout.tsx b/src/app/[orgId]/settings/api-keys/[apiKeyId]/layout.tsx new file mode 100644 index 00000000..a4c13c9a --- /dev/null +++ b/src/app/[orgId]/settings/api-keys/[apiKeyId]/layout.tsx @@ -0,0 +1,62 @@ +// This file is licensed under the Fossorial Commercial License. +// Unauthorized use, copying, modification, or distribution is strictly prohibited. +// +// Copyright (c) 2025 Fossorial LLC. All rights reserved. + +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"; + +interface SettingsLayoutProps { + children: React.ReactNode; + params: Promise<{ apiKeyId: string; orgId: string }>; +} + +export default async function SettingsLayout(props: SettingsLayoutProps) { + const params = await props.params; + + const { children } = props; + + let apiKey = null; + try { + const res = await internal.get>( + `/org/${params.orgId}/api-key/${params.apiKeyId}`, + await authCookieHeader() + ); + apiKey = res.data.data; + } catch (e) { + console.log(e); + redirect(`/${params.orgId}/settings/api-keys`); + } + + const navItems = [ + { + title: "Permissions", + href: "/{orgId}/settings/api-keys/{apiKeyId}/permissions" + } + ]; + + return ( + <> + + + + {children} + + + ); +} diff --git a/src/app/[orgId]/settings/api-keys/[apiKeyId]/page.tsx b/src/app/[orgId]/settings/api-keys/[apiKeyId]/page.tsx new file mode 100644 index 00000000..7df37cd6 --- /dev/null +++ b/src/app/[orgId]/settings/api-keys/[apiKeyId]/page.tsx @@ -0,0 +1,13 @@ +// This file is licensed under the Fossorial Commercial License. +// Unauthorized use, copying, modification, or distribution is strictly prohibited. +// +// Copyright (c) 2025 Fossorial LLC. All rights reserved. + +import { redirect } from "next/navigation"; + +export default async function ApiKeysPage(props: { + params: Promise<{ orgId: string; apiKeyId: string }>; +}) { + const params = await props.params; + redirect(`/${params.orgId}/settings/api-keys/${params.apiKeyId}/permissions`); +} diff --git a/src/app/[orgId]/settings/api-keys/[apiKeyId]/permissions/page.tsx b/src/app/[orgId]/settings/api-keys/[apiKeyId]/permissions/page.tsx new file mode 100644 index 00000000..d1e6f518 --- /dev/null +++ b/src/app/[orgId]/settings/api-keys/[apiKeyId]/permissions/page.tsx @@ -0,0 +1,138 @@ +// This file is licensed under the Fossorial Commercial License. +// Unauthorized use, copying, modification, or distribution is strictly prohibited. +// +// Copyright (c) 2025 Fossorial LLC. All rights reserved. + +"use client"; + +import PermissionsSelectBox from "@app/components/PermissionsSelectBox"; +import { + SettingsContainer, + SettingsSection, + SettingsSectionBody, + SettingsSectionDescription, + SettingsSectionFooter, + SettingsSectionHeader, + SettingsSectionTitle +} from "@app/components/Settings"; +import { Button } from "@app/components/ui/button"; +import { useEnvContext } from "@app/hooks/useEnvContext"; +import { toast } from "@app/hooks/useToast"; +import { createApiClient, formatAxiosError } from "@app/lib/api"; +import { ListApiKeyActionsResponse } from "@server/routers/apiKeys"; +import { AxiosResponse } from "axios"; +import { useParams } from "next/navigation"; +import { useEffect, useState } from "react"; + +export default function Page() { + const { env } = useEnvContext(); + const api = createApiClient({ env }); + const { orgId, apiKeyId } = useParams(); + + const [loadingPage, setLoadingPage] = useState(true); + const [selectedPermissions, setSelectedPermissions] = useState< + Record + >({}); + const [loadingSavePermissions, setLoadingSavePermissions] = + useState(false); + + useEffect(() => { + async function load() { + setLoadingPage(true); + + const res = await api + .get< + AxiosResponse + >(`/org/${orgId}/api-key/${apiKeyId}/actions`) + .catch((e) => { + toast({ + variant: "destructive", + title: "Error loading API key actions", + description: formatAxiosError( + e, + "Error loading API key actions" + ) + }); + }); + + if (res && res.status === 200) { + const data = res.data.data; + for (const action of data.actions) { + setSelectedPermissions((prev) => ({ + ...prev, + [action.actionId]: true + })); + } + } + + setLoadingPage(false); + } + + load(); + }, []); + + async function savePermissions() { + setLoadingSavePermissions(true); + + const actionsRes = await api + .post(`/org/${orgId}/api-key/${apiKeyId}/actions`, { + actionIds: Object.keys(selectedPermissions).filter( + (key) => selectedPermissions[key] + ) + }) + .catch((e) => { + console.error("Error setting permissions", e); + toast({ + variant: "destructive", + title: "Error setting permissions", + description: formatAxiosError(e) + }); + }); + + if (actionsRes && actionsRes.status === 200) { + toast({ + title: "Permissions updated", + description: "The permissions have been updated." + }); + } + + setLoadingSavePermissions(false); + } + + return ( + <> + {!loadingPage && ( + + + + + Permissions + + + Determine what this API key can do + + + + + + + + + + + + )} + + ); +} diff --git a/src/app/[orgId]/settings/api-keys/create/page.tsx b/src/app/[orgId]/settings/api-keys/create/page.tsx new file mode 100644 index 00000000..3ede2ac0 --- /dev/null +++ b/src/app/[orgId]/settings/api-keys/create/page.tsx @@ -0,0 +1,412 @@ +// This file is licensed under the Fossorial Commercial License. +// Unauthorized use, copying, modification, or distribution is strictly prohibited. +// +// Copyright (c) 2025 Fossorial LLC. All rights reserved. + +"use client"; + +import { + SettingsContainer, + SettingsSection, + SettingsSectionBody, + SettingsSectionDescription, + SettingsSectionForm, + SettingsSectionHeader, + SettingsSectionTitle +} from "@app/components/Settings"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage +} from "@app/components/ui/form"; +import HeaderTitle from "@app/components/SettingsSectionTitle"; +import { z } from "zod"; +import { useEffect, useState } from "react"; +import { useForm } from "react-hook-form"; +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 { + CreateOrgApiKeyBody, + CreateOrgApiKeyResponse +} from "@server/routers/apiKeys"; +import { ApiKey } from "@server/db/schemas"; +import { + InfoSection, + InfoSectionContent, + InfoSections, + InfoSectionTitle +} from "@app/components/InfoSection"; +import CopyToClipboard from "@app/components/CopyToClipboard"; +import moment from "moment"; +import CopyCodeBox from "@server/emails/templates/components/CopyCodeBox"; +import CopyTextBox from "@app/components/CopyTextBox"; +import PermissionsSelectBox from "@app/components/PermissionsSelectBox"; + +const createFormSchema = z.object({ + name: z + .string() + .min(2, { + message: "Name must be at least 2 characters." + }) + .max(255, { + message: "Name must not be longer than 255 characters." + }) +}); + +type CreateFormValues = z.infer; + +const copiedFormSchema = z + .object({ + copied: z.boolean() + }) + .refine( + (data) => { + return data.copied; + }, + { + message: "You must confirm that you have copied the API key.", + path: ["copied"] + } + ); + +type CopiedFormValues = z.infer; + +export default function Page() { + const { env } = useEnvContext(); + const api = createApiClient({ env }); + const { orgId } = useParams(); + const router = useRouter(); + + const [loadingPage, setLoadingPage] = useState(true); + const [createLoading, setCreateLoading] = useState(false); + const [apiKey, setApiKey] = useState(null); + const [selectedPermissions, setSelectedPermissions] = useState< + Record + >({}); + + const form = useForm({ + resolver: zodResolver(createFormSchema), + defaultValues: { + name: "" + } + }); + + const copiedForm = useForm({ + resolver: zodResolver(copiedFormSchema), + defaultValues: { + copied: false + } + }); + + async function onSubmit(data: CreateFormValues) { + setCreateLoading(true); + + let payload: CreateOrgApiKeyBody = { + name: data.name + }; + + const res = await api + .put< + AxiosResponse + >(`/org/${orgId}/api-key/`, payload) + .catch((e) => { + toast({ + variant: "destructive", + title: "Error creating API key", + description: formatAxiosError(e) + }); + }); + + if (res && res.status === 201) { + const data = res.data.data; + + console.log({ + actionIds: Object.keys(selectedPermissions).filter( + (key) => selectedPermissions[key] + ) + }); + + const actionsRes = await api + .post(`/org/${orgId}/api-key/${data.apiKeyId}/actions`, { + actionIds: Object.keys(selectedPermissions).filter( + (key) => selectedPermissions[key] + ) + }) + .catch((e) => { + console.error("Error setting permissions", e); + toast({ + variant: "destructive", + title: "Error setting permissions", + description: formatAxiosError(e) + }); + }); + + if (actionsRes) { + setApiKey(data); + } + } + + setCreateLoading(false); + } + + async function onCopiedSubmit(data: CopiedFormValues) { + if (!data.copied) { + return; + } + + router.push(`/${orgId}/settings/api-keys`); + } + + const formatLabel = (str: string) => { + return str + .replace(/([a-z0-9])([A-Z])/g, "$1 $2") + .replace(/^./, (char) => char.toUpperCase()); + }; + + useEffect(() => { + const load = async () => { + setLoadingPage(false); + }; + + load(); + }, []); + + return ( + <> +
+ + +
+ + {!loadingPage && ( +
+ + {!apiKey && ( + <> + + + + API Key Information + + + + +
+ + ( + + + Name + + + + + + + )} + /> + + +
+
+
+ + + + + Permissions + + + Determine what this API key can do + + + + + + + + )} + + {apiKey && ( + + + + Your API Key + + + + + + + Name + + + + + + + + Created + + + {moment( + apiKey.createdAt + ).format("lll")} + + + + + + + + Save Your API Key + + + You will only be able to see this + once. Make sure to copy it to a + secure place. + + + +

+ Your API key is: +

+ + + +
+ + ( + +
+ { + copiedForm.setValue( + "copied", + e as boolean + ); + }} + /> + +
+ +
+ )} + /> + + +
+
+ )} +
+ +
+ {!apiKey && ( + + )} + {!apiKey && ( + + )} + + {apiKey && ( + + )} +
+
+ )} + + ); +} diff --git a/src/app/[orgId]/settings/api-keys/page.tsx b/src/app/[orgId]/settings/api-keys/page.tsx new file mode 100644 index 00000000..ef1e3dd1 --- /dev/null +++ b/src/app/[orgId]/settings/api-keys/page.tsx @@ -0,0 +1,49 @@ +// This file is licensed under the Fossorial Commercial License. +// Unauthorized use, copying, modification, or distribution is strictly prohibited. +// +// Copyright (c) 2025 Fossorial LLC. All rights reserved. + +import { internal } from "@app/lib/api"; +import { authCookieHeader } from "@app/lib/api/cookies"; +import { AxiosResponse } from "axios"; +import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; +import OrgApiKeysTable, { OrgApiKeyRow } from "./OrgApiKeysTable"; +import { ListOrgApiKeysResponse } from "@server/routers/apiKeys"; + +type ApiKeyPageProps = { + params: Promise<{ orgId: string }>; +}; + +export const dynamic = "force-dynamic"; + +export default async function ApiKeysPage(props: ApiKeyPageProps) { + const params = await props.params; + let apiKeys: ListOrgApiKeysResponse["apiKeys"] = []; + try { + const res = await internal.get>( + `/org/${params.orgId}/api-keys`, + await authCookieHeader() + ); + apiKeys = res.data.data.apiKeys; + } catch (e) {} + + const rows: OrgApiKeyRow[] = apiKeys.map((key) => { + return { + name: key.name, + id: key.apiKeyId, + key: `${key.apiKeyId}••••••••••••••••••••${key.lastChars}`, + createdAt: key.createdAt + }; + }); + + return ( + <> + + + + + ); +} diff --git a/src/app/[orgId]/settings/general/layout.tsx b/src/app/[orgId]/settings/general/layout.tsx index 4b41b8c3..a2d9cc0a 100644 --- a/src/app/[orgId]/settings/general/layout.tsx +++ b/src/app/[orgId]/settings/general/layout.tsx @@ -1,7 +1,7 @@ import { internal } from "@app/lib/api"; import { authCookieHeader } from "@app/lib/api/cookies"; import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; -import { SidebarSettings } from "@app/components/SidebarSettings"; +import { HorizontalTabs } from "@app/components/HorizontalTabs"; import { verifySession } from "@app/lib/auth/verifySession"; import OrgProvider from "@app/providers/OrgProvider"; import OrgUserProvider from "@app/providers/OrgUserProvider"; @@ -57,7 +57,7 @@ export default async function GeneralSettingsPage({ redirect(`/${orgId}`); } - const sidebarNavItems = [ + const navItems = [ { title: "General", href: `/{orgId}/settings/general`, @@ -73,9 +73,9 @@ export default async function GeneralSettingsPage({ description="Configure your organization's general settings" /> - + {children} - + diff --git a/src/app/[orgId]/settings/general/page.tsx b/src/app/[orgId]/settings/general/page.tsx index 959a32a6..9819be59 100644 --- a/src/app/[orgId]/settings/general/page.tsx +++ b/src/app/[orgId]/settings/general/page.tsx @@ -31,7 +31,7 @@ import { CardTitle } from "@/components/ui/card"; import { AxiosResponse } from "axios"; -import { DeleteOrgResponse, ListOrgsResponse } from "@server/routers/org"; +import { DeleteOrgResponse, ListUserOrgsResponse } from "@server/routers/org"; import { redirect, useRouter } from "next/navigation"; import { SettingsContainer, @@ -43,6 +43,7 @@ import { SettingsSectionForm, SettingsSectionFooter } from "@app/components/Settings"; +import { useUserContext } from "@app/hooks/useUserContext"; const GeneralFormSchema = z.object({ name: z.string() @@ -57,6 +58,7 @@ export default function GeneralPage() { const router = useRouter(); const { org } = useOrgContext(); const api = createApiClient(useEnvContext()); + const { user } = useUserContext(); const [loadingDelete, setLoadingDelete] = useState(false); const [loadingSave, setLoadingSave] = useState(false); @@ -101,7 +103,9 @@ export default function GeneralPage() { async function pickNewOrgAndNavigate() { try { - const res = await api.get>(`/orgs`); + const res = await api.get>( + `/user/${user.userId}/orgs` + ); if (res.status === 200) { if (res.data.data.orgs.length > 0) { @@ -230,16 +234,14 @@ export default function GeneralPage() { loading={loadingSave} disabled={loadingSave} > - Save Settings + Save General Settings - - Danger Zone - + Danger Zone Once you delete this org, there is no going back. Please be certain. diff --git a/src/app/[orgId]/settings/layout.tsx b/src/app/[orgId]/settings/layout.tsx index 2618c1fb..ac5e552b 100644 --- a/src/app/[orgId]/settings/layout.tsx +++ b/src/app/[orgId]/settings/layout.tsx @@ -1,5 +1,4 @@ import { Metadata } from "next"; -import { TopbarNav } from "@app/components/TopbarNav"; import { Cog, Combine, @@ -8,24 +7,18 @@ import { Users, Waypoints } from "lucide-react"; -import { Header } from "@app/components/Header"; import { verifySession } from "@app/lib/auth/verifySession"; import { redirect } from "next/navigation"; import { internal } from "@app/lib/api"; import { AxiosResponse } from "axios"; -import { GetOrgResponse, ListOrgsResponse } from "@server/routers/org"; +import { GetOrgResponse, ListUserOrgsResponse } from "@server/routers/org"; import { authCookieHeader } from "@app/lib/api/cookies"; import { cache } from "react"; import { GetOrgUserResponse } from "@server/routers/user"; import UserProvider from "@app/providers/UserProvider"; -import { - Breadcrumb, - BreadcrumbItem, - BreadcrumbList, - BreadcrumbPage, - BreadcrumbSeparator -} from "@app/components/ui/breadcrumb"; -import Link from "next/link"; +import { Layout } from "@app/components/Layout"; +import { SidebarNavItem, SidebarNavProps } from "@app/components/SidebarNav"; +import { orgNavItems } from "@app/app/navigation"; export const dynamic = "force-dynamic"; @@ -34,34 +27,6 @@ export const metadata: Metadata = { description: "" }; -const topNavItems = [ - { - title: "Sites", - href: "/{orgId}/settings/sites", - icon: - }, - { - title: "Resources", - href: "/{orgId}/settings/resources", - icon: - }, - { - title: "Users & Roles", - href: "/{orgId}/settings/access", - icon: - }, - { - title: "Shareable Links", - href: "/{orgId}/settings/share-links", - icon: - }, - { - title: "General", - href: "/{orgId}/settings/general", - icon: - } -]; - interface SettingsLayoutProps { children: React.ReactNode; params: Promise<{ orgId: string }>; @@ -97,10 +62,13 @@ export default async function SettingsLayout(props: SettingsLayoutProps) { redirect(`/${params.orgId}`); } - let orgs: ListOrgsResponse["orgs"] = []; + let orgs: ListUserOrgsResponse["orgs"] = []; try { const getOrgs = cache(() => - internal.get>(`/orgs`, cookie) + internal.get>( + `/user/${user.userId}/orgs`, + cookie + ) ); const res = await getOrgs(); if (res && res.data.data.orgs) { @@ -109,21 +77,10 @@ export default async function SettingsLayout(props: SettingsLayoutProps) { } catch (e) {} return ( - <> -
-
-
- -
- -
- -
-
- -
+ + {children} -
- + + ); } diff --git a/src/app/[orgId]/settings/resources/ResourcesDataTable.tsx b/src/app/[orgId]/settings/resources/ResourcesDataTable.tsx index 9cc0f79f..a9db3e79 100644 --- a/src/app/[orgId]/settings/resources/ResourcesDataTable.tsx +++ b/src/app/[orgId]/settings/resources/ResourcesDataTable.tsx @@ -2,149 +2,29 @@ import { ColumnDef, - flexRender, - getCoreRowModel, - useReactTable, - getPaginationRowModel, - SortingState, - getSortedRowModel, - ColumnFiltersState, - getFilteredRowModel } from "@tanstack/react-table"; +import { DataTable } from "@app/components/ui/data-table"; -import { - Table, - TableBody, - TableCell, - TableContainer, - TableHead, - TableHeader, - TableRow -} from "@/components/ui/table"; -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"; - -interface ResourcesDataTableProps { +interface DataTableProps { columns: ColumnDef[]; data: TData[]; - addResource?: () => void; + createResource?: () => void; } export function ResourcesDataTable({ - addResource, columns, - data -}: ResourcesDataTableProps) { - const [sorting, setSorting] = useState([]); - const [columnFilters, setColumnFilters] = useState([]); - const [globalFilter, setGlobalFilter] = useState([]); - - const table = useReactTable({ - data, - columns, - getCoreRowModel: getCoreRowModel(), - getPaginationRowModel: getPaginationRowModel(), - onSortingChange: setSorting, - getSortedRowModel: getSortedRowModel(), - onColumnFiltersChange: setColumnFilters, - getFilteredRowModel: getFilteredRowModel(), - onGlobalFilterChange: setGlobalFilter, - initialState: { - pagination: { - pageSize: 20, - pageIndex: 0 - } - }, - state: { - sorting, - columnFilters, - globalFilter - } - }); - + data, + createResource +}: DataTableProps) { return ( -
-
-
- - table.setGlobalFilter(String(e.target.value)) - } - className="w-full pl-8" - /> - -
- -
- - - - {table.getHeaderGroups().map((headerGroup) => ( - - {headerGroup.headers.map((header) => { - return ( - - {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() - )} - - ))} - - )) - ) : ( - - - No resources. Create one to get started. - - - )} - -
-
-
- -
-
+ ); } diff --git a/src/app/[orgId]/settings/resources/ResourcesTable.tsx b/src/app/[orgId]/settings/resources/ResourcesTable.tsx index 63a2b416..bfb4f08b 100644 --- a/src/app/[orgId]/settings/resources/ResourcesTable.tsx +++ b/src/app/[orgId]/settings/resources/ResourcesTable.tsx @@ -21,10 +21,8 @@ import { } from "lucide-react"; import Link from "next/link"; import { useRouter } from "next/navigation"; -import CreateResourceForm from "./CreateResourceForm"; import { useState } from "react"; import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog"; -import { set } from "zod"; import { formatAxiosError } from "@app/lib/api"; import { toast } from "@app/hooks/useToast"; import { createApiClient } from "@app/lib/api"; @@ -58,7 +56,6 @@ export default function SitesTable({ resources, orgId }: ResourcesTableProps) { const api = createApiClient(useEnvContext()); - const [isCreateModalOpen, setIsCreateModalOpen] = useState(false); const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); const [selectedResource, setSelectedResource] = useState(); @@ -242,7 +239,7 @@ export default function SitesTable({ resources, orgId }: ResourcesTableProps) { Not Protected ) : ( - -- + - )} ); @@ -282,11 +279,6 @@ export default function SitesTable({ resources, orgId }: ResourcesTableProps) { return ( <> - - {selectedResource && ( { - setIsCreateModalOpen(true); + createResource={() => { + router.push(`/${orgId}/settings/resources/create`); }} /> diff --git a/src/app/[orgId]/settings/resources/[resourceId]/ResourceInfoBox.tsx b/src/app/[orgId]/settings/resources/[resourceId]/ResourceInfoBox.tsx index 330e0b62..86916755 100644 --- a/src/app/[orgId]/settings/resources/[resourceId]/ResourceInfoBox.tsx +++ b/src/app/[orgId]/settings/resources/[resourceId]/ResourceInfoBox.tsx @@ -90,7 +90,7 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) { )} - Visibilty + Visibility {resource.enabled ? "Enabled" : "Disabled"} diff --git a/src/app/[orgId]/settings/resources/[resourceId]/authentication/SetResourcePasswordForm.tsx b/src/app/[orgId]/settings/resources/[resourceId]/authentication/SetResourcePasswordForm.tsx index e4bdd1b4..3bf2966a 100644 --- a/src/app/[orgId]/settings/resources/[resourceId]/authentication/SetResourcePasswordForm.tsx +++ b/src/app/[orgId]/settings/resources/[resourceId]/authentication/SetResourcePasswordForm.tsx @@ -140,12 +140,6 @@ export default function SetResourcePasswordForm({ /> - - Users will be able to access - this resource by entering this - password. It must be at least 4 - characters long. - )} /> diff --git a/src/app/[orgId]/settings/resources/[resourceId]/authentication/SetResourcePincodeForm.tsx b/src/app/[orgId]/settings/resources/[resourceId]/authentication/SetResourcePincodeForm.tsx index 58a997bf..31ccbea6 100644 --- a/src/app/[orgId]/settings/resources/[resourceId]/authentication/SetResourcePincodeForm.tsx +++ b/src/app/[orgId]/settings/resources/[resourceId]/authentication/SetResourcePincodeForm.tsx @@ -147,33 +147,33 @@ export default function SetResourcePincodeForm({ - - Users will be able to access - this resource by entering this - PIN code. It must be at least 6 - digits long. - )} /> diff --git a/src/app/[orgId]/settings/resources/[resourceId]/authentication/page.tsx b/src/app/[orgId]/settings/resources/[resourceId]/authentication/page.tsx index 8f8e584c..0b0535e8 100644 --- a/src/app/[orgId]/settings/resources/[resourceId]/authentication/page.tsx +++ b/src/app/[orgId]/settings/resources/[resourceId]/authentication/page.tsx @@ -45,6 +45,9 @@ import { SwitchInput } from "@app/components/SwitchInput"; import { InfoPopup } from "@app/components/ui/info-popup"; import { Tag, TagInput } from "@app/components/tags/tag-input"; import { useRouter } from "next/navigation"; +import { UserType } from "@server/types/UserTypes"; +import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert"; +import { InfoIcon } from "lucide-react"; const UsersRolesFormSchema = z.object({ roles: z.array( @@ -175,7 +178,7 @@ export default function ResourceAuthenticationPage() { setAllUsers( usersResponse.data.data.users.map((user) => ({ id: user.id.toString(), - text: user.email + text: `${user.email || user.username}${user.type !== UserType.Internal ? ` (${user.idpName})` : ""}` })) ); @@ -183,7 +186,7 @@ export default function ResourceAuthenticationPage() { "users", resourceUsersResponse.data.data.users.map((i) => ({ id: i.userId.toString(), - text: i.email + text: `${i.email || i.username}${i.type !== UserType.Internal ? ` (${i.idpName})` : ""}` })) ); @@ -611,117 +614,127 @@ export default function ResourceAuthenticationPage() {
- {env.email.emailEnabled && ( - - - - One-time Passwords - - - Require email-based authentication for resource - access - - - - + + + + One-time Passwords + + + Require email-based authentication for resource + access + + + + {!env.email.emailEnabled && ( + + + + SMTP Required + + + SMTP must be enabled on the server to use one-time password authentication. + + + )} + - {whitelistEnabled && ( -
- - ( - - - - - - {/* @ts-ignore */} - { - return z - .string() - .email() - .or( - z - .string() - .regex( - /^\*@[\w.-]+\.[a-zA-Z]{2,}$/, - { - message: - "Invalid email address. Wildcard (*) must be the entire local part." - } - ) - ) - .safeParse( - tag - ).success; - }} - setActiveTagIndex={ - setActiveEmailTagIndex - } - placeholder="Enter an email" - tags={ - whitelistForm.getValues() - .emails - } - setTags={( - newRoles - ) => { - whitelistForm.setValue( - "emails", - newRoles as [ - Tag, - ...Tag[] - ] - ); - }} - allowDuplicates={ - false - } - sortTags={true} - /> - - - Press enter to add an - email after typing it in - the input field. - - - )} - /> - - - )} -
- - - -
- )} + {whitelistEnabled && env.email.emailEnabled && ( +
+ + ( + + + + + + {/* @ts-ignore */} + { + return z + .string() + .email() + .or( + z + .string() + .regex( + /^\*@[\w.-]+\.[a-zA-Z]{2,}$/, + { + message: + "Invalid email address. Wildcard (*) must be the entire local part." + } + ) + ) + .safeParse( + tag + ).success; + }} + setActiveTagIndex={ + setActiveEmailTagIndex + } + placeholder="Enter an email" + tags={ + whitelistForm.getValues() + .emails + } + setTags={( + newRoles + ) => { + whitelistForm.setValue( + "emails", + newRoles as [ + Tag, + ...Tag[] + ] + ); + }} + allowDuplicates={ + false + } + sortTags={true} + /> + + + Press enter to add an + email after typing it in + the input field. + + + )} + /> + + + )} +
+ + + +
); diff --git a/src/app/[orgId]/settings/resources/[resourceId]/general/page.tsx b/src/app/[orgId]/settings/resources/[resourceId]/general/page.tsx index 5d6cc81e..f1e152d5 100644 --- a/src/app/[orgId]/settings/resources/[resourceId]/general/page.tsx +++ b/src/app/[orgId]/settings/resources/[resourceId]/general/page.tsx @@ -48,7 +48,7 @@ import { useOrgContext } from "@app/hooks/useOrgContext"; import CustomDomainInput from "../CustomDomainInput"; import { createApiClient } from "@app/lib/api"; import { useEnvContext } from "@app/hooks/useEnvContext"; -import { subdomainSchema } from "@server/lib/schemas"; +import { subdomainSchema, tlsNameSchema } from "@server/lib/schemas"; import { CaretSortIcon, CheckIcon } from "@radix-ui/react-icons"; import { RadioGroup, RadioGroupItem } from "@app/components/ui/radio-group"; import { Label } from "@app/components/ui/label"; @@ -596,7 +596,7 @@ export default function GeneralForm() { disabled={saveLoading} form="general-settings-form" > - Save Settings + Save General Settings diff --git a/src/app/[orgId]/settings/resources/[resourceId]/layout.tsx b/src/app/[orgId]/settings/resources/[resourceId]/layout.tsx index af335c42..edb21303 100644 --- a/src/app/[orgId]/settings/resources/[resourceId]/layout.tsx +++ b/src/app/[orgId]/settings/resources/[resourceId]/layout.tsx @@ -7,7 +7,7 @@ import { import { AxiosResponse } from "axios"; import { redirect } from "next/navigation"; import { authCookieHeader } from "@app/lib/api/cookies"; -import { SidebarSettings } from "@app/components/SidebarSettings"; +import { HorizontalTabs } from "@app/components/HorizontalTabs"; import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; import { GetOrgResponse } from "@server/routers/org"; import OrgProvider from "@app/providers/OrgProvider"; @@ -80,48 +80,30 @@ export default async function ResourceLayout(props: ResourceLayoutProps) { redirect(`/${params.orgId}/settings/resources`); } - const sidebarNavItems = [ + const navItems = [ { title: "General", href: `/{orgId}/settings/resources/{resourceId}/general` - // icon: , }, { - title: "Connectivity", - href: `/{orgId}/settings/resources/{resourceId}/connectivity` - // icon: , + title: "Proxy", + href: `/{orgId}/settings/resources/{resourceId}/proxy` } ]; if (resource.http) { - sidebarNavItems.push({ + navItems.push({ title: "Authentication", href: `/{orgId}/settings/resources/{resourceId}/authentication` - // icon: , }); - sidebarNavItems.push({ + navItems.push({ title: "Rules", href: `/{orgId}/settings/resources/{resourceId}/rules` - // icon: , }); } return ( <> -
- - - - Resources - - - - {resource.name} - - - -
- - +
- {children} - + + {children} + +
diff --git a/src/app/[orgId]/settings/resources/[resourceId]/page.tsx b/src/app/[orgId]/settings/resources/[resourceId]/page.tsx index 8eb27e4e..a0d45a94 100644 --- a/src/app/[orgId]/settings/resources/[resourceId]/page.tsx +++ b/src/app/[orgId]/settings/resources/[resourceId]/page.tsx @@ -5,6 +5,6 @@ export default async function ResourcePage(props: { }) { const params = await props.params; redirect( - `/${params.orgId}/settings/resources/${params.resourceId}/connectivity` + `/${params.orgId}/settings/resources/${params.resourceId}/proxy` ); } diff --git a/src/app/[orgId]/settings/resources/[resourceId]/connectivity/page.tsx b/src/app/[orgId]/settings/resources/[resourceId]/proxy/page.tsx similarity index 58% rename from src/app/[orgId]/settings/resources/[resourceId]/connectivity/page.tsx rename to src/app/[orgId]/settings/resources/[resourceId]/proxy/page.tsx index 29841bf5..85c25415 100644 --- a/src/app/[orgId]/settings/resources/[resourceId]/connectivity/page.tsx +++ b/src/app/[orgId]/settings/resources/[resourceId]/proxy/page.tsx @@ -60,17 +60,28 @@ import { SettingsSectionTitle, SettingsSectionDescription, SettingsSectionBody, - SettingsSectionFooter + SettingsSectionFooter, + SettingsSectionForm } from "@app/components/Settings"; import { SwitchInput } from "@app/components/SwitchInput"; import { useRouter } from "next/navigation"; import { isTargetValid } from "@server/lib/validators"; +import { tlsNameSchema } from "@server/lib/schemas"; +import { ChevronsUpDown } from "lucide-react"; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger +} from "@app/components/ui/collapsible"; const addTargetSchema = z.object({ ip: z.string().refine(isTargetValid), method: z.string().nullable(), port: z.coerce.number().int().positive() - // protocol: z.string(), +}); + +const targetsSettingsSchema = z.object({ + stickySession: z.boolean() }); type LocalTarget = Omit< @@ -81,6 +92,47 @@ type LocalTarget = Omit< "protocol" >; +const proxySettingsSchema = z.object({ + setHostHeader: z + .string() + .optional() + .refine( + (data) => { + if (data) { + return tlsNameSchema.safeParse(data).success; + } + return true; + }, + { + message: + "Invalid custom Host Header value. Use domain name format, or save empty to unset custom Host Header." + } + ) +}); + +const tlsSettingsSchema = z.object({ + ssl: z.boolean(), + tlsServerName: z + .string() + .optional() + .refine( + (data) => { + if (data) { + return tlsNameSchema.safeParse(data).success; + } + return true; + }, + { + message: + "Invalid TLS Server Name. Use domain name format, or save empty to remove the TLS Server Name." + } + ) +}); + +type ProxySettingsValues = z.infer; +type TlsSettingsValues = z.infer; +type TargetsSettingsValues = z.infer; + export default function ReverseProxyTargets(props: { params: Promise<{ resourceId: number }>; }) { @@ -93,11 +145,13 @@ export default function ReverseProxyTargets(props: { const [targets, setTargets] = useState([]); const [site, setSite] = useState(); const [targetsToRemove, setTargetsToRemove] = useState([]); - const [sslEnabled, setSslEnabled] = useState(resource.ssl); - const [loading, setLoading] = useState(false); + const [httpsTlsLoading, setHttpsTlsLoading] = useState(false); + const [targetsLoading, setTargetsLoading] = useState(false); + const [proxySettingsLoading, setProxySettingsLoading] = useState(false); const [pageLoading, setPageLoading] = useState(true); + const [isAdvancedOpen, setIsAdvancedOpen] = useState(false); const router = useRouter(); const addTargetForm = useForm({ @@ -109,6 +163,28 @@ export default function ReverseProxyTargets(props: { } as z.infer }); + const tlsSettingsForm = useForm({ + resolver: zodResolver(tlsSettingsSchema), + defaultValues: { + ssl: resource.ssl, + tlsServerName: resource.tlsServerName || "" + } + }); + + const proxySettingsForm = useForm({ + resolver: zodResolver(proxySettingsSchema), + defaultValues: { + setHostHeader: resource.setHostHeader || "" + } + }); + + const targetsSettingsForm = useForm({ + resolver: zodResolver(targetsSettingsSchema), + defaultValues: { + stickySession: resource.stickySession + } + }); + useEffect(() => { const fetchTargets = async () => { try { @@ -229,13 +305,12 @@ export default function ReverseProxyTargets(props: { async function saveTargets() { try { - setLoading(true); + setTargetsLoading(true); for (let target of targets) { const data = { ip: target.ip, port: target.port, - // protocol: target.protocol, method: target.method, enabled: target.enabled }; @@ -248,27 +323,22 @@ export default function ReverseProxyTargets(props: { } else if (target.updated) { await api.post(`/target/${target.targetId}`, data); } - - setTargets([ - ...targets.map((t) => { - let res = { - ...t, - new: false, - updated: false - }; - return res; - }) - ]); } for (const targetId of targetsToRemove) { await api.delete(`/target/${targetId}`); - setTargets(targets.filter((t) => t.targetId !== targetId)); } + // Save sticky session setting + const stickySessionData = targetsSettingsForm.getValues(); + await api.post(`/resource/${params.resourceId}`, { + stickySession: stickySessionData.stickySession + }); + updateResource({ stickySession: stickySessionData.stickySession }); + toast({ title: "Targets updated", - description: "Targets updated successfully" + description: "Targets and settings updated successfully" }); setTargetsToRemove([]); @@ -277,43 +347,75 @@ export default function ReverseProxyTargets(props: { console.error(err); toast({ variant: "destructive", - title: "Operation failed", + title: "Failed to update targets", description: formatAxiosError( err, - "An error occurred during the save operation" + "An error occurred while updating targets" ) }); + } finally { + setTargetsLoading(false); } - - setLoading(false); } - async function saveSsl(val: boolean) { - const res = await api - .post(`/resource/${params.resourceId}`, { - ssl: val - }) - .catch((err) => { - console.error(err); - toast({ - variant: "destructive", - title: "Failed to update SSL configuration", - description: formatAxiosError( - err, - "An error occurred while updating the SSL configuration" - ) - }); + async function saveTlsSettings(data: TlsSettingsValues) { + try { + setHttpsTlsLoading(true); + await api.post(`/resource/${params.resourceId}`, { + ssl: data.ssl, + tlsServerName: data.tlsServerName || undefined + }); + updateResource({ + ...resource, + ssl: data.ssl, + tlsServerName: data.tlsServerName || undefined }); - - if (res && res.status === 200) { - setSslEnabled(val); - updateResource({ ssl: val }); - toast({ - title: "SSL Configuration", - description: "SSL configuration updated successfully" + title: "TLS settings updated", + description: "Your TLS settings have been updated successfully" }); - router.refresh(); + } catch (err) { + console.error(err); + toast({ + variant: "destructive", + title: "Failed to update TLS settings", + description: formatAxiosError( + err, + "An error occurred while updating TLS settings" + ) + }); + } finally { + setHttpsTlsLoading(false); + } + } + + async function saveProxySettings(data: ProxySettingsValues) { + try { + setProxySettingsLoading(true); + await api.post(`/resource/${params.resourceId}`, { + setHostHeader: data.setHostHeader || undefined + }); + updateResource({ + ...resource, + setHostHeader: data.setHostHeader || undefined + }); + toast({ + title: "Proxy settings updated", + description: + "Your proxy settings have been updated successfully" + }); + } catch (err) { + console.error(err); + toast({ + variant: "destructive", + title: "Failed to update proxy settings", + description: formatAxiosError( + err, + "An error occurred while updating proxy settings" + ) + }); + } finally { + setProxySettingsLoading(false); } } @@ -456,35 +558,159 @@ export default function ReverseProxyTargets(props: { - SSL Configuration + HTTPS & TLS Settings - Set up SSL to secure your connections with certificates + Configure TLS settings for your resource - { - await saveSsl(val); - }} - /> + +
+ + ( + + + { + field.onChange(val); + }} + /> + + + )} + /> + +
+ + + +
+ + ( + + + TLS Server Name + (SNI) + + + + + + The TLS Server Name + to use for SNI. + Leave empty to use + the default. + + + + )} + /> + +
+ + +
+ + +
)} - {/* Targets Section */} + - Target Configuration + Targets Configuration Set up targets to route traffic to your services + +
+ + {targets.length >= 2 && ( + ( + + + { + field.onChange(val); + }} + /> + + + )} + /> + )} + + +
+
Add Target @@ -629,13 +855,70 @@ export default function ReverseProxyTargets(props: { + + {resource.http && ( + + + + Additional Proxy Settings + + + Configure how your resource handles proxy settings + + + + + + + ( + + + Custom Host Header + + + + + + The host header to set when + proxying requests. Leave + empty to use the default. + + + + )} + /> + + + + + + + +
+ )} ); } diff --git a/src/app/[orgId]/settings/resources/[resourceId]/rules/page.tsx b/src/app/[orgId]/settings/resources/[resourceId]/rules/page.tsx index e85d6f3b..2a9fa00f 100644 --- a/src/app/[orgId]/settings/resources/[resourceId]/rules/page.tsx +++ b/src/app/[orgId]/settings/resources/[resourceId]/rules/page.tsx @@ -693,7 +693,7 @@ export default function ResourceRules(props: { control={addRuleForm.control} name="value" render={({ field }) => ( - + Add Rule diff --git a/src/app/[orgId]/settings/resources/CreateResourceForm.tsx b/src/app/[orgId]/settings/resources/create/page.tsx similarity index 54% rename from src/app/[orgId]/settings/resources/CreateResourceForm.tsx rename to src/app/[orgId]/settings/resources/create/page.tsx index 003a944d..704a1947 100644 --- a/src/app/[orgId]/settings/resources/CreateResourceForm.tsx +++ b/src/app/[orgId]/settings/resources/create/page.tsx @@ -1,6 +1,14 @@ "use client"; -import { Button, buttonVariants } from "@app/components/ui/button"; +import { + SettingsContainer, + SettingsSection, + SettingsSectionBody, + SettingsSectionDescription, + SettingsSectionForm, + SettingsSectionHeader, + SettingsSectionTitle +} from "@app/components/Settings"; import { Form, FormControl, @@ -10,48 +18,22 @@ import { FormLabel, FormMessage } from "@app/components/ui/form"; -import { Input } from "@app/components/ui/input"; -import { toast } from "@app/hooks/useToast"; -import { zodResolver } from "@hookform/resolvers/zod"; -import { useEffect, useState } from "react"; -import { useForm } from "react-hook-form"; +import HeaderTitle from "@app/components/SettingsSectionTitle"; import { z } from "zod"; -import { - Credenza, - CredenzaBody, - CredenzaClose, - CredenzaContent, - CredenzaDescription, - CredenzaFooter, - CredenzaHeader, - CredenzaTitle -} from "@app/components/Credenza"; +import { useEffect, useState } from "react"; +import { Controller, useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { Input } from "@app/components/ui/input"; +import { Button } from "@app/components/ui/button"; import { useParams, useRouter } from "next/navigation"; import { ListSitesResponse } from "@server/routers/site"; import { formatAxiosError } from "@app/lib/api"; -import { CheckIcon } from "lucide-react"; -import { - Popover, - PopoverContent, - PopoverTrigger -} from "@app/components/ui/popover"; -import { - Command, - CommandEmpty, - CommandGroup, - CommandInput, - CommandItem, - CommandList -} from "@app/components/ui/command"; -import { CaretSortIcon } from "@radix-ui/react-icons"; -import CustomDomainInput from "./[resourceId]/CustomDomainInput"; -import { AxiosResponse } from "axios"; -import { Resource } from "@server/db/schemas"; -import { useOrgContext } from "@app/hooks/useOrgContext"; import { createApiClient } from "@app/lib/api"; import { useEnvContext } from "@app/hooks/useEnvContext"; -import { cn } from "@app/lib/cn"; -import { Switch } from "@app/components/ui/switch"; +import { toast } from "@app/hooks/useToast"; +import { AxiosResponse } from "axios"; +import { Resource } from "@server/db/schemas"; +import { StrategySelect } from "@app/components/StrategySelect"; import { Select, SelectContent, @@ -60,222 +42,79 @@ import { SelectValue } from "@app/components/ui/select"; import { subdomainSchema } from "@server/lib/schemas"; -import Link from "next/link"; -import { SquareArrowOutUpRight } from "lucide-react"; -import CopyTextBox from "@app/components/CopyTextBox"; -import { RadioGroup, RadioGroupItem } from "@app/components/ui/radio-group"; -import { Label } from "@app/components/ui/label"; import { ListDomainsResponse } from "@server/routers/domain"; import LoaderPlaceholder from "@app/components/PlaceHolderLoader"; -import { StrategySelect } from "@app/components/StrategySelect"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList +} from "@app/components/ui/command"; +import { + Popover, + PopoverContent, + PopoverTrigger +} from "@app/components/ui/popover"; +import { CaretSortIcon, CheckIcon } from "@radix-ui/react-icons"; +import { cn } from "@app/lib/cn"; +import { SquareArrowOutUpRight } from "lucide-react"; +import CopyTextBox from "@app/components/CopyTextBox"; +import Link from "next/link"; -const createResourceFormSchema = z - .object({ - subdomain: z.string().optional(), - domainId: z.string().min(1).optional(), - name: z.string().min(1).max(255), - siteId: z.number(), - http: z.boolean(), - protocol: z.string(), - proxyPort: z.number().optional(), - isBaseDomain: z.boolean().optional() +const baseResourceFormSchema = z.object({ + name: z.string().min(1).max(255), + siteId: z.number(), + 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) }) - .refine( - (data) => { - if (!data.http) { - return z - .number() - .int() - .min(1) - .max(65535) - .safeParse(data.proxyPort).success; - } - return true; - }, - { - message: "Invalid port number", - path: ["proxyPort"] - } - ) - .refine( - (data) => { - if (data.http && !data.isBaseDomain) { - return subdomainSchema.safeParse(data.subdomain).success; - } - return true; - }, - { - message: "Invalid subdomain", - path: ["subdomain"] - } - ); +]); -type CreateResourceFormValues = z.infer; +const tcpUdpResourceFormSchema = z.object({ + protocol: z.string(), + proxyPort: z.number().int().min(1).max(65535) +}); -type CreateResourceFormProps = { - open: boolean; - setOpen: (open: boolean) => void; -}; +type BaseResourceFormValues = z.infer; +type HttpResourceFormValues = z.infer; +type TcpUdpResourceFormValues = z.infer; -export default function CreateResourceForm({ - open, - setOpen -}: CreateResourceFormProps) { - const [formKey, setFormKey] = useState(0); - const api = createApiClient(useEnvContext()); +type ResourceType = "http" | "raw"; - const [loading, setLoading] = useState(false); - const params = useParams(); +interface ResourceTypeOption { + id: ResourceType; + title: string; + description: string; + disabled?: boolean; +} - const orgId = params.orgId; +export default function Page() { + const { env } = useEnvContext(); + const api = createApiClient({ env }); + const { orgId } = useParams(); const router = useRouter(); - const { org } = useOrgContext(); - const { env } = useEnvContext(); - + const [loadingPage, setLoadingPage] = useState(true); const [sites, setSites] = useState([]); const [baseDomains, setBaseDomains] = useState< { domainId: string; baseDomain: string }[] >([]); + const [createLoading, setCreateLoading] = useState(false); const [showSnippets, setShowSnippets] = useState(false); const [resourceId, setResourceId] = useState(null); - const [domainType, setDomainType] = useState<"subdomain" | "basedomain">( - "subdomain" - ); - const [loadingPage, setLoadingPage] = useState(true); - const form = useForm({ - resolver: zodResolver(createResourceFormSchema), - defaultValues: { - subdomain: "", - domainId: "", - name: "", - http: true, - protocol: "tcp" - } - }); - - function reset() { - form.reset(); - setSites([]); - setShowSnippets(false); - setResourceId(null); - } - - useEffect(() => { - if (!open) { - return; - } - - reset(); - - const fetchSites = async () => { - const res = await api - .get>(`/org/${orgId}/sites/`) - .catch((e) => { - toast({ - variant: "destructive", - title: "Error fetching sites", - description: formatAxiosError( - e, - "An error occurred when fetching the sites" - ) - }); - }); - - if (res?.status === 200) { - setSites(res.data.data.sites); - - if (res.data.data.sites.length > 0) { - form.setValue("siteId", res.data.data.sites[0].siteId); - } - } - }; - - const fetchDomains = async () => { - const res = await api - .get< - AxiosResponse - >(`/org/${orgId}/domains/`) - .catch((e) => { - toast({ - variant: "destructive", - title: "Error fetching domains", - description: formatAxiosError( - e, - "An error occurred when fetching the domains" - ) - }); - }); - - if (res?.status === 200) { - const domains = res.data.data.domains; - setBaseDomains(domains); - if (domains.length) { - form.setValue("domainId", domains[0].domainId); - setFormKey((k) => k + 1); - } - } - }; - - const load = async () => { - setLoadingPage(true); - - await fetchSites(); - await fetchDomains(); - await new Promise((r) => setTimeout(r, 200)); - - setLoadingPage(false); - }; - - load(); - }, [open]); - - async function onSubmit(data: CreateResourceFormValues) { - const res = await api - .put>( - `/org/${orgId}/site/${data.siteId}/resource/`, - { - name: data.name, - subdomain: data.http ? data.subdomain : undefined, - domainId: data.http ? data.domainId : undefined, - http: data.http, - protocol: data.protocol, - proxyPort: data.http ? undefined : data.proxyPort, - siteId: data.siteId, - isBaseDomain: data.http ? data.isBaseDomain : undefined - } - ) - .catch((e) => { - toast({ - variant: "destructive", - title: "Error creating resource", - description: formatAxiosError( - e, - "An error occurred when creating the resource" - ) - }); - }); - - if (res && res.status === 201) { - const id = res.data.data.resourceId; - setResourceId(id); - - if (data.http) { - goToResource(id); - } else { - setShowSnippets(true); - router.refresh(); - } - } - } - - function goToResource(id?: number) { - // navigate to the resource page - router.push(`/${orgId}/settings/resources/${id || resourceId}`); - } - - const launchOptions = [ + const resourceTypes: ReadonlyArray = [ { id: "http", title: "HTTPS Resource", @@ -286,239 +125,436 @@ export default function CreateResourceForm({ id: "raw", title: "Raw TCP/UDP Resource", description: - "Proxy requests to your app over TCP/UDP using a port number." + "Proxy requests to your app over TCP/UDP using a port number.", + disabled: !env.flags.allowRawResources } ]; + const baseForm = useForm({ + resolver: zodResolver(baseResourceFormSchema), + defaultValues: { + name: "", + http: true + } + }); + + const httpForm = useForm({ + resolver: zodResolver(httpResourceFormSchema), + defaultValues: { + subdomain: "", + domainId: "", + isBaseDomain: false + } + }); + + const tcpUdpForm = useForm({ + resolver: zodResolver(tcpUdpResourceFormSchema), + defaultValues: { + protocol: "tcp", + proxyPort: undefined + } + }); + + async function onSubmit() { + setCreateLoading(true); + + const baseData = baseForm.getValues(); + const isHttp = baseData.http; + + try { + const payload = { + name: baseData.name, + siteId: baseData.siteId, + http: baseData.http + }; + + if (isHttp) { + const httpData = httpForm.getValues(); + if (httpData.isBaseDomain) { + Object.assign(payload, { + domainId: httpData.domainId, + isBaseDomain: true + }); + } else { + Object.assign(payload, { + subdomain: httpData.subdomain, + domainId: httpData.domainId, + isBaseDomain: false + }); + } + } else { + const tcpUdpData = tcpUdpForm.getValues(); + Object.assign(payload, { + protocol: tcpUdpData.protocol, + proxyPort: tcpUdpData.proxyPort + }); + } + + const res = await api + .put< + AxiosResponse + >(`/org/${orgId}/site/${baseData.siteId}/resource/`, payload) + .catch((e) => { + toast({ + variant: "destructive", + title: "Error creating resource", + description: formatAxiosError( + e, + "An error occurred when creating the resource" + ) + }); + }); + + if (res && res.status === 201) { + const id = res.data.data.resourceId; + setResourceId(id); + + if (isHttp) { + router.push(`/${orgId}/settings/resources/${id}`); + } else { + setShowSnippets(true); + router.refresh(); + } + } + } catch (e) { + console.error("Error creating resource:", e); + toast({ + variant: "destructive", + title: "Error creating resource", + description: "An unexpected error occurred" + }); + } + + setCreateLoading(false); + } + + useEffect(() => { + const load = async () => { + setLoadingPage(true); + + const fetchSites = async () => { + const res = await api + .get< + AxiosResponse + >(`/org/${orgId}/sites/`) + .catch((e) => { + toast({ + variant: "destructive", + title: "Error fetching sites", + description: formatAxiosError( + e, + "An error occurred when fetching the sites" + ) + }); + }); + + if (res?.status === 200) { + setSites(res.data.data.sites); + + if (res.data.data.sites.length > 0) { + baseForm.setValue( + "siteId", + res.data.data.sites[0].siteId + ); + } + } + }; + + const fetchDomains = async () => { + const res = await api + .get< + AxiosResponse + >(`/org/${orgId}/domains/`) + .catch((e) => { + toast({ + variant: "destructive", + title: "Error fetching domains", + description: formatAxiosError( + e, + "An error occurred when fetching the domains" + ) + }); + }); + + if (res?.status === 200) { + const domains = res.data.data.domains; + setBaseDomains(domains); + if (domains.length) { + httpForm.setValue("domainId", domains[0].domainId); + } + } + }; + + await fetchSites(); + await fetchDomains(); + + setLoadingPage(false); + }; + + load(); + }, []); + return ( <> - { - setOpen(val); - setLoading(false); +
+ + +
- // reset all values - form.reset(); - }} - > - - - Create Resource - - Create a new resource to proxy requests to your app - - - - {loadingPage ? ( - - ) : ( -
- {!showSnippets && ( -
- - ( - - - Name - - - - - - - )} - /> + {!loadingPage && ( +
+ {!showSnippets ? ( + + + + + Resource Information + + + + + + + ( + + + Name + + + + + + + This is the + display name for + the resource. + + + )} + /> - ( - - - Site - - - - - - - - - - - - - No - site - found. - - - {sites.map( - ( - site - ) => ( - { - form.setValue( - "siteId", - site.siteId - ); - }} - > - - { - site.name - } - - ) - )} - - - - - - - - This site will - provide connectivity - to the resource. - - - )} - /> - - {!env.flags.allowRawResources || ( -
- - Resource Type - - - form.setValue( - "http", - value === "http" - ) - } - /> - - You cannot change the - type of resource after - creation. - -
- )} - - {form.watch("http") && - env.flags - .allowBaseDomainResources && ( - ( - - - Domain Type - - - - - )} - /> - )} + + + + + + + No + site + found. + + + {sites.map( + ( + site + ) => ( + { + baseForm.setValue( + "siteId", + site.siteId + ); + }} + > + + { + site.name + } + + ) + )} + + + + + + + + This site will + provide + connectivity to + the resource. + + + )} + /> + + +
+
+
- {form.watch("http") && ( - <> - {domainType === - "subdomain" ? ( -
+ + + + Resource Type + + + Determine how you want to access your + resource + + + + { + baseForm.setValue( + "http", + value === "http" + ); + }} + cols={2} + /> + + + + {baseForm.watch("http") ? ( + + + + HTTPS Settings + + + Configure how your resource will be + accessed over HTTPS + + + + +
+ + {env.flags + .allowBaseDomainResources && ( + ( + + + Domain + Type + + + + + )} + /> + )} + + {!httpForm.watch( + "isBaseDomain" + ) && ( + Subdomain -
+
-
- ) : ( + + The subdomain + where your + resource will be + accessible. + +
+ )} + + {httpForm.watch( + "isBaseDomain" + ) && ( )} - - )} - - {!form.watch("http") && ( - <> - + +
+
+
+ ) : ( + + + + TCP/UDP Settings + + + Configure how your resource will be + accessed over TCP/UDP + + + + +
+ + ( @@ -659,12 +725,10 @@ export default function CreateResourceForm({ Protocol - table.setGlobalFilter(String(e.target.value)) - } - className="w-full pl-8" - /> - -
- -
- - - - {table.getHeaderGroups().map((headerGroup) => ( - - {headerGroup.headers.map((header) => { - return ( - - {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() - )} - - ))} - - )) - ) : ( - - - No links. Create one to get started. - - - )} - -
-
-
- -
-
+ ); } diff --git a/src/app/[orgId]/settings/share-links/ShareLinksTable.tsx b/src/app/[orgId]/settings/share-links/ShareLinksTable.tsx index 7d19b2b6..69c88cf7 100644 --- a/src/app/[orgId]/settings/share-links/ShareLinksTable.tsx +++ b/src/app/[orgId]/settings/share-links/ShareLinksTable.tsx @@ -306,7 +306,7 @@ export default function ShareLinksTable({ { + createShareLink={() => { setIsCreateModalOpen(true); }} /> diff --git a/src/app/[orgId]/settings/share-links/page.tsx b/src/app/[orgId]/settings/share-links/page.tsx index e09a6b54..0bfa023d 100644 --- a/src/app/[orgId]/settings/share-links/page.tsx +++ b/src/app/[orgId]/settings/share-links/page.tsx @@ -53,7 +53,7 @@ export default async function ShareLinksPage(props: ShareLinksPageProps) { return ( <> - + {/* */} { columns: ColumnDef[]; data: TData[]; - addSite?: () => void; + createSite?: () => void; } export function SitesDataTable({ - addSite, columns, - data + data, + createSite }: DataTableProps) { - const [sorting, setSorting] = useState([]); - const [columnFilters, setColumnFilters] = useState([]); - const [globalFilter, setGlobalFilter] = useState([]); - - const table = useReactTable({ - data, - columns, - getCoreRowModel: getCoreRowModel(), - getPaginationRowModel: getPaginationRowModel(), - onSortingChange: setSorting, - getSortedRowModel: getSortedRowModel(), - onColumnFiltersChange: setColumnFilters, - getFilteredRowModel: getFilteredRowModel(), - onGlobalFilterChange: setGlobalFilter, - initialState: { - pagination: { - pageSize: 20, - pageIndex: 0 - } - }, - state: { - sorting, - columnFilters, - globalFilter - } - }); - return ( -
-
-
- - table.setGlobalFilter(String(e.target.value)) - } - className="w-full pl-8" - /> - -
- -
- - - - {table.getHeaderGroups().map((headerGroup) => ( - - {headerGroup.headers.map((header) => { - return ( - - {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() - )} - - ))} - - )) - ) : ( - - - No sites. Create one to get started. - - - )} - -
-
-
- -
-
+ ); } diff --git a/src/app/[orgId]/settings/sites/SitesTable.tsx b/src/app/[orgId]/settings/sites/SitesTable.tsx index 43ae82a1..c032800f 100644 --- a/src/app/[orgId]/settings/sites/SitesTable.tsx +++ b/src/app/[orgId]/settings/sites/SitesTable.tsx @@ -47,7 +47,6 @@ type SitesTableProps = { export default function SitesTable({ sites, orgId }: SitesTableProps) { const router = useRouter(); - const [isCreateModalOpen, setIsCreateModalOpen] = useState(false); const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); const [selectedSite, setSelectedSite] = useState(null); const [rows, setRows] = useState(sites); @@ -164,7 +163,7 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) { ); } } else { - return --; + return -; } } }, @@ -281,15 +280,6 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) { return ( <> - { - setRows([val, ...rows]); - }} - orgId={orgId} - /> - {selectedSite && ( { - router.push(`/${orgId}/settings/sites/create`); - }} + createSite={() => + router.push(`/${orgId}/settings/sites/create`) + } /> ); diff --git a/src/app/[orgId]/settings/sites/[niceId]/general/page.tsx b/src/app/[orgId]/settings/sites/[niceId]/general/page.tsx index 66a7ddd1..f107d960 100644 --- a/src/app/[orgId]/settings/sites/[niceId]/general/page.tsx +++ b/src/app/[orgId]/settings/sites/[niceId]/general/page.tsx @@ -134,7 +134,7 @@ export default function GeneralPage() { loading={loading} disabled={loading} > - Save Settings + Save General Settings
diff --git a/src/app/[orgId]/settings/sites/[niceId]/layout.tsx b/src/app/[orgId]/settings/sites/[niceId]/layout.tsx index 6b6a58e2..5bcc8af9 100644 --- a/src/app/[orgId]/settings/sites/[niceId]/layout.tsx +++ b/src/app/[orgId]/settings/sites/[niceId]/layout.tsx @@ -4,7 +4,7 @@ import { GetSiteResponse } from "@server/routers/site"; import { AxiosResponse } from "axios"; import { redirect } from "next/navigation"; import { authCookieHeader } from "@app/lib/api/cookies"; -import { SidebarSettings } from "@app/components/SidebarSettings"; +import { HorizontalTabs } from "@app/components/HorizontalTabs"; import Link from "next/link"; import { ArrowLeft } from "lucide-react"; import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; @@ -38,7 +38,7 @@ export default async function SettingsLayout(props: SettingsLayoutProps) { redirect(`/${params.orgId}/settings/sites`); } - const sidebarNavItems = [ + const navItems = [ { title: "General", href: "/{orgId}/settings/sites/{niceId}/general" @@ -47,30 +47,16 @@ export default async function SettingsLayout(props: SettingsLayoutProps) { return ( <> -
- - - - Sites - - - - {site.name} - - - -
- - +
- {children} - + {children} +
); diff --git a/src/app/[orgId]/settings/sites/create/page.tsx b/src/app/[orgId]/settings/sites/create/page.tsx index 438d1ae7..38c8a772 100644 --- a/src/app/[orgId]/settings/sites/create/page.tsx +++ b/src/app/[orgId]/settings/sites/create/page.tsx @@ -25,7 +25,7 @@ import { useEffect, useState } from "react"; import { useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import { Input } from "@app/components/ui/input"; -import { Terminal, InfoIcon } from "lucide-react"; +import { InfoIcon, Terminal } from "lucide-react"; import { Button } from "@app/components/ui/button"; import CopyTextBox from "@app/components/CopyTextBox"; import CopyToClipboard from "@app/components/CopyToClipboard"; @@ -35,7 +35,13 @@ import { InfoSections, InfoSectionTitle } from "@app/components/InfoSection"; -import { FaWindows, FaApple, FaFreebsd, FaDocker } from "react-icons/fa"; +import { + FaApple, + FaCubes, + FaDocker, + FaFreebsd, + FaWindows +} from "react-icons/fa"; import { Checkbox } from "@app/components/ui/checkbox"; import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert"; import { generateKeypair } from "../[niceId]/wireguardConfig"; @@ -58,19 +64,16 @@ import { } from "@app/components/ui/breadcrumb"; import Link from "next/link"; import { QRCodeCanvas } from "qrcode.react"; -import QRContainer from "@app/components/QRContainer"; const createSiteFormSchema = z .object({ name: z .string() - .min(2, { - message: "Name must be at least 2 characters." - }) + .min(2, { message: "Name must be at least 2 characters." }) .max(30, { message: "Name must not be longer than 30 characters." }), - method: z.string(), + method: z.enum(["newt", "wireguard", "local"]), copied: z.boolean() }) .refine( @@ -88,20 +91,43 @@ const createSiteFormSchema = z type CreateSiteFormValues = z.infer; +type SiteType = "newt" | "wireguard" | "local"; + +interface TunnelTypeOption { + id: SiteType; + title: string; + description: string; + disabled?: boolean; +} + type Commands = { mac: Record; linux: Record; windows: Record; docker: Record; + podman: Record; }; +const platforms = [ + "linux", + "docker", + "podman", + "mac", + "windows", + "freebsd" +] as const; + +type Platform = (typeof platforms)[number]; + export default function Page() { const { env } = useEnvContext(); const api = createApiClient({ env }); const { orgId } = useParams(); const router = useRouter(); - const [tunnelTypes, setTunnelTypes] = useState([ + const [tunnelTypes, setTunnelTypes] = useState< + ReadonlyArray + >([ { id: "newt", title: "Newt Tunnel (Recommended)", @@ -125,7 +151,7 @@ export default function Page() { const [loadingPage, setLoadingPage] = useState(true); - const [platform, setPlatform] = useState("linux"); + const [platform, setPlatform] = useState("linux"); const [architecture, setArchitecture] = useState("amd64"); const [commands, setCommands] = useState(null); @@ -233,6 +259,29 @@ PersistentKeepalive = 5`; "Docker Run": [ `docker run -it fosrl/newt --id ${id} --secret ${secret} --endpoint ${endpoint}` ] + }, + podman: { + "Podman Quadlet": [ + `[Unit] +Description=Newt container + +[Container] +ContainerName=newt +Image=docker.io/fosrl/newt +Environment=PANGOLIN_ENDPOINT=${endpoint} +Environment=NEWT_ID=${id} +Environment=NEWT_SECRET=${secret} +# Secret=newt-secret,type=env,target=NEWT_SECRET + +[Service] +Restart=always + +[Install] +WantedBy=default.target` + ], + "Podman Run": [ + `podman run -it docker.io/fosrl/newt --id ${id} --secret ${secret} --endpoint ${endpoint}` + ] } }; setCommands(commands); @@ -248,6 +297,8 @@ PersistentKeepalive = 5`; return ["x64"]; case "docker": return ["Docker Compose", "Docker Run"]; + case "podman": + return ["Podman Quadlet", "Podman Run"]; case "freebsd": return ["amd64", "arm64"]; default: @@ -263,6 +314,8 @@ PersistentKeepalive = 5`; return "macOS"; case "docker": return "Docker"; + case "podman": + return "Podman"; case "freebsd": return "FreeBSD"; default: @@ -279,7 +332,7 @@ PersistentKeepalive = 5`; if (!platformCommands) { // get first key - const firstPlatform = Object.keys(commands)[0]; + const firstPlatform = Object.keys(commands)[0] as Platform; platformCommands = commands[firstPlatform as keyof Commands]; setPlatform(firstPlatform); @@ -305,6 +358,8 @@ PersistentKeepalive = 5`; return ; case "docker": return ; + case "podman": + return ; case "freebsd": return ; default: @@ -312,22 +367,15 @@ PersistentKeepalive = 5`; } }; - const form = useForm({ + const form = useForm({ resolver: zodResolver(createSiteFormSchema), - defaultValues: { - name: "", - copied: false, - method: "newt" - } + defaultValues: { name: "", copied: false, method: "newt" } }); 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 }; if (data.method == "wireguard") { if (!siteDefaults || !wgConfig) { @@ -456,10 +504,7 @@ PersistentKeepalive = 5`; setTunnelTypes((prev: any) => { return prev.map((item: any) => { - return { - ...item, - disabled: false - }; + return { ...item, disabled: false }; }); }); } @@ -473,20 +518,6 @@ PersistentKeepalive = 5`; return ( <> -
- - - - Sites - - - - Create Site - - - -
-
- This is the - display name for the - site. + This is the display + name for the site. )} @@ -560,12 +590,10 @@ PersistentKeepalive = 5`; - form.setValue("method", value) - } + defaultValue={form.getValues("method")} + onChange={(value) => { + form.setValue("method", value); + }} cols={3} /> @@ -691,13 +719,7 @@ PersistentKeepalive = 5`; Operating System

- {[ - "linux", - "docker", - "mac", - "windows", - "freebsd" - ].map((os) => ( + {platforms.map((os) => ( + + + { + setSelected(apiKeyROw); + }} + > + View settings + + { + setSelected(apiKeyROw); + setIsDeleteModalOpen(true); + }} + > + Delete + + + + ); + } + }, + { + accessorKey: "name", + header: ({ column }) => { + return ( + + ); + } + }, + { + accessorKey: "key", + header: "Key", + cell: ({ row }) => { + const r = row.original; + return {r.key}; + } + }, + { + accessorKey: "createdAt", + header: "Created At", + cell: ({ row }) => { + const r = row.original; + return {moment(r.createdAt).format("lll")} ; + } + }, + { + id: "actions", + cell: ({ row }) => { + const r = row.original; + return ( +
+ + + +
+ ); + } + } + ]; + + return ( + <> + {selected && ( + { + setIsDeleteModalOpen(val); + setSelected(null); + }} + dialog={ +
+

+ Are you sure you want to remove the API key{" "} + {selected?.name || selected?.id}? +

+ +

+ + Once removed, the API key will no longer be + able to be used. + +

+ +

+ To confirm, please type the name of the API key + below. +

+
+ } + buttonText="Confirm Delete API Key" + onConfirm={async () => deleteSite(selected!.id)} + string={selected.name} + title="Delete API Key" + /> + )} + + { + router.push(`/admin/api-keys/create`); + }} + /> + + ); +} diff --git a/src/app/admin/api-keys/[apiKeyId]/layout.tsx b/src/app/admin/api-keys/[apiKeyId]/layout.tsx new file mode 100644 index 00000000..be3147ea --- /dev/null +++ b/src/app/admin/api-keys/[apiKeyId]/layout.tsx @@ -0,0 +1,62 @@ +// This file is licensed under the Fossorial Commercial License. +// Unauthorized use, copying, modification, or distribution is strictly prohibited. +// +// Copyright (c) 2025 Fossorial LLC. All rights reserved. + +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"; + +interface SettingsLayoutProps { + children: React.ReactNode; + params: Promise<{ apiKeyId: string }>; +} + +export default async function SettingsLayout(props: SettingsLayoutProps) { + const params = await props.params; + + const { children } = props; + + let apiKey = null; + try { + const res = await internal.get>( + `/api-key/${params.apiKeyId}`, + await authCookieHeader() + ); + apiKey = res.data.data; + } catch (e) { + console.error(e); + redirect(`/admin/api-keys`); + } + + const navItems = [ + { + title: "Permissions", + href: "/admin/api-keys/{apiKeyId}/permissions" + } + ]; + + return ( + <> + + + + {children} + + + ); +} diff --git a/src/app/admin/api-keys/[apiKeyId]/page.tsx b/src/app/admin/api-keys/[apiKeyId]/page.tsx new file mode 100644 index 00000000..b0e4c3e5 --- /dev/null +++ b/src/app/admin/api-keys/[apiKeyId]/page.tsx @@ -0,0 +1,13 @@ +// This file is licensed under the Fossorial Commercial License. +// Unauthorized use, copying, modification, or distribution is strictly prohibited. +// +// Copyright (c) 2025 Fossorial LLC. All rights reserved. + +import { redirect } from "next/navigation"; + +export default async function ApiKeysPage(props: { + params: Promise<{ apiKeyId: string }>; +}) { + const params = await props.params; + redirect(`/admin/api-keys/${params.apiKeyId}/permissions`); +} diff --git a/src/app/admin/api-keys/[apiKeyId]/permissions/page.tsx b/src/app/admin/api-keys/[apiKeyId]/permissions/page.tsx new file mode 100644 index 00000000..c468c139 --- /dev/null +++ b/src/app/admin/api-keys/[apiKeyId]/permissions/page.tsx @@ -0,0 +1,139 @@ +// This file is licensed under the Fossorial Commercial License. +// Unauthorized use, copying, modification, or distribution is strictly prohibited. +// +// Copyright (c) 2025 Fossorial LLC. All rights reserved. + +"use client"; + +import PermissionsSelectBox from "@app/components/PermissionsSelectBox"; +import { + SettingsContainer, + SettingsSection, + SettingsSectionBody, + SettingsSectionDescription, + SettingsSectionFooter, + SettingsSectionHeader, + SettingsSectionTitle +} from "@app/components/Settings"; +import { Button } from "@app/components/ui/button"; +import { useEnvContext } from "@app/hooks/useEnvContext"; +import { toast } from "@app/hooks/useToast"; +import { createApiClient, formatAxiosError } from "@app/lib/api"; +import { ListApiKeyActionsResponse } from "@server/routers/apiKeys"; +import { AxiosResponse } from "axios"; +import { useParams } from "next/navigation"; +import { useEffect, useState } from "react"; + +export default function Page() { + const { env } = useEnvContext(); + const api = createApiClient({ env }); + const { apiKeyId } = useParams(); + + const [loadingPage, setLoadingPage] = useState(true); + const [selectedPermissions, setSelectedPermissions] = useState< + Record + >({}); + const [loadingSavePermissions, setLoadingSavePermissions] = + useState(false); + + useEffect(() => { + async function load() { + setLoadingPage(true); + + const res = await api + .get< + AxiosResponse + >(`/api-key/${apiKeyId}/actions`) + .catch((e) => { + toast({ + variant: "destructive", + title: "Error loading API key actions", + description: formatAxiosError( + e, + "Error loading API key actions" + ) + }); + }); + + if (res && res.status === 200) { + const data = res.data.data; + for (const action of data.actions) { + setSelectedPermissions((prev) => ({ + ...prev, + [action.actionId]: true + })); + } + } + + setLoadingPage(false); + } + + load(); + }, []); + + async function savePermissions() { + setLoadingSavePermissions(true); + + const actionsRes = await api + .post(`/api-key/${apiKeyId}/actions`, { + actionIds: Object.keys(selectedPermissions).filter( + (key) => selectedPermissions[key] + ) + }) + .catch((e) => { + console.error("Error setting permissions", e); + toast({ + variant: "destructive", + title: "Error setting permissions", + description: formatAxiosError(e) + }); + }); + + if (actionsRes && actionsRes.status === 200) { + toast({ + title: "Permissions updated", + description: "The permissions have been updated." + }); + } + + setLoadingSavePermissions(false); + } + + return ( + <> + {!loadingPage && ( + + + + + Permissions + + + Determine what this API key can do + + + + + + + + + + + + )} + + ); +} diff --git a/src/app/admin/api-keys/create/page.tsx b/src/app/admin/api-keys/create/page.tsx new file mode 100644 index 00000000..c76b1859 --- /dev/null +++ b/src/app/admin/api-keys/create/page.tsx @@ -0,0 +1,402 @@ +// This file is licensed under the Fossorial Commercial License. +// Unauthorized use, copying, modification, or distribution is strictly prohibited. +// +// Copyright (c) 2025 Fossorial LLC. All rights reserved. + +"use client"; + +import { + SettingsContainer, + SettingsSection, + SettingsSectionBody, + SettingsSectionDescription, + SettingsSectionForm, + SettingsSectionHeader, + SettingsSectionTitle +} from "@app/components/Settings"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage +} from "@app/components/ui/form"; +import HeaderTitle from "@app/components/SettingsSectionTitle"; +import { z } from "zod"; +import { useEffect, useState } from "react"; +import { useForm } from "react-hook-form"; +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 { + CreateOrgApiKeyBody, + CreateOrgApiKeyResponse +} from "@server/routers/apiKeys"; +import { + InfoSection, + InfoSectionContent, + InfoSections, + InfoSectionTitle +} from "@app/components/InfoSection"; +import CopyToClipboard from "@app/components/CopyToClipboard"; +import moment from "moment"; +import CopyTextBox from "@app/components/CopyTextBox"; +import PermissionsSelectBox from "@app/components/PermissionsSelectBox"; + +const createFormSchema = z.object({ + name: z + .string() + .min(2, { + message: "Name must be at least 2 characters." + }) + .max(255, { + message: "Name must not be longer than 255 characters." + }) +}); + +type CreateFormValues = z.infer; + +const copiedFormSchema = z + .object({ + copied: z.boolean() + }) + .refine( + (data) => { + return data.copied; + }, + { + message: "You must confirm that you have copied the API key.", + path: ["copied"] + } + ); + +type CopiedFormValues = z.infer; + +export default function Page() { + const { env } = useEnvContext(); + const api = createApiClient({ env }); + const router = useRouter(); + + const [loadingPage, setLoadingPage] = useState(true); + const [createLoading, setCreateLoading] = useState(false); + const [apiKey, setApiKey] = useState(null); + const [selectedPermissions, setSelectedPermissions] = useState< + Record + >({}); + + const form = useForm({ + resolver: zodResolver(createFormSchema), + defaultValues: { + name: "" + } + }); + + const copiedForm = useForm({ + resolver: zodResolver(copiedFormSchema), + defaultValues: { + copied: false + } + }); + + async function onSubmit(data: CreateFormValues) { + setCreateLoading(true); + + let payload: CreateOrgApiKeyBody = { + name: data.name + }; + + const res = await api + .put>(`/api-key`, payload) + .catch((e) => { + toast({ + variant: "destructive", + title: "Error creating API key", + description: formatAxiosError(e) + }); + }); + + if (res && res.status === 201) { + const data = res.data.data; + + console.log({ + actionIds: Object.keys(selectedPermissions).filter( + (key) => selectedPermissions[key] + ) + }); + + const actionsRes = await api + .post(`/api-key/${data.apiKeyId}/actions`, { + actionIds: Object.keys(selectedPermissions).filter( + (key) => selectedPermissions[key] + ) + }) + .catch((e) => { + console.error("Error setting permissions", e); + toast({ + variant: "destructive", + title: "Error setting permissions", + description: formatAxiosError(e) + }); + }); + + if (actionsRes) { + setApiKey(data); + } + } + + setCreateLoading(false); + } + + async function onCopiedSubmit(data: CopiedFormValues) { + if (!data.copied) { + return; + } + + router.push(`/admin/api-keys`); + } + + useEffect(() => { + const load = async () => { + setLoadingPage(false); + }; + + load(); + }, []); + + return ( + <> +
+ + +
+ + {!loadingPage && ( +
+ + {!apiKey && ( + <> + + + + API Key Information + + + + + + + ( + + + Name + + + + + + + )} + /> + + + + + + + + + + Permissions + + + Determine what this API key can do + + + + + + + + )} + + {apiKey && ( + + + + Your API Key + + + + + + + Name + + + + + + + + Created + + + {moment( + apiKey.createdAt + ).format("lll")} + + + + + + + + Save Your API Key + + + You will only be able to see this + once. Make sure to copy it to a + secure place. + + + +

+ Your API key is: +

+ + + +
+ + ( + +
+ { + copiedForm.setValue( + "copied", + e as boolean + ); + }} + /> + +
+ +
+ )} + /> + + +
+
+ )} +
+ +
+ {!apiKey && ( + + )} + {!apiKey && ( + + )} + + {apiKey && ( + + )} +
+
+ )} + + ); +} diff --git a/src/app/admin/api-keys/page.tsx b/src/app/admin/api-keys/page.tsx new file mode 100644 index 00000000..b4a00806 --- /dev/null +++ b/src/app/admin/api-keys/page.tsx @@ -0,0 +1,46 @@ +// This file is licensed under the Fossorial Commercial License. +// Unauthorized use, copying, modification, or distribution is strictly prohibited. +// +// Copyright (c) 2025 Fossorial LLC. All rights reserved. + +import { internal } from "@app/lib/api"; +import { authCookieHeader } from "@app/lib/api/cookies"; +import { AxiosResponse } from "axios"; +import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; +import { ListRootApiKeysResponse } from "@server/routers/apiKeys"; +import ApiKeysTable, { ApiKeyRow } from "./ApiKeysTable"; + +type ApiKeyPageProps = {}; + +export const dynamic = "force-dynamic"; + +export default async function ApiKeysPage(props: ApiKeyPageProps) { + let apiKeys: ListRootApiKeysResponse["apiKeys"] = []; + try { + const res = await internal.get>( + `/api-keys`, + await authCookieHeader() + ); + apiKeys = res.data.data.apiKeys; + } catch (e) {} + + const rows: ApiKeyRow[] = apiKeys.map((key) => { + return { + name: key.name, + id: key.apiKeyId, + key: `${key.apiKeyId}••••••••••••••••••••${key.lastChars}`, + createdAt: key.createdAt + }; + }); + + return ( + <> + + + + + ); +} diff --git a/src/app/admin/idp/AdminIdpDataTable.tsx b/src/app/admin/idp/AdminIdpDataTable.tsx new file mode 100644 index 00000000..8d64ce0b --- /dev/null +++ b/src/app/admin/idp/AdminIdpDataTable.tsx @@ -0,0 +1,31 @@ +"use client"; + +import { ColumnDef } from "@tanstack/react-table"; +import { DataTable } from "@app/components/ui/data-table"; +import { useRouter } from "next/navigation"; + +interface DataTableProps { + columns: ColumnDef[]; + data: TData[]; +} + +export function IdpDataTable({ + columns, + data +}: DataTableProps) { + const router = useRouter(); + + return ( + { + router.push("/admin/idp/create"); + }} + /> + ); +} diff --git a/src/app/admin/idp/AdminIdpTable.tsx b/src/app/admin/idp/AdminIdpTable.tsx new file mode 100644 index 00000000..b2415280 --- /dev/null +++ b/src/app/admin/idp/AdminIdpTable.tsx @@ -0,0 +1,214 @@ +"use client"; + +import { ColumnDef } from "@tanstack/react-table"; +import { IdpDataTable } from "./AdminIdpDataTable"; +import { Button } from "@app/components/ui/button"; +import { ArrowRight, ArrowUpDown, MoreHorizontal } from "lucide-react"; +import { useState } from "react"; +import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog"; +import { toast } from "@app/hooks/useToast"; +import { formatAxiosError } from "@app/lib/api"; +import { createApiClient } from "@app/lib/api"; +import { useEnvContext } from "@app/hooks/useEnvContext"; +import { Badge } from "@app/components/ui/badge"; +import { useRouter } from "next/navigation"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger +} from "@app/components/ui/dropdown-menu"; +import Link from "next/link"; + +export type IdpRow = { + idpId: number; + name: string; + type: string; + orgCount: number; +}; + +type Props = { + idps: IdpRow[]; +}; + +export default function IdpTable({ idps }: Props) { + const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); + const [selectedIdp, setSelectedIdp] = useState(null); + const api = createApiClient(useEnvContext()); + const router = useRouter(); + + const deleteIdp = async (idpId: number) => { + try { + await api.delete(`/idp/${idpId}`); + toast({ + title: "Success", + description: "Identity provider deleted successfully" + }); + setIsDeleteModalOpen(false); + router.refresh(); + } catch (e) { + toast({ + title: "Error", + description: formatAxiosError(e), + variant: "destructive" + }); + } + }; + + const getTypeDisplay = (type: string) => { + switch (type) { + case "oidc": + return "OAuth2/OIDC"; + default: + return type; + } + }; + + const columns: ColumnDef[] = [ + { + id: "dots", + cell: ({ row }) => { + const r = row.original; + + return ( + + + + + + + + View settings + + + { + setSelectedIdp(r); + setIsDeleteModalOpen(true); + }} + > + Delete + + + + ); + } + }, + { + accessorKey: "idpId", + header: ({ column }) => { + return ( + + ); + } + }, + { + accessorKey: "name", + header: ({ column }) => { + return ( + + ); + } + }, + { + accessorKey: "type", + header: ({ column }) => { + return ( + + ); + }, + cell: ({ row }) => { + const type = row.original.type; + return ( + {getTypeDisplay(type)} + ); + } + }, + { + id: "actions", + cell: ({ row }) => { + const siteRow = row.original; + return ( +
+ + + +
+ ); + } + } + ]; + + return ( + <> + {selectedIdp && ( + { + setIsDeleteModalOpen(val); + setSelectedIdp(null); + }} + dialog={ +
+

+ Are you sure you want to permanently delete the + identity provider {selectedIdp.name}? +

+

+ + This will remove the identity provider and + all associated configurations. Users who + authenticate through this provider will no + longer be able to log in. + +

+

+ To confirm, please type the name of the identity + provider below. +

+
+ } + buttonText="Confirm Delete Identity Provider" + onConfirm={async () => deleteIdp(selectedIdp.idpId)} + string={selectedIdp.name} + title="Delete Identity Provider" + /> + )} + + + + ); +} diff --git a/src/app/admin/idp/[idpId]/general/page.tsx b/src/app/admin/idp/[idpId]/general/page.tsx new file mode 100644 index 00000000..f7844c7c --- /dev/null +++ b/src/app/admin/idp/[idpId]/general/page.tsx @@ -0,0 +1,514 @@ +"use client"; + +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; +import { Button } from "@app/components/ui/button"; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage +} from "@app/components/ui/form"; +import { Input } from "@app/components/ui/input"; +import { useForm } from "react-hook-form"; +import { toast } from "@app/hooks/useToast"; +import { useRouter, useParams, redirect } from "next/navigation"; +import { + SettingsContainer, + SettingsSection, + SettingsSectionHeader, + SettingsSectionTitle, + SettingsSectionDescription, + SettingsSectionBody, + SettingsSectionForm, + SettingsSectionFooter, + SettingsSectionGrid +} from "@app/components/Settings"; +import { formatAxiosError } from "@app/lib/api"; +import { createApiClient } from "@app/lib/api"; +import { useEnvContext } from "@app/hooks/useEnvContext"; +import { useState, useEffect } from "react"; +import { SwitchInput } from "@app/components/SwitchInput"; +import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert"; +import { InfoIcon, ExternalLink } from "lucide-react"; +import { + InfoSection, + InfoSectionContent, + InfoSections, + InfoSectionTitle +} from "@app/components/InfoSection"; +import CopyToClipboard from "@app/components/CopyToClipboard"; +import { Badge } from "@app/components/ui/badge"; +import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext"; + +const GeneralFormSchema = z.object({ + name: z.string().min(2, { message: "Name must be at least 2 characters." }), + clientId: z.string().min(1, { message: "Client ID is required." }), + clientSecret: z.string().min(1, { message: "Client Secret is required." }), + authUrl: z.string().url({ message: "Auth URL must be a valid URL." }), + tokenUrl: z.string().url({ message: "Token URL must be a valid URL." }), + identifierPath: z + .string() + .min(1, { message: "Identifier Path is required." }), + emailPath: z.string().optional(), + namePath: z.string().optional(), + scopes: z.string().min(1, { message: "Scopes are required." }), + autoProvision: z.boolean().default(false) +}); + +type GeneralFormValues = z.infer; + +export default function GeneralPage() { + const { env } = useEnvContext(); + const api = createApiClient({ env }); + const router = useRouter(); + const { idpId } = useParams(); + const [loading, setLoading] = useState(false); + const [initialLoading, setInitialLoading] = useState(true); + const { isUnlocked } = useLicenseStatusContext(); + + const redirectUrl = `${env.app.dashboardUrl}/auth/idp/${idpId}/oidc/callback`; + + const form = useForm({ + resolver: zodResolver(GeneralFormSchema), + defaultValues: { + name: "", + clientId: "", + clientSecret: "", + authUrl: "", + tokenUrl: "", + identifierPath: "sub", + emailPath: "email", + namePath: "name", + scopes: "openid profile email", + autoProvision: true + } + }); + + useEffect(() => { + const loadIdp = async () => { + try { + const res = await api.get(`/idp/${idpId}`); + if (res.status === 200) { + const data = res.data.data; + form.reset({ + name: data.idp.name, + clientId: data.idpOidcConfig.clientId, + clientSecret: data.idpOidcConfig.clientSecret, + authUrl: data.idpOidcConfig.authUrl, + tokenUrl: data.idpOidcConfig.tokenUrl, + identifierPath: data.idpOidcConfig.identifierPath, + emailPath: data.idpOidcConfig.emailPath, + namePath: data.idpOidcConfig.namePath, + scopes: data.idpOidcConfig.scopes, + autoProvision: data.idp.autoProvision + }); + } + } catch (e) { + toast({ + title: "Error", + description: formatAxiosError(e), + variant: "destructive" + }); + router.push("/admin/idp"); + } finally { + setInitialLoading(false); + } + }; + + loadIdp(); + }, [idpId, api, form, router]); + + async function onSubmit(data: GeneralFormValues) { + setLoading(true); + + try { + const payload = { + name: data.name, + clientId: data.clientId, + clientSecret: data.clientSecret, + authUrl: data.authUrl, + tokenUrl: data.tokenUrl, + identifierPath: data.identifierPath, + emailPath: data.emailPath, + namePath: data.namePath, + autoProvision: data.autoProvision, + scopes: data.scopes + }; + + const res = await api.post(`/idp/${idpId}/oidc`, payload); + + if (res.status === 200) { + toast({ + title: "Success", + description: "Identity provider updated successfully" + }); + router.refresh(); + } + } catch (e) { + toast({ + title: "Error", + description: formatAxiosError(e), + variant: "destructive" + }); + } finally { + setLoading(false); + } + } + + if (initialLoading) { + return null; + } + + return ( + <> + + + + + General Information + + + Configure the basic information for your identity + provider + + + + + + + Redirect URL + + + + + + + + + + + About Redirect URL + + + This is the URL to which users will be + redirected after authentication. You need to + configure this URL in your identity provider + settings. + + + +
+ + ( + + Name + + + + + A display name for this + identity provider + + + + )} + /> + +
+ { + form.setValue( + "autoProvision", + checked + ); + }} + /> + {!isUnlocked() && ( + + Professional + + )} +
+ + When enabled, users will be + automatically created in the system upon + first login with the ability to map + users to roles and organizations. + + + +
+
+
+ + + + + + OAuth2/OIDC Configuration + + + Configure the OAuth2/OIDC provider endpoints and + credentials + + + + +
+ + ( + + + Client ID + + + + + + The OAuth2 client ID + from your identity + provider + + + + )} + /> + + ( + + + Client Secret + + + + + + The OAuth2 client secret + from your identity + provider + + + + )} + /> + + ( + + + Authorization URL + + + + + + The OAuth2 authorization + endpoint URL + + + + )} + /> + + ( + + + Token URL + + + + + + The OAuth2 token + endpoint URL + + + + )} + /> + + +
+
+
+ + + + + Token Configuration + + + Configure how to extract user information from + the ID token + + + + +
+ + + + + About JMESPath + + + The paths below use JMESPath + syntax to extract values from + the ID token. + + Learn more about JMESPath{" "} + + + + + + ( + + + Identifier Path + + + + + + The JMESPath to the user + identifier in the ID + token + + + + )} + /> + + ( + + + Email Path (Optional) + + + + + + The JMESPath to the + user's email in the ID + token + + + + )} + /> + + ( + + + Name Path (Optional) + + + + + + The JMESPath to the + user's name in the ID + token + + + + )} + /> + + ( + + + Scopes + + + + + + Space-separated list of + OAuth2 scopes to request + + + + )} + /> + + +
+
+
+
+
+ +
+ +
+ + ); +} diff --git a/src/app/admin/idp/[idpId]/layout.tsx b/src/app/admin/idp/[idpId]/layout.tsx new file mode 100644 index 00000000..d244e13d --- /dev/null +++ b/src/app/admin/idp/[idpId]/layout.tsx @@ -0,0 +1,63 @@ +import { internal } from "@app/lib/api"; +import { GetIdpResponse } from "@server/routers/idp"; +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"; + +interface SettingsLayoutProps { + children: React.ReactNode; + params: Promise<{ idpId: string }>; +} + +export default async function SettingsLayout(props: SettingsLayoutProps) { + const params = await props.params; + const { children } = props; + + let idp = null; + try { + const res = await internal.get>( + `/idp/${params.idpId}`, + await authCookieHeader() + ); + idp = res.data.data; + } catch { + redirect("/admin/idp"); + } + + const navItems: HorizontalTabs = [ + { + title: "General", + href: `/admin/idp/${params.idpId}/general` + }, + { + title: "Organization Policies", + href: `/admin/idp/${params.idpId}/policies`, + showProfessional: true + } + ]; + + return ( + <> + + +
+ {children} +
+ + ); +} diff --git a/src/app/admin/idp/[idpId]/page.tsx b/src/app/admin/idp/[idpId]/page.tsx new file mode 100644 index 00000000..a8701e74 --- /dev/null +++ b/src/app/admin/idp/[idpId]/page.tsx @@ -0,0 +1,8 @@ +import { redirect } from "next/navigation"; + +export default async function IdpPage(props: { + params: Promise<{ idpId: string }>; +}) { + const params = await props.params; + redirect(`/admin/idp/${params.idpId}/general`); +} diff --git a/src/app/admin/idp/[idpId]/policies/PolicyDataTable.tsx b/src/app/admin/idp/[idpId]/policies/PolicyDataTable.tsx new file mode 100644 index 00000000..222e98eb --- /dev/null +++ b/src/app/admin/idp/[idpId]/policies/PolicyDataTable.tsx @@ -0,0 +1,33 @@ +// This file is licensed under the Fossorial Commercial License. +// Unauthorized use, copying, modification, or distribution is strictly prohibited. +// +// Copyright (c) 2025 Fossorial LLC. All rights reserved. + +"use client"; + +import { ColumnDef } from "@tanstack/react-table"; +import { DataTable } from "@app/components/ui/data-table"; + +interface DataTableProps { + columns: ColumnDef[]; + data: TData[]; + onAdd: () => void; +} + +export function PolicyDataTable({ + columns, + data, + onAdd +}: DataTableProps) { + return ( + + ); +} diff --git a/src/app/admin/idp/[idpId]/policies/PolicyTable.tsx b/src/app/admin/idp/[idpId]/policies/PolicyTable.tsx new file mode 100644 index 00000000..df78c648 --- /dev/null +++ b/src/app/admin/idp/[idpId]/policies/PolicyTable.tsx @@ -0,0 +1,159 @@ +// This file is licensed under the Fossorial Commercial License. +// Unauthorized use, copying, modification, or distribution is strictly prohibited. +// +// Copyright (c) 2025 Fossorial LLC. All rights reserved. + +"use client"; + +import { ColumnDef } from "@tanstack/react-table"; +import { Button } from "@app/components/ui/button"; +import { + ArrowUpDown, + Trash2, + MoreHorizontal, + Pencil, + ArrowRight +} from "lucide-react"; +import { PolicyDataTable } from "./PolicyDataTable"; +import { Badge } from "@app/components/ui/badge"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger +} from "@app/components/ui/dropdown-menu"; +import Link from "next/link"; +import { InfoPopup } from "@app/components/ui/info-popup"; + +export interface PolicyRow { + orgId: string; + roleMapping?: string; + orgMapping?: string; +} + +interface Props { + policies: PolicyRow[]; + onDelete: (orgId: string) => void; + onAdd: () => void; + onEdit: (policy: PolicyRow) => void; +} + +export default function PolicyTable({ policies, onDelete, onAdd, onEdit }: Props) { + const columns: ColumnDef[] = [ + { + id: "dots", + cell: ({ row }) => { + const r = row.original; + + return ( + + + + + + { + onDelete(r.orgId); + }} + > + Delete + + + + ); + } + }, + { + accessorKey: "orgId", + header: ({ column }) => { + return ( + + ); + } + }, + { + accessorKey: "roleMapping", + header: ({ column }) => { + return ( + + ); + }, + cell: ({ row }) => { + const mapping = row.original.roleMapping; + return mapping ? ( + 50 ? `${mapping.substring(0, 50)}...` : mapping} + info={mapping} + /> + ) : ( + "--" + ); + } + }, + { + accessorKey: "orgMapping", + header: ({ column }) => { + return ( + + ); + }, + cell: ({ row }) => { + const mapping = row.original.orgMapping; + return mapping ? ( + 50 ? `${mapping.substring(0, 50)}...` : mapping} + info={mapping} + /> + ) : ( + "--" + ); + } + }, + { + id: "actions", + cell: ({ row }) => { + const policy = row.original; + return ( +
+ +
+ ); + } + } + ]; + + return ; +} diff --git a/src/app/admin/idp/[idpId]/policies/page.tsx b/src/app/admin/idp/[idpId]/policies/page.tsx new file mode 100644 index 00000000..9fb9b49b --- /dev/null +++ b/src/app/admin/idp/[idpId]/policies/page.tsx @@ -0,0 +1,645 @@ +// This file is licensed under the Fossorial Commercial License. +// Unauthorized use, copying, modification, or distribution is strictly prohibited. +// +// Copyright (c) 2025 Fossorial LLC. All rights reserved. + +"use client"; + +import { useEffect, useState } from "react"; +import { useParams, useRouter } from "next/navigation"; +import { createApiClient, formatAxiosError } from "@app/lib/api"; +import { useEnvContext } from "@app/hooks/useEnvContext"; +import { toast } from "@app/hooks/useToast"; +import { Button } from "@app/components/ui/button"; +import { Input } from "@app/components/ui/input"; +import { + Credenza, + CredenzaBody, + CredenzaClose, + CredenzaContent, + CredenzaDescription, + CredenzaFooter, + CredenzaHeader, + CredenzaTitle +} from "@app/components/Credenza"; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage +} from "@app/components/ui/form"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; +import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert"; +import { InfoIcon, ExternalLink, CheckIcon } from "lucide-react"; +import PolicyTable, { PolicyRow } from "./PolicyTable"; +import { AxiosResponse } from "axios"; +import { ListOrgsResponse } from "@server/routers/org"; +import { + Popover, + PopoverContent, + PopoverTrigger +} from "@app/components/ui/popover"; +import { cn } from "@app/lib/cn"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList +} from "@app/components/ui/command"; +import { CaretSortIcon } from "@radix-ui/react-icons"; +import Link from "next/link"; +import { Textarea } from "@app/components/ui/textarea"; +import { InfoPopup } from "@app/components/ui/info-popup"; +import { GetIdpResponse } from "@server/routers/idp"; +import { + SettingsContainer, + SettingsSection, + SettingsSectionHeader, + SettingsSectionTitle, + SettingsSectionDescription, + SettingsSectionBody, + SettingsSectionFooter, + SettingsSectionForm +} from "@app/components/Settings"; + +type Organization = { + orgId: string; + name: string; +}; + +const policyFormSchema = z.object({ + orgId: z.string().min(1, { message: "Organization is required" }), + roleMapping: z.string().optional(), + orgMapping: z.string().optional() +}); + +const defaultMappingsSchema = z.object({ + defaultRoleMapping: z.string().optional(), + defaultOrgMapping: z.string().optional() +}); + +type PolicyFormValues = z.infer; +type DefaultMappingsValues = z.infer; + +export default function PoliciesPage() { + const { env } = useEnvContext(); + const api = createApiClient({ env }); + const router = useRouter(); + const { idpId } = useParams(); + + const [pageLoading, setPageLoading] = useState(true); + const [addPolicyLoading, setAddPolicyLoading] = useState(false); + const [editPolicyLoading, setEditPolicyLoading] = useState(false); + const [deletePolicyLoading, setDeletePolicyLoading] = useState(false); + const [updateDefaultMappingsLoading, setUpdateDefaultMappingsLoading] = + useState(false); + const [policies, setPolicies] = useState([]); + const [organizations, setOrganizations] = useState([]); + const [showAddDialog, setShowAddDialog] = useState(false); + const [editingPolicy, setEditingPolicy] = useState(null); + + const form = useForm({ + resolver: zodResolver(policyFormSchema), + defaultValues: { + orgId: "", + roleMapping: "", + orgMapping: "" + } + }); + + const defaultMappingsForm = useForm({ + resolver: zodResolver(defaultMappingsSchema), + defaultValues: { + defaultRoleMapping: "", + defaultOrgMapping: "" + } + }); + + const loadIdp = async () => { + try { + const res = await api.get>( + `/idp/${idpId}` + ); + if (res.status === 200) { + const data = res.data.data; + defaultMappingsForm.reset({ + defaultRoleMapping: data.idp.defaultRoleMapping || "", + defaultOrgMapping: data.idp.defaultOrgMapping || "" + }); + } + } catch (e) { + toast({ + title: "Error", + description: formatAxiosError(e), + variant: "destructive" + }); + } + }; + + const loadPolicies = async () => { + try { + const res = await api.get(`/idp/${idpId}/org`); + if (res.status === 200) { + setPolicies(res.data.data.policies); + } + } catch (e) { + toast({ + title: "Error", + description: formatAxiosError(e), + variant: "destructive" + }); + } + }; + + const loadOrganizations = async () => { + try { + const res = await api.get>("/orgs"); + if (res.status === 200) { + const existingOrgIds = policies.map((p) => p.orgId); + const availableOrgs = res.data.data.orgs.filter( + (org) => !existingOrgIds.includes(org.orgId) + ); + setOrganizations(availableOrgs); + } + } catch (e) { + toast({ + title: "Error", + description: formatAxiosError(e), + variant: "destructive" + }); + } + }; + + useEffect(() => { + async function load() { + setPageLoading(true); + await loadPolicies(); + await loadIdp(); + setPageLoading(false); + } + load(); + }, [idpId]); + + const onAddPolicy = async (data: PolicyFormValues) => { + setAddPolicyLoading(true); + try { + const res = await api.put(`/idp/${idpId}/org/${data.orgId}`, { + roleMapping: data.roleMapping, + orgMapping: data.orgMapping + }); + if (res.status === 201) { + const newPolicy = { + orgId: data.orgId, + name: + organizations.find((org) => org.orgId === data.orgId) + ?.name || "", + roleMapping: data.roleMapping, + orgMapping: data.orgMapping + }; + setPolicies([...policies, newPolicy]); + toast({ + title: "Success", + description: "Policy added successfully" + }); + setShowAddDialog(false); + form.reset(); + } + } catch (e) { + toast({ + title: "Error", + description: formatAxiosError(e), + variant: "destructive" + }); + } finally { + setAddPolicyLoading(false); + } + }; + + const onEditPolicy = async (data: PolicyFormValues) => { + if (!editingPolicy) return; + + setEditPolicyLoading(true); + try { + const res = await api.post( + `/idp/${idpId}/org/${editingPolicy.orgId}`, + { + roleMapping: data.roleMapping, + orgMapping: data.orgMapping + } + ); + if (res.status === 200) { + setPolicies( + policies.map((policy) => + policy.orgId === editingPolicy.orgId + ? { + ...policy, + roleMapping: data.roleMapping, + orgMapping: data.orgMapping + } + : policy + ) + ); + toast({ + title: "Success", + description: "Policy updated successfully" + }); + setShowAddDialog(false); + setEditingPolicy(null); + form.reset(); + } + } catch (e) { + toast({ + title: "Error", + description: formatAxiosError(e), + variant: "destructive" + }); + } finally { + setEditPolicyLoading(false); + } + }; + + const onDeletePolicy = async (orgId: string) => { + setDeletePolicyLoading(true); + try { + const res = await api.delete(`/idp/${idpId}/org/${orgId}`); + if (res.status === 200) { + setPolicies( + policies.filter((policy) => policy.orgId !== orgId) + ); + toast({ + title: "Success", + description: "Policy deleted successfully" + }); + } + } catch (e) { + toast({ + title: "Error", + description: formatAxiosError(e), + variant: "destructive" + }); + } finally { + setDeletePolicyLoading(false); + } + }; + + const onUpdateDefaultMappings = async (data: DefaultMappingsValues) => { + setUpdateDefaultMappingsLoading(true); + try { + const res = await api.post(`/idp/${idpId}/oidc`, { + defaultRoleMapping: data.defaultRoleMapping, + defaultOrgMapping: data.defaultOrgMapping + }); + if (res.status === 200) { + toast({ + title: "Success", + description: "Default mappings updated successfully" + }); + } + } catch (e) { + toast({ + title: "Error", + description: formatAxiosError(e), + variant: "destructive" + }); + } finally { + setUpdateDefaultMappingsLoading(false); + } + }; + + if (pageLoading) { + return null; + } + + return ( + <> + + + + + About Organization Policies + + + Organization policies are used to control access to + organizations based on the user's ID token. You can + specify JMESPath expressions to extract role and + organization information from the ID token. For more + information, see{" "} + + the documentation + + + + + + + + + Default Mappings (Optional) + + + The default mappings are used when when there is not + an organization policy defined for an organization. + You can specify the default role and organization + mappings to fall back to here. + + + +
+ +
+ ( + + + Default Role Mapping + + + + + + JMESPath to extract role + information from the ID + token. The result of this + expression must return the + role name as defined in the + organization as a string. + + + + )} + /> + + ( + + + Default Organization Mapping + + + + + + JMESPath to extract + organization information + from the ID token. This + expression must return thr + org ID or true for the user + to be allowed to access the + organization. + + + + )} + /> +
+
+ + + + +
+
+ + { + loadOrganizations(); + form.reset({ + orgId: "", + roleMapping: "", + orgMapping: "" + }); + setEditingPolicy(null); + setShowAddDialog(true); + }} + onEdit={(policy) => { + setEditingPolicy(policy); + form.reset({ + orgId: policy.orgId, + roleMapping: policy.roleMapping || "", + orgMapping: policy.orgMapping || "" + }); + setShowAddDialog(true); + }} + /> +
+ + { + setShowAddDialog(val); + setEditingPolicy(null); + form.reset(); + }} + > + + + + {editingPolicy + ? "Edit Organization Policy" + : "Add Organization Policy"} + + + Configure access for an organization + + + +
+ + ( + + Organization + {editingPolicy ? ( + + ) : ( + + + + + + + + + + + + No org + found. + + + {organizations.map( + ( + org + ) => ( + { + form.setValue( + "orgId", + org.orgId + ); + }} + > + + { + org.name + } + + ) + )} + + + + + + )} + + + )} + /> + + ( + + + Role Mapping Path (Optional) + + + + + + JMESPath to extract role + information from the ID token. + The result of this expression + must return the role name as + defined in the organization as a + string. + + + + )} + /> + + ( + + + Organization Mapping Path + (Optional) + + + + + + JMESPath to extract organization + information from the ID token. + This expression must return the + org ID or true for the user to + be allowed to access the + organization. + + + + )} + /> + + +
+ + + + + + +
+
+ + ); +} diff --git a/src/app/admin/idp/create/page.tsx b/src/app/admin/idp/create/page.tsx new file mode 100644 index 00000000..034cc69a --- /dev/null +++ b/src/app/admin/idp/create/page.tsx @@ -0,0 +1,523 @@ +"use client"; + +import { + SettingsContainer, + SettingsSection, + SettingsSectionBody, + SettingsSectionDescription, + SettingsSectionForm, + SettingsSectionGrid, + SettingsSectionHeader, + SettingsSectionTitle +} from "@app/components/Settings"; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage +} from "@app/components/ui/form"; +import HeaderTitle from "@app/components/SettingsSectionTitle"; +import { z } from "zod"; +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"; +import { Button } from "@app/components/ui/button"; +import { createApiClient, formatAxiosError } from "@app/lib/api"; +import { useEnvContext } from "@app/hooks/useEnvContext"; +import { toast } from "@app/hooks/useToast"; +import { useRouter } from "next/navigation"; +import { Checkbox } from "@app/components/ui/checkbox"; +import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert"; +import { InfoIcon, ExternalLink } from "lucide-react"; +import { StrategySelect } from "@app/components/StrategySelect"; +import { SwitchInput } from "@app/components/SwitchInput"; +import { Badge } from "@app/components/ui/badge"; +import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext"; + +const createIdpFormSchema = z.object({ + name: z.string().min(2, { message: "Name must be at least 2 characters." }), + type: z.enum(["oidc"]), + clientId: z.string().min(1, { message: "Client ID is required." }), + clientSecret: z.string().min(1, { message: "Client Secret is required." }), + authUrl: z.string().url({ message: "Auth URL must be a valid URL." }), + tokenUrl: z.string().url({ message: "Token URL must be a valid URL." }), + identifierPath: z + .string() + .min(1, { message: "Identifier Path is required." }), + emailPath: z.string().optional(), + namePath: z.string().optional(), + scopes: z.string().min(1, { message: "Scopes are required." }), + autoProvision: z.boolean().default(false) +}); + +type CreateIdpFormValues = z.infer; + +interface ProviderTypeOption { + id: "oidc"; + title: string; + description: string; +} + +const providerTypes: ReadonlyArray = [ + { + id: "oidc", + title: "OAuth2/OIDC", + description: "Configure an OpenID Connect identity provider" + } +]; + +export default function Page() { + const { env } = useEnvContext(); + const api = createApiClient({ env }); + const router = useRouter(); + const [createLoading, setCreateLoading] = useState(false); + const { isUnlocked } = useLicenseStatusContext(); + + const form = useForm({ + resolver: zodResolver(createIdpFormSchema), + defaultValues: { + name: "", + type: "oidc", + clientId: "", + clientSecret: "", + authUrl: "", + tokenUrl: "", + identifierPath: "sub", + namePath: "name", + emailPath: "email", + scopes: "openid profile email", + autoProvision: false + } + }); + + async function onSubmit(data: CreateIdpFormValues) { + setCreateLoading(true); + + try { + const payload = { + name: data.name, + clientId: data.clientId, + clientSecret: data.clientSecret, + authUrl: data.authUrl, + tokenUrl: data.tokenUrl, + identifierPath: data.identifierPath, + emailPath: data.emailPath, + namePath: data.namePath, + autoProvision: data.autoProvision, + scopes: data.scopes + }; + + const res = await api.put("/idp/oidc", payload); + + if (res.status === 201) { + toast({ + title: "Success", + description: "Identity provider created successfully" + }); + router.push(`/admin/idp/${res.data.data.idpId}`); + } + } catch (e) { + toast({ + title: "Error", + description: formatAxiosError(e), + variant: "destructive" + }); + } finally { + setCreateLoading(false); + } + } + + return ( + <> +
+ + +
+ + + + + + General Information + + + Configure the basic information for your identity + provider + + + + +
+ + ( + + Name + + + + + A display name for this + identity provider + + + + )} + /> + +
+ { + form.setValue( + "autoProvision", + checked + ); + }} + /> + {!isUnlocked() && ( + + Professional + + )} +
+ + When enabled, users will be + automatically created in the system upon + first login with the ability to map + users to roles and organizations. + + + +
+
+
+ + + + + Provider Type + + + Select the type of identity provider you want to + configure + + + + { + form.setValue("type", value as "oidc"); + }} + cols={3} + /> + + + + {form.watch("type") === "oidc" && ( + + + + + OAuth2/OIDC Configuration + + + Configure the OAuth2/OIDC provider endpoints + and credentials + + + +
+ + ( + + + Client ID + + + + + + The OAuth2 client ID + from your identity + provider + + + + )} + /> + + ( + + + Client Secret + + + + + + The OAuth2 client secret + from your identity + provider + + + + )} + /> + + ( + + + Authorization URL + + + + + + The OAuth2 authorization + endpoint URL + + + + )} + /> + + ( + + + Token URL + + + + + + The OAuth2 token + endpoint URL + + + + )} + /> + + + + + + + Important Information + + + After creating the identity provider, + you will need to configure the callback + URL in your identity provider's + settings. The callback URL will be + provided after successful creation. + + +
+
+ + + + + Token Configuration + + + Configure how to extract user information + from the ID token + + + +
+ + + + + About JMESPath + + + The paths below use JMESPath + syntax to extract values from + the ID token. + + Learn more about JMESPath{" "} + + + + + + ( + + + Identifier Path + + + + + + The JMESPath to the user + identifier in the ID + token + + + + )} + /> + + ( + + + Email Path (Optional) + + + + + + The JMESPath to the + user's email in the ID + token + + + + )} + /> + + ( + + + Name Path (Optional) + + + + + + The JMESPath to the + user's name in the ID + token + + + + )} + /> + + ( + + + Scopes + + + + + + Space-separated list of + OAuth2 scopes to request + + + + )} + /> + + +
+
+
+ )} +
+ +
+ + +
+ + ); +} diff --git a/src/app/admin/idp/page.tsx b/src/app/admin/idp/page.tsx new file mode 100644 index 00000000..54657c2d --- /dev/null +++ b/src/app/admin/idp/page.tsx @@ -0,0 +1,28 @@ +import { internal } from "@app/lib/api"; +import { authCookieHeader } from "@app/lib/api/cookies"; +import { AxiosResponse } from "axios"; +import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; +import IdpTable, { IdpRow } from "./AdminIdpTable"; + +export default async function IdpPage() { + let idps: IdpRow[] = []; + try { + const res = await internal.get>( + `/idp`, + await authCookieHeader() + ); + idps = res.data.data.idps; + } catch (e) { + console.error(e); + } + + return ( + <> + + + + ); +} diff --git a/src/app/admin/layout.tsx b/src/app/admin/layout.tsx index f3e55a3f..fdc6c8e7 100644 --- a/src/app/admin/layout.tsx +++ b/src/app/admin/layout.tsx @@ -1,15 +1,15 @@ import { Metadata } from "next"; -import { TopbarNav } from "@app/components/TopbarNav"; import { Users } from "lucide-react"; -import { Header } from "@app/components/Header"; import { verifySession } from "@app/lib/auth/verifySession"; import { redirect } from "next/navigation"; import { cache } from "react"; import UserProvider from "@app/providers/UserProvider"; -import { ListOrgsResponse } from "@server/routers/org"; +import { ListUserOrgsResponse } from "@server/routers/org"; import { internal } from "@app/lib/api"; import { AxiosResponse } from "axios"; import { authCookieHeader } from "@app/lib/api/cookies"; +import { Layout } from "@app/components/Layout"; +import { adminNavItems } from "../navigation"; export const dynamic = "force-dynamic"; @@ -18,19 +18,11 @@ export const metadata: Metadata = { description: "" }; -const topNavItems = [ - { - title: "All Users", - href: "/admin/users", - icon: - } -]; - interface LayoutProps { children: React.ReactNode; } -export default async function SettingsLayout(props: LayoutProps) { +export default async function AdminLayout(props: LayoutProps) { const getUser = cache(verifySession); const user = await getUser(); @@ -39,10 +31,13 @@ export default async function SettingsLayout(props: LayoutProps) { } const cookie = await authCookieHeader(); - let orgs: ListOrgsResponse["orgs"] = []; + let orgs: ListUserOrgsResponse["orgs"] = []; try { const getOrgs = cache(() => - internal.get>(`/orgs`, cookie) + internal.get>( + `/user/${user.userId}/orgs`, + cookie + ) ); const res = await getOrgs(); if (res && res.data.data.orgs) { @@ -51,21 +46,10 @@ export default async function SettingsLayout(props: LayoutProps) { } catch (e) {} return ( - <> -
-
-
- -
- -
- -
-
- -
+ + {props.children} -
- + + ); } diff --git a/src/app/admin/license/LicenseKeysDataTable.tsx b/src/app/admin/license/LicenseKeysDataTable.tsx new file mode 100644 index 00000000..98ed814a --- /dev/null +++ b/src/app/admin/license/LicenseKeysDataTable.tsx @@ -0,0 +1,147 @@ +// This file is licensed under the Fossorial Commercial License. +// Unauthorized use, copying, modification, or distribution is strictly prohibited. +// +// Copyright (c) 2025 Fossorial LLC. All rights reserved. + +"use client"; + +import { ColumnDef } from "@tanstack/react-table"; +import { DataTable } from "@app/components/ui/data-table"; +import { Button } from "@app/components/ui/button"; +import { Badge } from "@app/components/ui/badge"; +import { LicenseKeyCache } from "@server/license/license"; +import { ArrowUpDown } from "lucide-react"; +import moment from "moment"; +import CopyToClipboard from "@app/components/CopyToClipboard"; + +type LicenseKeysDataTableProps = { + licenseKeys: LicenseKeyCache[]; + onDelete: (key: LicenseKeyCache) => void; + onCreate: () => void; +}; + +function obfuscateLicenseKey(key: string): string { + if (key.length <= 8) return key; + const firstPart = key.substring(0, 4); + const lastPart = key.substring(key.length - 4); + return `${firstPart}••••••••••••••••••••${lastPart}`; +} + +export function LicenseKeysDataTable({ + licenseKeys, + onDelete, + onCreate +}: LicenseKeysDataTableProps) { + const columns: ColumnDef[] = [ + { + accessorKey: "licenseKey", + header: ({ column }) => { + return ( + + ); + }, + cell: ({ row }) => { + const licenseKey = row.original.licenseKey; + return ( + + ); + } + }, + { + accessorKey: "valid", + header: ({ column }) => { + return ( + + ); + }, + cell: ({ row }) => { + return row.original.valid ? "Yes" : "No"; + } + }, + { + accessorKey: "type", + header: ({ column }) => { + return ( + + ); + }, + cell: ({ row }) => { + const type = row.original.type; + const label = + type === "SITES" ? "Additional Sites" : "Host License"; + const variant = type === "SITES" ? "secondary" : "default"; + return row.original.valid ? ( + {label} + ) : null; + } + }, + { + accessorKey: "numSites", + header: ({ column }) => { + return ( + + ); + } + }, + { + id: "delete", + cell: ({ row }) => ( +
+ +
+ ) + } + ]; + + return ( + + ); +} diff --git a/src/app/admin/license/components/SitePriceCalculator.tsx b/src/app/admin/license/components/SitePriceCalculator.tsx new file mode 100644 index 00000000..56228769 --- /dev/null +++ b/src/app/admin/license/components/SitePriceCalculator.tsx @@ -0,0 +1,166 @@ +// This file is licensed under the Fossorial Commercial License. +// Unauthorized use, copying, modification, or distribution is strictly prohibited. +// +// Copyright (c) 2025 Fossorial LLC. All rights reserved. + +import { useState } from "react"; +import { Button } from "@app/components/ui/button"; +import { MinusCircle, PlusCircle } from "lucide-react"; +import { + Credenza, + CredenzaBody, + CredenzaClose, + CredenzaContent, + CredenzaDescription, + CredenzaFooter, + CredenzaHeader, + CredenzaTitle +} from "@app/components/Credenza"; + +type SitePriceCalculatorProps = { + isOpen: boolean; + onOpenChange: (open: boolean) => void; + mode: "license" | "additional-sites"; +}; + +export function SitePriceCalculator({ + isOpen, + onOpenChange, + mode +}: SitePriceCalculatorProps) { + const [siteCount, setSiteCount] = useState(3); + const pricePerSite = 5; + const licenseFlatRate = 125; + + const incrementSites = () => { + setSiteCount((prev) => prev + 1); + }; + + const decrementSites = () => { + setSiteCount((prev) => (prev > 1 ? prev - 1 : 1)); + }; + + function continueToPayment() { + if (mode === "license") { + // open in new tab + window.open( + `https://payment.fossorial.io/buy/dab98d3d-9976-49b1-9e55-1580059d833f?quantity=${siteCount}`, + "_blank" + ); + } else { + window.open( + `https://payment.fossorial.io/buy/2b881c36-ea5d-4c11-8652-9be6810a054f?quantity=${siteCount}`, + "_blank" + ); + } + } + + const totalCost = + mode === "license" + ? licenseFlatRate + siteCount * pricePerSite + : siteCount * pricePerSite; + + return ( + + + + + {mode === "license" + ? "Purchase License" + : "Purchase Additional Sites"} + + + Choose how many sites you want to{" "} + {mode === "license" + ? "purchase a license for. You can always add more sites later." + : "add to your existing license."} + + + +
+
+
+ Number of Sites +
+
+ + + {siteCount} + + +
+
+ +
+ {mode === "license" && ( +
+ + License fee: + + + ${licenseFlatRate.toFixed(2)} + +
+ )} +
+ + Price per site: + + + ${pricePerSite.toFixed(2)} + +
+
+ + Number of sites: + + {siteCount} +
+
+ Total: + ${totalCost.toFixed(2)} / mo +
+ +

+ For the most up-to-date pricing, please visit + our{" "} + + pricing page + + . +

+
+
+
+ + + + + + +
+
+ ); +} diff --git a/src/app/admin/license/page.tsx b/src/app/admin/license/page.tsx new file mode 100644 index 00000000..316200c4 --- /dev/null +++ b/src/app/admin/license/page.tsx @@ -0,0 +1,522 @@ +// This file is licensed under the Fossorial Commercial License. +// Unauthorized use, copying, modification, or distribution is strictly prohibited. +// +// Copyright (c) 2025 Fossorial LLC. All rights reserved. + +"use client"; + +import { useState, useEffect } from "react"; +import { LicenseKeyCache } from "@server/license/license"; +import { createApiClient } from "@app/lib/api"; +import { useEnvContext } from "@app/hooks/useEnvContext"; +import { toast } from "@app/hooks/useToast"; +import { formatAxiosError } from "@app/lib/api"; +import { LicenseKeysDataTable } from "./LicenseKeysDataTable"; +import { AxiosResponse } from "axios"; +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 { zodResolver } from "@hookform/resolvers/zod"; +import { useForm } from "react-hook-form"; +import { z } from "zod"; +import { + Credenza, + CredenzaBody, + CredenzaClose, + CredenzaContent, + CredenzaDescription, + CredenzaFooter, + CredenzaHeader, + CredenzaTitle +} from "@app/components/Credenza"; +import { useRouter } from "next/navigation"; +import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext"; +import { + SettingsContainer, + SettingsSectionTitle as SSTitle, + SettingsSection, + SettingsSectionDescription, + SettingsSectionGrid, + SettingsSectionHeader, + SettingsSectionFooter +} from "@app/components/Settings"; +import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; +import { Badge } from "@app/components/ui/badge"; +import { Check, ShieldCheck, ShieldOff } from "lucide-react"; +import CopyTextBox from "@app/components/CopyTextBox"; +import { Progress } from "@app/components/ui/progress"; +import { MinusCircle, PlusCircle } from "lucide-react"; +import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog"; +import { SitePriceCalculator } from "./components/SitePriceCalculator"; +import Link from "next/link"; +import { Checkbox } from "@app/components/ui/checkbox"; + +const formSchema = z.object({ + licenseKey: z + .string() + .nonempty({ message: "License key is required" }) + .max(255), + agreeToTerms: z.boolean().refine((val) => val === true, { + message: "You must agree to the license terms" + }) +}); + +function obfuscateLicenseKey(key: string): string { + if (key.length <= 8) return key; + const firstPart = key.substring(0, 4); + const lastPart = key.substring(key.length - 4); + return `${firstPart}••••••••••••••••••••${lastPart}`; +} + +export default function LicensePage() { + const api = createApiClient(useEnvContext()); + const [rows, setRows] = useState([]); + const [isCreateModalOpen, setIsCreateModalOpen] = useState(false); + const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); + const [selectedLicenseKey, setSelectedLicenseKey] = + useState(null); + const router = useRouter(); + const { licenseStatus, updateLicenseStatus } = useLicenseStatusContext(); + const [hostLicense, setHostLicense] = useState(null); + const [isPurchaseModalOpen, setIsPurchaseModalOpen] = useState(false); + const [purchaseMode, setPurchaseMode] = useState< + "license" | "additional-sites" + >("license"); + + // Separate loading states for different actions + const [isInitialLoading, setIsInitialLoading] = useState(true); + const [isActivatingLicense, setIsActivatingLicense] = useState(false); + const [isDeletingLicense, setIsDeletingLicense] = useState(false); + const [isRecheckingLicense, setIsRecheckingLicense] = useState(false); + + const form = useForm>({ + resolver: zodResolver(formSchema), + defaultValues: { + licenseKey: "", + agreeToTerms: false + } + }); + + useEffect(() => { + async function load() { + setIsInitialLoading(true); + await loadLicenseKeys(); + setIsInitialLoading(false); + } + load(); + }, []); + + async function loadLicenseKeys() { + try { + const response = + await api.get>( + "/license/keys" + ); + const keys = response.data.data; + setRows(keys); + const hostKey = keys.find((key) => key.type === "HOST"); + if (hostKey) { + setHostLicense(hostKey.licenseKey); + } else { + setHostLicense(null); + } + } catch (e) { + toast({ + title: "Failed to load license keys", + description: formatAxiosError( + e, + "An error occurred loading license keys" + ) + }); + } + } + + async function deleteLicenseKey(key: string) { + try { + setIsDeletingLicense(true); + const encodedKey = encodeURIComponent(key); + const res = await api.delete(`/license/${encodedKey}`); + if (res.data.data) { + updateLicenseStatus(res.data.data); + } + await loadLicenseKeys(); + toast({ + title: "License key deleted", + description: "The license key has been deleted" + }); + setIsDeleteModalOpen(false); + } catch (e) { + toast({ + title: "Failed to delete license key", + description: formatAxiosError( + e, + "An error occurred deleting license key" + ) + }); + } finally { + setIsDeletingLicense(false); + } + } + + async function recheck() { + try { + setIsRecheckingLicense(true); + const res = await api.post(`/license/recheck`); + if (res.data.data) { + updateLicenseStatus(res.data.data); + } + await loadLicenseKeys(); + toast({ + title: "License keys rechecked", + description: "All license keys have been rechecked" + }); + } catch (e) { + toast({ + title: "Failed to recheck license keys", + description: formatAxiosError( + e, + "An error occurred rechecking license keys" + ) + }); + } finally { + setIsRecheckingLicense(false); + } + } + + async function onSubmit(values: z.infer) { + try { + setIsActivatingLicense(true); + const res = await api.post("/license/activate", { + licenseKey: values.licenseKey + }); + if (res.data.data) { + updateLicenseStatus(res.data.data); + } + + toast({ + title: "License key activated", + description: "The license key has been successfully activated." + }); + + setIsCreateModalOpen(false); + form.reset(); + await loadLicenseKeys(); + } catch (e) { + toast({ + variant: "destructive", + title: "Failed to activate license key", + description: formatAxiosError( + e, + "An error occurred while activating the license key." + ) + }); + } finally { + setIsActivatingLicense(false); + } + } + + if (isInitialLoading) { + return null; + } + + return ( + <> + { + setIsPurchaseModalOpen(val); + }} + mode={purchaseMode} + /> + + { + setIsCreateModalOpen(val); + form.reset(); + }} + > + + + Activate License Key + + Enter a license key to activate it. + + + +
+ + ( + + License Key + + + + + + )} + /> + ( + + + + +
+ + By checking this box, you + confirm that you have read + and agree to the license + terms corresponding to the + tier associated with your + license key. +
+ + View Fossorial + Commercial License & + Subscription Terms + +
+ +
+
+ )} + /> + + +
+ + + + + + +
+
+ + {selectedLicenseKey && ( + { + setIsDeleteModalOpen(val); + setSelectedLicenseKey(null); + }} + dialog={ +
+

+ Are you sure you want to delete the license key{" "} + + {obfuscateLicenseKey( + selectedLicenseKey.licenseKey + )} + + ? +

+

+ + This will remove the license key and all + associated permissions granted by it. + +

+

+ To confirm, please type the license key below. +

+
+ } + buttonText="Confirm Delete License Key" + onConfirm={async () => + deleteLicenseKey(selectedLicenseKey.licenseKeyEncrypted) + } + string={selectedLicenseKey.licenseKey} + title="Delete License Key" + /> + )} + + + + + + + + Host License + + Manage the main license key for the host. + + +
+
+ {licenseStatus?.isLicenseValid ? ( +
+
+ + {licenseStatus?.tier === + "PROFESSIONAL" + ? "Professional License" + : licenseStatus?.tier === + "ENTERPRISE" + ? "Enterprise License" + : "Licensed"} +
+
+ ) : ( +
+
+ Not Licensed +
+
+ )} +
+ {licenseStatus?.hostId && ( +
+
+ Host ID +
+ +
+ )} + {hostLicense && ( +
+
+ License Key +
+ +
+ )} +
+ + + +
+ + + Sites Usage + + View the number of sites using this license. + + +
+
+
+ {licenseStatus?.usedSites || 0}{" "} + {licenseStatus?.usedSites === 1 + ? "site" + : "sites"}{" "} + in system +
+
+ {licenseStatus?.maxSites && ( +
+
+ + {licenseStatus.usedSites || 0} of{" "} + {licenseStatus.maxSites} sites used + + + {Math.round( + ((licenseStatus.usedSites || + 0) / + licenseStatus.maxSites) * + 100 + )} + % + +
+ +
+ )} +
+ + {!licenseStatus?.isHostLicensed ? ( + <> + + + ) : ( + <> + + + )} + +
+
+ { + setSelectedLicenseKey(key); + setIsDeleteModalOpen(true); + }} + onCreate={() => setIsCreateModalOpen(true)} + /> +
+ + ); +} diff --git a/src/app/admin/users/AdminUsersDataTable.tsx b/src/app/admin/users/AdminUsersDataTable.tsx index b125c539..7532a8cc 100644 --- a/src/app/admin/users/AdminUsersDataTable.tsx +++ b/src/app/admin/users/AdminUsersDataTable.tsx @@ -2,29 +2,8 @@ import { ColumnDef, - flexRender, - getCoreRowModel, - useReactTable, - getPaginationRowModel, - SortingState, - getSortedRowModel, - ColumnFiltersState, - getFilteredRowModel } from "@tanstack/react-table"; - -import { - Table, - TableBody, - TableCell, - TableContainer, - TableHead, - TableHeader, - TableRow -} from "@/components/ui/table"; -import { useState } from "react"; -import { Input } from "@app/components/ui/input"; -import { DataTablePagination } from "@app/components/DataTablePagination"; -import { Search } from "lucide-react"; +import { DataTable } from "@app/components/ui/data-table"; interface DataTableProps { columns: ColumnDef[]; @@ -35,107 +14,13 @@ export function UsersDataTable({ columns, data }: DataTableProps) { - const [sorting, setSorting] = useState([]); - const [columnFilters, setColumnFilters] = useState([]); - - const table = useReactTable({ - data, - columns, - getCoreRowModel: getCoreRowModel(), - getPaginationRowModel: getPaginationRowModel(), - onSortingChange: setSorting, - getSortedRowModel: getSortedRowModel(), - onColumnFiltersChange: setColumnFilters, - getFilteredRowModel: getFilteredRowModel(), - initialState: { - pagination: { - pageSize: 20, - pageIndex: 0 - } - }, - state: { - sorting, - columnFilters - } - }); - return ( -
-
-
- - table - .getColumn("name") - ?.setFilterValue(event.target.value) - } - className="w-full pl-8" - /> - -
-
- - - - {table.getHeaderGroups().map((headerGroup) => ( - - {headerGroup.headers.map((header) => { - return ( - - {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() - )} - - ))} - - )) - ) : ( - - - This server has no users. - - - )} - -
-
-
- -
-
+ ); } diff --git a/src/app/admin/users/AdminUsersTable.tsx b/src/app/admin/users/AdminUsersTable.tsx index 0ead375d..68ad2790 100644 --- a/src/app/admin/users/AdminUsersTable.tsx +++ b/src/app/admin/users/AdminUsersTable.tsx @@ -14,7 +14,12 @@ import { useEnvContext } from "@app/hooks/useEnvContext"; export type GlobalUserRow = { id: string; - email: string; + name: string | null; + username: string; + email: string | null; + type: string; + idpId: number | null; + idpName: string; dateCreated: string; }; @@ -67,6 +72,22 @@ export default function UsersTable({ users }: Props) { ); } }, + { + accessorKey: "username", + header: ({ column }) => { + return ( + + ); + } + }, { accessorKey: "email", header: ({ column }) => { @@ -83,6 +104,38 @@ export default function UsersTable({ users }: Props) { ); } }, + { + accessorKey: "name", + header: ({ column }) => { + return ( + + ); + } + }, + { + accessorKey: "idpName", + header: ({ column }) => { + return ( + + ); + } + }, { id: "actions", cell: ({ row }) => { @@ -120,8 +173,12 @@ export default function UsersTable({ users }: Props) {

Are you sure you want to permanently delete{" "} - {selected?.email || selected?.id} from - the server? + + {selected?.email || + selected?.name || + selected?.username} + {" "} + from the server?

@@ -133,14 +190,16 @@ export default function UsersTable({ users }: Props) {

- To confirm, please type the email of the user + To confirm, please type the name of the user below.

} buttonText="Confirm Delete User" onConfirm={async () => deleteUser(selected!.id)} - string={selected.email} + string={ + selected.email || selected.name || selected.username + } title="Delete User from Server" /> )} diff --git a/src/app/admin/users/page.tsx b/src/app/admin/users/page.tsx index a8ab19a2..6e2290cb 100644 --- a/src/app/admin/users/page.tsx +++ b/src/app/admin/users/page.tsx @@ -4,6 +4,8 @@ import { AxiosResponse } from "axios"; import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; import { AdminListUsersResponse } from "@server/routers/user/adminListUsers"; import UsersTable, { GlobalUserRow } from "./AdminUsersTable"; +import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert"; +import { InfoIcon } from "lucide-react"; type PageProps = { params: Promise<{ orgId: string }>; @@ -27,6 +29,11 @@ export default async function UsersPage(props: PageProps) { return { id: row.id, email: row.email, + name: row.name, + username: row.username, + type: row.type, + idpId: row.idpId, + idpName: row.idpName || "Internal", dateCreated: row.dateCreated, serverAdmin: row.serverAdmin }; @@ -38,6 +45,13 @@ export default async function UsersPage(props: PageProps) { title="Manage All Users" description="View and manage all users in the system" /> + + + About User Management + + This table displays all root user objects in the system. Each user may belong to multiple organizations. Removing a user from an organization does not delete their root user object - they will remain in the system. To completely remove a user from the system, you must delete their root user object using the delete action in this table. + + ); diff --git a/src/app/auth/idp/[idpId]/oidc/callback/ValidateOidcToken.tsx b/src/app/auth/idp/[idpId]/oidc/callback/ValidateOidcToken.tsx new file mode 100644 index 00000000..87a7683f --- /dev/null +++ b/src/app/auth/idp/[idpId]/oidc/callback/ValidateOidcToken.tsx @@ -0,0 +1,125 @@ +"use client"; + +import { useEnvContext } from "@app/hooks/useEnvContext"; +import { createApiClient, formatAxiosError } from "@app/lib/api"; +import { ValidateOidcUrlCallbackResponse } from "@server/routers/idp"; +import { AxiosResponse } from "axios"; +import { useRouter } from "next/navigation"; +import { useEffect, useState } from "react"; +import { + Card, + CardHeader, + CardTitle, + CardContent, + CardDescription +} from "@/components/ui/card"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { Loader2, CheckCircle2, AlertCircle } from "lucide-react"; +import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext"; + +type ValidateOidcTokenParams = { + orgId: string; + idpId: string; + code: string | undefined; + expectedState: string | undefined; + stateCookie: string | undefined; + idp: { name: string }; +}; + +export default function ValidateOidcToken(props: ValidateOidcTokenParams) { + const { env } = useEnvContext(); + const api = createApiClient({ env }); + const router = useRouter(); + + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const { licenseStatus, isLicenseViolation } = useLicenseStatusContext(); + + useEffect(() => { + async function validate() { + setLoading(true); + + console.log("Validating OIDC token", { + code: props.code, + expectedState: props.expectedState, + stateCookie: props.stateCookie + }); + + if (isLicenseViolation()) { + await new Promise((resolve) => setTimeout(resolve, 5000)); + } + + try { + const res = await api.post< + AxiosResponse + >(`/auth/idp/${props.idpId}/oidc/validate-callback`, { + code: props.code, + state: props.expectedState, + storedState: props.stateCookie + }); + + console.log("Validate OIDC token response", res.data); + + const redirectUrl = res.data.data.redirectUrl; + + if (!redirectUrl) { + router.push("/"); + } + + setLoading(false); + await new Promise((resolve) => setTimeout(resolve, 100)); + + if (redirectUrl.startsWith("http")) { + window.location.href = res.data.data.redirectUrl; // this is validated by the parent using this component + } else { + router.push(res.data.data.redirectUrl); + } + } catch (e) { + setError(formatAxiosError(e, "Error validating OIDC token")); + } finally { + setLoading(false); + } + } + + validate(); + }, []); + + return ( +
+ + + Connecting to {props.idp.name} + Validating your identity + + + {loading && ( +
+ + Connecting... +
+ )} + {!loading && !error && ( +
+ + Connected +
+ )} + {error && ( + + + + + There was a problem connecting to{" "} + {props.idp.name}. Please contact your + administrator. + + {error} + + + )} +
+
+
+ ); +} diff --git a/src/app/auth/idp/[idpId]/oidc/callback/page.tsx b/src/app/auth/idp/[idpId]/oidc/callback/page.tsx new file mode 100644 index 00000000..cba74790 --- /dev/null +++ b/src/app/auth/idp/[idpId]/oidc/callback/page.tsx @@ -0,0 +1,42 @@ +import { cookies } from "next/headers"; +import ValidateOidcToken from "./ValidateOidcToken"; +import { idp } from "@server/db/schemas"; +import db from "@server/db"; +import { eq } from "drizzle-orm"; + +export default async function Page(props: { + params: Promise<{ orgId: string; idpId: string }>; + searchParams: Promise<{ + code: string; + state: string; + }>; +}) { + const params = await props.params; + const searchParams = await props.searchParams; + + const allCookies = await cookies(); + const stateCookie = allCookies.get("p_oidc_state")?.value; + + // query db directly in server component because just need the name + const [idpRes] = await db + .select({ name: idp.name }) + .from(idp) + .where(eq(idp.idpId, parseInt(params.idpId!))); + + if (!idpRes) { + return
IdP not found
; + } + + return ( + <> + + + ); +} diff --git a/src/app/auth/layout.tsx b/src/app/auth/layout.tsx index 2f0728ea..9a149f75 100644 --- a/src/app/auth/layout.tsx +++ b/src/app/auth/layout.tsx @@ -18,7 +18,7 @@ export default async function AuthLayout({ children }: AuthLayoutProps) { const user = await getUser(); return ( - <> +
{user && (
@@ -27,9 +27,11 @@ export default async function AuthLayout({ children }: AuthLayoutProps) { )} -
- {children} +
+
+ {children} +
- +
); } diff --git a/src/app/auth/login/DashboardLoginForm.tsx b/src/app/auth/login/DashboardLoginForm.tsx index 715a0fb9..b15dd518 100644 --- a/src/app/auth/login/DashboardLoginForm.tsx +++ b/src/app/auth/login/DashboardLoginForm.tsx @@ -8,7 +8,7 @@ import { CardTitle } from "@/components/ui/card"; import { createApiClient } from "@app/lib/api"; -import LoginForm from "@app/components/LoginForm"; +import LoginForm, { LoginFormIDP } from "@app/components/LoginForm"; import { useEnvContext } from "@app/hooks/useEnvContext"; import { useRouter } from "next/navigation"; import { useEffect } from "react"; @@ -17,10 +17,12 @@ import { cleanRedirect } from "@app/lib/cleanRedirect"; type DashboardLoginFormProps = { redirect?: string; + idps?: LoginFormIDP[]; }; export default function DashboardLoginForm({ - redirect + redirect, + idps }: DashboardLoginFormProps) { const router = useRouter(); // const api = createApiClient(useEnvContext()); @@ -51,12 +53,15 @@ export default function DashboardLoginForm({

Welcome to Pangolin

-

Log in to get started

+

+ Log in to get started +

{ if (redirect) { const safe = cleanRedirect(redirect); diff --git a/src/app/auth/login/page.tsx b/src/app/auth/login/page.tsx index e10c18ce..8227c1a0 100644 --- a/src/app/auth/login/page.tsx +++ b/src/app/auth/login/page.tsx @@ -6,6 +6,9 @@ import DashboardLoginForm from "./DashboardLoginForm"; import { Mail } from "lucide-react"; import { pullEnv } from "@app/lib/pullEnv"; import { cleanRedirect } from "@app/lib/cleanRedirect"; +import db from "@server/db"; +import { idp } from "@server/db/schemas"; +import { LoginFormIDP } from "@app/components/LoginForm"; export const dynamic = "force-dynamic"; @@ -31,10 +34,16 @@ export default async function Page(props: { redirectUrl = cleanRedirect(searchParams.redirect as string); } + const idps = await db.select().from(idp); + const loginIdps = idps.map((idp) => ({ + idpId: idp.idpId, + name: idp.name + })) as LoginFormIDP[]; + return ( <> {isInvite && ( -
+

@@ -48,7 +57,7 @@ export default async function Page(props: {

)} - + {(!signUpDisabled || isInvite) && (

diff --git a/src/app/auth/resource/[resourceId]/ResourceAuthPortal.tsx b/src/app/auth/resource/[resourceId]/ResourceAuthPortal.tsx index 2480cd67..c7eca2c7 100644 --- a/src/app/auth/resource/[resourceId]/ResourceAuthPortal.tsx +++ b/src/app/auth/resource/[resourceId]/ResourceAuthPortal.tsx @@ -33,7 +33,7 @@ import { useRouter } from "next/navigation"; import { Alert, AlertDescription } from "@app/components/ui/alert"; import { formatAxiosError } from "@app/lib/api"; import { AxiosResponse } from "axios"; -import LoginForm from "@app/components/LoginForm"; +import LoginForm, { LoginFormIDP } from "@app/components/LoginForm"; import { AuthWithPasswordResponse, AuthWithWhitelistResponse @@ -81,6 +81,7 @@ type ResourceAuthPortalProps = { id: number; }; redirect: string; + idps?: LoginFormIDP[]; }; export default function ResourceAuthPortal(props: ResourceAuthPortalProps) { @@ -376,31 +377,37 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) { index={ 0 } + obscured /> @@ -490,7 +497,8 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) { className={`${numMethods <= 1 ? "mt-0" : ""}`} > await handleSSOAuth() } diff --git a/src/app/auth/resource/[resourceId]/page.tsx b/src/app/auth/resource/[resourceId]/page.tsx index 006faa45..af31de98 100644 --- a/src/app/auth/resource/[resourceId]/page.tsx +++ b/src/app/auth/resource/[resourceId]/page.tsx @@ -13,6 +13,9 @@ import ResourceNotFound from "./ResourceNotFound"; import ResourceAccessDenied from "./ResourceAccessDenied"; import AccessToken from "./AccessToken"; import { pullEnv } from "@app/lib/pullEnv"; +import { LoginFormIDP } from "@app/components/LoginForm"; +import db from "@server/db"; +import { idp } from "@server/db/schemas"; export default async function ResourceAuthPage(props: { params: Promise<{ resourceId: number }>; @@ -84,7 +87,6 @@ export default async function ResourceAuthPage(props: { redirect(redirectUrl); } - // convert the dashboard token into a resource session token let userIsUnauthorized = false; if (user && authInfo.sso) { @@ -128,6 +130,12 @@ export default async function ResourceAuthPage(props: { ); } + const idps = await db.select().from(idp); + const loginIdps = idps.map((idp) => ({ + idpId: idp.idpId, + name: idp.name + })) as LoginFormIDP[]; + return ( <> {userIsUnauthorized && isSSOOnly ? ( @@ -148,6 +156,7 @@ export default async function ResourceAuthPage(props: { id: authInfo.resourceId }} redirect={redirectUrl} + idps={loginIdps} />

)} diff --git a/src/app/auth/signup/page.tsx b/src/app/auth/signup/page.tsx index 69e023da..7f2205b4 100644 --- a/src/app/auth/signup/page.tsx +++ b/src/app/auth/signup/page.tsx @@ -50,7 +50,7 @@ export default async function Page(props: { return ( <> {isInvite && ( -
+

diff --git a/src/app/auth/verify-email/page.tsx b/src/app/auth/verify-email/page.tsx index 033fa75d..10ad809f 100644 --- a/src/app/auth/verify-email/page.tsx +++ b/src/app/auth/verify-email/page.tsx @@ -36,7 +36,7 @@ export default async function Page(props: { return ( <> diff --git a/src/app/components/LicenseViolation.tsx b/src/app/components/LicenseViolation.tsx new file mode 100644 index 00000000..75d544d3 --- /dev/null +++ b/src/app/components/LicenseViolation.tsx @@ -0,0 +1,67 @@ +// This file is licensed under the Fossorial Commercial License. +// Unauthorized use, copying, modification, or distribution is strictly prohibited. +// +// Copyright (c) 2025 Fossorial LLC. All rights reserved. + +"use client"; + +import { Button } from "@app/components/ui/button"; +import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext"; +import { useState } from "react"; + +export default function LicenseViolation() { + const { licenseStatus } = useLicenseStatusContext(); + const [isDismissed, setIsDismissed] = useState(false); + + if (!licenseStatus || isDismissed) return null; + + // Show invalid license banner + if (licenseStatus.isHostLicensed && !licenseStatus.isLicenseValid) { + return ( +
+
+

+ Invalid or expired license keys detected. Follow license + terms to continue using all features. +

+ +
+
+ ); + } + + // Show usage violation banner + if ( + licenseStatus.maxSites && + licenseStatus.usedSites && + licenseStatus.usedSites > licenseStatus.maxSites + ) { + return ( +
+
+

+ License Violation: This server is using{" "} + {licenseStatus.usedSites} sites which exceeds its + licensed limit of {licenseStatus.maxSites} sites. Follow + license terms to continue using all features. +

+ +
+
+ ); + } + + return null; +} diff --git a/src/app/components/SupporterMessage.tsx b/src/app/components/SupporterMessage.tsx new file mode 100644 index 00000000..f21cd52c --- /dev/null +++ b/src/app/components/SupporterMessage.tsx @@ -0,0 +1,38 @@ +"use client"; + +import React from "react"; +import confetti from "canvas-confetti"; +import { Star } from "lucide-react"; + +export default function SupporterMessage({ tier }: { tier: string }) { + return ( +
+ { + // Get the bounding box of the element + const rect = ( + e.target as HTMLElement + ).getBoundingClientRect(); + + // Trigger confetti centered on the word "Pangolin" + confetti({ + particleCount: 100, + spread: 70, + origin: { + x: (rect.left + rect.width / 2) / window.innerWidth, + y: rect.top / window.innerHeight + }, + colors: ["#FFA500", "#FF4500", "#FFD700"] + }); + }} + > + Pangolin + + +
+ Thank you for supporting Pangolin as a {tier}! +
+
+ ); +} diff --git a/src/app/globals.css b/src/app/globals.css index cb32e061..e2a6e31a 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -1,73 +1,120 @@ -@tailwind base; -@tailwind components; -@tailwind utilities; +@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'; +@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%); +} + +.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%); +} + +@theme inline { + --color-background: var(--background); + --color-foreground: var(--foreground); + + --color-card: var(--card); + --color-card-foreground: var(--card-foreground); + + --color-popover: var(--popover); + --color-popover-foreground: var(--popover-foreground); + + --color-primary: var(--primary); + --color-primary-foreground: var(--primary-foreground); + + --color-secondary: var(--secondary); + --color-secondary-foreground: var(--secondary-foreground); + + --color-muted: var(--muted); + --color-muted-foreground: var(--muted-foreground); + + --color-accent: var(--accent); + --color-accent-foreground: var(--accent-foreground); + + --color-destructive: var(--destructive); + --color-destructive-foreground: var(--destructive-foreground); + + --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); + + --radius-lg: var(--radius); + --radius-md: calc(var(--radius) - 2px); + --radius-sm: calc(var(--radius) - 4px); +} @layer base { - :root { - --background: 0 0% 100%; - --foreground: 20 0.0% 10.0%; - --card: 0 0% 100%; - --card-foreground: 20 0.0% 10.0%; - --popover: 0 0% 100%; - --popover-foreground: 20 0.0% 10.0%; - --primary: 24.6 95% 53.1%; - --primary-foreground: 60 9.1% 97.8%; - --secondary: 60 4.8% 95.9%; - --secondary-foreground: 24 9.8% 10%; - --muted: 60 4.8% 85.0%; - --muted-foreground: 25 5.3% 44.7%; - --accent: 60 4.8% 90%; - --accent-foreground: 24 9.8% 10%; - --destructive: 0 84.2% 60.2%; - --destructive-foreground: 60 9.1% 97.8%; - --border: 20 5.9% 80%; - --input: 20 5.9% 75%; - --ring: 24.6 95% 53.1%; - --radius: 0.75rem; - --chart-1: 12 76% 61%; - --chart-2: 173 58% 39%; - --chart-3: 197 37% 24%; - --chart-4: 43 74% 66%; - --chart-5: 27 87% 67%; - } - - .dark { - --background: 20 0.0% 10.0%; - --foreground: 60 9.1% 97.8%; - --card: 20 0.0% 10.0%; - --card-foreground: 60 9.1% 97.8%; - --popover: 20 0.0% 10.0%; - --popover-foreground: 60 9.1% 97.8%; - --primary: 20.5 90.2% 48.2%; - --primary-foreground: 60 9.1% 97.8%; - --secondary: 12 6.5% 15.0%; - --secondary-foreground: 60 9.1% 97.8%; - --muted: 12 6.5% 25.0%; - --muted-foreground: 24 5.4% 63.9%; - --accent: 12 2.5% 15.0%; - --accent-foreground: 60 9.1% 97.8%; - --destructive: 0 72.2% 50.6%; - --destructive-foreground: 60 9.1% 97.8%; - --border: 12 6.5% 30.0%; - --input: 12 6.5% 35.0%; - --ring: 20.5 90.2% 48.2%; - --chart-1: 220 70% 50%; - --chart-2: 160 60% 45%; - --chart-3: 30 80% 55%; - --chart-4: 280 65% 60%; - --chart-5: 340 75% 55%; + *, + ::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; + } } - diff --git a/src/app/layout.tsx b/src/app/layout.tsx index bc322572..e0089bc5 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,24 +1,24 @@ import type { Metadata } from "next"; import "./globals.css"; -import { Figtree, Inter } from "next/font/google"; +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 { Separator } from "@app/components/ui/separator"; import { pullEnv } from "@app/lib/pullEnv"; -import { BookOpenText, ExternalLink } from "lucide-react"; -import Image from "next/image"; import SupportStatusProvider from "@app/providers/SupporterStatusProvider"; -import { createApiClient, internal, priv } from "@app/lib/api"; +import { priv } from "@app/lib/api"; 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"; export const metadata: Metadata = { title: `Dashboard - Pangolin`, description: "" }; -export const dynamic = 'force-dynamic'; +export const dynamic = "force-dynamic"; // const font = Figtree({ subsets: ["latin"] }); const font = Inter({ subsets: ["latin"] }); @@ -34,17 +34,21 @@ export default async function RootLayout({ visible: true } as any; - const res = await priv.get< - AxiosResponse - >("supporter-key/visible"); + const res = await priv.get>( + "supporter-key/visible" + ); supporterData.visible = res.data.data.visible; supporterData.tier = res.data.data.tier; - const version = env.app.version; + const licenseStatusRes = + await priv.get>( + "/license/status" + ); + const licenseStatus = licenseStatusRes.data.data; return ( - + - - {/* Main content */} -
- {children} -
- - {/* Footer */} - -
+ +
diff --git a/src/app/navigation.tsx b/src/app/navigation.tsx new file mode 100644 index 00000000..b05bf30b --- /dev/null +++ b/src/app/navigation.tsx @@ -0,0 +1,103 @@ +import { SidebarNavItem } from "@app/components/SidebarNav"; +import { + Home, + Settings, + Users, + Link as LinkIcon, + Waypoints, + Combine, + Fingerprint, + KeyRound, + TicketCheck +} from "lucide-react"; + +export const orgLangingNavItems: SidebarNavItem[] = [ + { + title: "Overview", + href: "/{orgId}", + icon: + } +]; + +export const rootNavItems: SidebarNavItem[] = [ + { + title: "Home", + href: "/", + icon: + } +]; + +export const orgNavItems: SidebarNavItem[] = [ + { + title: "Sites", + href: "/{orgId}/settings/sites", + icon: + }, + { + title: "Resources", + href: "/{orgId}/settings/resources", + icon: + }, + { + title: "Access Control", + href: "/{orgId}/settings/access", + icon: , + autoExpand: true, + children: [ + { + title: "Users", + href: "/{orgId}/settings/access/users", + children: [ + { + title: "Invitations", + href: "/{orgId}/settings/access/invitations" + } + ] + }, + { + title: "Roles", + href: "/{orgId}/settings/access/roles" + } + ] + }, + { + title: "Shareable Links", + href: "/{orgId}/settings/share-links", + icon: + }, + { + title: "API Keys", + href: "/{orgId}/settings/api-keys", + icon: , + showProfessional: true + }, + { + title: "Settings", + href: "/{orgId}/settings/general", + icon: + } +]; + +export const adminNavItems: SidebarNavItem[] = [ + { + title: "All Users", + href: "/admin/users", + icon: + }, + { + title: "API Keys", + href: "/admin/api-keys", + icon: , + showProfessional: true + }, + { + title: "Identity Providers", + href: "/admin/idp", + icon: + }, + { + title: "License", + href: "/admin/license", + icon: + } +]; diff --git a/src/app/page.tsx b/src/app/page.tsx index 8e9c044d..6cab7cbd 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,17 +1,16 @@ import { internal } from "@app/lib/api"; import { authCookieHeader } from "@app/lib/api/cookies"; -import ProfileIcon from "@app/components/ProfileIcon"; import { verifySession } from "@app/lib/auth/verifySession"; import UserProvider from "@app/providers/UserProvider"; -import { ListOrgsResponse } from "@server/routers/org"; +import { ListUserOrgsResponse } from "@server/routers/org"; import { AxiosResponse } from "axios"; -import { ArrowUpRight } from "lucide-react"; -import Link from "next/link"; import { redirect } from "next/navigation"; import { cache } from "react"; import OrganizationLanding from "./components/OrganizationLanding"; import { pullEnv } from "@app/lib/pullEnv"; import { cleanRedirect } from "@app/lib/cleanRedirect"; +import { Layout } from "@app/components/Layout"; +import { rootNavItems } from "./navigation"; export const dynamic = "force-dynamic"; @@ -37,10 +36,7 @@ export default async function Page(props: { } } - if ( - !user.emailVerified && - env.flags.emailVerificationRequired - ) { + if (!user.emailVerified && env.flags.emailVerificationRequired) { if (params.redirect) { const safe = cleanRedirect(params.redirect); redirect(`/auth/verify-email?redirect=${safe}`); @@ -49,10 +45,10 @@ export default async function Page(props: { } } - let orgs: ListOrgsResponse["orgs"] = []; + let orgs: ListUserOrgsResponse["orgs"] = []; try { - const res = await internal.get>( - `/orgs`, + const res = await internal.get>( + `/user/${user.userId}/orgs`, await authCookieHeader() ); @@ -62,35 +58,26 @@ export default async function Page(props: { } catch (e) {} if (!orgs.length) { - if ( - !env.flags.disableUserCreateOrg || - user.serverAdmin - ) { + if (!env.flags.disableUserCreateOrg || user.serverAdmin) { redirect("/setup"); } } return ( - <> -
- {user && ( - -
- -
-
- )} - + +
({ name: org.name, id: org.orgId }))} />
-
- + + ); } diff --git a/src/app/setup/layout.tsx b/src/app/setup/layout.tsx index 0e2583f9..e254037d 100644 --- a/src/app/setup/layout.tsx +++ b/src/app/setup/layout.tsx @@ -1,3 +1,4 @@ +import { Layout } from "@app/components/Layout"; import ProfileIcon from "@app/components/ProfileIcon"; import { verifySession } from "@app/lib/auth/verifySession"; import { pullEnv } from "@app/lib/pullEnv"; @@ -5,6 +6,11 @@ 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"; +import { authCookieHeader } from "@app/lib/api/cookies"; export const metadata: Metadata = { title: `Setup - Pangolin`, @@ -27,27 +33,37 @@ export default async function SetupLayout({ redirect("/?redirect=/setup"); } - if ( - !(!env.flags.disableUserCreateOrg || user.serverAdmin) - ) { + if (!(!env.flags.disableUserCreateOrg || user.serverAdmin)) { redirect("/"); } + let orgs: ListUserOrgsResponse["orgs"] = []; + try { + const getOrgs = cache(async () => + internal.get>( + `/user/${user.userId}/orgs`, + await authCookieHeader() + ) + ); + const res = await getOrgs(); + if (res && res.data.data.orgs) { + orgs = res.data.data.orgs; + } + } catch (e) {} + return ( <> -
- {user && ( - -
- -
-
- )} - -
- {children} -
-
+ + +
+ {children} +
+
+
); } diff --git a/src/app/setup/page.tsx b/src/app/setup/page.tsx index 1e416d26..5420748c 100644 --- a/src/app/setup/page.tsx +++ b/src/app/setup/page.tsx @@ -61,6 +61,9 @@ export default function StepperForm() { const router = useRouter(); const checkOrgIdAvailability = useCallback(async (value: string) => { + if (loading) { + return; + } try { const res = await api.get(`/org/checkId`, { params: { diff --git a/src/components/Breadcrumbs.tsx b/src/components/Breadcrumbs.tsx new file mode 100644 index 00000000..9740759a --- /dev/null +++ b/src/components/Breadcrumbs.tsx @@ -0,0 +1,68 @@ +"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 = segment; + + // // Format labels + // if (segment === "settings") { + // label = "Settings"; + // } else if (segment === "sites") { + // label = "Sites"; + // } else if (segment === "resources") { + // label = "Resources"; + // } else if (segment === "access") { + // label = "Access Control"; + // } else if (segment === "general") { + // label = "General"; + // } else if (segment === "share-links") { + // label = "Shareable Links"; + // } else if (segment === "users") { + // label = "Users"; + // } else if (segment === "roles") { + // label = "Roles"; + // } else if (segment === "invitations") { + // label = "Invitations"; + // } else if (segment === "proxy") { + // label = "proxy"; + // } else if (segment === "authentication") { + // label = "Authentication"; + // } + + return { label, href }; + }); + + return ( + + ); +} diff --git a/src/components/ConfirmDeleteDialog.tsx b/src/components/ConfirmDeleteDialog.tsx index 5ed13d51..a928ed60 100644 --- a/src/components/ConfirmDeleteDialog.tsx +++ b/src/components/ConfirmDeleteDialog.tsx @@ -105,7 +105,7 @@ export default function InviteUserForm({ {title} -
{dialog}
+
{dialog}
(null); const copyToClipboard = async () => { if (textRef.current) { try { - await navigator.clipboard.writeText( - textRef.current.textContent || "" - ); + await navigator.clipboard.writeText(text); setIsCopied(true); setTimeout(() => setIsCopied(false), 2000); } catch (err) { @@ -28,7 +34,7 @@ export default function CopyTextBox({ return (
-                {text}
+                {displayText || text}
             
- - - - - - No organizations found. - - {(!env.flags.disableUserCreateOrg || - user.serverAdmin) && ( - <> - - - { - router.push( - "/setup" - ); - }} - > - - New Organization - - - - - - )} - - - {orgs.map((org) => ( - { - router.push( - `/${org.orgId}/settings` - ); - }} - > - - {org.name} - - ))} - - - - - - )} -
-

- - ); -} - -export default Header; diff --git a/src/components/HorizontalTabs.tsx b/src/components/HorizontalTabs.tsx new file mode 100644 index 00000000..eb590eb0 --- /dev/null +++ b/src/components/HorizontalTabs.tsx @@ -0,0 +1,102 @@ +"use client"; + +import React from "react"; +import Link from "next/link"; +import { useParams, usePathname } from "next/navigation"; +import { cn } from "@app/lib/cn"; +import { buttonVariants } from "@/components/ui/button"; +import { Badge } from "@app/components/ui/badge"; +import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext"; + +export type HorizontalTabs = Array<{ + title: string; + href: string; + icon?: React.ReactNode; + showProfessional?: boolean; +}>; + +interface HorizontalTabsProps { + children: React.ReactNode; + items: HorizontalTabs; + disabled?: boolean; +} + +export function HorizontalTabs({ + children, + items, + disabled = false +}: HorizontalTabsProps) { + const pathname = usePathname(); + const params = useParams(); + const { licenseStatus, isUnlocked } = useLicenseStatusContext(); + + function hydrateHref(href: string) { + return href + .replace("{orgId}", params.orgId as string) + .replace("{resourceId}", params.resourceId as string) + .replace("{niceId}", params.niceId as string) + .replace("{userId}", params.userId as string) + .replace("{apiKeyId}", params.apiKeyId as string); + } + + return ( +
+
+
+
+ {items.map((item) => { + const hydratedHref = hydrateHref(item.href); + const isActive = + pathname.startsWith(hydratedHref) && + !pathname.includes("create"); + const isProfessional = + item.showProfessional && !isUnlocked(); + const isDisabled = + disabled || (isProfessional && !isUnlocked()); + + return ( + { + if (isDisabled) { + e.preventDefault(); + } + }} + tabIndex={isDisabled ? -1 : undefined} + aria-disabled={isDisabled} + > +
+ {item.icon && item.icon} + {item.title} + {isProfessional && ( + + Professional + + )} +
+ + ); + })} +
+
+
+
{children}
+
+ ); +} diff --git a/src/components/Layout.tsx b/src/components/Layout.tsx new file mode 100644 index 00000000..12cb09d6 --- /dev/null +++ b/src/components/Layout.tsx @@ -0,0 +1,240 @@ +"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 { 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"; + +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; + }>; + }>; + showSidebar?: boolean; + showBreadcrumbs?: boolean; + showHeader?: boolean; + showTopBar?: boolean; +} + +export function Layout({ + children, + orgId, + orgs, + navItems = [], + showSidebar = true, + showBreadcrumbs = true, + showHeader = true, + showTopBar = true +}: LayoutProps) { + const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false); + const { env } = useEnvContext(); + const pathname = usePathname(); + const isAdminPage = pathname?.startsWith("/admin"); + const { user } = useUserContext(); + + return ( +
+ {/* Full width header */} + {showHeader && ( +
+
+
+ {showSidebar && ( +
+ + + + + + + Navigation Menu + + + Main navigation menu for the + application + +
+
+ + setIsMobileMenuOpen( + false + ) + } + /> +
+ {!isAdminPage && + user.serverAdmin && ( +
+ + setIsMobileMenuOpen( + false + ) + } + > + + Server Admin + +
+ )} +
+
+ + + {env?.app?.version && ( +
+ v{env.app.version} +
+ )} +
+
+
+
+ )} + + Pangolin Logo + + {showBreadcrumbs && ( +
+ +
+ )} +
+ {showTopBar && ( +
+
+ + Documentation + +
+
+ +
+
+ )} +
+ {showBreadcrumbs && ( +
+ +
+ )} +
+ )} + +
+ {/* Desktop Sidebar */} + {showSidebar && ( +
+
+
+ +
+ {!isAdminPage && user.serverAdmin && ( +
+ + + Server Admin + +
+ )} +
+
+ + +
+
+ + Open Source + + +
+ {env?.app?.version && ( +
+ v{env.app.version} +
+ )} +
+
+
+ )} + + {/* Main content */} +
+
+
+ {children} +
+
+
+
+
+ ); +} diff --git a/src/components/LoginForm.tsx b/src/components/LoginForm.tsx index 987e9c7f..1594e577 100644 --- a/src/components/LoginForm.tsx +++ b/src/components/LoginForm.tsx @@ -25,7 +25,7 @@ import { Alert, AlertDescription } from "@/components/ui/alert"; import { LoginResponse } from "@server/routers/auth"; import { useRouter } from "next/navigation"; import { AxiosResponse } from "axios"; -import { formatAxiosError } from "@app/lib/api";; +import { formatAxiosError } from "@app/lib/api"; import { LockIcon } from "lucide-react"; import { createApiClient } from "@app/lib/api"; import { useEnvContext } from "@app/hooks/useEnvContext"; @@ -37,11 +37,19 @@ import { } from "./ui/input-otp"; import Link from "next/link"; import { REGEXP_ONLY_DIGITS_AND_CHARS } from "input-otp"; -import Image from 'next/image' +import Image from "next/image"; +import { GenerateOidcUrlResponse } from "@server/routers/idp"; +import { Separator } from "./ui/separator"; + +export type LoginFormIDP = { + idpId: number; + name: string; +}; type LoginFormProps = { redirect?: string; onLogin?: () => void | Promise; + idps?: LoginFormIDP[]; }; const formSchema = z.object({ @@ -55,7 +63,7 @@ const mfaSchema = z.object({ code: z.string().length(6, { message: "Invalid code" }) }); -export default function LoginForm({ redirect, onLogin }: LoginFormProps) { +export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) { const router = useRouter(); const { env } = useEnvContext(); @@ -64,6 +72,7 @@ export default function LoginForm({ redirect, onLogin }: LoginFormProps) { const [error, setError] = useState(null); const [loading, setLoading] = useState(false); + const hasIdp = idps && idps.length > 0; const [mfaRequested, setMfaRequested] = useState(false); @@ -130,60 +139,83 @@ export default function LoginForm({ redirect, onLogin }: LoginFormProps) { setLoading(false); } + async function loginWithIdp(idpId: number) { + try { + const res = await api.post>( + `/auth/idp/${idpId}/oidc/generate-url`, + { + redirectUrl: redirect || "/" + } + ); + + console.log(res); + + if (!res) { + setError("An error occurred while logging in"); + return; + } + + const data = res.data.data; + window.location.href = data.redirectUrl; + } catch (e) { + console.error(formatAxiosError(e)); + } + } + return (
{!mfaRequested && ( - - - ( - - Email - - - - - - )} - /> - -
+ <> + + ( - Password + Email - + )} /> -
- - Forgot your password? - +
+ ( + + Password + + + + + + )} + /> + +
+ + Forgot your password? + +
-
- - + + + )} {mfaRequested && ( @@ -193,7 +225,8 @@ export default function LoginForm({ redirect, onLogin }: LoginFormProps) { Two-Factor Authentication

- Enter the code from your authenticator app or one of your single-use backup codes. + Enter the code from your authenticator app or one of + your single-use backup codes.

@@ -274,16 +307,47 @@ export default function LoginForm({ redirect, onLogin }: LoginFormProps) { )} {!mfaRequested && ( - + <> + + + {hasIdp && ( + <> +
+
+ +
+
+ + Or continue with + +
+
+ + {idps.map((idp) => ( + + ))} + + )} + )} {mfaRequested && ( diff --git a/src/components/OrgSelector.tsx b/src/components/OrgSelector.tsx new file mode 100644 index 00000000..626156cf --- /dev/null +++ b/src/components/OrgSelector.tsx @@ -0,0 +1,124 @@ +"use client"; + +import { Button } from "@app/components/ui/button"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, + CommandSeparator +} from "@app/components/ui/command"; +import { + Popover, + PopoverContent, + PopoverTrigger +} from "@app/components/ui/popover"; +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 { useRouter } from "next/navigation"; +import { useState } from "react"; +import { useUserContext } from "@app/hooks/useUserContext"; + +interface OrgSelectorProps { + orgId?: string; + orgs?: ListUserOrgsResponse["orgs"]; +} + +export function OrgSelector({ orgId, orgs }: OrgSelectorProps) { + const { user } = useUserContext(); + const [open, setOpen] = useState(false); + const router = useRouter(); + const { env } = useEnvContext(); + + return ( + + + + + + + + + No organizations found. + + {(!env.flags.disableUserCreateOrg || + user.serverAdmin) && ( + <> + + + { + router.push( + "/setup" + ); + }} + > + + New Organization + + + + + + )} + + + {orgs?.map((org) => ( + { + router.push( + `/${org.orgId}/settings` + ); + }} + > + + {org.name} + + ))} + + + + + + ); +} diff --git a/src/components/PermissionsSelectBox.tsx b/src/components/PermissionsSelectBox.tsx new file mode 100644 index 00000000..a6f9addd --- /dev/null +++ b/src/components/PermissionsSelectBox.tsx @@ -0,0 +1,235 @@ +// This file is licensed under the Fossorial Commercial License. +// Unauthorized use, copying, modification, or distribution is strictly prohibited. +// +// Copyright (c) 2025 Fossorial LLC. All rights reserved. + +"use client"; + +import { CheckboxWithLabel } from "@app/components/ui/checkbox"; +import { + InfoSection, + InfoSectionContent, + InfoSections, + InfoSectionTitle +} from "@app/components/InfoSection"; + +type PermissionsSelectBoxProps = { + root?: boolean; + selectedPermissions: Record; + onChange: (updated: Record) => void; +}; + +function getActionsCategories(root: boolean) { + const actionsByCategory: Record> = { + Organization: { + "Get Organization": "getOrg", + "Update Organization": "updateOrg", + "Get Organization User": "getOrgUser", + "List Organization Domains": "listOrgDomains", + "Check Org ID": "checkOrgId", + }, + + Site: { + "Create Site": "createSite", + "Delete Site": "deleteSite", + "Get Site": "getSite", + "List Sites": "listSites", + "Update Site": "updateSite", + "List Allowed Site Roles": "listSiteRoles" + }, + + Resource: { + "Create Resource": "createResource", + "Delete Resource": "deleteResource", + "Get Resource": "getResource", + "List Resources": "listResources", + "Update Resource": "updateResource", + "List Resource Users": "listResourceUsers", + "Set Resource Users": "setResourceUsers", + "Set Allowed Resource Roles": "setResourceRoles", + "List Allowed Resource Roles": "listResourceRoles", + "Set Resource Password": "setResourcePassword", + "Set Resource Pincode": "setResourcePincode", + "Set Resource Email Whitelist": "setResourceWhitelist", + "Get Resource Email Whitelist": "getResourceWhitelist" + }, + + Target: { + "Create Target": "createTarget", + "Delete Target": "deleteTarget", + "Get Target": "getTarget", + "List Targets": "listTargets", + "Update Target": "updateTarget" + }, + + Role: { + "Create Role": "createRole", + "Delete Role": "deleteRole", + "Get Role": "getRole", + "List Roles": "listRoles", + "Update Role": "updateRole", + "List Allowed Role Resources": "listRoleResources" + }, + + User: { + "Invite User": "inviteUser", + "Remove User": "removeUser", + "List Users": "listUsers", + "Add User Role": "addUserRole" + }, + + "Access Token": { + "Generate Access Token": "generateAccessToken", + "Delete Access Token": "deleteAcessToken", + "List Access Tokens": "listAccessTokens" + }, + + "Resource Rule": { + "Create Resource Rule": "createResourceRule", + "Delete Resource Rule": "deleteResourceRule", + "List Resource Rules": "listResourceRules", + "Update Resource Rule": "updateResourceRule" + } + }; + + if (root) { + actionsByCategory["Organization"] = { + "List Organizations": "listOrgs", + "Check ID": "checkOrgId", + "Create Organization": "createOrg", + "Delete Organization": "deleteOrg", + "List API Keys": "listApiKeys", + "List API Key Actions": "listApiKeyActions", + "Set API Key Allowed Actions": "setApiKeyActions", + "Create API Key": "createApiKey", + "Delete API Key": "deleteApiKey", + ...actionsByCategory["Organization"] + }; + + actionsByCategory["Identity Provider (IDP)"] = { + "Create IDP": "createIdp", + "Update IDP": "updateIdp", + "Delete IDP": "deleteIdp", + "List IDP": "listIdps", + "Get IDP": "getIdp", + "Create IDP Org Policy": "createIdpOrg", + "Delete IDP Org Policy": "deleteIdpOrg", + "List IDP Orgs": "listIdpOrgs", + "Update IDP Org": "updateIdpOrg" + }; + } + + return actionsByCategory; +} + +export default function PermissionsSelectBox({ + root, + selectedPermissions, + onChange +}: PermissionsSelectBoxProps) { + const actionsByCategory = getActionsCategories(root ?? false); + + const togglePermission = (key: string, checked: boolean) => { + onChange({ + ...selectedPermissions, + [key]: checked + }); + }; + + const areAllCheckedInCategory = (actions: Record) => { + return Object.values(actions).every( + (action) => selectedPermissions[action] + ); + }; + + const toggleAllInCategory = ( + actions: Record, + value: boolean + ) => { + const updated = { ...selectedPermissions }; + Object.values(actions).forEach((action) => { + updated[action] = value; + }); + onChange(updated); + }; + + const allActions = Object.values(actionsByCategory).flatMap(Object.values); + const allPermissionsChecked = allActions.every( + (action) => selectedPermissions[action] + ); + + const toggleAllPermissions = (checked: boolean) => { + const updated: Record = {}; + allActions.forEach((action) => { + updated[action] = checked; + }); + onChange(updated); + }; + + return ( + <> +
+ + toggleAllPermissions(checked as boolean) + } + /> +
+ + {Object.entries(actionsByCategory).map( + ([category, actions]) => { + const allChecked = areAllCheckedInCategory(actions); + return ( + + {category} + +
+ + toggleAllInCategory( + actions, + checked as boolean + ) + } + /> + {Object.entries(actions).map( + ([label, value]) => ( + + togglePermission( + value, + checked as boolean + ) + } + /> + ) + )} +
+
+
+ ); + } + )} +
+ + ); +} diff --git a/src/components/ProfessionalContentOverlay.tsx b/src/components/ProfessionalContentOverlay.tsx new file mode 100644 index 00000000..cd484a2b --- /dev/null +++ b/src/components/ProfessionalContentOverlay.tsx @@ -0,0 +1,42 @@ +// This file is licensed under the Fossorial Commercial License. +// Unauthorized use, copying, modification, or distribution is strictly prohibited. +// +// Copyright (c) 2025 Fossorial LLC. All rights reserved. + +"use client"; + +import { cn } from "@app/lib/cn"; + +type ProfessionalContentOverlayProps = { + children: React.ReactNode; + isProfessional?: boolean; +}; + +export function ProfessionalContentOverlay({ + children, + isProfessional = false +}: ProfessionalContentOverlayProps) { + return ( +
+ {isProfessional && ( +
+
+

+ Professional Edition Required +

+

+ This feature is only available in the Professional + Edition. +

+
+
+ )} + {children} +
+ ); +} diff --git a/src/components/ProfileIcon.tsx b/src/components/ProfileIcon.tsx index 9a132b60..55b939f0 100644 --- a/src/components/ProfileIcon.tsx +++ b/src/components/ProfileIcon.tsx @@ -22,6 +22,7 @@ import { useUserContext } from "@app/hooks/useUserContext"; import Disable2FaForm from "./Disable2FaForm"; import Enable2FaForm from "./Enable2FaForm"; import SupporterStatus from "./SupporterStatus"; +import { UserType } from "@server/types/UserTypes"; export default function ProfileIcon() { const { setTheme, theme } = useTheme(); @@ -38,7 +39,9 @@ export default function ProfileIcon() { const [openDisable2fa, setOpenDisable2fa] = useState(false); function getInitials() { - return user.email.substring(0, 1).toUpperCase(); + return (user.email || user.name || user.username) + .substring(0, 1) + .toUpperCase(); } function handleThemeChange(theme: "light" | "dark" | "system") { @@ -66,7 +69,10 @@ export default function ProfileIcon() { -
+
+ + {user.email || user.name || user.username} +
- {user.serverAdmin && ( + {user.serverAdmin ? (

Server Admin

+ ) : ( +

+ {user.idpName || "Internal"} +

)} - {!user.twoFactorEnabled && ( - setOpenEnable2fa(true)} - > - Enable Two-factor - + {user?.type === UserType.Internal && ( + <> + {!user.twoFactorEnabled && ( + setOpenEnable2fa(true)} + > + Enable Two-factor + + )} + {user.twoFactorEnabled && ( + setOpenDisable2fa(true)} + > + Disable Two-factor + + )} + + )} - {user.twoFactorEnabled && ( - setOpenDisable2fa(true)} - > - Disable Two-factor - - )} - Theme {(["light", "dark", "system"] as const).map( (themeOption) => ( @@ -150,12 +164,6 @@ export default function ProfileIcon() { - - {user.email} - -
- -
); diff --git a/src/components/Settings.tsx b/src/components/Settings.tsx index c540c748..7fa689f8 100644 --- a/src/components/Settings.tsx +++ b/src/components/Settings.tsx @@ -1,31 +1,69 @@ export function SettingsContainer({ children }: { children: React.ReactNode }) { - return
{children}
+ return
{children}
; } export function SettingsSection({ children }: { children: React.ReactNode }) { - return
{children}
+ return
{children}
; } -export function SettingsSectionHeader({ children }: { children: React.ReactNode }) { - return
{children}
+export function SettingsSectionHeader({ + children +}: { + children: React.ReactNode; +}) { + return
{children}
; } -export function SettingsSectionForm({ children }: { children: React.ReactNode }) { - return
{children}
+export function SettingsSectionForm({ + children +}: { + children: React.ReactNode; +}) { + return
{children}
; } -export function SettingsSectionTitle({ children }: { children: React.ReactNode }) { - return

{children}

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

+ {children} +

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

{children}

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

{children}

; } -export function SettingsSectionBody({ children }: { children: React.ReactNode }) { - return
{children}
+export function SettingsSectionBody({ + children +}: { + children: React.ReactNode; +}) { + return
{children}
; } -export function SettingsSectionFooter({ children }: { children: React.ReactNode }) { - return
{children}
+export function SettingsSectionFooter({ + children +}: { + children: React.ReactNode; +}) { + return
{children}
; +} + +export function SettingsSectionGrid({ + children, + cols +}: { + children: React.ReactNode; + cols: number; +}) { + return
{children}
; } diff --git a/src/components/SidebarNav.tsx b/src/components/SidebarNav.tsx index 542327bf..d6de9615 100644 --- a/src/components/SidebarNav.tsx +++ b/src/components/SidebarNav.tsx @@ -1,31 +1,34 @@ "use client"; -import React, { useEffect } from "react"; +import React, { useState, useEffect } from "react"; import Link from "next/link"; -import { useParams, usePathname, useRouter } from "next/navigation"; +import { useParams, usePathname } from "next/navigation"; import { cn } from "@app/lib/cn"; -import { buttonVariants } from "@/components/ui/button"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; +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"; -interface SidebarNavProps extends React.HTMLAttributes { - items: { - href: string; - title: string; - icon?: React.ReactNode; - }[]; +export interface SidebarNavItem { + href: string; + title: string; + icon?: React.ReactNode; + children?: SidebarNavItem[]; + autoExpand?: boolean; + showProfessional?: boolean; +} + +export interface SidebarNavProps extends React.HTMLAttributes { + items: SidebarNavItem[]; disabled?: boolean; + onItemClick?: () => void; } export function SidebarNav({ className, items, disabled = false, + onItemClick, ...props }: SidebarNavProps) { const pathname = usePathname(); @@ -34,25 +37,33 @@ 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(); - const [selectedValue, setSelectedValue] = React.useState(getSelectedValue()); + function findAutoExpandedAndActivePath( + items: SidebarNavItem[], + parentHrefs: string[] = [] + ) { + items.forEach((item) => { + const hydratedHref = hydrateHref(item.href); + const currentPath = [...parentHrefs, hydratedHref]; - useEffect(() => { - setSelectedValue(getSelectedValue()); - }, [usePathname()]); + if (item.autoExpand || pathname.startsWith(hydratedHref)) { + currentPath.forEach((href) => autoExpanded.add(href)); + } - const router = useRouter(); - - const handleSelectChange = (value: string) => { - if (!disabled) { - router.push(value); + if (item.children) { + findAutoExpandedAndActivePath(item.children, currentPath); + } + }); } - }; - function getSelectedValue() { - const item = items.find((item) => hydrateHref(item.href) === pathname); - return hydrateHref(item?.href || ""); - } + findAutoExpandedAndActivePath(items); + return autoExpanded; + }); + const { licenseStatus, isUnlocked } = useLicenseStatusContext(); + + const { user } = useUserContext(); function hydrateHref(val: string): string { return val @@ -62,68 +73,116 @@ export function SidebarNav({ .replace("{userId}", userId); } - return ( -
-
- -
- -
+
+ { + if (isDisabled) { + e.preventDefault(); + } else if (onItemClick) { + onItemClick(); + } + }} + tabIndex={isDisabled ? -1 : undefined} + aria-disabled={isDisabled} + > +
+ {item.icon && ( + + {item.icon} + + )} + {item.title} +
+ {isProfessional && ( + + Professional + + )} + + {hasChildren && ( + + )} +
+
+ {hasChildren && isExpanded && ( +
+ {renderItems(item.children || [], level + 1)} +
+ )} +
+ ); + }); + } + + return ( + ); } diff --git a/src/components/StrategySelect.tsx b/src/components/StrategySelect.tsx index f36f13c9..f6a899f8 100644 --- a/src/components/StrategySelect.tsx +++ b/src/components/StrategySelect.tsx @@ -4,38 +4,39 @@ import { cn } from "@app/lib/cn"; import { RadioGroup, RadioGroupItem } from "./ui/radio-group"; import { useState } from "react"; -interface StrategyOption { - id: string; +interface StrategyOption { + id: TValue; title: string; description: string; - disabled?: boolean; // New optional property + disabled?: boolean; } -interface StrategySelectProps { - options: StrategyOption[]; - defaultValue?: string; - onChange?: (value: string) => void; +interface StrategySelectProps { + options: ReadonlyArray>; + defaultValue?: TValue; + onChange?: (value: TValue) => void; cols?: number; } -export function StrategySelect({ +export function StrategySelect({ options, defaultValue, onChange, cols -}: StrategySelectProps) { - const [selected, setSelected] = useState(defaultValue); +}: StrategySelectProps) { + const [selected, setSelected] = useState(defaultValue); return ( { - setSelected(value); - onChange?.(value); + onValueChange={(value: string) => { + const typedValue = value as TValue; + setSelected(typedValue); + onChange?.(typedValue); }} className={`grid md:grid-cols-${cols ? cols : 1} gap-4`} > - {options.map((option) => ( + {options.map((option: StrategyOption) => (