7 | Secret Key-Value Pairs
8 |
9 |
10 |
11 |
12 | Specify sensitive value and corresponding key of the secret. The key must be of type: DNS Subdomain
13 |
14 |
8 |
9 | ## Description
10 |
11 | This is a python based webapp for using Bitnami-Sealed-Secrets in a web-gui.
12 |
13 | This app uses the kubeseal binary of the original project:
14 |
15 | The docker images can be found here:
16 |
17 | * https://hub.docker.com/repository/docker/kubesealwebgui/ui
18 | * https://hub.docker.com/repository/docker/kubesealwebgui/api
19 |
20 | ## Demo
21 |
22 | 
23 |
24 | ## Prerequisites
25 |
26 | To use this Web-Gui you have to install [Bitnami-Sealed-Secrets](https://github.com/bitnami-labs/sealed-secrets) in your cluster first!
27 |
28 | ## Installation
29 |
30 | You can use the helm chart which is included inside this repository to install kubseal-webgui.
31 |
32 | You can install the chart directly from the GitHub Container Registry as an OCI artifact:
33 |
34 | ```bash
35 | # Make sure to configure all required values (with helm's --set argument) documented in our helm Chart before installing.
36 | helm install kubeseal-webgui oci://ghcr.io/jaydee94/kubeseal-webgui/charts/kubeseal-webgui --namespace
37 | ```
38 |
39 | ## Usage
40 |
41 | Mount the public certificate of your sealed secrets controller to `/kubeseal-webgui/cert/kubeseal-cert.pem` in the container.
42 |
43 | Please use the [helm chart](https://github.com/Jaydee94/kubeseal-webgui/tree/master/chart/kubeseal-webgui) which is included in this repository.
44 |
45 | ## Upgrade from 2.0.X to 2.1.0
46 |
47 | When upgrading to `2.1.0` make sure that you also update the helm chart for installing kubeseal-webgui.
48 | The application reads namespaces from current kubernetes cluster and needs to have access to list them.
49 | If your default serviceaccount has this RBAC rule already you could disable `serviceaccount.create` in the `values.yaml` of the helm chart.
50 |
51 | ## Upgrade from 2.0.X to 3.0.X
52 |
53 | When upgrading to `3.0.X` you dont need to deploy a ingress route to the api. The nginx serving the ui will proxy the requests to the api.
54 | You can use the new helm chart located inside the `chart` folder to deploy the new kubseal-webgui version.
55 |
56 | ## Upgrade from 4.0.X to 4.1.X
57 |
58 | When upgrading from `4.0.X` to `4.1.X` you need to use the provided helm chart in version `>=5.0.0` **if you use the autofetch certificate feature**.
59 | This is because the autofetch certificate functionality is no longer executed as an initContainer.
60 | The api container will fetch the certificate from the sealed-secrets controller on application startup.
61 |
62 | ### Get Public-Cert from sealed-secrets controller
63 |
64 | (Login to your kubernetes cluster first)
65 |
66 | `kubeseal --fetch-cert --controller-name --controller-namespace > kubeseal-cert.pem`
67 |
68 | # Contribute
69 |
70 | ## Working on the API
71 |
72 | ### Requirements
73 |
74 | * Make sure you have Python 3.12 installed.
75 |
76 | #### Setup API
77 |
78 | * Clone this repository and run `cd api`.
79 | * `python3 -m venv venv` (to create a virtual environment called `venv` that doesn't interfere with other projects)
80 | * `source venv/bin/activate` (to activate the virtual environment)
81 | * `python -m pip install .` (to install all required packages for this project)
82 | * `pytest` (should run all tests successfully)
83 |
84 | ### Local API testing
85 |
86 | * Running uvicorn server
87 |
88 | ```bash
89 | MOCK_ENABLED=true poetry run uvicorn kubeseal_webgui_api.app:app --port 5000 --log-config config/logging_config.yaml
90 | ```
91 |
92 | or use a container and set the environment variables there
93 |
94 | ```bash
95 | docker build -t api -f Dockerfile.api .
96 | docker run --rm -t \
97 | -p 5000:5000 \
98 | -e MOCK_ENABLED=TRUE \
99 | -e KUBESEAL_CERT=/tmp/cert.pem \
100 | api
101 | ```
102 |
103 | ## Working on the UI
104 |
105 | ### Setup UI
106 |
107 | * Clone this repository and run `cd ui`.
108 | * You can either use `yarn` or `npm` for the following commands.
109 | * `yarn install` to install all dependencies
110 | * `npm install` to install all dependencies
111 |
112 | ### Local UI testing
113 |
114 | * `yarn dev` to compile and start HTTP server on `port 8080` with hot-reloads for development
115 | * `npm run dev` to compile and start HTTP server on `port 8080` with hot-reloads for development
116 |
--------------------------------------------------------------------------------
/ui/src/components/SecretFormInputs.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
8 |
19 |
20 |
21 |
22 |
23 |
24 | Specify scope of the secret. Scopes for sealed secrets
25 |
26 |
27 |
28 |
29 |
30 |
31 |
36 |
47 |
48 |
53 |
54 |
60 | {{ favoriteNamespaces.has(item.value) ? 'mdi-heart' : 'mdi-heart-outline' }}
61 |
62 |
63 |
64 |
65 |
66 |
67 |
68 |
69 |
70 |
71 | Select the target namespace where the sealed secret will be deployed.
72 |
73 |
74 |
75 |
76 |
81 |
95 |
96 |
97 |
98 |
99 |
100 | Specify name of the secret. The secret name must be of type: DNS Subdomain
101 |
102 |
103 |
104 |
105 |
106 |
107 |
108 |
109 |
161 |
--------------------------------------------------------------------------------
/.github/workflows/kind.yaml:
--------------------------------------------------------------------------------
1 | name: End to end test
2 | on:
3 | pull_request:
4 | types:
5 | - opened
6 | - reopened
7 | - synchronize
8 | - edited
9 | push:
10 | branches:
11 | - main
12 | - master
13 |
14 | jobs:
15 | create-cluster:
16 | runs-on: ubuntu-latest
17 | steps:
18 | - name: checkout
19 | uses: actions/checkout@v4
20 | - name: Create k8s kind cluster
21 | uses: helm/kind-action@v1
22 | with:
23 | config: ./kind-config.yaml
24 | wait: 3m
25 | - name: Setup ingress controller
26 | run: |
27 | kubectl apply -f https://raw.githubusercontent.com/kubernetes/ingress-nginx/main/deploy/static/provider/kind/deploy.yaml
28 | kubectl wait --namespace ingress-nginx \
29 | --for=condition=ready pod \
30 | --selector=app.kubernetes.io/component=controller \
31 | --timeout=90s
32 | - name: Setup kubeseal controller
33 | run: |
34 | helm repo add sealed-secrets https://bitnami-labs.github.io/sealed-secrets
35 | helm install sealed-secrets -n kube-system \
36 | --set-string fullnameOverride=sealed-secrets-controller \
37 | sealed-secrets/sealed-secrets
38 | - name: Build and upload images
39 | run: |
40 | docker build -t ghcr.io/jaydee94/kubeseal-webgui/api:snapshot -f Dockerfile.api .
41 | docker build -t ghcr.io/jaydee94/kubeseal-webgui/ui:snapshot -f Dockerfile.ui .
42 | kind load docker-image --name chart-testing ghcr.io/jaydee94/kubeseal-webgui/api:snapshot
43 | kind load docker-image --name chart-testing ghcr.io/jaydee94/kubeseal-webgui/ui:snapshot
44 | - name: Deploy stuff
45 | run: |
46 | kubectl create namespace kubeseal-webgui
47 | helm template \
48 | --release-name e2e-test \
49 | --namespace kubeseal-webgui \
50 | --set api.image.tag=snapshot \
51 | --set api.url=http://$(hostname -f):80 \
52 | --set autoFetchCertResources=null \
53 | --set image.pullPolicy=Never \
54 | --set ingress.enabled=true \
55 | --set ingress.hostname=$(hostname -f) \
56 | --set resources=null \
57 | --set sealedSecrets.autoFetchCert=true \
58 | --set ui.image.tag=snapshot \
59 | --set securityContext.runAsUser=1042 \
60 | chart/kubeseal-webgui \
61 | | kubectl apply \
62 | -f - \
63 | --namespace kubeseal-webgui
64 | - name: Wait until ready
65 | run: |
66 | while ! curl -f http://$(hostname -f):80/namespaces
67 | do
68 | sleep 5
69 | echo "wait 5s"
70 | done
71 | timeout-minutes: 1
72 | - name: Call URL
73 | run: |
74 | curl -f http://$(hostname -f):80/namespaces
75 | curl -f http://$(hostname -f):80/config
76 | echo '{"secret": "a","namespace": "kube-system","scope": "strict","secrets": [{"key": "a","value": "YQ=="},{"key": "b","value": "Yw=="}]}' \
77 | | curl -f -H 'content-type: application/json' -X POST --data @- http://$(hostname -f):80/secrets
78 | - name: Test Secrets
79 | run: |
80 | API_URL="https://$(hostname -f):443"
81 | kubectl create namespace e2e
82 | strict_secret=$(
83 | echo '{"secret": "strict-secret", "namespace": "e2e", "scope": "strict", "secrets": [{"key": "a-secret","value": "YQ=="}]}' |
84 | curl -f -H 'content-type: application/json' -X POST -k --data @- "${API_URL}/secrets" |
85 | jq -r -s '.[0][] | select(.key=="a-secret") | "a-secret: " + .value')
86 | namespace_secret=$(
87 | echo '{"namespace": "e2e", "scope": "namespace-wide", "secrets": [{"key": "a-secret","value": "YQ=="}]}' |
88 | curl -f -H 'content-type: application/json' -X POST -k --data @- "${API_URL}/secrets" |
89 | jq -r -s '.[0][] | select(.key=="a-secret") | "a-secret: " + .value')
90 | cluster_secret=$(
91 | echo '{"scope": "cluster-wide", "secrets": [{"key": "different-secret","value": "YQ=="}]}' |
92 | curl -f -H 'content-type: application/json' -X POST -k --data @- "${API_URL}/secrets" |
93 | jq -r -s '.[0][] | select(.key=="different-secret") | "a-secret: " + .value')
94 |
95 | cat <
8 |
9 | # with ingress and autofetch certificate
10 | helm install kubeseal-webgui oci://ghcr.io/jaydee94/kubeseal-webgui/charts/kubeseal-webgui --namespace --set ingress.enabled=true --set api.url="http://kubeseal-webgui.example.com" --set sealedSecrets.autoFetchCert=true
11 | ```
12 |
13 | ## Upgrade from 5.X.X to 6.X.X
14 |
15 | **IMAGES have been moved to GitHub Container Registry!!!**
16 |
17 | Container Images are now uploaded to the GitHub Registry instead of DockerHub.
18 |
19 | [API-Image](https://github.com/Jaydee94/kubeseal-webgui/pkgs/container/kubeseal-webgui%2Fapi)
20 |
21 | [UI-Image](https://github.com/Jaydee94/kubeseal-webgui/pkgs/container/kubeseal-webgui%2Fui)
22 |
23 | ## Uninstalling the Chart
24 |
25 | ```console
26 | To uninstall/delete the my-release deployment:
27 |
28 | helm uninstall kubeseal-webgui kubesealwebgui/kubeseal-webgui --namespace
29 | ```
30 |
31 | The command removes all the Kubernetes components associated with the chart and deletes the release.
32 |
33 | ## Configuration
34 |
35 | | Parameter | Description | Default |
36 | |-------------------------------------------|---------------------------------------------------|----------------------------------------|
37 | | `replicaCount` | Number of nodes | `1` |
38 | | `annotations` | Optional annotations for the pods | `{}` |
39 | | `api.image.repository` | Image-Repository and name of the api image. | `ghcr.io/jaydee94/kubeseal-webgui/api` |
40 | | `api.image.tag` | Image Tag of the api image. | `4.5.4` |
41 | | `api.environment` | Additional env variables for the api image. | `{}` |
42 | | `api.loglevel` | Loglevel for the api image. | `INFO` |
43 | | `ui.image.repository` | Image-Repository and name of the ui image. | `ghcr.io/jaydee94/kubeseal-webgui/ui` |
44 | | `ui.image.tag` | Image Tag of the ui image. | `4.5.4` |
45 | | `image.pullPolicy` | Image Pull Policy | `Always` |
46 | | `nameOverride` | Name-Override for the objects | `""` |
47 | | `fullnameOverride` | Fullname-Override for the objects | `""` |
48 | | `customServiceAccountName` | Optionallyn define your own serviceaccount to use | `true` |
49 | | `tolerations` | Add tolerations to the deployment. | `[]` |
50 | | `affinity` | Add affinity rules to the deployment. | `{}` |
51 | | `nodeSelector` | Add a nodeSelector to the deployment. | `{}` |
52 | | `displayName` | Optional display name for the kubeseal instance | `""` |
53 | | `resources.limits.cpu` | Limits CPU | `` |
54 | | `resources.limits.memory` | Limits memory | `256Mi` |
55 | | `resources.requests.cpu` | Requests CPU | `20m` |
56 | | `resources.requests.memory` | Requests memory | `20m` |
57 | | `ingress.enabled` | Enable an ingress route | `false` |
58 | | `ingress.annotations` | Additional annotations for the ingress object. | `{}` |
59 | | `ingress.ingressClassName` | Additional ingressClassName. | `""` |
60 | | `ingress.hostname` | The hostname for the ingress route | `kubeseal-webgui.example.com` |
61 | | `ingress.tls.enabled` | Enable TLS for the ingress route | `false` |
62 | | `ingress.tls.secretName` | The secret name for private and public key | `""` |
63 | | `route.enabled` | Deploy OpenShift route | `false` |
64 | | `route.hostname` | Set Hostname of the route | `""` |
65 | | `route.tls.enabled` | Enable/Disable TLS for OpenShift Route | `true` |
66 | | `route.tls.termination` | TLS Termination of the route | `""` |
67 | | `route.tls.insecureEdgeTerminationPolicy` | TLS insecureEdgeTerminationPolicy of the route | `""` |
68 | | `sealedSecrets.autoFetchCert` | Load the cert from the Controller on start | `false` |
69 | | `sealedSecrets.controllerName` | Deployment name of the Controller | `sealed-secrets-controller` |
70 | | `sealedSecrets.controllerNamespace` | Namespace the Controller resides in | `kube-system` |
71 | | `sealedSecrets.cert` | Public-Key of your SealedSecrets controller | `""` |
72 | | `api.environment` | Additional API environment variables | `{}` |
73 |
--------------------------------------------------------------------------------
/ui/tests/test_ui.py:
--------------------------------------------------------------------------------
1 | import os
2 | from playwright.sync_api import sync_playwright, Page
3 |
4 |
5 | def test_ui_start():
6 | with sync_playwright() as ctx:
7 | browser = ctx.chromium.launch(headless=True)
8 | page = browser.new_page()
9 | page.goto("http://localhost:8080")
10 | page.wait_for_load_state("load")
11 | assert page.title() == "Kubeseal WebGui - Sealed Secrets Management"
12 | browser.close()
13 |
14 |
15 | def test_secret_form_with_value():
16 | with sync_playwright() as ctx:
17 | browser = ctx.chromium.launch(headless=True)
18 | page = browser.new_page()
19 | page.goto("http://localhost:8080")
20 | page.wait_for_load_state("load")
21 |
22 | disabled_encrypt_button(page)
23 | namespace_select(page)
24 | secret_name(page)
25 | scope_strict(page)
26 | add_secret_key_value(page)
27 | click_encrypt_button(page)
28 |
29 | browser.close()
30 |
31 |
32 | def test_secret_form_with_file():
33 | with sync_playwright() as ctx:
34 | browser = ctx.chromium.launch(headless=True)
35 | page = browser.new_page()
36 | page.goto("http://localhost:8080")
37 | page.wait_for_load_state("load")
38 |
39 | disabled_encrypt_button(page)
40 | namespace_select(page)
41 | secret_name(page)
42 | scope_strict(page)
43 | add_secret_key_file(page)
44 | click_encrypt_button(page)
45 |
46 | browser.close()
47 |
48 |
49 | def test_secret_form_with_invalid_file():
50 | with sync_playwright() as ctx:
51 | browser = ctx.chromium.launch(headless=True)
52 | page = browser.new_page()
53 | page.goto("http://localhost:8080")
54 | page.wait_for_load_state("load")
55 |
56 | file_input = page.locator('input[type="file"]#fileInput')
57 | # Try to upload a non-valid file
58 | invalid_file_path = os.path.join(os.getcwd(), "large_test_file.txt")
59 | with open(invalid_file_path, "w") as f:
60 | f.write(
61 | "X" * (10 * 1024 * 1024)
62 | ) # Create a 10MB file (assuming it's too large)
63 | file_input.set_input_files(invalid_file_path)
64 |
65 | # Check for the snackbar error message
66 | snackbar_message = page.locator("div[role='status']").get_by_text("File size should be less than 1 MB!")
67 | assert (
68 | snackbar_message.is_visible()
69 | ), "Snackbar error message should be visible for invalid file"
70 |
71 | if os.path.exists(invalid_file_path):
72 | os.remove(invalid_file_path)
73 | browser.close()
74 |
75 |
76 | def namespace_select(page: Page):
77 | input_selector = "input#namespaceSelection"
78 | page.wait_for_selector(input_selector, timeout=10000)
79 | page.click(input_selector)
80 | page.wait_for_selector(".v-list-item-title")
81 | suggestions = page.query_selector_all(".v-list-item-title")
82 | assert len(suggestions) > 0, "No suggestions found."
83 | first_suggestion_text = suggestions[0].inner_text()
84 | suggestions[0].click()
85 | selected_text_selector = ".v-autocomplete__selection-text"
86 | page.wait_for_selector(selected_text_selector, timeout=10000)
87 | displayed_text = page.inner_text(selected_text_selector)
88 | assert (
89 | displayed_text == first_suggestion_text
90 | ), f"Expected '{first_suggestion_text}', but got '{displayed_text}'"
91 |
92 |
93 | def secret_name(page: Page):
94 | input_selector = "#secretName"
95 | page.wait_for_selector(input_selector)
96 | input_text = "valid-secret-name"
97 | page.fill(input_selector, input_text)
98 | assert (
99 | page.input_value(input_selector) == input_text
100 | ), f"Expected {input_text}, but got {page.input_value(input_selector)}"
101 |
102 |
103 | def scope_strict(page: Page):
104 | select_selector = "div.v-select"
105 | page.wait_for_selector(select_selector)
106 | page.click(select_selector)
107 | item_selector = "div.v-list-item"
108 | page.wait_for_selector(item_selector)
109 | items = page.query_selector_all(item_selector)
110 | assert len(items) > 0, "No items found in the dropdown."
111 | for item in items:
112 | title_element = item.query_selector("div.v-list-item-title")
113 | if title_element and title_element.inner_text().strip() == "strict":
114 | item.click()
115 | break
116 |
117 |
118 | def add_secret_key_value(page: Page):
119 | page.wait_for_selector("textarea#secretKey")
120 | page.fill("textarea#secretKey", "my-secret-key")
121 | assert page.locator("textarea#secretKey").input_value() == "my-secret-key"
122 | page.wait_for_selector("textarea#secretValue")
123 | page.fill("textarea#secretValue", "my-secret-value")
124 | assert page.locator("textarea#secretValue").input_value() == "my-secret-value"
125 | file_input = page.locator('input[type="file"]#fileInput')
126 | assert (
127 | not file_input.is_enabled()
128 | ), "File input should be disabled when value is filled"
129 |
130 |
131 | def add_secret_key_file(page: Page):
132 | page.wait_for_selector("textarea#secretKey")
133 | page.fill("textarea#secretKey", "my-secret-key")
134 | assert page.locator("textarea#secretKey").input_value() == "my-secret-key"
135 |
136 | file_input = page.locator('input[type="file"]#fileInput')
137 | assert file_input.is_visible()
138 | test_file_path = os.path.join(os.getcwd(), "test_file.txt")
139 | with open(test_file_path, "w") as f:
140 | f.write("This is a test file.")
141 | file_input.set_input_files(test_file_path)
142 | if os.path.exists(test_file_path):
143 | os.remove(test_file_path)
144 |
145 |
146 | def disabled_encrypt_button(page: Page):
147 | encrypt_button = page.locator('button:has-text("Encrypt")')
148 | encrypt_button.wait_for()
149 | assert encrypt_button.is_disabled()
150 |
151 |
152 | def click_encrypt_button(page: Page):
153 | # Mock the fetchEncodedSecrets function by overriding it in the browser context
154 | page.evaluate(
155 | """
156 | const appElement = document.querySelector('#app');
157 | const vueApp = appElement.__vue_app__;
158 |
159 | if (vueApp) {
160 | vueApp._instance.proxy.fetchEncodedSecrets = function() {
161 | window.fetchEncodedSecretsCalled = true;
162 | };
163 | }
164 | """
165 | )
166 | encrypt_button = page.locator('button:has-text("Encrypt")')
167 | encrypt_button.wait_for()
168 | assert encrypt_button.is_enabled()
169 | encrypt_button.click()
170 | is_called = page.evaluate("window.fetchEncodedSecretsCalled === true")
171 | assert is_called, "fetchEncodedSecrets function was not called!"
172 |
--------------------------------------------------------------------------------
/kind-setup.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 | # Set-Up a kind cluster with kubeseal and kubeseal-webgui
3 | # The ui will listen on http://localhost:7180
4 |
5 | set -euo pipefail
6 |
7 | # Configuration
8 | readonly CLUSTER_NAME="chart-testing"
9 | readonly NAMESPACE="kubeseal-webgui"
10 | readonly E2E_NAMESPACE="e2e"
11 | HOSTNAME_FQDN="$(hostname -f)"
12 | readonly API_URL="https://${HOSTNAME_FQDN}:7143"
13 | readonly TIMEOUT="90s"
14 | readonly MAX_RETRIES=3
15 | readonly RETRY_DELAY=5
16 |
17 | # Colors for output
18 | readonly RED='\033[0;31m'
19 | readonly GREEN='\033[0;32m'
20 | readonly YELLOW='\033[1;33m'
21 | readonly NC='\033[0m' # No Color
22 |
23 | # Helper functions
24 | log_info() {
25 | echo -e "${GREEN}[INFO]${NC} $*"
26 | }
27 |
28 | log_warn() {
29 | echo -e "${YELLOW}[WARN]${NC} $*"
30 | }
31 |
32 | log_error() {
33 | echo -e "${RED}[ERROR]${NC} $*" >&2
34 | }
35 |
36 | wait_for_api() {
37 | local url="$1"
38 | local retries="$2"
39 | local delay="$3"
40 |
41 | for i in $(seq 1 "$retries"); do
42 | if curl -s -k -f "$url" > /dev/null 2>&1; then
43 | log_info "API is ready"
44 | return 0
45 | fi
46 | log_warn "Waiting for API (attempt $i/$retries)..."
47 | sleep "$delay"
48 | done
49 |
50 | log_error "API failed to become ready after $retries attempts"
51 | return 1
52 | }
53 |
54 | create_sealed_secret() {
55 | local name="$1"
56 | local namespace="$2"
57 | local scope="$3"
58 | local key="$4"
59 | local annotations="$5"
60 |
61 | local payload
62 | if [[ "$scope" == "strict" ]]; then
63 | payload="{\"secret\": \"$name\", \"namespace\": \"$namespace\", \"scope\": \"$scope\", \"secrets\": [{\"key\": \"$key\",\"value\": \"YQ==\"}]}"
64 | elif [[ "$scope" == "namespace-wide" ]]; then
65 | payload="{\"namespace\": \"$namespace\", \"scope\": \"$scope\", \"secrets\": [{\"key\": \"$key\",\"value\": \"YQ==\"}]}"
66 | else
67 | payload="{\"scope\": \"$scope\", \"secrets\": [{\"key\": \"$key\",\"value\": \"YQ==\"}]}"
68 | fi
69 |
70 | local encrypted_data
71 | encrypted_data=$(echo "$payload" | \
72 | curl -sf -H 'content-type: application/json' -X POST -k --data @- "${API_URL}/secrets" | \
73 | jq -r -s ".[0][] | select(.key==\"$key\") | \"$key: \" + .value")
74 |
75 | cat </dev/null || true
116 | if helm list -n kube-system | grep -q "^sealed-secrets"; then
117 | log_info "Sealed-secrets already installed, skipping"
118 | else
119 | helm install sealed-secrets -n kube-system \
120 | --set-string fullnameOverride=sealed-secrets-controller \
121 | sealed-secrets/sealed-secrets
122 | fi
123 |
124 | log_info "Installing ingress-nginx"
125 | if kubectl get namespace ingress-nginx &>/dev/null; then
126 | log_info "Ingress-nginx already installed, skipping"
127 | else
128 | kubectl apply -f https://raw.githubusercontent.com/kubernetes/ingress-nginx/main/deploy/static/provider/kind/deploy.yaml
129 | fi
130 | kubectl wait --namespace ingress-nginx \
131 | --for=condition=ready pod \
132 | --selector=app.kubernetes.io/component=controller \
133 | --timeout="$TIMEOUT"
134 |
135 | log_info "Building Docker images in parallel"
136 | docker build -t ghcr.io/jaydee94/kubeseal-webgui/api:snapshot -f Dockerfile.api . &
137 | api_build_pid=$!
138 | docker build -t ghcr.io/jaydee94/kubeseal-webgui/ui:snapshot -f Dockerfile.ui . &
139 | ui_build_pid=$!
140 |
141 | # Wait for both builds to complete
142 | wait "$api_build_pid" || { log_error "API build failed"; exit 1; }
143 | wait "$ui_build_pid" || { log_error "UI build failed"; exit 1; }
144 | log_info "Docker images built successfully"
145 |
146 | log_info "Loading images into kind cluster"
147 | kind load docker-image --name "$CLUSTER_NAME" \
148 | ghcr.io/jaydee94/kubeseal-webgui/api:snapshot \
149 | ghcr.io/jaydee94/kubeseal-webgui/ui:snapshot
150 |
151 | log_info "Creating namespace: $NAMESPACE"
152 | kubectl create namespace "$NAMESPACE" 2>/dev/null || log_info "Namespace '$NAMESPACE' already exists"
153 |
154 | log_info "Deploying kubeseal-webgui via Helm"
155 | helm template \
156 | --release-name e2e-test \
157 | --create-namespace \
158 | --namespace "$NAMESPACE" \
159 | --set api.image.tag=snapshot \
160 | --set api.url="$API_URL" \
161 | --set autoFetchCertResources=null \
162 | --set image.pullPolicy=Never \
163 | --set ingress.enabled=true \
164 | --set ingress.hostname="$(hostname -f)" \
165 | --set resources=null \
166 | --set sealedSecrets.autoFetchCert=true \
167 | --set ui.image.tag=snapshot \
168 | --set securityContext.runAsUser=1042 \
169 | chart/kubeseal-webgui \
170 | | kubectl apply -f - --namespace "$NAMESPACE"
171 |
172 | log_info "Restarting deployment to use new images"
173 | kubectl rollout restart deployment/e2e-test-kubeseal-webgui -n "$NAMESPACE"
174 |
175 | log_info "Waiting for kubeseal-webgui deployment to complete"
176 | kubectl rollout status deployment/e2e-test-kubeseal-webgui -n "$NAMESPACE" --timeout="$TIMEOUT"
177 |
178 | kubectl wait --namespace "$NAMESPACE" \
179 | --for=condition=ready pod \
180 | --selector=app=kubeseal-webgui \
181 | --timeout="$TIMEOUT"
182 |
183 | log_info "Verifying API accessibility"
184 | wait_for_api "$API_URL" "$MAX_RETRIES" "$RETRY_DELAY"
185 |
186 | log_info "Creating test namespace: $E2E_NAMESPACE"
187 | kubectl create namespace "$E2E_NAMESPACE" 2>/dev/null || log_info "Namespace '$E2E_NAMESPACE' already exists"
188 |
189 | log_info "Creating sealed secrets for testing"
190 | create_sealed_secret "strict-secret" "$E2E_NAMESPACE" "strict" "a-secret" "{ }"
191 | create_sealed_secret "namespace-secret" "$E2E_NAMESPACE" "namespace-wide" "a-secret" "{ sealedsecrets.bitnami.com/namespace-wide: \"true\" }"
192 | create_sealed_secret "cluster-secret" "$E2E_NAMESPACE" "cluster-wide" "a-secret" "{ sealedsecrets.bitnami.com/cluster-wide: \"true\" }"
193 |
194 | log_info "Waiting for secrets to be unsealed"
195 | sleep 5
196 |
197 | log_info "Verifying unsealed secrets"
198 | for secret_name in strict-secret namespace-secret cluster-secret; do
199 | if [[ "$(kubectl get secret "$secret_name" -n "$E2E_NAMESPACE" \
200 | -o go-template --template '{{ index .data "a-secret" }}')" == "YQ==" ]]; then
201 | log_info "Testing ${secret_name}: OK"
202 | else
203 | log_error "Secret $secret_name verification failed"
204 | exit 1
205 | fi
206 | done
207 |
208 | log_info "Setup complete! Access the UI at: http://$(hostname -f):7180"
209 |
--------------------------------------------------------------------------------
/api/tests/test_run_kubeseal.py:
--------------------------------------------------------------------------------
1 | from base64 import b64encode
2 | from unittest.mock import MagicMock, patch
3 |
4 | import pytest
5 |
6 | from kubeseal_webgui_api.routers.kubeseal import (
7 | Scope,
8 | decode_base64_bytearray,
9 | decode_base64_string,
10 | run_kubeseal,
11 | valid_k8s_name,
12 | )
13 | from kubeseal_webgui_api.routers.models import Secret
14 |
15 |
16 | @pytest.mark.parametrize(
17 | "value",
18 | [
19 | "abc",
20 | "l" + "o" * 60 + "ng",
21 | "some-1-too-check",
22 | "ends-on-digit-1",
23 | "1starts-with-it",
24 | "some.dots.or_underscore",
25 | "long-" + "a" * 248,
26 | ],
27 | )
28 | def test_valid_k8s_name(value):
29 | # given a valid k8s-label-name
30 | # when valid_k8s_name is called on the label-name
31 | # then the label-name is returned unchanged
32 | assert valid_k8s_name(value) == value
33 |
34 |
35 | @pytest.mark.parametrize(
36 | "value",
37 | [
38 | "",
39 | "-something",
40 | "too-l" + "o" * 247 + "ng",
41 | "ähm",
42 | "_not-valid",
43 | "with spaces",
44 | " not-trimmed ",
45 | "no-special-chars-like/,#+%",
46 | "ends-on-dash-",
47 | "Uppercase",
48 | "U",
49 | "uPPer",
50 | ],
51 | )
52 | def test_invalid_k8s_name(value):
53 | # given an invalid k8s-label-name
54 | # when valid_k8s_name is called on the label-name
55 | # then a ValueError is raised
56 | with pytest.raises(ValueError, match="Invalid k8s name"):
57 | valid_k8s_name(value)
58 |
59 |
60 | def test_run_kubeseal_with_with_empty_string_namespace():
61 | # given an empty string secretNamespace
62 | # when run_kubeseal is called
63 | # then raise ValueError
64 | with pytest.raises(ValueError, match="secret_namespace was not given"):
65 | run_kubeseal([Secret(key="foo", value="YmFy")], "", "secretName")
66 |
67 |
68 | def test_run_kubeseal_with_with_none_namespace():
69 | # given a None secretNamespace
70 | # when run_kubeseal is called
71 | # then raise ValueError
72 | with pytest.raises(ValueError, match="secret_namespace was not given"):
73 | run_kubeseal([Secret(key="foo", value="YmFy")], None, "secretName")
74 |
75 |
76 | def test_run_kubeseal_with_with_empty_string_secret_name():
77 | # given an empty string secretName
78 | # when run_kubeseal is called
79 | # then raise ValueError
80 | with pytest.raises(ValueError, match="secret_name was not given"):
81 | run_kubeseal([Secret(key="foo", value="YmFy")], "secretNamespace", "")
82 |
83 |
84 | def test_run_kubeseal_with_with_none_secret_name():
85 | # given a None secretName
86 | # when run_kubeseal is called
87 | # then raise ValueError
88 | with pytest.raises(ValueError, match="secret_name was not given"):
89 | run_kubeseal([Secret(key="foo", value="YmFy")], "secretNamespace", None)
90 |
91 |
92 | def test_run_kubeseal_with_with_empty_secrets_list_but_otherwise_valid_inputs():
93 | # given an empty list
94 | # when run_kubeseal is called
95 | sealed_secrets = run_kubeseal([], "secretNamespace", "secretName")
96 | # then return empty list
97 | assert sealed_secrets == []
98 |
99 |
100 | @patch("kubeseal_webgui_api.routers.kubeseal.run_kubeseal_command")
101 | @pytest.mark.parametrize("scope", list(Scope))
102 | def test_run_kubeseal_with_scope(mock_run_command: MagicMock, scope: Scope):
103 | encoded_value = b64encode(b"something").decode("ascii")
104 | secrets = [Secret(key="foo", value=encoded_value)]
105 | run_kubeseal(
106 | secrets,
107 | "namespace",
108 | "name",
109 | scope,
110 | )
111 | mock_run_command.assert_called_with(secrets[0], "namespace", "name", scope)
112 |
113 |
114 | @patch("kubeseal_webgui_api.routers.kubeseal.run_kubeseal_command")
115 | @pytest.mark.parametrize("scope", list(Scope))
116 | def test_run_kubeseal_with_scope_needed_params_only(
117 | mock_run_command: MagicMock, scope: Scope
118 | ):
119 | encoded_value = b64encode(b"something").decode("ascii")
120 | secrets = [
121 | Secret(
122 | key="foo",
123 | value=encoded_value,
124 | )
125 | ]
126 | if scope.needs_namespace():
127 | namespace = "namespace"
128 | else:
129 | namespace = None
130 | if scope.needs_name():
131 | name = "name"
132 | else:
133 | name = None
134 | run_kubeseal(
135 | secrets,
136 | namespace,
137 | name,
138 | scope,
139 | )
140 | mock_run_command.assert_called_with(secrets[0], namespace, name, scope)
141 |
142 |
143 | @pytest.mark.container()
144 | @pytest.mark.cluster()
145 | def test_run_kubeseal_with_cli():
146 | # given run test against cli with test cluster
147 | # when run_kubeseal is called
148 | # then return valid encrypted secret
149 | pass
150 |
151 |
152 | @pytest.mark.cluster()
153 | @patch(
154 | "kubeseal_webgui_api.app_config.settings.kubeseal_binary", "/bin/no-such-thing-here"
155 | )
156 | def test_run_kubeseal_without_cli():
157 | # given k8s cluster but no kubeseal cli
158 | # when run_kubeseal is called
159 | # then raise RuntimeError
160 | with pytest.raises(RuntimeError):
161 | run_kubeseal(
162 | [Secret(key="foo", value="YmFy")], "secret-namespace", "secret-name"
163 | )
164 |
165 |
166 | def test_run_kubeseal_with_invalid_secrets_list_but_otherwise_valid_inputs():
167 | # given a secret list with string element
168 | # when run_kubeseal is called
169 | # then raise ValueError
170 | with pytest.raises(
171 | ValueError, match="Input of cleartext_secrets was not a list of dicts."
172 | ):
173 | run_kubeseal(["this-should-be-a-dict-object"], "secretNamespace", "secretName") # type: ignore
174 |
175 |
176 | @pytest.mark.container()
177 | def test_run_kubeseal_without_k8s_cluster():
178 | # given kubeseal cli but no k8s cluster
179 | # when run_kubeseal is called
180 | # then raise RuntimeError
181 | with pytest.raises(RuntimeError) as error_cert_missing:
182 | run_kubeseal(
183 | [Secret(key="foo", value="YmFy")], "secret-namespace", "secret-name"
184 | )
185 | assert "/kubeseal-webgui/cert/kubeseal-cert.pem: no such file or directory" in str(
186 | error_cert_missing
187 | )
188 |
189 |
190 | @pytest.mark.parametrize(
191 | ("base64_input", "expected_output"),
192 | [("YWJjZGVm", "abcdef"), ("w6TDtsO8", "äöü"), ("LV8jIT8kwqc=", "-_#!?$§")],
193 | )
194 | def test_decode_base64_string(base64_input, expected_output):
195 | """
196 | Test decode_base64_string.
197 |
198 | Given a tuple with a Base64 input string and the corresponding output string.
199 | When calling decode_base64_string on input string.
200 | Then return the corresponding output string.
201 | """
202 | base64_encoded_string = decode_base64_string(base64_input)
203 | assert base64_encoded_string == expected_output
204 |
205 |
206 | @pytest.mark.parametrize(
207 | ("base64_input", "expected_output"),
208 | [
209 | ("YWJjZGVm", b"abcdef"),
210 | ("w6TDtsO8", "äöü".encode("utf-8")),
211 | ("LV8jIT8kwqc=", "-_#!?$§".encode("utf-8")),
212 | ],
213 | )
214 | def test_decode_base64_bytearray(base64_input, expected_output):
215 | """
216 | Test decode_base64_bytearray.
217 |
218 | Given a tuple with a Base64 input string and the corresponding output bytearray.
219 | When calling decode_base64_bytearray on input string.
220 | Then return the corresponding output bytearray.
221 | """
222 | decoded = decode_base64_bytearray(base64_input)
223 | assert decoded == bytearray(expected_output)
224 |
--------------------------------------------------------------------------------
/api/kubeseal_webgui_api/routers/kubeseal.py:
--------------------------------------------------------------------------------
1 | import base64
2 | import logging
3 | import re
4 | import subprocess # noqa: S404 the binary has to be configured by an admin
5 | from typing import List, Optional, Union, overload
6 |
7 | from fastapi import APIRouter, HTTPException
8 |
9 | from kubeseal_webgui_api.app_config import settings
10 | from kubeseal_webgui_api.routers.models import Data, KeyValuePair, Scope, Secret
11 |
12 | router = APIRouter()
13 | LOGGER = logging.getLogger("kubeseal-webgui")
14 |
15 |
16 | @router.post("/secrets", response_model=List[KeyValuePair])
17 | def encrypt(data: Data) -> list[KeyValuePair]:
18 | try:
19 | return run_kubeseal(
20 | data.secrets,
21 | data.namespace,
22 | data.secret,
23 | data.scope or Scope.STRICT,
24 | )
25 | except (KeyError, ValueError) as e:
26 | raise HTTPException(400, f"Invalid data for sealing secrets: {e}")
27 | except RuntimeError:
28 | raise HTTPException(500, "Server is dreaming...")
29 |
30 |
31 | def is_blank(value: Optional[str]) -> bool:
32 | return value is None or value.strip() == ""
33 |
34 |
35 | def verify(name: str, value: Optional[str], mandatory: bool = True) -> None:
36 | if mandatory and is_blank(value):
37 | error_message = f"{name} was not given"
38 | LOGGER.error(error_message)
39 | raise ValueError(error_message)
40 |
41 |
42 | def run_kubeseal(
43 | cleartext_secrets: List[Secret],
44 | secret_namespace: Optional[str],
45 | secret_name: Optional[str],
46 | scope: Scope = Scope.STRICT,
47 | ) -> list[KeyValuePair]:
48 | """Check input and initiate kubeseal-cli call."""
49 |
50 | verify("secret_namespace", secret_namespace, scope.needs_namespace())
51 | verify("secret_name", secret_name, scope.needs_name())
52 |
53 | list_of_non_dict_inputs = [
54 | element for element in cleartext_secrets if not isinstance(element, Secret)
55 | ]
56 | if cleartext_secrets and list_of_non_dict_inputs:
57 | error_message = "Input of cleartext_secrets was not a list of dicts."
58 | raise ValueError(error_message)
59 |
60 | return [
61 | run_kubeseal_command(
62 | cleartext_secret_tuple, secret_namespace, secret_name, scope
63 | )
64 | for cleartext_secret_tuple in cleartext_secrets
65 | ]
66 |
67 |
68 | def valid_k8s_name(value: str | None) -> str:
69 | if not value:
70 | raise ValueError("Invalid k8s name: Must not be empty or None")
71 | if re.match(r"^[a-z0-9]([a-z0-9_.-]{,251}[a-z0-9])?$", value):
72 | return value
73 | raise ValueError(f"Invalid k8s name: {value}")
74 |
75 |
76 | def run_kubeseal_command(
77 | cleartext_secret_tuple: Secret,
78 | secret_namespace: Optional[str],
79 | secret_name: Optional[str],
80 | scope: Scope = Scope.STRICT,
81 | ) -> KeyValuePair:
82 | LOGGER.info(
83 | "Sealing secret '%s.%s' for namespace '%s' with scope '%s'.",
84 | secret_name,
85 | cleartext_secret_tuple.key,
86 | secret_namespace,
87 | scope,
88 | )
89 |
90 | if settings.mock_enabled:
91 | return KeyValuePair(
92 | key=cleartext_secret_tuple.key,
93 | value="AgBy3... (Mock Sealed Secret)"
94 | )
95 |
96 | if cleartext_secret_tuple.value is not None:
97 | cleartext_secret = decode_base64_string(cleartext_secret_tuple.value)
98 | return encrypt_value_or_file(
99 | cleartext_secret_tuple,
100 | secret_namespace,
101 | secret_name,
102 | cleartext_secret,
103 | settings.kubeseal_binary,
104 | settings.kubeseal_cert,
105 | scope,
106 | )
107 | if cleartext_secret_tuple.file is not None:
108 | file_secret = decode_base64_bytearray(cleartext_secret_tuple.file)
109 | return encrypt_value_or_file(
110 | cleartext_secret_tuple,
111 | secret_namespace,
112 | secret_name,
113 | file_secret,
114 | settings.kubeseal_binary,
115 | settings.kubeseal_cert,
116 | scope,
117 | encoding=None,
118 | )
119 | raise RuntimeError("Invalid parameters. Must have a file or a value")
120 |
121 |
122 | @overload
123 | def encrypt_value_or_file(
124 | cleartext_secret_tuple: Secret,
125 | secret_namespace: str | None,
126 | secret_name: str | None,
127 | cleartext_secret: str,
128 | binary: str,
129 | cert: str,
130 | scope: Scope,
131 | encoding: str = "utf-8",
132 | ) -> KeyValuePair: ...
133 |
134 |
135 | @overload
136 | def encrypt_value_or_file(
137 | cleartext_secret_tuple: Secret,
138 | secret_namespace: str | None,
139 | secret_name: str | None,
140 | cleartext_secret: bytearray,
141 | binary: str,
142 | cert: str,
143 | scope: Scope,
144 | encoding: None = None,
145 | ) -> KeyValuePair: ...
146 |
147 |
148 | def encrypt_value_or_file(
149 | cleartext_secret_tuple: Secret,
150 | secret_namespace: str | None,
151 | secret_name: str | None,
152 | cleartext_secret: Union[str, bytearray],
153 | binary: str,
154 | cert: str,
155 | scope: Scope,
156 | encoding: Optional[str] = "utf-8",
157 | ) -> KeyValuePair:
158 | kubeseal_command_cmd = construct_kubeseal_cmd(
159 | secret_namespace, secret_name, binary, cert, scope
160 | )
161 | try:
162 | kubeseal_subprocess = subprocess.Popen( # noqa: S603 input has been checked above
163 | kubeseal_command_cmd,
164 | stdin=subprocess.PIPE,
165 | stdout=subprocess.PIPE,
166 | stderr=subprocess.PIPE,
167 | encoding=encoding,
168 | )
169 | except FileNotFoundError as file_error:
170 | raise RuntimeError("Could not find kubeseal binary") from file_error
171 |
172 | if encoding:
173 | output, error = kubeseal_subprocess.communicate(input=cleartext_secret)
174 | else:
175 | output_bytes, error_bytes = kubeseal_subprocess.communicate(
176 | input=cleartext_secret
177 | )
178 | output, error = output_bytes.decode("utf-8"), error_bytes.decode("utf-8")
179 |
180 | if error:
181 | error_message = f"Error in run_kubeseal: {error}"
182 | LOGGER.error(error_message)
183 | raise RuntimeError(error_message)
184 |
185 | sealed_secret = "".join(output.split("\n"))
186 | return KeyValuePair(key=cleartext_secret_tuple.key, value=sealed_secret)
187 |
188 |
189 | def construct_kubeseal_cmd(
190 | secret_namespace: str | None,
191 | secret_name: str | None,
192 | binary: str,
193 | cert: str,
194 | scope: Scope,
195 | ) -> list[str]:
196 | exec_kubeseal_command = [
197 | binary,
198 | "--raw",
199 | "--from-file=/dev/stdin",
200 | "--cert",
201 | cert,
202 | "--scope",
203 | scope.value,
204 | ]
205 | if scope.needs_namespace():
206 | exec_kubeseal_command.extend(
207 | [
208 | "--namespace",
209 | valid_k8s_name(secret_namespace),
210 | ]
211 | )
212 | if scope.needs_name():
213 | exec_kubeseal_command.extend(
214 | [
215 | "--name",
216 | valid_k8s_name(secret_name),
217 | ]
218 | )
219 |
220 | return exec_kubeseal_command
221 |
222 |
223 | def decode_base64_string(base64_string_message: str) -> str:
224 | """Decode base64 ascii-encoded input."""
225 | base64_bytes = base64_string_message.encode("ascii")
226 | message_bytes = base64.b64decode(base64_bytes)
227 | return message_bytes.decode("utf-8")
228 |
229 |
230 | def decode_base64_bytearray(base64_string_message: str) -> bytearray:
231 | """Decode base64 ascii-encoded input."""
232 | base64_bytes = base64_string_message.encode("ascii")
233 | return bytearray(base64.b64decode(base64_bytes))
234 |
--------------------------------------------------------------------------------
/ui/src/assets/styles.css:
--------------------------------------------------------------------------------
1 | /* Modern Design System - Global Styles */
2 |
3 | /* CSS Custom Properties */
4 | :root {
5 | /* Gradients */
6 | --gradient-primary: linear-gradient(135deg, #007bff 0%, #0056b3 100%);
7 | --gradient-secondary: linear-gradient(135deg, #fd7e14 0%, #e8590c 100%);
8 | --gradient-accent: linear-gradient(135deg, #17a2b8 0%, #117a8b 100%);
9 | --gradient-success: linear-gradient(135deg, #28a745 0%, #1e7e34 100%);
10 | --gradient-warm: linear-gradient(135deg, #fd7e14 0%, #ffc107 100%);
11 | --gradient-cool: linear-gradient(135deg, #007bff 0%, #17a2b8 100%);
12 |
13 | /* Glassmorphism - Minimal */
14 | --glass-bg: rgba(255, 255, 255, 0.05);
15 | --glass-bg-strong: rgba(255, 255, 255, 0.08);
16 | --glass-border: rgba(255, 255, 255, 0.1);
17 | --glass-shadow: 0 2px 8px 0 rgba(31, 38, 135, 0.15);
18 |
19 | /* Shadows - Minimal */
20 | --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05);
21 | --shadow-md: 0 2px 4px rgba(0, 0, 0, 0.08);
22 | --shadow-lg: 0 4px 8px rgba(0, 0, 0, 0.1);
23 | --shadow-xl: 0 8px 16px rgba(0, 0, 0, 0.12);
24 | --shadow-glow-primary: 0 4px 12px rgba(0, 123, 255, 0.2);
25 | --shadow-glow-secondary: 0 4px 12px rgba(253, 126, 20, 0.2);
26 |
27 | /* Transitions */
28 | --transition-fast: 0.15s cubic-bezier(0.4, 0, 0.2, 1);
29 | --transition-base: 0.3s cubic-bezier(0.4, 0, 0.2, 1);
30 | --transition-slow: 0.5s cubic-bezier(0.4, 0, 0.2, 1);
31 |
32 | /* Border Radius */
33 | --radius-sm: 8px;
34 | --radius-md: 12px;
35 | --radius-lg: 16px;
36 | --radius-xl: 24px;
37 | --radius-full: 9999px;
38 |
39 | /* Spacing Scale */
40 | --spacing-xs: 4px;
41 | --spacing-sm: 8px;
42 | --spacing-md: 16px;
43 | --spacing-lg: 24px;
44 | --spacing-xl: 32px;
45 | --spacing-2xl: 48px;
46 |
47 | /* Typography Scale */
48 | --font-size-xs: 0.75rem; /* 12px */
49 | --font-size-sm: 0.875rem; /* 14px */
50 | --font-size-base: 1rem; /* 16px */
51 | --font-size-lg: 1.125rem; /* 18px */
52 | --font-size-xl: 1.25rem; /* 20px */
53 | --font-size-2xl: 1.5rem; /* 24px */
54 | }
55 |
56 | /* Dark Mode Overrides - Minimal */
57 | .v-theme--dark {
58 | --glass-bg: rgba(0, 0, 0, 0.1);
59 | --glass-bg-strong: rgba(0, 0, 0, 0.15);
60 | --glass-border: rgba(255, 255, 255, 0.08);
61 | --glass-shadow: 0 2px 8px 0 rgba(0, 0, 0, 0.3);
62 | }
63 |
64 | /* Glassmorphism Utilities - Minimal */
65 | .glass {
66 | background: var(--glass-bg);
67 | backdrop-filter: blur(4px);
68 | -webkit-backdrop-filter: blur(4px);
69 | border: 1px solid var(--glass-border);
70 | box-shadow: var(--shadow-sm);
71 | }
72 |
73 | .glass-strong {
74 | background: var(--glass-bg-strong);
75 | backdrop-filter: blur(6px);
76 | -webkit-backdrop-filter: blur(6px);
77 | border: 1px solid var(--glass-border);
78 | box-shadow: var(--shadow-sm);
79 | }
80 |
81 | .glass-app-bar {
82 | background: linear-gradient(135deg, rgba(0, 123, 255, 0.03), rgba(253, 126, 20, 0.03)) !important;
83 | backdrop-filter: blur(4px);
84 | -webkit-backdrop-filter: blur(4px);
85 | border-bottom: 1px solid var(--glass-border) !important;
86 | box-shadow: var(--shadow-sm) !important;
87 | }
88 |
89 | /* Gradient Backgrounds */
90 | .bg-gradient-primary {
91 | background: var(--gradient-primary);
92 | }
93 |
94 | .bg-gradient-secondary {
95 | background: var(--gradient-secondary);
96 | }
97 |
98 | .bg-gradient-accent {
99 | background: var(--gradient-accent);
100 | }
101 |
102 | /* Gradient Text */
103 | .text-gradient-primary {
104 | background: var(--gradient-primary);
105 | -webkit-background-clip: text;
106 | -webkit-text-fill-color: transparent;
107 | background-clip: text;
108 | }
109 |
110 | .text-gradient-secondary {
111 | background: var(--gradient-secondary);
112 | -webkit-background-clip: text;
113 | -webkit-text-fill-color: transparent;
114 | background-clip: text;
115 | }
116 |
117 | .text-gradient-warm {
118 | background: var(--gradient-warm);
119 | -webkit-background-clip: text;
120 | -webkit-text-fill-color: transparent;
121 | background-clip: text;
122 | }
123 |
124 | /* Animation Utilities */
125 | .transition-all {
126 | transition: all var(--transition-base);
127 | }
128 |
129 | .transition-fast {
130 | transition: all var(--transition-fast);
131 | }
132 |
133 | .transition-slow {
134 | transition: all var(--transition-slow);
135 | }
136 |
137 | /* Hover Effects - Minimal */
138 | .hover-scale {
139 | transition: transform var(--transition-fast);
140 | }
141 |
142 | .hover-scale:hover {
143 | transform: scale(1.01);
144 | }
145 |
146 | .hover-lift {
147 | transition: transform var(--transition-fast), box-shadow var(--transition-fast);
148 | }
149 |
150 | .hover-lift:hover {
151 | transform: translateY(-1px);
152 | box-shadow: var(--shadow-md);
153 | }
154 |
155 | .hover-glow-primary {
156 | transition: box-shadow var(--transition-base);
157 | }
158 |
159 | .hover-glow-primary:hover {
160 | box-shadow: var(--shadow-glow-primary);
161 | }
162 |
163 | .hover-glow-secondary {
164 | transition: box-shadow var(--transition-base);
165 | }
166 |
167 | .hover-glow-secondary:hover {
168 | box-shadow: var(--shadow-glow-secondary);
169 | }
170 |
171 | /* Pulse Animation */
172 | @keyframes pulse {
173 | 0%, 100% {
174 | opacity: 1;
175 | transform: scale(1);
176 | }
177 | 50% {
178 | opacity: 0.9;
179 | transform: scale(1.02);
180 | }
181 | }
182 |
183 | .pulse {
184 | animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
185 | }
186 |
187 | /* Fade Animations */
188 | .fade-enter-active,
189 | .fade-leave-active {
190 | transition: opacity var(--transition-base);
191 | }
192 |
193 | .fade-enter-from,
194 | .fade-leave-to {
195 | opacity: 0;
196 | }
197 |
198 | /* Slide Animations */
199 | .slide-up-enter-active,
200 | .slide-up-leave-active {
201 | transition: all var(--transition-base);
202 | }
203 |
204 | .slide-up-enter-from {
205 | opacity: 0;
206 | transform: translateY(20px);
207 | }
208 |
209 | .slide-up-leave-to {
210 | opacity: 0;
211 | transform: translateY(-20px);
212 | }
213 |
214 | /* Modern Card Styles - Minimal */
215 | .modern-card {
216 | border-radius: var(--radius-md) !important;
217 | transition: all var(--transition-fast) !important;
218 | overflow: hidden;
219 | border: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
220 | }
221 |
222 | .modern-card:hover {
223 | box-shadow: var(--shadow-md) !important;
224 | border-color: rgba(var(--v-theme-primary), 0.3);
225 | }
226 |
227 | /* Card Accents */
228 | .result-card {
229 | border-top: 4px solid rgb(var(--v-theme-success)) !important;
230 | }
231 |
232 | .secret-key-card {
233 | border-left: 4px solid rgb(var(--v-theme-primary)) !important;
234 | }
235 |
236 | /* Modern Button Styles - Minimal */
237 | .modern-btn-gradient {
238 | border-radius: var(--radius-md) !important;
239 | transition: all var(--transition-fast) !important;
240 | }
241 |
242 | .encrypt-btn {
243 | border-radius: var(--radius-md) !important;
244 | font-size: 1.1rem !important;
245 | font-weight: 600 !important;
246 | letter-spacing: 0.5px !important;
247 | text-transform: none !important;
248 | background: rgb(var(--v-theme-primary)) !important;
249 | color: rgb(var(--v-theme-on-primary)) !important;
250 | transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1) !important;
251 | box-shadow: 0 4px 12px rgba(var(--v-theme-primary), 0.25) !important;
252 | height: 56px !important;
253 | border: 1px solid rgba(255, 255, 255, 0.1) !important;
254 | }
255 |
256 | .encrypt-btn .v-icon {
257 | transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
258 | }
259 |
260 | .encrypt-btn:hover:not(:disabled) {
261 | transform: translateY(-2px);
262 | box-shadow: 0 8px 20px rgba(var(--v-theme-primary), 0.35) !important;
263 | filter: brightness(1.05);
264 | }
265 |
266 | .encrypt-btn:hover .v-icon {
267 | transform: scale(1.1) rotate(-10deg);
268 | }
269 |
270 | .encrypt-btn:active:not(:disabled) {
271 | transform: translateY(0);
272 | box-shadow: 0 2px 8px rgba(var(--v-theme-primary), 0.25) !important;
273 | }
274 |
275 |
276 |
277 | /* Gradient Border */
278 | .gradient-border {
279 | position: relative;
280 | border-radius: var(--radius-md);
281 | padding: 2px;
282 | background: var(--gradient-primary);
283 | }
284 |
285 | .gradient-border::before {
286 | content: '';
287 | position: absolute;
288 | inset: 2px;
289 | background: rgb(var(--v-theme-surface));
290 | border-radius: calc(var(--radius-md) - 2px);
291 | z-index: -1;
292 | }
293 |
294 | /* Smooth Scroll */
295 | html {
296 | scroll-behavior: smooth;
297 | }
298 |
299 | /* Custom Scrollbar */
300 | ::-webkit-scrollbar {
301 | width: 8px;
302 | height: 8px;
303 | }
304 |
305 | ::-webkit-scrollbar-track {
306 | background: rgba(0, 0, 0, 0.05);
307 | border-radius: var(--radius-full);
308 | }
309 |
310 | ::-webkit-scrollbar-thumb {
311 | background: var(--gradient-primary);
312 | border-radius: var(--radius-full);
313 | transition: background var(--transition-base);
314 | }
315 |
316 | ::-webkit-scrollbar-thumb:hover {
317 | background: var(--gradient-secondary);
318 | }
319 |
320 | /* Loading Shimmer Effect */
321 | @keyframes shimmer {
322 | 0% {
323 | background-position: -1000px 0;
324 | }
325 | 100% {
326 | background-position: 1000px 0;
327 | }
328 | }
329 |
330 | .shimmer {
331 | animation: shimmer 2s linear infinite;
332 | background: linear-gradient(
333 | to right,
334 | rgba(255, 255, 255, 0) 0%,
335 | rgba(255, 255, 255, 0.3) 50%,
336 | rgba(255, 255, 255, 0) 100%
337 | );
338 | background-size: 1000px 100%;
339 | }
340 |
341 | /* Focus Styles */
342 | .v-field--focused {
343 | box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.2) !important;
344 | }
345 |
346 | /* Modern Input Styles */
347 | .modern-input .v-field__outline {
348 | --v-field-border-opacity: 0.15;
349 | }
350 |
351 | .modern-input.v-input--is-focused .v-field__outline {
352 | --v-field-border-opacity: 0.5;
353 | }
354 |
355 | .modern-input .v-field--focused {
356 | box-shadow: 0 0 0 4px rgba(0, 123, 255, 0.1);
357 | }
358 |
359 | /* Add colored left border on focus for better visibility */
360 | .modern-input.v-input--is-focused .v-field__input {
361 | border-left: 3px solid rgb(var(--v-theme-primary));
362 | padding-left: calc(var(--v-field-padding-start) - 3px);
363 | border-top-left-radius: 0;
364 | border-bottom-left-radius: 0;
365 | }
366 |
367 | .modern-input .v-field {
368 | border-radius: var(--radius-md) !important;
369 | transition: all var(--transition-base) !important;
370 | }
371 |
372 | .modern-input .v-field:hover {
373 | box-shadow: var(--shadow-sm) !important;
374 | }
375 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | Apache License
2 | Version 2.0, January 2004
3 | http://www.apache.org/licenses/
4 |
5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6 |
7 | 1. Definitions.
8 |
9 | "License" shall mean the terms and conditions for use, reproduction,
10 | and distribution as defined by Sections 1 through 9 of this document.
11 |
12 | "Licensor" shall mean the copyright owner or entity authorized by
13 | the copyright owner that is granting the License.
14 |
15 | "Legal Entity" shall mean the union of the acting entity and all
16 | other entities that control, are controlled by, or are under common
17 | control with that entity. For the purposes of this definition,
18 | "control" means (i) the power, direct or indirect, to cause the
19 | direction or management of such entity, whether by contract or
20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the
21 | outstanding shares, or (iii) beneficial ownership of such entity.
22 |
23 | "You" (or "Your") shall mean an individual or Legal Entity
24 | exercising permissions granted by this License.
25 |
26 | "Source" form shall mean the preferred form for making modifications,
27 | including but not limited to software source code, documentation
28 | source, and configuration files.
29 |
30 | "Object" form shall mean any form resulting from mechanical
31 | transformation or translation of a Source form, including but
32 | not limited to compiled object code, generated documentation,
33 | and conversions to other media types.
34 |
35 | "Work" shall mean the work of authorship, whether in Source or
36 | Object form, made available under the License, as indicated by a
37 | copyright notice that is included in or attached to the work
38 | (an example is provided in the Appendix below).
39 |
40 | "Derivative Works" shall mean any work, whether in Source or Object
41 | form, that is based on (or derived from) the Work and for which the
42 | editorial revisions, annotations, elaborations, or other modifications
43 | represent, as a whole, an original work of authorship. For the purposes
44 | of this License, Derivative Works shall not include works that remain
45 | separable from, or merely link (or bind by name) to the interfaces of,
46 | the Work and Derivative Works thereof.
47 |
48 | "Contribution" shall mean any work of authorship, including
49 | the original version of the Work and any modifications or additions
50 | to that Work or Derivative Works thereof, that is intentionally
51 | submitted to Licensor for inclusion in the Work by the copyright owner
52 | or by an individual or Legal Entity authorized to submit on behalf of
53 | the copyright owner. For the purposes of this definition, "submitted"
54 | means any form of electronic, verbal, or written communication sent
55 | to the Licensor or its representatives, including but not limited to
56 | communication on electronic mailing lists, source code control systems,
57 | and issue tracking systems that are managed by, or on behalf of, the
58 | Licensor for the purpose of discussing and improving the Work, but
59 | excluding communication that is conspicuously marked or otherwise
60 | designated in writing by the copyright owner as "Not a Contribution."
61 |
62 | "Contributor" shall mean Licensor and any individual or Legal Entity
63 | on behalf of whom a Contribution has been received by Licensor and
64 | subsequently incorporated within the Work.
65 |
66 | 2. Grant of Copyright License. Subject to the terms and conditions of
67 | this License, each Contributor hereby grants to You a perpetual,
68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable
69 | copyright license to reproduce, prepare Derivative Works of,
70 | publicly display, publicly perform, sublicense, and distribute the
71 | Work and such Derivative Works in Source or Object form.
72 |
73 | 3. Grant of Patent License. Subject to the terms and conditions of
74 | this License, each Contributor hereby grants to You a perpetual,
75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable
76 | (except as stated in this section) patent license to make, have made,
77 | use, offer to sell, sell, import, and otherwise transfer the Work,
78 | where such license applies only to those patent claims licensable
79 | by such Contributor that are necessarily infringed by their
80 | Contribution(s) alone or by combination of their Contribution(s)
81 | with the Work to which such Contribution(s) was submitted. If You
82 | institute patent litigation against any entity (including a
83 | cross-claim or counterclaim in a lawsuit) alleging that the Work
84 | or a Contribution incorporated within the Work constitutes direct
85 | or contributory patent infringement, then any patent licenses
86 | granted to You under this License for that Work shall terminate
87 | as of the date such litigation is filed.
88 |
89 | 4. Redistribution. You may reproduce and distribute copies of the
90 | Work or Derivative Works thereof in any medium, with or without
91 | modifications, and in Source or Object form, provided that You
92 | meet the following conditions:
93 |
94 | (a) You must give any other recipients of the Work or
95 | Derivative Works a copy of this License; and
96 |
97 | (b) You must cause any modified files to carry prominent notices
98 | stating that You changed the files; and
99 |
100 | (c) You must retain, in the Source form of any Derivative Works
101 | that You distribute, all copyright, patent, trademark, and
102 | attribution notices from the Source form of the Work,
103 | excluding those notices that do not pertain to any part of
104 | the Derivative Works; and
105 |
106 | (d) If the Work includes a "NOTICE" text file as part of its
107 | distribution, then any Derivative Works that You distribute must
108 | include a readable copy of the attribution notices contained
109 | within such NOTICE file, excluding those notices that do not
110 | pertain to any part of the Derivative Works, in at least one
111 | of the following places: within a NOTICE text file distributed
112 | as part of the Derivative Works; within the Source form or
113 | documentation, if provided along with the Derivative Works; or,
114 | within a display generated by the Derivative Works, if and
115 | wherever such third-party notices normally appear. The contents
116 | of the NOTICE file are for informational purposes only and
117 | do not modify the License. You may add Your own attribution
118 | notices within Derivative Works that You distribute, alongside
119 | or as an addendum to the NOTICE text from the Work, provided
120 | that such additional attribution notices cannot be construed
121 | as modifying the License.
122 |
123 | You may add Your own copyright statement to Your modifications and
124 | may provide additional or different license terms and conditions
125 | for use, reproduction, or distribution of Your modifications, or
126 | for any such Derivative Works as a whole, provided Your use,
127 | reproduction, and distribution of the Work otherwise complies with
128 | the conditions stated in this License.
129 |
130 | 5. Submission of Contributions. Unless You explicitly state otherwise,
131 | any Contribution intentionally submitted for inclusion in the Work
132 | by You to the Licensor shall be under the terms and conditions of
133 | this License, without any additional terms or conditions.
134 | Notwithstanding the above, nothing herein shall supersede or modify
135 | the terms of any separate license agreement you may have executed
136 | with Licensor regarding such Contributions.
137 |
138 | 6. Trademarks. This License does not grant permission to use the trade
139 | names, trademarks, service marks, or product names of the Licensor,
140 | except as required for reasonable and customary use in describing the
141 | origin of the Work and reproducing the content of the NOTICE file.
142 |
143 | 7. Disclaimer of Warranty. Unless required by applicable law or
144 | agreed to in writing, Licensor provides the Work (and each
145 | Contributor provides its Contributions) on an "AS IS" BASIS,
146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
147 | implied, including, without limitation, any warranties or conditions
148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
149 | PARTICULAR PURPOSE. You are solely responsible for determining the
150 | appropriateness of using or redistributing the Work and assume any
151 | risks associated with Your exercise of permissions under this License.
152 |
153 | 8. Limitation of Liability. In no event and under no legal theory,
154 | whether in tort (including negligence), contract, or otherwise,
155 | unless required by applicable law (such as deliberate and grossly
156 | negligent acts) or agreed to in writing, shall any Contributor be
157 | liable to You for damages, including any direct, indirect, special,
158 | incidental, or consequential damages of any character arising as a
159 | result of this License or out of the use or inability to use the
160 | Work (including but not limited to damages for loss of goodwill,
161 | work stoppage, computer failure or malfunction, or any and all
162 | other commercial damages or losses), even if such Contributor
163 | has been advised of the possibility of such damages.
164 |
165 | 9. Accepting Warranty or Additional Liability. While redistributing
166 | the Work or Derivative Works thereof, You may choose to offer,
167 | and charge a fee for, acceptance of support, warranty, indemnity,
168 | or other liability obligations and/or rights consistent with this
169 | License. However, in accepting such obligations, You may act only
170 | on Your own behalf and on Your sole responsibility, not on behalf
171 | of any other Contributor, and only if You agree to indemnify,
172 | defend, and hold each Contributor harmless for any liability
173 | incurred by, or claims asserted against, such Contributor by reason
174 | of your accepting any such warranty or additional liability.
175 |
176 | END OF TERMS AND CONDITIONS
177 |
--------------------------------------------------------------------------------
/ui/src/components/Secrets.vue:
--------------------------------------------------------------------------------
1 |
2 |