mirror of
https://github.com/fosrl/pangolin.git
synced 2025-08-01 00:24:38 +02:00
Merge branch 'dev' into auth-providers-clients
This commit is contained in:
commit
752c474983
156 changed files with 12954 additions and 3559 deletions
32
LICENSE
32
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
|
||||
|
||||
|
|
|
@ -136,7 +136,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
|
||||
|
||||
|
|
|
@ -52,6 +52,7 @@ esbuild
|
|||
bundle: true,
|
||||
outfile: argv.out,
|
||||
format: "esm",
|
||||
minify: true,
|
||||
banner: {
|
||||
js: banner,
|
||||
},
|
||||
|
|
|
@ -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"]
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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=
|
||||
|
|
203
install/main.go
203
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()
|
||||
}
|
||||
|
||||
// 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)
|
||||
if useNewStyle {
|
||||
cmd = exec.Command("docker", append([]string{"compose"}, args...)...)
|
||||
} else {
|
||||
cmd = exec.Command("docker-compose", args...)
|
||||
}
|
||||
|
||||
// Start containers
|
||||
fmt.Printf("Using %s command to start containers...\n", getCommandString(useNewStyle))
|
||||
if err := executeCommand("-f", "docker-compose.yml", "up", "-d"); err != nil {
|
||||
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)
|
||||
}
|
||||
|
||||
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)
|
||||
fmt.Println("Restarting 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", "restart", container); err != nil {
|
||||
return fmt.Errorf("failed to restart %s container: %v", container, err)
|
||||
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)
|
||||
}
|
|
@ -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 |
|
||||
|
|
310
internationalization/tr.md
Normal file
310
internationalization/tr.md
Normal file
|
@ -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<br> | Temel WireGuard<br> | |
|
||||
| Compatible with all WireGuard clients<br> | Tüm WireGuard istemcileriyle uyumlu<br> | |
|
||||
| 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 | |
|
2337
package-lock.json
generated
2337
package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
@ -33,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-scroll-area": "1.2.3",
|
||||
"@radix-ui/react-select": "2.1.4",
|
||||
|
@ -57,6 +58,7 @@
|
|||
"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",
|
||||
|
@ -90,6 +92,7 @@
|
|||
"swagger-ui-express": "^5.0.1",
|
||||
"tailwind-merge": "2.6.0",
|
||||
"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",
|
||||
|
@ -104,6 +107,7 @@
|
|||
"@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",
|
||||
|
|
|
@ -6,6 +6,7 @@ import createHttpError from "http-errors";
|
|||
import HttpCode from "@server/types/HttpCode";
|
||||
|
||||
export enum ActionsEnum {
|
||||
createOrgUser = "createOrgUser",
|
||||
listOrgs = "listOrgs",
|
||||
listUserOrgs = "listUserOrgs",
|
||||
createOrg = "createOrg",
|
||||
|
@ -82,7 +83,14 @@ export enum ActionsEnum {
|
|||
deleteIdpOrg = "deleteIdpOrg",
|
||||
listIdpOrgs = "listIdpOrgs",
|
||||
updateIdpOrg = "updateIdpOrg",
|
||||
checkOrgId = "checkOrgId"
|
||||
checkOrgId = "checkOrgId",
|
||||
createApiKey = "createApiKey",
|
||||
deleteApiKey = "deleteApiKey",
|
||||
setApiKeyActions = "setApiKeyActions",
|
||||
setApiKeyOrgs = "setApiKeyOrgs",
|
||||
listApiKeyActions = "listApiKeyActions",
|
||||
listApiKeys = "listApiKeys",
|
||||
getApiKey = "getApiKey"
|
||||
}
|
||||
|
||||
export async function checkUserActionPermission(
|
||||
|
|
0
server/db/schemas/hostMeta.ts
Normal file
0
server/db/schemas/hostMeta.ts
Normal file
|
@ -85,7 +85,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", {
|
||||
|
@ -107,7 +112,7 @@ export const exitNodes = sqliteTable("exitNodes", {
|
|||
name: text("name").notNull(),
|
||||
address: text("address").notNull(), // this is the address of the wireguard interface in gerbil
|
||||
endpoint: text("endpoint").notNull(), // this is how to reach gerbil externally - gets put into the wireguard config
|
||||
publicKey: text("pubicKey").notNull(),
|
||||
publicKey: text("publicKey").notNull(),
|
||||
listenPort: integer("listenPort").notNull(),
|
||||
reachableAt: text("reachableAt") // this is the internal address of the gerbil http server for command control
|
||||
});
|
||||
|
@ -529,6 +534,57 @@ export const idpOidcConfig = sqliteTable("idpOidcConfig", {
|
|||
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<typeof orgs>;
|
||||
export type User = InferSelectModel<typeof users>;
|
||||
export type Site = InferSelectModel<typeof sites>;
|
||||
|
@ -571,3 +627,6 @@ export type UserClient = InferSelectModel<typeof userClients>;
|
|||
export type Domain = InferSelectModel<typeof domains>;
|
||||
export type SupporterKey = InferSelectModel<typeof supporterKey>;
|
||||
export type Idp = InferSelectModel<typeof idp>;
|
||||
export type ApiKey = InferSelectModel<typeof apiKeys>;
|
||||
export type ApiKeyAction = InferSelectModel<typeof apiKeyActions>;
|
||||
export type ApiKeyOrg = InferSelectModel<typeof apiKeyOrg>;
|
||||
|
|
|
@ -4,7 +4,9 @@ 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();
|
||||
|
@ -14,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
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -25,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[];
|
||||
|
|
112
server/integrationApiServer.ts
Normal file
112
server/integrationApiServer.ts
Normal file
|
@ -0,0 +1,112 @@
|
|||
// 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 { csrfProtectionMiddleware } from "./middlewares/csrfProtection";
|
||||
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(csrfProtectionMiddleware);
|
||||
}
|
||||
|
||||
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" }]
|
||||
});
|
||||
}
|
|
@ -13,6 +13,7 @@ import stoi from "./stoi";
|
|||
import db from "@server/db";
|
||||
import { SupporterKey, supporterKey } from "@server/db/schemas";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { license } from "@server/license/license";
|
||||
|
||||
const portSchema = z.number().positive().gt(0).lte(65535);
|
||||
|
||||
|
@ -59,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),
|
||||
|
@ -95,14 +100,7 @@ const configSchema = z.object({
|
|||
.string()
|
||||
.optional()
|
||||
.transform(getEnvOrYaml("SERVER_SECRET"))
|
||||
.pipe(
|
||||
z
|
||||
.string()
|
||||
.min(
|
||||
32,
|
||||
"SERVER_SECRET must be at least 32 characters long"
|
||||
)
|
||||
)
|
||||
.pipe(z.string().min(8))
|
||||
}),
|
||||
traefik: z.object({
|
||||
http_entrypoint: z.string(),
|
||||
|
@ -270,13 +268,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;
|
||||
}
|
||||
|
@ -322,7 +327,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: {
|
||||
|
|
|
@ -1,40 +1,12 @@
|
|||
import * as crypto from "crypto";
|
||||
|
||||
const ALGORITHM = "aes-256-gcm";
|
||||
import CryptoJS from "crypto-js";
|
||||
|
||||
export function encrypt(value: string, key: string): string {
|
||||
const iv = crypto.randomBytes(12);
|
||||
const keyBuffer = Buffer.from(key, "base64"); // assuming base64 input
|
||||
|
||||
const cipher = crypto.createCipheriv(ALGORITHM, keyBuffer, iv);
|
||||
|
||||
const encrypted = Buffer.concat([
|
||||
cipher.update(value, "utf8"),
|
||||
cipher.final()
|
||||
]);
|
||||
const authTag = cipher.getAuthTag();
|
||||
|
||||
return [
|
||||
iv.toString("base64"),
|
||||
encrypted.toString("base64"),
|
||||
authTag.toString("base64")
|
||||
].join(":");
|
||||
const ciphertext = CryptoJS.AES.encrypt(value, key).toString();
|
||||
return ciphertext;
|
||||
}
|
||||
|
||||
export function decrypt(encryptedValue: string, key: string): string {
|
||||
const [ivB64, encryptedB64, authTagB64] = encryptedValue.split(":");
|
||||
|
||||
const iv = Buffer.from(ivB64, "base64");
|
||||
const encrypted = Buffer.from(encryptedB64, "base64");
|
||||
const authTag = Buffer.from(authTagB64, "base64");
|
||||
const keyBuffer = Buffer.from(key, "base64");
|
||||
|
||||
const decipher = crypto.createDecipheriv(ALGORITHM, keyBuffer, iv);
|
||||
decipher.setAuthTag(authTag);
|
||||
|
||||
const decrypted = Buffer.concat([
|
||||
decipher.update(encrypted),
|
||||
decipher.final()
|
||||
]);
|
||||
return decrypted.toString("utf8");
|
||||
const bytes = CryptoJS.AES.decrypt(encryptedValue, key);
|
||||
const originalText = bytes.toString(CryptoJS.enc.Utf8);
|
||||
return originalText;
|
||||
}
|
||||
|
|
|
@ -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());
|
481
server/license/license.ts
Normal file
481
server/license/license.ts
Normal file
|
@ -0,0 +1,481 @@
|
|||
// 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";
|
||||
|
||||
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;
|
||||
};
|
||||
|
||||
export type LicenseKeyCache = {
|
||||
licenseKey: string;
|
||||
licenseKeyEncrypted: string;
|
||||
valid: boolean;
|
||||
iat?: Date;
|
||||
type?: "LICENSE" | "SITES";
|
||||
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: "LICENSE" | "SITES";
|
||||
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<LicenseKeyCache>(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<boolean> {
|
||||
const status = await this.check();
|
||||
if (status.isHostLicensed) {
|
||||
if (status.isLicenseValid) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public async check(): Promise<LicenseStatus> {
|
||||
// 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<TokenPayload>(
|
||||
decryptedToken,
|
||||
this.publicKey
|
||||
);
|
||||
|
||||
this.licenseKeyCache.set<LicenseKeyCache>(decryptedKey, {
|
||||
licenseKey: decryptedKey,
|
||||
licenseKeyEncrypted: key.licenseKeyId,
|
||||
valid: payload.valid,
|
||||
type: payload.type,
|
||||
numSites: payload.quantity,
|
||||
iat: new Date(payload.iat * 1000)
|
||||
});
|
||||
|
||||
if (payload.type === "LICENSE") {
|
||||
foundHostKey = true;
|
||||
}
|
||||
} catch (e) {
|
||||
logger.error(
|
||||
`Error validating license key: ${key.licenseKeyId}`
|
||||
);
|
||||
logger.error(e);
|
||||
|
||||
this.licenseKeyCache.set<LicenseKeyCache>(
|
||||
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<LicenseKeyCache>(
|
||||
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<LicenseKeyCache>(
|
||||
key.licenseKey,
|
||||
cached
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const payload = validateJWT<TokenPayload>(
|
||||
licenseKeyRes,
|
||||
this.publicKey
|
||||
);
|
||||
cached.valid = payload.valid;
|
||||
cached.type = payload.type;
|
||||
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<LicenseKeyCache>(
|
||||
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<LicenseKeyCache>(
|
||||
key.licenseKey
|
||||
)!;
|
||||
|
||||
logger.debug("Checking key", cached);
|
||||
|
||||
if (cached.type === "LICENSE") {
|
||||
status.isLicenseValid = cached.valid;
|
||||
}
|
||||
|
||||
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<TokenPayload>(
|
||||
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<ValidateLicenseAPIResponse> {
|
||||
// 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;
|
114
server/license/licenseJwt.ts
Normal file
114
server/license/licenseJwt.ts
Normal file
|
@ -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<Payload>(
|
||||
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 };
|
|
@ -17,3 +17,7 @@ export * from "./verifyAccessTokenAccess";
|
|||
export * from "./verifyUserIsServerAdmin";
|
||||
export * from "./verifyIsLoggedInUser";
|
||||
export * from "./verifyClientAccess";
|
||||
export * from "./integration";
|
||||
export * from "./verifyValidLicense";
|
||||
export * from "./verifyUserHasAction";
|
||||
export * from "./verifyApiKeyAccess";
|
||||
|
|
17
server/middlewares/integration/index.ts
Normal file
17
server/middlewares/integration/index.ts
Normal file
|
@ -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";
|
115
server/middlewares/integration/verifyAccessTokenAccess.ts
Normal file
115
server/middlewares/integration/verifyAccessTokenAccess.ts
Normal file
|
@ -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"
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
65
server/middlewares/integration/verifyApiKey.ts
Normal file
65
server/middlewares/integration/verifyApiKey.ts
Normal file
|
@ -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<void> {
|
||||
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"
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
86
server/middlewares/integration/verifyApiKeyApiKeyAccess.ts
Normal file
86
server/middlewares/integration/verifyApiKeyApiKeyAccess.ts
Normal file
|
@ -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"
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
61
server/middlewares/integration/verifyApiKeyHasAction.ts
Normal file
61
server/middlewares/integration/verifyApiKeyHasAction.ts
Normal file
|
@ -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<any> {
|
||||
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"
|
||||
)
|
||||
);
|
||||
}
|
||||
};
|
||||
}
|
44
server/middlewares/integration/verifyApiKeyIsRoot.ts
Normal file
44
server/middlewares/integration/verifyApiKeyIsRoot.ts
Normal file
|
@ -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<void> {
|
||||
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"
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
66
server/middlewares/integration/verifyApiKeyOrgAccess.ts
Normal file
66
server/middlewares/integration/verifyApiKeyOrgAccess.ts
Normal file
|
@ -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"
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
90
server/middlewares/integration/verifyApiKeyResourceAccess.ts
Normal file
90
server/middlewares/integration/verifyApiKeyResourceAccess.ts
Normal file
|
@ -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"
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
132
server/middlewares/integration/verifyApiKeyRoleAccess.ts
Normal file
132
server/middlewares/integration/verifyApiKeyRoleAccess.ts
Normal file
|
@ -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"
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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"
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
94
server/middlewares/integration/verifyApiKeySiteAccess.ts
Normal file
94
server/middlewares/integration/verifyApiKeySiteAccess.ts
Normal file
|
@ -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"
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
117
server/middlewares/integration/verifyApiKeyTargetAccess.ts
Normal file
117
server/middlewares/integration/verifyApiKeyTargetAccess.ts
Normal file
|
@ -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"
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
72
server/middlewares/integration/verifyApiKeyUserAccess.ts
Normal file
72
server/middlewares/integration/verifyApiKeyUserAccess.ts
Normal file
|
@ -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"
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
104
server/middlewares/verifyApiKeyAccess.ts
Normal file
104
server/middlewares/verifyApiKeyAccess.ts
Normal file
|
@ -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"
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
33
server/middlewares/verifyValidLicense.ts
Normal file
33
server/middlewares/verifyValidLicense.ts
Normal file
|
@ -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"
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
|
@ -13,5 +13,6 @@ export enum OpenAPITags {
|
|||
Rule = "Rule",
|
||||
AccessToken = "Access Token",
|
||||
Idp = "Identity Provider",
|
||||
Client = "Client"
|
||||
Client = "Client",
|
||||
ApiKey = "API Key"
|
||||
}
|
||||
|
|
133
server/routers/apiKeys/createOrgApiKey.ts
Normal file
133
server/routers/apiKeys/createOrgApiKey.ts
Normal file
|
@ -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<typeof bodySchema>;
|
||||
|
||||
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<any> {
|
||||
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<CreateOrgApiKeyResponse>(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"
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
105
server/routers/apiKeys/createRootApiKey.ts
Normal file
105
server/routers/apiKeys/createRootApiKey.ts
Normal file
|
@ -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<typeof bodySchema>;
|
||||
|
||||
export type CreateRootApiKeyResponse = {
|
||||
apiKeyId: string;
|
||||
name: string;
|
||||
apiKey: string;
|
||||
lastChars: string;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
export async function createRootApiKey(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<any> {
|
||||
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<CreateRootApiKeyResponse>(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"
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
81
server/routers/apiKeys/deleteApiKey.ts
Normal file
81
server/routers/apiKeys/deleteApiKey.ts
Normal file
|
@ -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<any> {
|
||||
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")
|
||||
);
|
||||
}
|
||||
}
|
104
server/routers/apiKeys/deleteOrgApiKey.ts
Normal file
104
server/routers/apiKeys/deleteOrgApiKey.ts
Normal file
|
@ -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<any> {
|
||||
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")
|
||||
);
|
||||
}
|
||||
}
|
81
server/routers/apiKeys/getApiKey.ts
Normal file
81
server/routers/apiKeys/getApiKey.ts
Normal file
|
@ -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<ReturnType<typeof query>>[0]
|
||||
>;
|
||||
|
||||
export async function getApiKey(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<any> {
|
||||
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<GetApiKeyResponse>(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")
|
||||
);
|
||||
}
|
||||
}
|
16
server/routers/apiKeys/index.ts
Normal file
16
server/routers/apiKeys/index.ts
Normal file
|
@ -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";
|
118
server/routers/apiKeys/listApiKeyActions.ts
Normal file
118
server/routers/apiKeys/listApiKeyActions.ts
Normal file
|
@ -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<ReturnType<typeof queryActions>>;
|
||||
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<any> {
|
||||
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<ListApiKeyActionsResponse>(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")
|
||||
);
|
||||
}
|
||||
}
|
121
server/routers/apiKeys/listOrgApiKeys.ts
Normal file
121
server/routers/apiKeys/listOrgApiKeys.ts
Normal file
|
@ -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<ReturnType<typeof queryApiKeys>>;
|
||||
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<any> {
|
||||
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<ListOrgApiKeysResponse>(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")
|
||||
);
|
||||
}
|
||||
}
|
90
server/routers/apiKeys/listRootApiKeys.ts
Normal file
90
server/routers/apiKeys/listRootApiKeys.ts
Normal file
|
@ -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<ReturnType<typeof queryApiKeys>>;
|
||||
pagination: { total: number; limit: number; offset: number };
|
||||
};
|
||||
|
||||
export async function listRootApiKeys(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<any> {
|
||||
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<ListRootApiKeysResponse>(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")
|
||||
);
|
||||
}
|
||||
}
|
141
server/routers/apiKeys/setApiKeyActions.ts
Normal file
141
server/routers/apiKeys/setApiKeyActions.ts
Normal file
|
@ -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<any> {
|
||||
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")
|
||||
);
|
||||
}
|
||||
}
|
122
server/routers/apiKeys/setApiKeyOrgs.ts
Normal file
122
server/routers/apiKeys/setApiKeyOrgs.ts
Normal file
|
@ -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<any> {
|
||||
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")
|
||||
);
|
||||
}
|
||||
}
|
|
@ -12,6 +12,8 @@ import * as client from "./client";
|
|||
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,
|
||||
|
@ -29,6 +31,8 @@ import {
|
|||
verifyUserIsServerAdmin,
|
||||
verifyIsLoggedInUser,
|
||||
verifyClientAccess,
|
||||
verifyApiKeyAccess,
|
||||
verifyValidLicense
|
||||
} from "@server/middlewares";
|
||||
import { verifyUserHasAction } from "../middlewares/verifyUserHasAction";
|
||||
import { ActionsEnum } from "@server/auth/actions";
|
||||
|
@ -498,7 +502,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,
|
||||
|
@ -567,6 +579,155 @@ 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);
|
||||
|
|
129
server/routers/idp/createIdpOrgPolicy.ts
Normal file
129
server/routers/idp/createIdpOrgPolicy.ts
Normal file
|
@ -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<any> {
|
||||
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<CreateIdpOrgPolicyResponse>(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")
|
||||
);
|
||||
}
|
||||
}
|
|
@ -7,10 +7,11 @@ 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 { 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();
|
||||
|
||||
|
@ -67,7 +68,7 @@ export async function createOidcIdp(
|
|||
);
|
||||
}
|
||||
|
||||
const {
|
||||
let {
|
||||
clientId,
|
||||
clientSecret,
|
||||
authUrl,
|
||||
|
@ -80,6 +81,10 @@ export async function createOidcIdp(
|
|||
autoProvision
|
||||
} = parsedBody.data;
|
||||
|
||||
if (!(await license.isUnlocked())) {
|
||||
autoProvision = false;
|
||||
}
|
||||
|
||||
const key = config.getRawConfig().server.secret;
|
||||
|
||||
const encryptedSecret = encrypt(clientSecret, key);
|
||||
|
|
|
@ -6,7 +6,7 @@ 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 } from "@server/db/schemas";
|
||||
import { idp, idpOidcConfig, idpOrg } from "@server/db/schemas";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { OpenAPITags, registry } from "@server/openApi";
|
||||
|
||||
|
@ -67,6 +67,11 @@ export async function deleteIdp(
|
|||
.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)
|
||||
|
|
95
server/routers/idp/deleteIdpOrgPolicy.ts
Normal file
95
server/routers/idp/deleteIdpOrgPolicy.ts
Normal file
|
@ -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<any> {
|
||||
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<null>(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")
|
||||
);
|
||||
}
|
||||
}
|
|
@ -6,7 +6,7 @@ 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 } from "@server/db/schemas";
|
||||
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";
|
||||
|
@ -27,6 +27,10 @@ const bodySchema = z
|
|||
})
|
||||
.strict();
|
||||
|
||||
const ensureTrailingSlash = (url: string): string => {
|
||||
return url.endsWith('/') ? url : `${url}/`;
|
||||
};
|
||||
|
||||
export type GenerateOidcUrlResponse = {
|
||||
redirectUrl: string;
|
||||
};
|
||||
|
@ -106,12 +110,13 @@ export async function generateOidcUrl(
|
|||
const codeVerifier = arctic.generateCodeVerifier();
|
||||
const state = arctic.generateState();
|
||||
const url = client.createAuthorizationURLWithPKCE(
|
||||
existingIdp.idpOidcConfig.authUrl,
|
||||
ensureTrailingSlash(existingIdp.idpOidcConfig.authUrl),
|
||||
state,
|
||||
arctic.CodeChallengeMethod.S256,
|
||||
codeVerifier,
|
||||
parsedScopes
|
||||
);
|
||||
logger.debug("Generated OIDC URL", { url });
|
||||
|
||||
const stateJwt = jsonwebtoken.sign(
|
||||
{
|
||||
|
|
|
@ -5,3 +5,7 @@ export * from "./listIdps";
|
|||
export * from "./generateOidcUrl";
|
||||
export * from "./validateOidcCallback";
|
||||
export * from "./getIdp";
|
||||
export * from "./createIdpOrgPolicy";
|
||||
export * from "./deleteIdpOrgPolicy";
|
||||
export * from "./listIdpOrgPolicies";
|
||||
export * from "./updateIdpOrgPolicy";
|
||||
|
|
121
server/routers/idp/listIdpOrgPolicies.ts
Normal file
121
server/routers/idp/listIdpOrgPolicies.ts
Normal file
|
@ -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<Awaited<ReturnType<typeof query>>>;
|
||||
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<any> {
|
||||
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<number>`count(*)` })
|
||||
.from(idpOrg)
|
||||
.where(eq(idpOrg.idpId, idpId));
|
||||
|
||||
return response<ListIdpOrgPoliciesResponse>(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")
|
||||
);
|
||||
}
|
||||
}
|
|
@ -1,7 +1,7 @@
|
|||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import { db } from "@server/db";
|
||||
import { idp } from "@server/db/schemas";
|
||||
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";
|
||||
|
@ -33,8 +33,10 @@ async function query(limit: number, offset: number) {
|
|||
idpId: idp.idpId,
|
||||
name: idp.name,
|
||||
type: idp.type,
|
||||
orgCount: sql<number>`count(${idpOrg.orgId})`
|
||||
})
|
||||
.from(idp)
|
||||
.leftJoin(idpOrg, sql`${idp.idpId} = ${idpOrg.idpId}`)
|
||||
.groupBy(idp.idpId)
|
||||
.limit(limit)
|
||||
.offset(offset);
|
||||
|
@ -46,6 +48,7 @@ export type ListIdpsResponse = {
|
|||
idpId: number;
|
||||
name: string;
|
||||
type: string;
|
||||
orgCount: number;
|
||||
}>;
|
||||
pagination: {
|
||||
total: number;
|
||||
|
|
233
server/routers/idp/oidcAutoProvision.ts
Normal file
233
server/routers/idp/oidcAutoProvision.ts
Normal file
|
@ -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);
|
||||
}
|
131
server/routers/idp/updateIdpOrgPolicy.ts
Normal file
131
server/routers/idp/updateIdpOrgPolicy.ts
Normal file
|
@ -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<any> {
|
||||
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<UpdateIdpOrgPolicyResponse>(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")
|
||||
);
|
||||
}
|
||||
}
|
|
@ -11,6 +11,7 @@ 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({
|
||||
|
@ -41,7 +42,7 @@ export type UpdateIdpResponse = {
|
|||
|
||||
registry.registerPath({
|
||||
method: "post",
|
||||
path: "/idp/:idpId/oidc",
|
||||
path: "/idp/{idpId}/oidc",
|
||||
description: "Update an OIDC IdP.",
|
||||
tags: [OpenAPITags.Idp],
|
||||
request: {
|
||||
|
@ -84,7 +85,7 @@ export async function updateOidcIdp(
|
|||
}
|
||||
|
||||
const { idpId } = parsedParams.data;
|
||||
const {
|
||||
let {
|
||||
clientId,
|
||||
clientSecret,
|
||||
authUrl,
|
||||
|
@ -99,6 +100,10 @@ export async function updateOidcIdp(
|
|||
defaultOrgMapping
|
||||
} = parsedBody.data;
|
||||
|
||||
if (!(await license.isUnlocked())) {
|
||||
autoProvision = false;
|
||||
}
|
||||
|
||||
// Check if IDP exists and is of type OIDC
|
||||
const [existingIdp] = await db
|
||||
.select()
|
||||
|
|
|
@ -1,22 +1,30 @@
|
|||
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 } from "drizzle-orm";
|
||||
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({
|
||||
|
@ -146,7 +154,7 @@ export async function validateOidcCallback(
|
|||
}
|
||||
|
||||
const tokens = await client.validateAuthorizationCode(
|
||||
existingIdp.idpOidcConfig.tokenUrl,
|
||||
ensureTrailingSlash(existingIdp.idpOidcConfig.tokenUrl),
|
||||
code,
|
||||
codeVerifier
|
||||
);
|
||||
|
@ -202,42 +210,54 @@ export async function validateOidcCallback(
|
|||
);
|
||||
|
||||
if (existingIdp.idp.autoProvision) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"Auto provisioning is not supported"
|
||||
)
|
||||
);
|
||||
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
|
||||
});
|
||||
} else {
|
||||
if (!existingUser) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.UNAUTHORIZED,
|
||||
"User not found in the IdP"
|
||||
"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<ValidateOidcUrlCallbackResponse>(res, {
|
||||
// data: {
|
||||
// redirectUrl: postAuthRedirectUrl
|
||||
// },
|
||||
// success: true,
|
||||
// error: false,
|
||||
// message: "OIDC callback validated successfully",
|
||||
// status: HttpCode.CREATED
|
||||
// });
|
||||
|
||||
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<ValidateOidcUrlCallbackResponse>(res, {
|
||||
data: {
|
||||
redirectUrl: postAuthRedirectUrl
|
||||
},
|
||||
success: true,
|
||||
error: false,
|
||||
message: "OIDC callback validated successfully",
|
||||
status: HttpCode.CREATED
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
|
@ -246,13 +266,3 @@ export async function validateOidcCallback(
|
|||
);
|
||||
}
|
||||
}
|
||||
|
||||
function hydrateOrgMapping(
|
||||
orgMapping: string | null,
|
||||
orgId: string
|
||||
): string | undefined {
|
||||
if (!orgMapping) {
|
||||
return undefined;
|
||||
}
|
||||
return orgMapping.split("{{orgId}}").join(orgId);
|
||||
}
|
||||
|
|
499
server/routers/integration.ts
Normal file
499
server/routers/integration.ts
Normal file
|
@ -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
|
||||
);
|
|
@ -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);
|
||||
|
|
62
server/routers/license/activateLicense.ts
Normal file
62
server/routers/license/activateLicense.ts
Normal file
|
@ -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<any> {
|
||||
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")
|
||||
);
|
||||
}
|
||||
}
|
78
server/routers/license/deleteLicenseKey.ts
Normal file
78
server/routers/license/deleteLicenseKey.ts
Normal file
|
@ -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<any> {
|
||||
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")
|
||||
);
|
||||
}
|
||||
}
|
36
server/routers/license/getLicenseStatus.ts
Normal file
36
server/routers/license/getLicenseStatus.ts
Normal file
|
@ -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<any> {
|
||||
try {
|
||||
const status = await license.check();
|
||||
|
||||
return sendResponse<GetLicenseStatusResponse>(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")
|
||||
);
|
||||
}
|
||||
}
|
10
server/routers/license/index.ts
Normal file
10
server/routers/license/index.ts
Normal file
|
@ -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";
|
36
server/routers/license/listLicenseKeys.ts
Normal file
36
server/routers/license/listLicenseKeys.ts
Normal file
|
@ -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<any> {
|
||||
try {
|
||||
const keys = license.listKeys();
|
||||
|
||||
return sendResponse<ListLicenseKeysResponse>(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")
|
||||
);
|
||||
}
|
||||
}
|
42
server/routers/license/recheckStatus.ts
Normal file
42
server/routers/license/recheckStatus.ts
Normal file
|
@ -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<any> {
|
||||
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")
|
||||
);
|
||||
}
|
||||
}
|
|
@ -31,7 +31,7 @@ const listOrgsSchema = z.object({
|
|||
|
||||
registry.registerPath({
|
||||
method: "get",
|
||||
path: "/user/:userId/orgs",
|
||||
path: "/user/{userId}/orgs",
|
||||
description: "List all organizations for a user.",
|
||||
tags: [OpenAPITags.Org, OpenAPITags.User],
|
||||
request: {
|
||||
|
|
|
@ -39,7 +39,6 @@ const createHttpResourceSchema = z
|
|||
isBaseDomain: z.boolean().optional(),
|
||||
siteId: z.number(),
|
||||
http: z.boolean(),
|
||||
protocol: z.string(),
|
||||
domainId: z.string()
|
||||
})
|
||||
.strict()
|
||||
|
@ -203,7 +202,7 @@ async function createHttpResource(
|
|||
);
|
||||
}
|
||||
|
||||
const { name, subdomain, isBaseDomain, http, protocol, domainId } =
|
||||
const { name, subdomain, isBaseDomain, http, domainId } =
|
||||
parsedBody.data;
|
||||
|
||||
const [orgDomain] = await db
|
||||
|
@ -262,7 +261,7 @@ async function createHttpResource(
|
|||
name,
|
||||
subdomain,
|
||||
http,
|
||||
protocol,
|
||||
protocol: "tcp",
|
||||
ssl: true,
|
||||
isBaseDomain
|
||||
})
|
||||
|
|
|
@ -69,7 +69,9 @@ function queryResources(
|
|||
http: resources.http,
|
||||
protocol: resources.protocol,
|
||||
proxyPort: resources.proxyPort,
|
||||
enabled: resources.enabled
|
||||
enabled: resources.enabled,
|
||||
tlsServerName: resources.tlsServerName,
|
||||
setHostHeader: resources.setHostHeader
|
||||
})
|
||||
.from(resources)
|
||||
.leftJoin(sites, eq(resources.siteId, sites.siteId))
|
||||
|
@ -103,7 +105,9 @@ function queryResources(
|
|||
http: resources.http,
|
||||
protocol: resources.protocol,
|
||||
proxyPort: resources.proxyPort,
|
||||
enabled: resources.enabled
|
||||
enabled: resources.enabled,
|
||||
tlsServerName: resources.tlsServerName,
|
||||
setHostHeader: resources.setHostHeader
|
||||
})
|
||||
.from(resources)
|
||||
.leftJoin(sites, eq(resources.siteId, sites.siteId))
|
||||
|
|
|
@ -16,6 +16,7 @@ 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";
|
||||
|
@ -42,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().optional(),
|
||||
setHostHeader: z.string().optional()
|
||||
})
|
||||
.strict()
|
||||
.refine((data) => Object.keys(data).length > 0, {
|
||||
|
@ -69,6 +73,24 @@ 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;
|
||||
|
@ -77,6 +99,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()
|
||||
|
|
|
@ -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);
|
||||
|
||||
|
|
|
@ -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: {
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
: {})
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
207
server/routers/user/createOrgUser.ts
Normal file
207
server/routers/user/createOrgUser.ts
Normal file
|
@ -0,0 +1,207 @@
|
|||
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().email().optional(),
|
||||
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<any> {
|
||||
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<CreateOrgUserResponse>(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")
|
||||
);
|
||||
}
|
||||
}
|
|
@ -9,3 +9,4 @@ export * from "./adminListUsers";
|
|||
export * from "./adminRemoveUser";
|
||||
export * from "./listInvitations";
|
||||
export * from "./removeInvitation";
|
||||
export * from "./createOrgUser";
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
|
205
server/setup/scripts/1.3.0.ts
Normal file
205
server/setup/scripts/1.3.0.ts
Normal file
|
@ -0,0 +1,205 @@
|
|||
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");
|
||||
|
||||
await migration();
|
||||
|
||||
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);
|
||||
}
|
17
server/setup/setHostMeta.ts
Normal file
17
server/setup/setHostMeta.ts
Normal file
|
@ -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() });
|
||||
}
|
|
@ -8,7 +8,8 @@ import { AxiosResponse } from "axios";
|
|||
import { authCookieHeader } from "@app/lib/api/cookies";
|
||||
import { redirect } from "next/navigation";
|
||||
import { Layout } from "@app/components/Layout";
|
||||
import { orgNavItems } from "../navigation";
|
||||
import { orgLangingNavItems, orgNavItems, rootNavItems } from "../navigation";
|
||||
import { ListUserOrgsResponse } from "@server/routers/org";
|
||||
|
||||
type OrgPageProps = {
|
||||
params: Promise<{ orgId: string }>;
|
||||
|
@ -43,12 +44,23 @@ export default async function OrgPage(props: OrgPageProps) {
|
|||
redirect(`/${orgId}/settings`);
|
||||
}
|
||||
|
||||
let orgs: ListUserOrgsResponse["orgs"] = [];
|
||||
try {
|
||||
const getOrgs = cache(async () =>
|
||||
internal.get<AxiosResponse<ListUserOrgsResponse>>(
|
||||
`/user/${user.userId}/orgs`,
|
||||
await authCookieHeader()
|
||||
)
|
||||
);
|
||||
const res = await getOrgs();
|
||||
if (res && res.data.data.orgs) {
|
||||
orgs = res.data.data.orgs;
|
||||
}
|
||||
} catch (e) {}
|
||||
|
||||
return (
|
||||
<UserProvider user={user}>
|
||||
<Layout
|
||||
orgId={orgId}
|
||||
navItems={orgNavItems}
|
||||
>
|
||||
<Layout orgId={orgId} navItems={orgLangingNavItems} orgs={orgs}>
|
||||
{overview && (
|
||||
<div className="w-full max-w-4xl mx-auto md:mt-32 mt-4">
|
||||
<OrganizationLandingCard
|
||||
|
|
|
@ -1,369 +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<string | null>(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<z.infer<typeof formSchema>>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
email: "",
|
||||
validForHours: "72",
|
||||
roleId: ""
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setSendEmail(env.email.emailEnabled);
|
||||
form.reset();
|
||||
setInviteLink(null);
|
||||
setExpiresInDays(1);
|
||||
}
|
||||
}, [open, env.email.emailEnabled, form]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
return;
|
||||
}
|
||||
|
||||
async function fetchRoles() {
|
||||
const res = await api
|
||||
.get<
|
||||
AxiosResponse<ListRolesResponse>
|
||||
>(`/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);
|
||||
}
|
||||
}
|
||||
|
||||
fetchRoles();
|
||||
}, [open]);
|
||||
|
||||
async function onSubmit(values: z.infer<typeof formSchema>) {
|
||||
setLoading(true);
|
||||
|
||||
const res = await api
|
||||
.post<AxiosResponse<InviteUserResponse>>(
|
||||
`/org/${org?.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);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Credenza
|
||||
open={open}
|
||||
onOpenChange={(val) => {
|
||||
setOpen(val);
|
||||
if (!val) {
|
||||
setInviteLink(null);
|
||||
setLoading(false);
|
||||
setExpiresInDays(1);
|
||||
form.reset();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<CredenzaContent>
|
||||
<CredenzaHeader>
|
||||
<CredenzaTitle>Invite User</CredenzaTitle>
|
||||
<CredenzaDescription>
|
||||
Give new users access to your organization
|
||||
</CredenzaDescription>
|
||||
</CredenzaHeader>
|
||||
<CredenzaBody>
|
||||
<div className="space-y-4">
|
||||
{!inviteLink && (
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="space-y-4"
|
||||
id="invite-user-form"
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Email</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{env.email.emailEnabled && (
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="send-email"
|
||||
checked={sendEmail}
|
||||
onCheckedChange={(e) =>
|
||||
setSendEmail(
|
||||
e as boolean
|
||||
)
|
||||
}
|
||||
/>
|
||||
<label
|
||||
htmlFor="send-email"
|
||||
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
>
|
||||
Send invite email to user
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="roleId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Role</FormLabel>
|
||||
<Select
|
||||
onValueChange={
|
||||
field.onChange
|
||||
}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select role" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{roles.map(
|
||||
(role) => (
|
||||
<SelectItem
|
||||
key={
|
||||
role.roleId
|
||||
}
|
||||
value={role.roleId.toString()}
|
||||
>
|
||||
{
|
||||
role.name
|
||||
}
|
||||
</SelectItem>
|
||||
)
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="validForHours"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
Valid For
|
||||
</FormLabel>
|
||||
<Select
|
||||
onValueChange={
|
||||
field.onChange
|
||||
}
|
||||
defaultValue={field.value.toString()}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select duration" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{validFor.map(
|
||||
(option) => (
|
||||
<SelectItem
|
||||
key={
|
||||
option.hours
|
||||
}
|
||||
value={option.hours.toString()}
|
||||
>
|
||||
{
|
||||
option.name
|
||||
}
|
||||
</SelectItem>
|
||||
)
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
)}
|
||||
|
||||
{inviteLink && (
|
||||
<div className="max-w-md space-y-4">
|
||||
{sendEmail && (
|
||||
<p>
|
||||
An email has been sent to the user
|
||||
with the access link below. They
|
||||
must access the link to accept the
|
||||
invitation.
|
||||
</p>
|
||||
)}
|
||||
{!sendEmail && (
|
||||
<p>
|
||||
The user has been invited. They must
|
||||
access the link below to accept the
|
||||
invitation.
|
||||
</p>
|
||||
)}
|
||||
<p>
|
||||
The invite will expire in{" "}
|
||||
<b>
|
||||
{expiresInDays}{" "}
|
||||
{expiresInDays === 1
|
||||
? "day"
|
||||
: "days"}
|
||||
</b>
|
||||
.
|
||||
</p>
|
||||
<CopyTextBox
|
||||
text={inviteLink}
|
||||
wrapText={false}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CredenzaBody>
|
||||
<CredenzaFooter>
|
||||
<CredenzaClose asChild>
|
||||
<Button variant="outline">Close</Button>
|
||||
</CredenzaClose>
|
||||
<Button
|
||||
type="submit"
|
||||
form="invite-user-form"
|
||||
loading={loading}
|
||||
disabled={inviteLink !== null || loading}
|
||||
>
|
||||
Create Invitation
|
||||
</Button>
|
||||
</CredenzaFooter>
|
||||
</CredenzaContent>
|
||||
</Credenza>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -24,7 +24,7 @@ export function UsersDataTable<TData, TValue>({
|
|||
searchPlaceholder="Search users..."
|
||||
searchColumn="email"
|
||||
onAdd={inviteUser}
|
||||
addButtonText="Invite User"
|
||||
addButtonText="Create User"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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";
|
||||
|
@ -41,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<UserRow | null>(null);
|
||||
|
||||
const [users, setUsers] = useState<UserRow[]>(u);
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const api = createApiClient(useEnvContext());
|
||||
|
||||
const { user, updateUser } = useUserContext();
|
||||
const { org } = useOrgContext();
|
||||
|
||||
|
@ -281,16 +275,11 @@ export default function UsersTable({ users: u }: UsersTableProps) {
|
|||
title="Remove User from Organization"
|
||||
/>
|
||||
|
||||
<InviteUserForm
|
||||
open={isInviteModalOpen}
|
||||
setOpen={setIsInviteModalOpen}
|
||||
/>
|
||||
|
||||
<UsersDataTable
|
||||
columns={columns}
|
||||
data={users}
|
||||
inviteUser={() => {
|
||||
setIsInviteModalOpen(true);
|
||||
router.push(`/${org?.org.orgId}/settings/access/users/create`);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
|
|
793
src/app/[orgId]/settings/access/users/create/page.tsx
Normal file
793
src/app/[orgId]/settings/access/users/create/page.tsx
Normal file
|
@ -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<UserType | null>("internal");
|
||||
const [inviteLink, setInviteLink] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [expiresInDays, setExpiresInDays] = useState(1);
|
||||
const [roles, setRoles] = useState<{ roleId: number; name: string }[]>([]);
|
||||
const [idps, setIdps] = useState<IdpOption[]>([]);
|
||||
const [sendEmail, setSendEmail] = useState(env.email.emailEnabled);
|
||||
const [selectedIdp, setSelectedIdp] = useState<IdpOption | null>(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<z.infer<typeof internalFormSchema>>({
|
||||
resolver: zodResolver(internalFormSchema),
|
||||
defaultValues: {
|
||||
email: "",
|
||||
validForHours: "72",
|
||||
roleId: ""
|
||||
}
|
||||
});
|
||||
|
||||
const externalForm = useForm<z.infer<typeof externalFormSchema>>({
|
||||
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<AxiosResponse<ListRolesResponse>>(`/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<AxiosResponse<ListIdpsResponse>>("/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<typeof internalFormSchema>
|
||||
) {
|
||||
setLoading(true);
|
||||
|
||||
const res = await api
|
||||
.post<AxiosResponse<InviteUserResponse>>(
|
||||
`/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<typeof externalFormSchema>
|
||||
) {
|
||||
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<UserTypeOption> = [
|
||||
{
|
||||
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 (
|
||||
<>
|
||||
<div className="flex justify-between">
|
||||
<HeaderTitle
|
||||
title="Create User"
|
||||
description="Follow the steps below to create a new user"
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
router.push(`/${orgId}/settings/access/users`);
|
||||
}}
|
||||
>
|
||||
See All Users
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<SettingsContainer>
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
User Type
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
Determine how you want to create the user
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionBody>
|
||||
<StrategySelect
|
||||
options={userTypes}
|
||||
defaultValue={userType || undefined}
|
||||
onChange={(value) => {
|
||||
setUserType(value as UserType);
|
||||
if (value === "internal") {
|
||||
internalForm.reset();
|
||||
} else if (value === "oidc") {
|
||||
externalForm.reset();
|
||||
setSelectedIdp(null);
|
||||
}
|
||||
}}
|
||||
cols={2}
|
||||
/>
|
||||
</SettingsSectionBody>
|
||||
</SettingsSection>
|
||||
|
||||
{userType === "internal" && dataLoaded && (
|
||||
<>
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
User Information
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
Enter the details for the new user
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionBody>
|
||||
<SettingsSectionForm>
|
||||
<Form {...internalForm}>
|
||||
<form
|
||||
onSubmit={internalForm.handleSubmit(
|
||||
onSubmitInternal
|
||||
)}
|
||||
className="space-y-4"
|
||||
id="create-user-form"
|
||||
>
|
||||
<FormField
|
||||
control={
|
||||
internalForm.control
|
||||
}
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
Email
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{env.email.emailEnabled && (
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="send-email"
|
||||
checked={sendEmail}
|
||||
onCheckedChange={(
|
||||
e
|
||||
) =>
|
||||
setSendEmail(
|
||||
e as boolean
|
||||
)
|
||||
}
|
||||
/>
|
||||
<label
|
||||
htmlFor="send-email"
|
||||
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
>
|
||||
Send invite email to
|
||||
user
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<FormField
|
||||
control={
|
||||
internalForm.control
|
||||
}
|
||||
name="validForHours"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
Valid For
|
||||
</FormLabel>
|
||||
<Select
|
||||
onValueChange={
|
||||
field.onChange
|
||||
}
|
||||
defaultValue={
|
||||
field.value
|
||||
}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select duration" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{validFor.map(
|
||||
(
|
||||
option
|
||||
) => (
|
||||
<SelectItem
|
||||
key={
|
||||
option.hours
|
||||
}
|
||||
value={option.hours.toString()}
|
||||
>
|
||||
{
|
||||
option.name
|
||||
}
|
||||
</SelectItem>
|
||||
)
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={
|
||||
internalForm.control
|
||||
}
|
||||
name="roleId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
Role
|
||||
</FormLabel>
|
||||
<Select
|
||||
onValueChange={
|
||||
field.onChange
|
||||
}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select role" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{roles.map(
|
||||
(
|
||||
role
|
||||
) => (
|
||||
<SelectItem
|
||||
key={
|
||||
role.roleId
|
||||
}
|
||||
value={role.roleId.toString()}
|
||||
>
|
||||
{
|
||||
role.name
|
||||
}
|
||||
</SelectItem>
|
||||
)
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{inviteLink && (
|
||||
<div className="max-w-md space-y-4">
|
||||
{sendEmail && (
|
||||
<p>
|
||||
An email has
|
||||
been sent to the
|
||||
user with the
|
||||
access link
|
||||
below. They must
|
||||
access the link
|
||||
to accept the
|
||||
invitation.
|
||||
</p>
|
||||
)}
|
||||
{!sendEmail && (
|
||||
<p>
|
||||
The user has
|
||||
been invited.
|
||||
They must access
|
||||
the link below
|
||||
to accept the
|
||||
invitation.
|
||||
</p>
|
||||
)}
|
||||
<p>
|
||||
The invite will
|
||||
expire in{" "}
|
||||
<b>
|
||||
{expiresInDays}{" "}
|
||||
{expiresInDays ===
|
||||
1
|
||||
? "day"
|
||||
: "days"}
|
||||
</b>
|
||||
.
|
||||
</p>
|
||||
<CopyTextBox
|
||||
text={inviteLink}
|
||||
wrapText={false}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
</Form>
|
||||
</SettingsSectionForm>
|
||||
</SettingsSectionBody>
|
||||
</SettingsSection>
|
||||
</>
|
||||
)}
|
||||
|
||||
{userType !== "internal" && dataLoaded && (
|
||||
<>
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
Identity Provider
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
Select the identity provider for the
|
||||
external user
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionBody>
|
||||
{idps.length === 0 ? (
|
||||
<p className="text-muted-foreground">
|
||||
No identity providers are
|
||||
configured. Please configure an
|
||||
identity provider before creating
|
||||
external users.
|
||||
</p>
|
||||
) : (
|
||||
<Form {...externalForm}>
|
||||
<FormField
|
||||
control={externalForm.control}
|
||||
name="idpId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<StrategySelect
|
||||
options={idps.map(
|
||||
(idp) => ({
|
||||
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}
|
||||
/>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</Form>
|
||||
)}
|
||||
</SettingsSectionBody>
|
||||
</SettingsSection>
|
||||
|
||||
{idps.length > 0 && (
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
User Information
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
Enter the details for the new user
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionBody>
|
||||
<SettingsSectionForm>
|
||||
<Form {...externalForm}>
|
||||
<form
|
||||
onSubmit={externalForm.handleSubmit(
|
||||
onSubmitExternal
|
||||
)}
|
||||
className="space-y-4"
|
||||
id="create-user-form"
|
||||
>
|
||||
<FormField
|
||||
control={
|
||||
externalForm.control
|
||||
}
|
||||
name="username"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
Username
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
This must
|
||||
match the
|
||||
unique
|
||||
username
|
||||
that exists
|
||||
in the
|
||||
selected
|
||||
identity
|
||||
provider.
|
||||
</p>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={
|
||||
externalForm.control
|
||||
}
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
Email
|
||||
(Optional)
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={
|
||||
externalForm.control
|
||||
}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
Name
|
||||
(Optional)
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={
|
||||
externalForm.control
|
||||
}
|
||||
name="roleId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
Role
|
||||
</FormLabel>
|
||||
<Select
|
||||
onValueChange={
|
||||
field.onChange
|
||||
}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select role" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{roles.map(
|
||||
(
|
||||
role
|
||||
) => (
|
||||
<SelectItem
|
||||
key={
|
||||
role.roleId
|
||||
}
|
||||
value={role.roleId.toString()}
|
||||
>
|
||||
{
|
||||
role.name
|
||||
}
|
||||
</SelectItem>
|
||||
)
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
</SettingsSectionForm>
|
||||
</SettingsSectionBody>
|
||||
</SettingsSection>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</SettingsContainer>
|
||||
|
||||
<div className="flex justify-end space-x-2 mt-8">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
router.push(`/${orgId}/settings/access/users`);
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
{userType && dataLoaded && (
|
||||
<Button
|
||||
type="submit"
|
||||
form="create-user-form"
|
||||
loading={loading}
|
||||
disabled={
|
||||
loading ||
|
||||
(userType === "internal" && inviteLink !== null)
|
||||
}
|
||||
>
|
||||
Create User
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
33
src/app/[orgId]/settings/api-keys/OrgApiKeysDataTable.tsx
Normal file
33
src/app/[orgId]/settings/api-keys/OrgApiKeysDataTable.tsx
Normal file
|
@ -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<TData, TValue> {
|
||||
columns: ColumnDef<TData, TValue>[];
|
||||
data: TData[];
|
||||
addApiKey?: () => void;
|
||||
}
|
||||
|
||||
export function OrgApiKeysDataTable<TData, TValue>({
|
||||
addApiKey,
|
||||
columns,
|
||||
data
|
||||
}: DataTableProps<TData, TValue>) {
|
||||
return (
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={data}
|
||||
title="API Keys"
|
||||
searchPlaceholder="Search API keys..."
|
||||
searchColumn="name"
|
||||
onAdd={addApiKey}
|
||||
addButtonText="Generate API Key"
|
||||
/>
|
||||
);
|
||||
}
|
204
src/app/[orgId]/settings/api-keys/OrgApiKeysTable.tsx
Normal file
204
src/app/[orgId]/settings/api-keys/OrgApiKeysTable.tsx
Normal file
|
@ -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<OrgApiKeyRow | null>(null);
|
||||
const [rows, setRows] = useState<OrgApiKeyRow[]>(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<OrgApiKeyRow>[] = [
|
||||
{
|
||||
id: "dots",
|
||||
cell: ({ row }) => {
|
||||
const apiKeyROw = row.original;
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" className="h-8 w-8 p-0">
|
||||
<span className="sr-only">Open menu</span>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setSelected(apiKeyROw);
|
||||
}}
|
||||
>
|
||||
<span>View settings</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setSelected(apiKeyROw);
|
||||
setIsDeleteModalOpen(true);
|
||||
}}
|
||||
>
|
||||
<span className="text-red-500">Delete</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "name",
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
column.toggleSorting(column.getIsSorted() === "asc")
|
||||
}
|
||||
>
|
||||
Name
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "key",
|
||||
header: "Key",
|
||||
cell: ({ row }) => {
|
||||
const r = row.original;
|
||||
return <span className="font-mono">{r.key}</span>;
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "createdAt",
|
||||
header: "Created At",
|
||||
cell: ({ row }) => {
|
||||
const r = row.original;
|
||||
return <span>{moment(r.createdAt).format("lll")} </span>;
|
||||
}
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
cell: ({ row }) => {
|
||||
const r = row.original;
|
||||
return (
|
||||
<div className="flex items-center justify-end">
|
||||
<Link href={`/${orgId}/settings/api-keys/${r.id}`}>
|
||||
<Button variant={"outlinePrimary"} className="ml-2">
|
||||
Edit
|
||||
<ArrowRight className="ml-2 w-4 h-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
{selected && (
|
||||
<ConfirmDeleteDialog
|
||||
open={isDeleteModalOpen}
|
||||
setOpen={(val) => {
|
||||
setIsDeleteModalOpen(val);
|
||||
setSelected(null);
|
||||
}}
|
||||
dialog={
|
||||
<div className="space-y-4">
|
||||
<p>
|
||||
Are you sure you want to remove the API key{" "}
|
||||
<b>{selected?.name || selected?.id}</b> from the
|
||||
organization?
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<b>
|
||||
Once removed, the API key will no longer be
|
||||
able to be used.
|
||||
</b>
|
||||
</p>
|
||||
|
||||
<p>
|
||||
To confirm, please type the name of the API key
|
||||
below.
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
buttonText="Confirm Delete API Key"
|
||||
onConfirm={async () => deleteSite(selected!.id)}
|
||||
string={selected.name}
|
||||
title="Delete API Key"
|
||||
/>
|
||||
)}
|
||||
|
||||
<OrgApiKeysDataTable
|
||||
columns={columns}
|
||||
data={rows}
|
||||
addApiKey={() => {
|
||||
router.push(`/${orgId}/settings/api-keys/create`);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
62
src/app/[orgId]/settings/api-keys/[apiKeyId]/layout.tsx
Normal file
62
src/app/[orgId]/settings/api-keys/[apiKeyId]/layout.tsx
Normal file
|
@ -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<AxiosResponse<GetApiKeyResponse>>(
|
||||
`/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 (
|
||||
<>
|
||||
<SettingsSectionTitle title={`${apiKey?.name} Settings`} />
|
||||
|
||||
<ApiKeyProvider apiKey={apiKey}>
|
||||
<HorizontalTabs items={navItems}>{children}</HorizontalTabs>
|
||||
</ApiKeyProvider>
|
||||
</>
|
||||
);
|
||||
}
|
13
src/app/[orgId]/settings/api-keys/[apiKeyId]/page.tsx
Normal file
13
src/app/[orgId]/settings/api-keys/[apiKeyId]/page.tsx
Normal file
|
@ -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`);
|
||||
}
|
|
@ -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<boolean>(true);
|
||||
const [selectedPermissions, setSelectedPermissions] = useState<
|
||||
Record<string, boolean>
|
||||
>({});
|
||||
const [loadingSavePermissions, setLoadingSavePermissions] =
|
||||
useState<boolean>(false);
|
||||
|
||||
useEffect(() => {
|
||||
async function load() {
|
||||
setLoadingPage(true);
|
||||
|
||||
const res = await api
|
||||
.get<
|
||||
AxiosResponse<ListApiKeyActionsResponse>
|
||||
>(`/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 && (
|
||||
<SettingsContainer>
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
Permissions
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
Determine what this API key can do
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionBody>
|
||||
<PermissionsSelectBox
|
||||
selectedPermissions={selectedPermissions}
|
||||
onChange={setSelectedPermissions}
|
||||
/>
|
||||
|
||||
<SettingsSectionFooter>
|
||||
<Button
|
||||
onClick={async () => {
|
||||
await savePermissions();
|
||||
}}
|
||||
loading={loadingSavePermissions}
|
||||
disabled={loadingSavePermissions}
|
||||
>
|
||||
Save Permissions
|
||||
</Button>
|
||||
</SettingsSectionFooter>
|
||||
</SettingsSectionBody>
|
||||
</SettingsSection>
|
||||
</SettingsContainer>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
412
src/app/[orgId]/settings/api-keys/create/page.tsx
Normal file
412
src/app/[orgId]/settings/api-keys/create/page.tsx
Normal file
|
@ -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<typeof createFormSchema>;
|
||||
|
||||
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<typeof copiedFormSchema>;
|
||||
|
||||
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<CreateOrgApiKeyResponse | null>(null);
|
||||
const [selectedPermissions, setSelectedPermissions] = useState<
|
||||
Record<string, boolean>
|
||||
>({});
|
||||
|
||||
const form = useForm<CreateFormValues>({
|
||||
resolver: zodResolver(createFormSchema),
|
||||
defaultValues: {
|
||||
name: ""
|
||||
}
|
||||
});
|
||||
|
||||
const copiedForm = useForm<CopiedFormValues>({
|
||||
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<CreateOrgApiKeyResponse>
|
||||
>(`/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 (
|
||||
<>
|
||||
<div className="flex justify-between">
|
||||
<HeaderTitle
|
||||
title="Generate API Key"
|
||||
description="Generate a new API key for your organization"
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
router.push(`/${orgId}/settings/api-keys`);
|
||||
}}
|
||||
>
|
||||
See All API Keys
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{!loadingPage && (
|
||||
<div>
|
||||
<SettingsContainer>
|
||||
{!apiKey && (
|
||||
<>
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
API Key Information
|
||||
</SettingsSectionTitle>
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionBody>
|
||||
<SettingsSectionForm>
|
||||
<Form {...form}>
|
||||
<form
|
||||
className="space-y-4"
|
||||
id="create-site-form"
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
Name
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
autoComplete="off"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
</SettingsSectionForm>
|
||||
</SettingsSectionBody>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
Permissions
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
Determine what this API key can do
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionBody>
|
||||
<PermissionsSelectBox
|
||||
selectedPermissions={
|
||||
selectedPermissions
|
||||
}
|
||||
onChange={setSelectedPermissions}
|
||||
/>
|
||||
</SettingsSectionBody>
|
||||
</SettingsSection>
|
||||
</>
|
||||
)}
|
||||
|
||||
{apiKey && (
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
Your API Key
|
||||
</SettingsSectionTitle>
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionBody>
|
||||
<InfoSections cols={2}>
|
||||
<InfoSection>
|
||||
<InfoSectionTitle>
|
||||
Name
|
||||
</InfoSectionTitle>
|
||||
<InfoSectionContent>
|
||||
<CopyToClipboard
|
||||
text={apiKey.name}
|
||||
/>
|
||||
</InfoSectionContent>
|
||||
</InfoSection>
|
||||
<InfoSection>
|
||||
<InfoSectionTitle>
|
||||
Created
|
||||
</InfoSectionTitle>
|
||||
<InfoSectionContent>
|
||||
{moment(
|
||||
apiKey.createdAt
|
||||
).format("lll")}
|
||||
</InfoSectionContent>
|
||||
</InfoSection>
|
||||
</InfoSections>
|
||||
|
||||
<Alert variant="neutral">
|
||||
<InfoIcon className="h-4 w-4" />
|
||||
<AlertTitle className="font-semibold">
|
||||
Save Your API Key
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
You will only be able to see this
|
||||
once. Make sure to copy it to a
|
||||
secure place.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<h4 className="font-semibold">
|
||||
Your API key is:
|
||||
</h4>
|
||||
|
||||
<CopyTextBox
|
||||
text={`${apiKey.apiKeyId}.${apiKey.apiKey}`}
|
||||
/>
|
||||
|
||||
<Form {...copiedForm}>
|
||||
<form
|
||||
className="space-y-4"
|
||||
id="copied-form"
|
||||
>
|
||||
<FormField
|
||||
control={copiedForm.control}
|
||||
name="copied"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="terms"
|
||||
defaultChecked={
|
||||
copiedForm.getValues(
|
||||
"copied"
|
||||
) as boolean
|
||||
}
|
||||
onCheckedChange={(
|
||||
e
|
||||
) => {
|
||||
copiedForm.setValue(
|
||||
"copied",
|
||||
e as boolean
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<label
|
||||
htmlFor="terms"
|
||||
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
>
|
||||
I have copied
|
||||
the API key
|
||||
</label>
|
||||
</div>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
</SettingsSectionBody>
|
||||
</SettingsSection>
|
||||
)}
|
||||
</SettingsContainer>
|
||||
|
||||
<div className="flex justify-end space-x-2 mt-8">
|
||||
{!apiKey && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={createLoading || apiKey !== null}
|
||||
onClick={() => {
|
||||
router.push(`/${orgId}/settings/api-keys`);
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
)}
|
||||
{!apiKey && (
|
||||
<Button
|
||||
type="button"
|
||||
loading={createLoading}
|
||||
disabled={createLoading || apiKey !== null}
|
||||
onClick={() => {
|
||||
form.handleSubmit(onSubmit)();
|
||||
}}
|
||||
>
|
||||
Generate
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{apiKey && (
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
copiedForm.handleSubmit(onCopiedSubmit)();
|
||||
}}
|
||||
>
|
||||
Done
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
49
src/app/[orgId]/settings/api-keys/page.tsx
Normal file
49
src/app/[orgId]/settings/api-keys/page.tsx
Normal file
|
@ -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<AxiosResponse<ListOrgApiKeysResponse>>(
|
||||
`/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 (
|
||||
<>
|
||||
<SettingsSectionTitle
|
||||
title="Manage API Keys"
|
||||
description="API keys are used to authenticate with the integration API"
|
||||
/>
|
||||
|
||||
<OrgApiKeysTable apiKeys={rows} orgId={params.orgId} />
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -242,7 +242,7 @@ export default function GeneralPage() {
|
|||
loading={loadingSave}
|
||||
disabled={loadingSave}
|
||||
>
|
||||
Save Settings
|
||||
Save General Settings
|
||||
</Button>
|
||||
</SettingsSectionFooter>
|
||||
</SettingsSection>
|
||||
|
|
|
@ -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<ResourceRow | null>();
|
||||
|
@ -242,7 +239,7 @@ export default function SitesTable({ resources, orgId }: ResourcesTableProps) {
|
|||
<span>Not Protected</span>
|
||||
</span>
|
||||
) : (
|
||||
<span>--</span>
|
||||
<span>-</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
@ -282,11 +279,6 @@ export default function SitesTable({ resources, orgId }: ResourcesTableProps) {
|
|||
|
||||
return (
|
||||
<>
|
||||
<CreateResourceForm
|
||||
open={isCreateModalOpen}
|
||||
setOpen={setIsCreateModalOpen}
|
||||
/>
|
||||
|
||||
{selectedResource && (
|
||||
<ConfirmDeleteDialog
|
||||
open={isDeleteModalOpen}
|
||||
|
@ -328,7 +320,7 @@ export default function SitesTable({ resources, orgId }: ResourcesTableProps) {
|
|||
columns={columns}
|
||||
data={resources}
|
||||
createResource={() => {
|
||||
setIsCreateModalOpen(true);
|
||||
router.push(`/${orgId}/settings/resources/create`);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
|
|
|
@ -140,12 +140,6 @@ export default function SetResourcePasswordForm({
|
|||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
<FormDescription>
|
||||
Users will be able to access
|
||||
this resource by entering this
|
||||
password. It must be at least 4
|
||||
characters long.
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
|
|
@ -147,33 +147,33 @@ export default function SetResourcePincodeForm({
|
|||
<InputOTPGroup className="flex">
|
||||
<InputOTPSlot
|
||||
index={0}
|
||||
obscured
|
||||
/>
|
||||
<InputOTPSlot
|
||||
index={1}
|
||||
obscured
|
||||
/>
|
||||
<InputOTPSlot
|
||||
index={2}
|
||||
obscured
|
||||
/>
|
||||
<InputOTPSlot
|
||||
index={3}
|
||||
obscured
|
||||
/>
|
||||
<InputOTPSlot
|
||||
index={4}
|
||||
obscured
|
||||
/>
|
||||
<InputOTPSlot
|
||||
index={5}
|
||||
obscured
|
||||
/>
|
||||
</InputOTPGroup>
|
||||
</InputOTP>
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
<FormDescription>
|
||||
Users will be able to access
|
||||
this resource by entering this
|
||||
PIN code. It must be at least 6
|
||||
digits long.
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue