diff --git a/.github/workflows/build-rock.yaml b/.github/workflows/build-rock.yaml index 6f2148f..1e3d0ba 100644 --- a/.github/workflows/build-rock.yaml +++ b/.github/workflows/build-rock.yaml @@ -11,13 +11,13 @@ jobs: uses: actions/checkout@v4 - uses: canonical/craft-actions/rockcraft-pack@main id: rockcraft - - - name: Install Skopeo - run: | - sudo snap install skopeo --edge --devmode + with: + rockcraft-channel: edge + - name: Import the image to Docker registry run: | - sudo skopeo --insecure-policy copy oci-archive:${{ steps.rockcraft.outputs.rock }} docker-daemon:gocert:latest + sudo rockcraft.skopeo --insecure-policy copy oci-archive:${{ steps.rockcraft.outputs.rock }} docker-daemon:gocert:latest + - name: Create files required by GoCert run: | printf 'key_path: "/etc/config/key.pem"\ncert_path: "/etc/config/cert.pem"\ndb_path: "/etc/config/certs.db"\nport: 3000\npebble_notifications: true\n' > config.yaml diff --git a/.github/workflows/publish-rock.yaml b/.github/workflows/publish-rock.yaml index c0820eb..e7c3c6d 100644 --- a/.github/workflows/publish-rock.yaml +++ b/.github/workflows/publish-rock.yaml @@ -17,9 +17,10 @@ jobs: username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - - name: Install skopeo + - name: Install rockcraft run: | - sudo snap install --devmode --channel edge skopeo + sudo snap install rockcraft --classic --channel edge + - uses: actions/download-artifact@v4 with: name: rock @@ -29,7 +30,7 @@ jobs: image_name="$(yq '.name' rockcraft.yaml)" version="$(yq '.version' rockcraft.yaml)" rock_file=$(ls *.rock | tail -n 1) - sudo skopeo \ + sudo rockcraft.skopeo \ --insecure-policy \ copy \ oci-archive:"${rock_file}" \ diff --git a/.github/workflows/scan-rock.yaml b/.github/workflows/scan-rock.yaml index 3cb0efc..2dd7a8c 100644 --- a/.github/workflows/scan-rock.yaml +++ b/.github/workflows/scan-rock.yaml @@ -10,9 +10,9 @@ jobs: - name: Checkout repository uses: actions/checkout@v4 - - name: Install skopeo + - name: Install rockcraft run: | - sudo snap install --devmode --channel edge skopeo + sudo snap install rockcraft --classic --channel edge - name: Install yq run: | @@ -29,7 +29,7 @@ jobs: version="$(yq '.version' rockcraft.yaml)" echo "version=${version}" >> $GITHUB_ENV rock_file=$(ls *.rock | tail -n 1) - sudo skopeo \ + sudo rockcraft.skopeo \ --insecure-policy \ copy \ oci-archive:"${rock_file}" \ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index eead84e..6ff020a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -11,7 +11,6 @@ 7. Run the project: `gocert -config config.yaml` Commands assume you're running them from the top level git repo directory - ## Testing ### Unit Tests @@ -41,6 +40,6 @@ npm run lint ```bash rockcraft pack -v version=$(yq '.version' rockcraft.yaml) -sudo skopeo --insecure-policy copy oci-archive:gocert_${version}_amd64.rock docker-daemon:gocert:${version} +sudo rockcraft.skopeo --insecure-policy copy oci-archive:gocert_${version}_amd64.rock docker-daemon:gocert:${version} docker run gocert:${version} ``` \ No newline at end of file diff --git a/cmd/gocert/main.go b/cmd/gocert/main.go index 5c43364..115b665 100644 --- a/cmd/gocert/main.go +++ b/cmd/gocert/main.go @@ -6,17 +6,21 @@ import ( "os" server "github.com/canonical/gocert/internal/api" + "github.com/canonical/gocert/internal/config" ) func main() { log.SetOutput(os.Stderr) configFilePtr := flag.String("config", "", "The config file to be provided to the server") flag.Parse() - if *configFilePtr == "" { - log.Fatalf("Providing a valid config file is required.") + log.Fatalf("Providing a config file is required.") + } + conf, err := config.Validate(*configFilePtr) + if err != nil { + log.Fatalf("Couldn't validate config file: %s", err) } - srv, err := server.NewServer(*configFilePtr) + srv, err := server.NewServer(conf.Port, conf.Cert, conf.Key, conf.DBPath, conf.PebbleNotificationsEnabled) if err != nil { log.Fatalf("Couldn't create server: %s", err) } diff --git a/cmd/gocert/main_test.go b/cmd/gocert/main_test.go index 58d37d7..3b62a2e 100644 --- a/cmd/gocert/main_test.go +++ b/cmd/gocert/main_test.go @@ -140,7 +140,7 @@ func TestGoCertFail(t *testing.T) { ConfigYAML string ExpectedOutput string }{ - {"flags not set", []string{}, validConfig, "Providing a valid config file is required."}, + {"flags not set", []string{}, validConfig, "Providing a config file is required."}, {"config file not valid", []string{"-config", "config.yaml"}, invalidConfig, "config file validation failed:"}, {"database not connectable", []string{"-config", "config.yaml"}, invalidDBConfig, "Couldn't connect to database:"}, } diff --git a/internal/api/handlers.go b/internal/api/handlers.go index 965ead5..21368a6 100644 --- a/internal/api/handlers.go +++ b/internal/api/handlers.go @@ -50,7 +50,19 @@ func newFrontendFileServer() http.Handler { if err != nil { log.Fatal(err) } - return http.FileServer(http.FS(frontendFS)) + + fileServer := http.FileServer(http.FS(frontendFS)) + + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + path := r.URL.Path + if strings.HasSuffix(path, "/") || path == "/" { + path = "/certificate_requests.html" + } else if !strings.Contains(path, ".") { + path += ".html" + } + r.URL.Path = path + fileServer.ServeHTTP(w, r) + }) } // the health check endpoint simply returns a http.StatusOK diff --git a/internal/api/middleware.go b/internal/api/middleware.go index 8e8e3d1..1a978b0 100644 --- a/internal/api/middleware.go +++ b/internal/api/middleware.go @@ -3,6 +3,7 @@ package server import ( "log" "net/http" + "strings" "github.com/canonical/gocert/internal/metrics" "github.com/prometheus/client_golang/prometheus/promhttp" @@ -71,10 +72,15 @@ func metricsMiddleware(ctx *middlewareContext) middleware { func loggingMiddleware(ctx *middlewareContext) middleware { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - clonedWwriter := newResponseWriter(w) - next.ServeHTTP(w, r) - log.Println(r.Method, r.URL.Path, clonedWwriter.statusCode, http.StatusText(clonedWwriter.statusCode)) - ctx.responseStatusCode = clonedWwriter.statusCode + clonedWriter := newResponseWriter(w) + next.ServeHTTP(clonedWriter, r) + + // Suppress logging for static files + if !strings.HasPrefix(r.URL.Path, "/_next") { + log.Println(r.Method, r.URL.Path, clonedWriter.statusCode, http.StatusText(clonedWriter.statusCode)) + } + + ctx.responseStatusCode = clonedWriter.statusCode }) } } diff --git a/internal/api/server.go b/internal/api/server.go index 5910d76..ee2ca8b 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -7,72 +7,17 @@ import ( "fmt" "log" "net/http" - "os" "os/exec" "time" "github.com/canonical/gocert/internal/certdb" - "gopkg.in/yaml.v3" ) -type ConfigYAML struct { - KeyPath string `yaml:"key_path"` - CertPath string `yaml:"cert_path"` - DBPath string `yaml:"db_path"` - Port int `yaml:"port"` - Pebblenotificationsenabled bool `yaml:"pebble_notifications"` -} - -type Config struct { - Key []byte - Cert []byte - DBPath string - Port int - PebbleNotificationsEnabled bool -} - type Environment struct { DB *certdb.CertificateRequestsRepository SendPebbleNotifications bool } -// validateConfigFile opens and processes the given yaml file, and catches errors in the process -func validateConfigFile(filePath string) (Config, error) { - validationErr := errors.New("config file validation failed: ") - config := Config{} - configYaml, err := os.ReadFile(filePath) - if err != nil { - return config, errors.Join(validationErr, err) - } - c := ConfigYAML{} - if err := yaml.Unmarshal(configYaml, &c); err != nil { - return config, errors.Join(validationErr, err) - } - cert, err := os.ReadFile(c.CertPath) - if err != nil { - return config, errors.Join(validationErr, err) - } - key, err := os.ReadFile(c.KeyPath) - if err != nil { - return config, errors.Join(validationErr, err) - } - dbfile, err := os.OpenFile(c.DBPath, os.O_CREATE|os.O_RDONLY, 0644) - if err != nil { - return config, errors.Join(validationErr, err) - } - err = dbfile.Close() - if err != nil { - return config, errors.Join(validationErr, err) - } - - config.Cert = cert - config.Key = key - config.DBPath = c.DBPath - config.Port = c.Port - config.PebbleNotificationsEnabled = c.Pebblenotificationsenabled - return config, nil -} - func SendPebbleNotification(key, request_id string) error { cmd := exec.Command("pebble", "notify", key, fmt.Sprintf("request_id=%s", request_id)) if err := cmd.Run(); err != nil { @@ -82,27 +27,23 @@ func SendPebbleNotification(key, request_id string) error { } // NewServer creates an environment and an http server with handlers that Go can start listening to -func NewServer(configFile string) (*http.Server, error) { - config, err := validateConfigFile(configFile) - if err != nil { - return nil, err - } - serverCerts, err := tls.X509KeyPair(config.Cert, config.Key) +func NewServer(port int, cert []byte, key []byte, dbPath string, pebbleNotificationsEnabled bool) (*http.Server, error) { + serverCerts, err := tls.X509KeyPair(cert, key) if err != nil { return nil, err } - db, err := certdb.NewCertificateRequestsRepository(config.DBPath, "CertificateRequests") + db, err := certdb.NewCertificateRequestsRepository(dbPath, "CertificateRequests") if err != nil { log.Fatalf("Couldn't connect to database: %s", err) } env := &Environment{} env.DB = db - env.SendPebbleNotifications = config.PebbleNotificationsEnabled + env.SendPebbleNotifications = pebbleNotificationsEnabled router := NewGoCertRouter(env) s := &http.Server{ - Addr: fmt.Sprintf(":%d", config.Port), + Addr: fmt.Sprintf(":%d", port), ReadTimeout: 10 * time.Second, WriteTimeout: 10 * time.Second, diff --git a/internal/api/server_test.go b/internal/api/server_test.go index 8a6caea..535ac71 100644 --- a/internal/api/server_test.go +++ b/internal/api/server_test.go @@ -85,24 +85,6 @@ Q53tuiWQeoxNOjHiWstBPELxGbW6447JyVVbNYGUk+VFU7okzA6sRTJ/5Ysda4Sf auNQc2hruhr/2plhFUYoZHPzGz7d5zUGKymhCoS8BsFVtD0WDL4srdtY/W2Us7TD D7DC34n8CH9+avz9sCRwxpjxKnYW/BeyK0c4n9uZpjI8N4sOVqy6yWBUseww -----END RSA PRIVATE KEY-----` - validConfig = `key_path: "./key_test.pem" -cert_path: "./cert_test.pem" -db_path: "./certs.db" -port: 8000` - wrongCertConfig = `key_path: "./key_test.pem" -cert_path: "./cert_test_wrong.pem" -db_path: "./certs.db" -port: 8000` - wrongKeyConfig = `key_path: "./key_test_wrong.pem" -cert_path: "./cert_test.pem" -db_path: "./certs.db" -port: 8000` - invalidYAMLConfig = `wrong: fields -every: where` - invalidFileConfig = `key_path: "./nokeyfile.pem" -cert_path: "./nocertfile.pem" -db_path: "./certs.db" -port: 8000` ) func TestMain(m *testing.M) { @@ -131,11 +113,7 @@ func TestMain(m *testing.M) { } func TestNewServerSuccess(t *testing.T) { - writeConfigErr := os.WriteFile("config.yaml", []byte(validConfig), 0644) - if writeConfigErr != nil { - log.Fatalf("Error writing config file") - } - s, err := server.NewServer("config.yaml") + s, err := server.NewServer(8000, []byte(validCert), []byte(validPK), "certs.db", false) if err != nil { t.Errorf("Error occured: %s", err) } @@ -144,30 +122,9 @@ func TestNewServerSuccess(t *testing.T) { } } -func TestNewServerFail(t *testing.T) { - testCases := []struct { - desc string - config string - }{ - { - desc: "wrong certificate", - config: wrongCertConfig, - }, - { - desc: "wrong key", - config: wrongKeyConfig, - }, - } - for _, tC := range testCases { - writeConfigErr := os.WriteFile("config.yaml", []byte(tC.config), 0644) - if writeConfigErr != nil { - log.Fatalf("Error writing config file") - } - t.Run(tC.desc, func(t *testing.T) { - _, err := server.NewServer("config.yaml") - if err == nil { - t.Errorf("Expected error") - } - }) +func TestInvalidKeyFailure(t *testing.T) { + _, err := server.NewServer(8000, []byte(validCert), []byte{}, "certs.db", false) + if err == nil { + t.Errorf("No error was thrown for invalid key") } } diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..0adc8a0 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,73 @@ +package config + +import ( + "errors" + "os" + + "gopkg.in/yaml.v3" +) + +type ConfigYAML struct { + KeyPath string `yaml:"key_path"` + CertPath string `yaml:"cert_path"` + DBPath string `yaml:"db_path"` + Port int `yaml:"port"` + Pebblenotificationsenabled bool `yaml:"pebble_notifications"` +} + +type Config struct { + Key []byte + Cert []byte + DBPath string + Port int + PebbleNotificationsEnabled bool +} + +// Validate opens and processes the given yaml file, and catches errors in the process +func Validate(filePath string) (Config, error) { + validationErr := errors.New("config file validation failed: ") + config := Config{} + configYaml, err := os.ReadFile(filePath) + if err != nil { + return config, errors.Join(validationErr, err) + } + c := ConfigYAML{} + if err := yaml.Unmarshal(configYaml, &c); err != nil { + return config, errors.Join(validationErr, err) + } + if c.CertPath == "" { + return config, errors.Join(validationErr, errors.New("`cert_path` is empty")) + } + cert, err := os.ReadFile(c.CertPath) + if err != nil { + return config, errors.Join(validationErr, err) + } + if c.KeyPath == "" { + return config, errors.Join(validationErr, errors.New("`key_path` is empty")) + } + key, err := os.ReadFile(c.KeyPath) + if err != nil { + return config, errors.Join(validationErr, err) + } + if c.DBPath == "" { + return config, errors.Join(validationErr, errors.New("`db_path` is empty")) + } + dbfile, err := os.OpenFile(c.DBPath, os.O_CREATE|os.O_RDONLY, 0644) + if err != nil { + return config, errors.Join(validationErr, err) + } + err = dbfile.Close() + if err != nil { + return config, errors.Join(validationErr, err) + } + if c.Port == 0 { + return config, errors.Join(validationErr, errors.New("`port` is empty")) + } + + config.Cert = cert + config.Key = key + config.DBPath = c.DBPath + config.Port = c.Port + config.PebbleNotificationsEnabled = c.Pebblenotificationsenabled + return config, nil +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..0aecd23 --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,122 @@ +package config_test + +import ( + "log" + "os" + "strings" + "testing" + + "github.com/canonical/gocert/internal/config" +) + +const ( + validCert = `Whatever cert content` + validPK = `Whatever key content` + validConfig = `key_path: "./key_test.pem" +cert_path: "./cert_test.pem" +db_path: "./certs.db" +port: 8000` + noCertPathConfig = `key_path: "./key_test.pem" +db_path: "./certs.db" +port: 8000` + noKeyPathConfig = `cert_path: "./cert_test.pem" +db_path: "./certs.db" +port: 8000` + noDBPathConfig = `key_path: "./key_test.pem" +cert_path: "./cert_test.pem" +port: 8000` + wrongCertPathConfig = `key_path: "./key_test.pem" +cert_path: "./cert_test_wrong.pem" +db_path: "./certs.db" +port: 8000` + wrongKeyPathConfig = `key_path: "./key_test_wrong.pem" +cert_path: "./cert_test.pem" +db_path: "./certs.db" +port: 8000` + invalidYAMLConfig = `just_an=invalid +yaml.here` +) + +func TestMain(m *testing.M) { + testfolder, err := os.MkdirTemp("./", "configtest-") + if err != nil { + log.Fatalf("couldn't create temp directory") + } + writeCertErr := os.WriteFile(testfolder+"/cert_test.pem", []byte(validCert), 0644) + writeKeyErr := os.WriteFile(testfolder+"/key_test.pem", []byte(validPK), 0644) + if writeCertErr != nil || writeKeyErr != nil { + log.Fatalf("couldn't create temp testing file") + } + if err := os.Chdir(testfolder); err != nil { + log.Fatalf("couldn't enter testing directory") + } + + exitval := m.Run() + + if err := os.Chdir("../"); err != nil { + log.Fatalf("couldn't change back to parent directory") + } + if err := os.RemoveAll(testfolder); err != nil { + log.Fatalf("couldn't remove temp testing directory") + } + os.Exit(exitval) +} + +func TestGoodConfigSuccess(t *testing.T) { + writeConfigErr := os.WriteFile("config.yaml", []byte(validConfig), 0644) + if writeConfigErr != nil { + t.Fatalf("Error writing config file") + } + conf, err := config.Validate("config.yaml") + if err != nil { + t.Fatalf("Error occured: %s", err) + } + + if conf.Cert == nil { + t.Fatalf("No certificates were configured for server") + } + + if conf.Key == nil { + t.Fatalf("No key was configured for server") + } + + if conf.DBPath == "" { + t.Fatalf("No database path was configured for server") + } + + if conf.Port != 8000 { + t.Fatalf("Port was not configured correctly") + } + +} + +func TestBadConfigFail(t *testing.T) { + cases := []struct { + Name string + ConfigYAML string + ExpectedError string + }{ + {"no cert path", noCertPathConfig, "`cert_path` is empty"}, + {"no key path", noKeyPathConfig, "`key_path` is empty"}, + {"no db path", noDBPathConfig, "`db_path` is empty"}, + {"wrong cert path", wrongCertPathConfig, "no such file or directory"}, + {"wrong key path", wrongKeyPathConfig, "no such file or directory"}, + {"invalid yaml", invalidYAMLConfig, "unmarshal errors"}, + } + + for _, tc := range cases { + writeConfigErr := os.WriteFile("config.yaml", []byte(tc.ConfigYAML), 0644) + if writeConfigErr != nil { + t.Errorf("Failed writing config file") + } + _, err := config.Validate("config.yaml") + if err == nil { + t.Errorf("Expected error, got nil") + } + + if !strings.Contains(err.Error(), tc.ExpectedError) { + t.Errorf("Expected error not found: %s", err) + } + + } +} diff --git a/ui/package-lock.json b/ui/package-lock.json index 1a978e0..2e76764 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -7,6 +7,7 @@ "name": "gocert", "dependencies": { "next": "14.2.3", + "pkijs": "^3.1.0", "react": "^18", "react-dom": "^18", "react-query": "^3.39.3", @@ -1982,6 +1983,19 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/asn1js": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.5.tgz", + "integrity": "sha512-FVnvrKJwpt9LP2lAMl8qZswRNm3T4q9CON+bxldk2iwk3FFpuwhx2FfinyitizWHsVYyaY+y5JzDR0rCMV5yTQ==", + "dependencies": { + "pvtsutils": "^1.3.2", + "pvutils": "^1.1.3", + "tslib": "^2.4.0" + }, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/assertion-error": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-1.1.0.tgz", @@ -2138,6 +2152,14 @@ "node": ">=10.16.0" } }, + "node_modules/bytestreamjs": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/bytestreamjs/-/bytestreamjs-2.0.1.tgz", + "integrity": "sha512-U1Z/ob71V/bXfVABvNr/Kumf5VyeQRBEm6Txb0PQ6S7V5GpBM3w4Cbqz/xPDicR5tN0uvDifng8C+5qECeGwyQ==", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/cac": { "version": "6.7.14", "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", @@ -5132,6 +5154,21 @@ "pathe": "^1.1.2" } }, + "node_modules/pkijs": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/pkijs/-/pkijs-3.1.0.tgz", + "integrity": "sha512-N+OCWUp6xrg7OkG+4DIiZUOsp3qMztjq8RGCc1hSY92dsUG8cTlAo7pEkfRGjcdyBv2c1Y9bjAzqdTJAlctuNg==", + "dependencies": { + "asn1js": "^3.0.5", + "bytestreamjs": "^2.0.0", + "pvtsutils": "^1.3.2", + "pvutils": "^1.1.3", + "tslib": "^2.4.0" + }, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/possible-typed-array-names": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.0.0.tgz", @@ -5235,6 +5272,22 @@ "node": ">=6" } }, + "node_modules/pvtsutils": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/pvtsutils/-/pvtsutils-1.3.5.tgz", + "integrity": "sha512-ARvb14YB9Nm2Xi6nBq1ZX6dAM0FsJnuk+31aUp4TrcZEdKUlSqOqsxJHUPJDNE3qiIp+iUPEIeR6Je/tgV7zsA==", + "dependencies": { + "tslib": "^2.6.1" + } + }, + "node_modules/pvutils": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/pvutils/-/pvutils-1.1.3.tgz", + "integrity": "sha512-pMpnA0qRdFp32b1sJl1wOJNxZLQ2cbQx+k6tjNtZ8CpvVhNqEPRgivZ2WOUev2YMajecdH7ctUPDvEe87nariQ==", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/querystringify": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", @@ -6786,9 +6839,9 @@ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==" }, "node_modules/ws": { - "version": "8.17.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.17.0.tgz", - "integrity": "sha512-uJq6108EgZMAl20KagGkzCKfMEjxmKvZHG7Tlq0Z6nOky7YF7aq4mOx6xK8TJ/i1LeK4Qus7INktacctDgY8Ow==", + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz", + "integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==", "dev": true, "engines": { "node": ">=10.0.0" diff --git a/ui/package.json b/ui/package.json index 71bf398..cbf3c62 100644 --- a/ui/package.json +++ b/ui/package.json @@ -12,6 +12,7 @@ }, "dependencies": { "next": "14.2.3", + "pkijs": "^3.1.0", "react": "^18", "react-dom": "^18", "react-query": "^3.39.3", diff --git a/ui/src/app/certificate_requests/components.tsx b/ui/src/app/certificate_requests/components.tsx new file mode 100644 index 0000000..146ebc4 --- /dev/null +++ b/ui/src/app/certificate_requests/components.tsx @@ -0,0 +1,41 @@ +import { Dispatch, SetStateAction } from "react" +import { ConfirmationModalData } from "./row" + +interface ConfirmationModalProps { + modalData: ConfirmationModalData + setModalData: Dispatch> +} + + +export function ConfirmationModal({ modalData, setModalData }: ConfirmationModalProps) { + const confirmQuery = () => { + modalData?.func() + setModalData(null) + } + return ( + + ) +} + +export function SuccessNotification({ successMessage }: { successMessage: string }) { + return ( +
+
+

+ {successMessage} +

+
+
+ ); +} \ No newline at end of file diff --git a/ui/src/app/certificate_requests/page.test.tsx b/ui/src/app/certificate_requests/page.test.tsx deleted file mode 100644 index 50eaaba..0000000 --- a/ui/src/app/certificate_requests/page.test.tsx +++ /dev/null @@ -1,8 +0,0 @@ -import { expect, test } from 'vitest' -import { render, screen } from '@testing-library/react' -import CertificateRequests from './page' - -test('CertificateRequestsPage', () => { - render(< CertificateRequests />) - expect(screen.getByRole('table', {})).toBeDefined() -}) \ No newline at end of file diff --git a/ui/src/app/certificate_requests/page.tsx b/ui/src/app/certificate_requests/page.tsx index 61a0757..5daa0eb 100644 --- a/ui/src/app/certificate_requests/page.tsx +++ b/ui/src/app/certificate_requests/page.tsx @@ -2,16 +2,44 @@ import { useQuery } from "react-query" import { CertificateRequestsTable } from "./table" -import { getCertificateRequests } from "./queries" -import { CSREntry } from "./types" +import { getCertificateRequests } from "../queries" +import { CSREntry } from "../types" + +function Error({ msg }: { msg: string }) { + return ( + +
+
+
+

An error occured trying to load certificate requests

+

{msg}

+
+
+
+ + ) +} + +function Loading() { + return ( + +
+
+
+

Loading...

+
+
+
+ + ) +} export default function CertificateRequests() { const query = useQuery('csrs', getCertificateRequests) - if (query.status == "loading"){ return
Loading...
} - if (query.status == "error") { return
error :(
} - if (query.data == undefined) { return
No data
} - const csrs = Array.from(query.data) + if (query.status == "loading") { return } + if (query.status == "error") { return } + const csrs = Array.from(query.data ? query.data : []) return ( - + ) } \ No newline at end of file diff --git a/ui/src/app/certificate_requests/queries.ts b/ui/src/app/certificate_requests/queries.ts deleted file mode 100644 index 3702304..0000000 --- a/ui/src/app/certificate_requests/queries.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { CSREntry } from "./types" - -export async function getCertificateRequests(): Promise { - const response = await fetch("/api/v1/certificate_requests") - if (!response.ok) { - throw new Error('Network response was not ok') - } - return response.json() -} \ No newline at end of file diff --git a/ui/src/app/certificate_requests/row.test.tsx b/ui/src/app/certificate_requests/row.test.tsx index dbe8269..c0cc548 100644 --- a/ui/src/app/certificate_requests/row.test.tsx +++ b/ui/src/app/certificate_requests/row.test.tsx @@ -1,11 +1,82 @@ import { expect, test } from 'vitest' -import { render, screen } from '@testing-library/react' +import { Dispatch, SetStateAction } from "react" +import { render, screen, fireEvent } from '@testing-library/react' +import { QueryClient, QueryClientProvider } from 'react-query' import Row from './row' +const csr = +{ + 'ID': 1, + 'CSR': `-----BEGIN CERTIFICATE REQUEST----- +MIIC5zCCAc8CAQAwRzEWMBQGA1UEAwwNMTAuMTUyLjE4My41MzEtMCsGA1UELQwk +MzlhY2UxOTUtZGM1YS00MzJiLTgwOTAtYWZlNmFiNGI0OWNmMIIBIjANBgkqhkiG +9w0BAQEFAAOCAQ8AMIIBCgKCAQEAjM5Wz+HRtDveRzeDkEDM4ornIaefe8d8nmFi +pUat9qCU3U9798FR460DHjCLGxFxxmoRitzHtaR4ew5H036HlGB20yas/CMDgSUI +69DyAsyPwEJqOWBGO1LL50qXdl5/jOkO2voA9j5UsD1CtWSklyhbNhWMpYqj2ObW +XcaYj9Gx/TwYhw8xsJ/QRWyCrvjjVzH8+4frfDhBVOyywN7sq+I3WwCbyBBcN8uO +yae0b/q5+UJUiqgpeOAh/4Y7qI3YarMj4cm7dwmiCVjedUwh65zVyHtQUfLd8nFW +Kl9775mNBc1yicvKDU3ZB5hZ1MZtpbMBwaA1yMSErs/fh5KaXwIDAQABoFswWQYJ +KoZIhvcNAQkOMUwwSjBIBgNVHREEQTA/hwQKmLc1gjd2YXVsdC1rOHMtMC52YXVs +dC1rOHMtZW5kcG9pbnRzLnZhdWx0LnN2Yy5jbHVzdGVyLmxvY2FsMA0GCSqGSIb3 +DQEBCwUAA4IBAQCJt8oVDbiuCsik4N5AOJIT7jKsMb+j0mizwjahKMoCHdx+zv0V +FGkhlf0VWPAdEu3gHdJfduX88WwzJ2wBBUK38UuprAyvfaZfaYUgFJQNC6DH1fIa +uHYEhvNJBdFJHaBvW7lrSFi57fTA9IEPrB3m/XN3r2F4eoHnaJJqHZmMwqVHck87 +cAQXk3fvTWuikHiCHqqdSdjDYj/8cyiwCrQWpV245VSbOE0WesWoEnSdFXVUfE1+ +RSKeTRuuJMcdGqBkDnDI22myj0bjt7q8eqBIjTiLQLnAFnQYpcCrhc8dKU9IJlv1 +H9Hay4ZO9LRew3pEtlx2WrExw/gpUcWM8rTI +-----END CERTIFICATE REQUEST-----`, + 'Certificate': `-----BEGIN CERTIFICATE----- +MIIDrDCCApSgAwIBAgIURKr+jf7hj60SyAryIeN++9wDdtkwDQYJKoZIhvcNAQEL +BQAwOTELMAkGA1UEBhMCVVMxKjAoBgNVBAMMIXNlbGYtc2lnbmVkLWNlcnRpZmlj +YXRlcy1vcGVyYXRvcjAeFw0yNDAzMjcxMjQ4MDRaFw0yNTAzMjcxMjQ4MDRaMEcx +FjAUBgNVBAMMDTEwLjE1Mi4xODMuNTMxLTArBgNVBC0MJDM5YWNlMTk1LWRjNWEt +NDMyYi04MDkwLWFmZTZhYjRiNDljZjCCASIwDQYJKoZIhvcNAQEBBQADggEPADCC +AQoCggEBAIzOVs/h0bQ73kc3g5BAzOKK5yGnn3vHfJ5hYqVGrfaglN1Pe/fBUeOt +Ax4wixsRccZqEYrcx7WkeHsOR9N+h5RgdtMmrPwjA4ElCOvQ8gLMj8BCajlgRjtS +y+dKl3Zef4zpDtr6APY+VLA9QrVkpJcoWzYVjKWKo9jm1l3GmI/Rsf08GIcPMbCf +0EVsgq7441cx/PuH63w4QVTsssDe7KviN1sAm8gQXDfLjsmntG/6uflCVIqoKXjg +If+GO6iN2GqzI+HJu3cJoglY3nVMIeuc1ch7UFHy3fJxVipfe++ZjQXNconLyg1N +2QeYWdTGbaWzAcGgNcjEhK7P34eSml8CAwEAAaOBnTCBmjAhBgNVHSMEGjAYgBYE +FN/vgl9cAapV7hH9lEyM7qYS958aMB0GA1UdDgQWBBRJJDZkHr64VqTC24DPQVld +Ba3iPDAMBgNVHRMBAf8EAjAAMEgGA1UdEQRBMD+CN3ZhdWx0LWs4cy0wLnZhdWx0 +LWs4cy1lbmRwb2ludHMudmF1bHQuc3ZjLmNsdXN0ZXIubG9jYWyHBAqYtzUwDQYJ +KoZIhvcNAQELBQADggEBAEH9NTwDiSsoQt/QXkWPMBrB830K0dlwKl5WBNgVxFP+ +hSfQ86xN77jNSp2VxOksgzF9J9u/ubAXvSFsou4xdP8MevBXoFJXeqMERq5RW3gc +WyhXkzguv3dwH+n43GJFP6MQ+n9W/nPZCUQ0Iy7ueAvj0HFhGyZzAE2wxNFZdvCs +gCX3nqYpp70oZIFDrhmYwE5ij5KXlHD4/1IOfNUKCDmQDgGPLI1tVtwQLjeRq7Hg +XVelpl/LXTQawmJyvDaVT/Q9P+WqoDiMjrqF6Sy7DzNeeccWVqvqX5TVS6Ky56iS +Mvo/+PAJHkBciR5Xn+Wg2a+7vrZvT6CBoRSOTozlLSM= +-----END CERTIFICATE-----` +} + +let actionMenuExpanded = 0 +const setActionMenuExpanded = (val: number) => { + actionMenuExpanded = val +} + +const queryClient = new QueryClient() test('Certificate Requests Table Row', () => { - render() - expect(screen.getByText('1')).toBeDefined() -}) -// TODO: when certificate rejected => rejected status -// TODO: when certificate empty => outstanding status -// TODO: when certificate anything else => certificate.NotAfter \ No newline at end of file + render( + + >} /> + + ) + expect(screen.getByText('10.152.183.53')).toBeDefined() // Common name of CSR + expect(screen.getByLabelText('certificate-expiry-date').innerHTML).toMatch(/^Thu Mar 27/) + const openActionsButton = screen.getByLabelText("action-menu-button") + fireEvent.click(openActionsButton); + expect(actionMenuExpanded).toBe(1) + render( + + >} /> + + + ) + expect(screen.getByText('rejected')).toBeDefined() + render( + + >} /> + + ) + expect(screen.getByText('outstanding')).toBeDefined() +}) \ No newline at end of file diff --git a/ui/src/app/certificate_requests/row.tsx b/ui/src/app/certificate_requests/row.tsx index 0ef4a03..dc4d5be 100644 --- a/ui/src/app/certificate_requests/row.tsx +++ b/ui/src/app/certificate_requests/row.tsx @@ -1,11 +1,8 @@ -import { useState, Dispatch, SetStateAction } from "react" -const extractCSR = (csrPemString: string) => { - //TODO -} - -const extractCert = (certPemString: string) => { - //TODO -} +import { useState, Dispatch, SetStateAction, useEffect, useRef } from "react" +import { UseMutationResult, useMutation, useQueryClient } from "react-query" +import { extractCSR, extractCert } from "../utils" +import { deleteCSR, rejectCSR } from "../queries" +import { ConfirmationModal, SuccessNotification } from "./components" type rowProps = { id: number, @@ -15,64 +12,130 @@ type rowProps = { ActionMenuExpanded: number setActionMenuExpanded: Dispatch> } +export type ConfirmationModalData = { + func: () => void + warningText: string +} | null + export default function Row({ id, csr, certificate, ActionMenuExpanded, setActionMenuExpanded }: rowProps) { + const [successNotification, setSuccessNotification] = useState(null) const [detailsMenuOpen, setDetailsMenuOpen] = useState(false) + const [confirmationModalData, setConfirmationModalData] = useState(null) + + const csrObj = extractCSR(csr) + const certObj = extractCert(certificate) + + const queryClient = useQueryClient() + const deleteMutation = useMutation(deleteCSR, { + onSuccess: () => queryClient.invalidateQueries('csrs') + }) + const rejectMutation = useMutation(rejectCSR, { + onSuccess: () => queryClient.invalidateQueries('csrs') + }) + + const mutationFunc = (mutation: UseMutationResult) => { + mutation.mutate(id.toString()) + } + const handleReject = () => { + setConfirmationModalData({ + func: () => mutationFunc(rejectMutation), + warningText: "Rejecting a Certificate Request means the CSR will remain in this application, but its status will be moved to rejected and the associated certificate will be deleted if there is any. This action cannot be undone." + }) + } + const handleDelete = () => { + setConfirmationModalData({ + func: () => mutationFunc(deleteMutation), + warningText: "Deleting a Certificate Request means this row will be completely removed from the application. This action cannot be undone." + }) + } + const handleCopy = () => { + navigator.clipboard.writeText(csr).then(function () { + setSuccessNotification("CSR copied to clipboard") + setTimeout(() => { + setSuccessNotification(null); + }, 2500); + }, function (err) { + console.error('could not copy text: ', err); + }); + } + const handleDownload = () => { + const blob = new Blob([csr], { type: 'text/plain' }); + const link = document.createElement('a'); + link.href = URL.createObjectURL(blob); + link.download = "csr.pem"; // TODO: change this to .pem + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(link.href); + }; const toggleActionMenu = () => { if (ActionMenuExpanded == id) { setActionMenuExpanded(0) - }else{ + } else { setActionMenuExpanded(id) } } return ( - - {id} - - - example.com - - {certificate == "" ? "outstanding" : (certificate == "rejected" ? "rejected" : "fulfilled")} - {certificate == "" ? "" : (certificate == "rejected" ? "" : "date")} - - - - - - - - - - - - - + {csrObj.subjects.find((e) => e.type == "Common Name")?.value} + + {certificate == "" ? "outstanding" : (certificate == "rejected" ? "rejected" : "fulfilled")} + {certificate == "" ? "" : (certificate == "rejected" ? "" : certObj?.notAfter)} + + + + + + + + {certificate == "rejected" ? + : + } + + + + + + - - - -
-
-

Common Name: example.com

-

Subject Alternative Names: example.com, 127.0.0.1, 1.2.3.4.5.56

+ + +
+
+

Common Name: {csrObj.subjects.find((e) => e.type == "Common Name")?.value}

+
-
- - + + + {confirmationModalData != null && } + {successNotification && + + + + + + } + ) } \ No newline at end of file diff --git a/ui/src/app/certificate_requests/table.test.tsx b/ui/src/app/certificate_requests/table.test.tsx new file mode 100644 index 0000000..9a075f4 --- /dev/null +++ b/ui/src/app/certificate_requests/table.test.tsx @@ -0,0 +1,105 @@ +import { expect, test } from 'vitest' +import { render, screen } from '@testing-library/react' +import { CertificateRequestsTable } from './table' +import { QueryClient, QueryClientProvider } from 'react-query' + +const rows = [ + { + 'ID': 1, + 'CSR': `-----BEGIN CERTIFICATE REQUEST----- +MIICszCCAZsCAQAwFjEUMBIGA1UEAwwLZXhhbXBsZS5jb20wggEiMA0GCSqGSIb3 +DQEBAQUAA4IBDwAwggEKAoIBAQDC5KgrADpuOUPwSh0YLmpWF66VTcciIGC2HcGn +oJknL7pm5q9qhfWGIdvKKlIA6cBB32jPd0QcYDsx7+AvzEvBuO7mq7v2Q1sPU4Q+ +L0s2pLJges6/cnDWvk/p5eBjDLOqHhUNzpMUga9SgIod8yymTZm3eqQvt1ABdwTg +FzBs5QdSm2Ny1fEbbcRE+Rv5rqXyJb2isXSujzSuS22VqslDIyqnY5WaLg+pjZyR ++0j13ecJsdh6/MJMUZWheimV2Yv7SFtxzFwbzBMO9YFS098sy4F896eBHLNe9cUC ++d1JDtLaewlMogjHBHAxmP54dhe6vvc78anElKKP4hm5N5nlAgMBAAGgWDBWBgkq +hkiG9w0BCQ4xSTBHMA4GA1UdDwEB/wQEAwIFoDAdBgNVHSUEFjAUBggrBgEFBQcD +AQYIKwYBBQUHAwIwFgYDVR0RBA8wDYILZXhhbXBsZS5jb20wDQYJKoZIhvcNAQEL +BQADggEBACP1VKEGVYKoVLMDJS+EZ0CPwIYWsO4xBXgK6atHe8WIChVn/8I7eo60 +cuMDiy4LR70G++xL1tpmYGRbx21r9d/shL2ehp9VdClX06qxlcGxiC/F8eThRuS5 +zHcdNqSVyMoLJ0c7yWHJahN5u2bn1Lov34yOEqGGpWCGF/gT1nEvM+p/v30s89f2 +Y/uPl4g3jpGqLCKTASWJDGnZLroLICOzYTVs5P3oj+VueSUwYhGK5tBnS2x5FHID +uMNMgwl0fxGMQZjrlXyCBhXBm1k6PmwcJGJF5LQ31c+5aTTMFU7SyZhlymctB8mS +y+ErBQsRpcQho6Ok+HTXQQUcx7WNcwI= +-----END CERTIFICATE REQUEST-----`, + 'Certificate': "" + }, + { + 'ID': 2, + 'CSR': `-----BEGIN CERTIFICATE REQUEST----- +MIIDGjCCAgICAQAwajEVMBMGA1UEAwwMZXhlbXBsYXIuY29tMQswCQYDVQQGEwJV +UzESMBAGA1UECAwJTG91aXNpYW5hMRQwEgYDVQQHDAtOZXcgT3JsZWFuczEaMBgG +A1UECgwRQ2VydGlmaWNhdGUgVG9vbHMwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAw +ggEKAoIBAQDZD+5Kz84PVxW6zUEd0wwWPC4h0vxSMX1kuDM+NibZdgB/kE+g9OI5 +qvK7bOrGrXEs0GOuI4M7pl/6e42+it/oE0v5tWBZxka8J+bqYE5J8NGal/oIgOo7 +evpx5QPFFlJlJOJdH4bFJfp6GMO/3tO3Ip7O0Q3iitTnDA2gJC6aQW7hclVCk4ls +lWFVyUFRRubKW0/LEmgNl9DNuQyfLp1yB3159r1NiKT9M+/ATrmBYF2ZiCWBWz4C +ySja6+r4UB/LZYwdp/n7rRtwX1R/B6HPsXw/nGsjU6+OyYS/oDJwNWpT3+dsa7sD +cSm1SNhJuKwC74nYGfH0y4FNsPW3cpiZAgMBAAGgazBpBgkqhkiG9w0BCQ4xXDBa +MA4GA1UdDwEB/wQEAwIFoDAdBgNVHSUEFjAUBggrBgEFBQcDAQYIKwYBBQUHAwIw +KQYDVR0RBCIwIIIMZXhlbXBsYXIuY29tghB3d3cuZXhlbXBsYXIuY29tMA0GCSqG +SIb3DQEBCwUAA4IBAQAuqwxmBklZ86ypTkchZJmUsyF8y/THqncFDW8RGWIB3Usi +tK9qb8EE92MoWboo4m4bcX74y+eUo3xBev6ZZwdScy8OHLhA/MMI8EElpeYt+Hc2 +2gvIs7WNemo3cCTpOtwvROWYpzxMp/z2/Zui9D57oTFcTdBjlJPyU5K4bCz+nNGV +81ifHK1xAUECfJp1IR7hFv2c2JbkwwD3KSCsyqc+/xtQLbrEPGWF1R0Gp9N1hxKv +WsDOAOH6qKQKQg3BO/xmRoohC6GL4CuhP7HYGi7+wziNhNZQa4GtE/k9DyIXVtJy +yuf2PnfXCKnaIWRJNoEqDCZRVMfA5BFSwTPITqyo +-----END CERTIFICATE REQUEST-----`, + 'Certificate': "rejected" + }, + { + 'ID': 3, + 'CSR': `-----BEGIN CERTIFICATE REQUEST----- +MIIC5zCCAc8CAQAwRzEWMBQGA1UEAwwNMTAuMTUyLjE4My41MzEtMCsGA1UELQwk +MzlhY2UxOTUtZGM1YS00MzJiLTgwOTAtYWZlNmFiNGI0OWNmMIIBIjANBgkqhkiG +9w0BAQEFAAOCAQ8AMIIBCgKCAQEAjM5Wz+HRtDveRzeDkEDM4ornIaefe8d8nmFi +pUat9qCU3U9798FR460DHjCLGxFxxmoRitzHtaR4ew5H036HlGB20yas/CMDgSUI +69DyAsyPwEJqOWBGO1LL50qXdl5/jOkO2voA9j5UsD1CtWSklyhbNhWMpYqj2ObW +XcaYj9Gx/TwYhw8xsJ/QRWyCrvjjVzH8+4frfDhBVOyywN7sq+I3WwCbyBBcN8uO +yae0b/q5+UJUiqgpeOAh/4Y7qI3YarMj4cm7dwmiCVjedUwh65zVyHtQUfLd8nFW +Kl9775mNBc1yicvKDU3ZB5hZ1MZtpbMBwaA1yMSErs/fh5KaXwIDAQABoFswWQYJ +KoZIhvcNAQkOMUwwSjBIBgNVHREEQTA/hwQKmLc1gjd2YXVsdC1rOHMtMC52YXVs +dC1rOHMtZW5kcG9pbnRzLnZhdWx0LnN2Yy5jbHVzdGVyLmxvY2FsMA0GCSqGSIb3 +DQEBCwUAA4IBAQCJt8oVDbiuCsik4N5AOJIT7jKsMb+j0mizwjahKMoCHdx+zv0V +FGkhlf0VWPAdEu3gHdJfduX88WwzJ2wBBUK38UuprAyvfaZfaYUgFJQNC6DH1fIa +uHYEhvNJBdFJHaBvW7lrSFi57fTA9IEPrB3m/XN3r2F4eoHnaJJqHZmMwqVHck87 +cAQXk3fvTWuikHiCHqqdSdjDYj/8cyiwCrQWpV245VSbOE0WesWoEnSdFXVUfE1+ +RSKeTRuuJMcdGqBkDnDI22myj0bjt7q8eqBIjTiLQLnAFnQYpcCrhc8dKU9IJlv1 +H9Hay4ZO9LRew3pEtlx2WrExw/gpUcWM8rTI +-----END CERTIFICATE REQUEST-----`, + 'Certificate': `-----BEGIN CERTIFICATE----- +MIIDrDCCApSgAwIBAgIURKr+jf7hj60SyAryIeN++9wDdtkwDQYJKoZIhvcNAQEL +BQAwOTELMAkGA1UEBhMCVVMxKjAoBgNVBAMMIXNlbGYtc2lnbmVkLWNlcnRpZmlj +YXRlcy1vcGVyYXRvcjAeFw0yNDAzMjcxMjQ4MDRaFw0yNTAzMjcxMjQ4MDRaMEcx +FjAUBgNVBAMMDTEwLjE1Mi4xODMuNTMxLTArBgNVBC0MJDM5YWNlMTk1LWRjNWEt +NDMyYi04MDkwLWFmZTZhYjRiNDljZjCCASIwDQYJKoZIhvcNAQEBBQADggEPADCC +AQoCggEBAIzOVs/h0bQ73kc3g5BAzOKK5yGnn3vHfJ5hYqVGrfaglN1Pe/fBUeOt +Ax4wixsRccZqEYrcx7WkeHsOR9N+h5RgdtMmrPwjA4ElCOvQ8gLMj8BCajlgRjtS +y+dKl3Zef4zpDtr6APY+VLA9QrVkpJcoWzYVjKWKo9jm1l3GmI/Rsf08GIcPMbCf +0EVsgq7441cx/PuH63w4QVTsssDe7KviN1sAm8gQXDfLjsmntG/6uflCVIqoKXjg +If+GO6iN2GqzI+HJu3cJoglY3nVMIeuc1ch7UFHy3fJxVipfe++ZjQXNconLyg1N +2QeYWdTGbaWzAcGgNcjEhK7P34eSml8CAwEAAaOBnTCBmjAhBgNVHSMEGjAYgBYE +FN/vgl9cAapV7hH9lEyM7qYS958aMB0GA1UdDgQWBBRJJDZkHr64VqTC24DPQVld +Ba3iPDAMBgNVHRMBAf8EAjAAMEgGA1UdEQRBMD+CN3ZhdWx0LWs4cy0wLnZhdWx0 +LWs4cy1lbmRwb2ludHMudmF1bHQuc3ZjLmNsdXN0ZXIubG9jYWyHBAqYtzUwDQYJ +KoZIhvcNAQELBQADggEBAEH9NTwDiSsoQt/QXkWPMBrB830K0dlwKl5WBNgVxFP+ +hSfQ86xN77jNSp2VxOksgzF9J9u/ubAXvSFsou4xdP8MevBXoFJXeqMERq5RW3gc +WyhXkzguv3dwH+n43GJFP6MQ+n9W/nPZCUQ0Iy7ueAvj0HFhGyZzAE2wxNFZdvCs +gCX3nqYpp70oZIFDrhmYwE5ij5KXlHD4/1IOfNUKCDmQDgGPLI1tVtwQLjeRq7Hg +XVelpl/LXTQawmJyvDaVT/Q9P+WqoDiMjrqF6Sy7DzNeeccWVqvqX5TVS6Ky56iS +Mvo/+PAJHkBciR5Xn+Wg2a+7vrZvT6CBoRSOTozlLSM= +-----END CERTIFICATE-----` + }, +] + +const queryClient = new QueryClient() +test('CertificateRequestsPage', () => { + render( + + < CertificateRequestsTable csrs={rows} /> + + ) + expect(screen.getByRole('table', {})).toBeDefined() + expect(screen.getByText('example.com')).toBeDefined() // Common Name of one of the CSR's +}) \ No newline at end of file diff --git a/ui/src/app/certificate_requests/table.tsx b/ui/src/app/certificate_requests/table.tsx index 3af69cd..7615d6b 100644 --- a/ui/src/app/certificate_requests/table.tsx +++ b/ui/src/app/certificate_requests/table.tsx @@ -1,8 +1,22 @@ -import { useContext, useState } from "react" +import { useContext, useState, Dispatch, SetStateAction } from "react" import { AsideContext } from "../nav" import Row from "./row" -import { CSREntry } from "./types" +import { CSREntry } from "../types" +function EmptyState({ asideOpen, setAsideOpen }: { asideOpen: boolean, setAsideOpen: Dispatch> }) { + return ( + +
+
+
+

No CSRs available yet.

+ +
+
+
+ + ) +} type TableProps = { csrs: CSREntry[] @@ -32,7 +46,7 @@ function sortByCertStatus(a: CSREntry, b: CSREntry) { } } -export function CertificateRequestsTable({csrs: rows}: TableProps) { +export function CertificateRequestsTable({ csrs: rows }: TableProps) { const { isOpen: isAsideOpen, setIsOpen: setAsideIsOpen } = useContext(AsideContext) const [actionsMenuExpanded, setActionsMenuExpanded] = useState(0) @@ -41,9 +55,9 @@ export function CertificateRequestsTable({csrs: rows}: TableProps) { const sortedRows = () => { switch (sortedColumn) { case "csr": - return (sortDescending? rows.sort(sortByCSRStatus).reverse() : rows.sort(sortByCSRStatus)) + return (sortDescending ? rows.sort(sortByCSRStatus).reverse() : rows.sort(sortByCSRStatus)) case "cert": - return (sortDescending? rows.sort(sortByCertStatus).reverse() : rows.sort(sortByCertStatus)) + return (sortDescending ? rows.sort(sortByCertStatus).reverse() : rows.sort(sortByCertStatus)) default: return rows } @@ -53,7 +67,7 @@ export function CertificateRequestsTable({csrs: rows}: TableProps) {

Certificate Requests

- + {rows.length > 0 && }
@@ -63,8 +77,8 @@ export function CertificateRequestsTable({csrs: rows}: TableProps) { ID Details - {setSortedColumn('csr');setSortDescending(!sortDescending)}}>CSR Status - {setSortedColumn('cert');setSortDescending(!sortDescending)}}>Certificate Expiry Date + { setSortedColumn('csr'); setSortDescending(!sortDescending) }}>CSR Status + { setSortedColumn('cert'); setSortDescending(!sortDescending) }}>Certificate Expiry Date Actions @@ -72,10 +86,11 @@ export function CertificateRequestsTable({csrs: rows}: TableProps) { { sortedRows().map((row) => ( - + ) - )} + )} + {rows.length == 0 && }
diff --git a/ui/src/app/favicon.ico b/ui/src/app/favicon.ico new file mode 100644 index 0000000..2f5238b Binary files /dev/null and b/ui/src/app/favicon.ico differ diff --git a/ui/src/app/layout.tsx b/ui/src/app/layout.tsx index 55ae9cd..dae56ac 100644 --- a/ui/src/app/layout.tsx +++ b/ui/src/app/layout.tsx @@ -8,7 +8,6 @@ export const metadata: Metadata = { description: "A certificate management application", }; - export default function RootLayout({ children, }: Readonly<{ @@ -16,10 +15,13 @@ export default function RootLayout({ }>) { return ( + + + - - {children} - + + {children} + ); diff --git a/ui/src/app/nav.test.tsx b/ui/src/app/nav.test.tsx index 08bb5de..fa0fd1c 100644 --- a/ui/src/app/nav.test.tsx +++ b/ui/src/app/nav.test.tsx @@ -1,11 +1,11 @@ import { expect, describe, it } from "vitest"; -import {render, fireEvent, screen} from '@testing-library/react' +import { render, fireEvent, screen } from '@testing-library/react' import Navigation from "./nav"; import { CertificateRequestsTable } from "./certificate_requests/table"; describe('Navigation', () => { it('should open aside when clicking button', () => { - render() + render() const addCSRButton = screen.getByLabelText(/add-csr-button/i) expect(screen.getByLabelText(/aside-panel/i).className.endsWith('is-collapsed')).toBe(true) fireEvent.click(addCSRButton) diff --git a/ui/src/app/nav.tsx b/ui/src/app/nav.tsx index 6c25305..e3c1623 100644 --- a/ui/src/app/nav.tsx +++ b/ui/src/app/nav.tsx @@ -1,8 +1,10 @@ "use client" -import { SetStateAction, Dispatch, useState, createContext, useEffect , ChangeEvent} from "react" -import { QueryClient, QueryClientProvider } from "react-query"; +import { SetStateAction, Dispatch, useState, createContext, useEffect, ChangeEvent } from "react" +import { QueryClient, QueryClientProvider, useMutation } from "react-query"; import Image from "next/image"; +import { postCSR } from "./queries"; +import { extractCSR } from "./utils"; type AsideContextType = { isOpen: boolean, @@ -10,11 +12,34 @@ type AsideContextType = { } export const AsideContext = createContext({ isOpen: false, setIsOpen: () => { } }); +function SubmitCSR({ csrText, onClickFunc }: { csrText: string, onClickFunc: any }) { + let csrIsValid = false + try { + extractCSR(csrText.trim()) + csrIsValid = true + } + catch { } + + const validationComponent = csrText == "" ? <> : csrIsValid ?
Valid CSR
:
Invalid CSR
+ const buttonComponent = csrIsValid ? : + return ( + <> + {validationComponent} + {buttonComponent} + + ) +} + export function Aside({ isOpen, setIsOpen }: { isOpen: boolean, setIsOpen: Dispatch> }) { + const mutation = useMutation(postCSR, { + onSuccess: () => { + queryClient.invalidateQueries('csrs') + }, + }) const [CSRPEMString, setCSRPEMString] = useState("") const handleTextChange = (event: ChangeEvent) => { setCSRPEMString(event.target.value); - }; + } const handleFileChange = (event: ChangeEvent) => { const file = event.target.files?.[0] if (file) { @@ -25,15 +50,15 @@ export function Aside({ isOpen, setIsOpen }: { isOpen: boolean, setIsOpen: Dispa setCSRPEMString(e.target.result.toString()); } } - }; - reader.readAsText(file); + }; + reader.readAsText(file); } - }; + }; return ( -