diff --git a/.env.example b/.env.example index b44289db..ff95a40e 100644 --- a/.env.example +++ b/.env.example @@ -22,6 +22,13 @@ POSTGRES_PASSWORD=postgres POSTGRES_DB=envsync POSTGRES_PORT=5432 +# --- Dynamic Secret E2E Testing (separate Postgres for lease creation tests) --- +DYNAMIC_SECRET_PG_HOST=localhost +DYNAMIC_SECRET_PG_PORT=5433 +DYNAMIC_SECRET_PG_USER=dynroot +DYNAMIC_SECRET_PG_PASSWORD=dynrootpass +DYNAMIC_SECRET_PG_DATABASE=dynamic_secrets + # --- S3 / RustFS --- S3_BUCKET=envsync-bucket S3_REGION=us-east-1 diff --git a/.gitignore b/.gitignore index c1b08322..27d2e3c7 100644 --- a/.gitignore +++ b/.gitignore @@ -48,3 +48,4 @@ packages/license-server test-results *.log +.omo diff --git a/AGENTS.md b/AGENTS.md index 596a7279..299db9de 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,15 +6,23 @@ Environment variable management platform — store, sync, and manage secrets acr - `packages/envsync-api/` — Backend API (Hono + Bun) - `packages/envsync-cli/` — CLI client (Go) +- `packages/envsync-management-api/` — Enterprise management API (wraps envsync-api) +- `packages/envsync-keycloak-theme/` — Custom Keycloak login theme (Docker-bundled) +- `packages/deploy-cli/` — Self-hosted deployment CLI (TypeScript, published to npm) +- `packages/deploy-core/` — Shared deployment primitives (TypeScript) +- `packages/deploy/` — OSS deploy package (TypeScript) - `apps/envsync-web/` — Web dashboard (React + Vite) - `apps/envsync-landing/` — Marketing landing page (React + Vite) +- `apps/envsync-management-web/` — Enterprise management dashboard (React + Vite) - `sdks/envsync-ts-sdk/` — TypeScript SDK (auto-generated) - `sdks/envsync-go-sdk/` — Go SDK (auto-generated) +- `sdks/envsync-management-ts-sdk/` — Management API TypeScript SDK (auto-generated) +- `sdks/envsync-management-go-sdk/` — Management API Go SDK (auto-generated) ## Runtimes - Bun 1.3+ (TS packages, package manager) -- Go 1.21+ (CLI) +- Go 1.24+ (CLI) - Node 18+ (compatibility) ## Package manager @@ -63,6 +71,7 @@ Single `.env` file at the repo root. All TS packages read from it via the `load- - `.github/workflows/ci.yaml` — build + mock tests + e2e tests - `.github/workflows/deploy-fe.yaml` — frontend deployment - `.github/workflows/release.yml` — release workflow +- `.github/workflows/scan.yaml` — SonarQube static analysis (push to main) ## Cross-cutting conventions diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2d8cd9f9..c2c4fbd3 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -4,7 +4,7 @@ See the [README](README.md) and per-package `AGENTS.md` files for full architecture details. Essentials: -- **Bun 1.3+**, **Go 1.21+**, **Docker** +- **Bun 1.3+**, **Go 1.24+**, **Docker** - `bun install` — install all JS/TS dependencies - `docker compose up -d` — start Postgres, miniKMS, OpenFGA, Keycloak, Mailpit - Keycloak is built locally from `packages/envsync-keycloak-theme`; no GHCR auth is required for local auth/E2E diff --git a/README.md b/README.md index 4bcc59f2..7ec8723b 100644 --- a/README.md +++ b/README.md @@ -1,19 +1,110 @@ -# EnvSync Monorepo +

+ EnvSync Logo +

-EnvSync is an environment variable and secrets management platform with: -- a Bun API -- a Go CLI -- a React dashboard -- generated SDKs +

EnvSync

-## What Changed +

+ Ship environment variables without the drift. +

-The repo now uses: -- Keycloak instead of Zitadel -- ClickStack / HyperDX instead of the old Grafana/Loki/Tempo stack -- miniKMS for secret storage flows -- a local bootstrap flow that seeds ClickStack sources and dashboards automatically -- a locally built Keycloak image from `packages/envsync-keycloak-theme` for dev and E2E +

+ CLI-first secrets and config delivery for dev, staging, CI, and production. +

+ +

+ + CI Status + + + License + + + Version + + + Stars + +

+ +--- + +## Why EnvSync? + +**The problem:** `.env` files are the #1 source of credential leaks. Teams share secrets via Slack DMs, email threads, and Google Docs. Each copy is a potential breach. + +**The solution:** EnvSync provides a single source of truth for environment variables and secrets, with: +- 🔄 **Point-in-time rollback** — Undo any secret change to any previous state +- ✅ **Approval workflows** — Require review before production changes +- 🔐 **End-to-end encryption** — AES-256 at rest, TLS in transit +- 🚀 **CLI-first workflow** — `envsync pull` and `envsync push` in your terminal +- 🌐 **28+ integrations** — GitHub, GitLab, Vercel, AWS, and more + +--- + +## Quick Start + +```bash +# Install CLI +curl -fsSL https://cli.envsync.cloud/install.sh | sh + +# Login +envsync login + +# Pull secrets +envsync pull --env development + +# Push changes +envsync push --env staging +``` + +--- + +## Architecture + +``` +┌─────────────────────────────────────────────────────────────┐ +│ EnvSync Platform │ +├─────────────────────────────────────────────────────────────┤ +│ 🖥️ Dashboard │ ⌨️ CLI │ 📦 SDKs │ +│ (React + Vite) │ (Go) │ (TypeScript, Go) │ +└─────────┬─────────┴───────┬────────┴──────────┬────────────┘ + │ │ │ + ▼ ▼ ▼ +┌─────────────────────────────────────────────────────────────┐ +│ Core API │ +│ (Bun + Hono + PostgreSQL) │ +├─────────────────────────────────────────────────────────────┤ +│ 🔐 Secrets │ 🔄 Rotation │ 🌐 Integrations │ +│ 📋 Variables │ ⏰ Dynamic │ 📊 Audit Logs │ +│ 🔑 OIDC/SAML │ 📤 Log Fwd │ 🪝 Webhooks │ +└─────────────────────────────────────────────────────────────┘ +``` + +--- + +## Features + +### Core +| Feature | Description | +|---------|-------------| +| 🔐 **Secrets Management** | Store, sync, and manage secrets across teams | +| 📋 **Environment Variables** | Version-controlled config with rollback | +| 👥 **Team Management** | Users, teams, roles, and permissions | +| 📊 **Audit Logs** | Track every change with full history | +| 🔄 **Change Requests** | Approval workflows for production | + +### Enterprise +| Feature | Description | +|---------|-------------| +| 🔑 **OIDC Auth** | GitHub Actions, GitLab CI, K8s service accounts | +| 🛡️ **SAML SSO** | Okta, OneLogin, Azure AD, Google, Duo, Rippling | +| 🔄 **Secret Rotation** | Auto-rotate DB creds, AWS IAM, Azure SP | +| ⏰ **Dynamic Secrets** | Short-lived credentials with auto-expiry | +| 📤 **Log Forwarding** | Datadog, Splunk, Sumo Logic | +| 🌐 **28 Integrations** | GitHub, GitLab, Vercel, AWS, Azure, and more | + +--- ## Monorepo Layout @@ -23,160 +114,120 @@ The repo now uses: | `packages/envsync-cli` | Go CLI | | `apps/envsync-web` | React dashboard | | `apps/envsync-landing` | Landing page | -| `packages/deploy-cli` | self-hosted deployment CLI | -| `packages/envsync-keycloak-theme` | custom Keycloak theme | -| `sdks/` | generated TypeScript and Go SDKs | -| `scripts/` | local bootstrap and helper scripts | +| `packages/deploy-cli` | Self-hosted deployment CLI | +| `packages/envsync-keycloak-theme` | Custom Keycloak theme | +| `sdks/` | Generated TypeScript and Go SDKs | +| `scripts/` | Local bootstrap and helper scripts | -## Local Development +--- -Use this from the repo root: +## Local Development ```bash +# 1. Clone and setup +git clone https://github.com/EnvSync-Cloud/envsync.git +cd envsync cp .env.example .env bun install + +# 2. Start infrastructure docker compose up -d + +# 3. Initialize bun run cli:init bun run cli:create-dev-user --seed bun run clickstack:sync + +# 4. Start development bun run dev ``` -If you previously tried local auth on `localhost`, clear browser site data for both `localhost` and `*.lvh.me` before retrying login on `app.lvh.me`. - -What that does: -- creates local `.env` from `.env.example` -- starts local infra with Docker Compose -- runs DB migrations -- bootstraps RustFS and Keycloak clients -- builds the local Keycloak image from repo source when needed -- seeds a local dev user, org, apps, envs, secrets, and sample data -- seeds local ClickStack / HyperDX sources and dashboards -- starts the apps with Turbo - -`lvh.me` is a wildcard hostname that resolves to `127.0.0.1`, so `app.lvh.me`, `auth.lvh.me`, and `api.lvh.me` all point to your machine without editing `/etc/hosts`. - -Use `lvh.me` for browser-facing local auth. `localhost` is not the supported browser login path because Keycloak 26 treats `localhost` as a secure context and can break local auth cookies. - -## Local Services - -Main local endpoints: +### Local Services | Service | URL | |---------|-----| -| Dashboard | `http://app.lvh.me:8001` | -| API | `http://api.lvh.me:4000` | -| Keycloak | `http://auth.lvh.me:8080` | -| ClickStack / HyperDX | `http://localhost:8800` | -| Mailpit | `http://localhost:8025` | -| RustFS S3 API | `http://localhost:19000` | -| RustFS Console | `http://localhost:19001` | -| OpenFGA | `http://localhost:8090` | - -## Local HyperDX Login - -For local dev, `bun run clickstack:sync` seeds a default HyperDX user and dashboards. - -UI login: -- email: `local-operator@envsync.local` -- password: `EnvsyncLocal!123` - -Notes: -- the script also writes `.env.local` for the frontend telemetry config -- rerun `bun run clickstack:sync` after recreating the ClickStack container or volumes - -## Root Commands - -| Command | Description | -|--------|-------------| -| `bun run dev` | run API, web, and landing locally via Turbo | -| `bun run build` | build the workspace | -| `bun run cli:init` | local infra bootstrap, migrations, RustFS bucket, Keycloak client setup | -| `bun run cli:create-dev-user --seed` | create the local dev user and seed sample data | -| `bun run cli services up` | start Docker Compose services | -| `bun run cli services down` | stop Docker Compose services | -| `bun run cli services status` | inspect Docker Compose services | -| `bun run clickstack:sync` | sync local ClickStack OTLP config and seed sources/dashboards | -| `bun run clickstack:bootstrap` | reseed local ClickStack dashboards only | -| `bun run test:mock` | run mock tests | -| `bun run test:e2e` | run E2E tests from the repo root; it runs `e2e-setup init` first | - -## Sim Test - -Run the API simulation load test from the API package: +| 🖥️ Dashboard | `http://app.lvh.me:8001` | +| ⚡ API | `http://api.lvh.me:4000` | +| 🔐 Keycloak | `http://auth.lvh.me:8080` | +| 📊 HyperDX | `http://localhost:8800` | +| 📧 Mailpit | `http://localhost:8025` | +| 🗄️ RustFS | `http://localhost:19000` | +| 🔑 OpenFGA | `http://localhost:8090` | -```bash -cd packages/envsync-api -bun run scripts/sim.ts -``` +--- -Useful variants: +## CLI Usage ```bash -SIM_WORKERS=50 bun run scripts/sim.ts -SIM_WORKERS=200 SIM_DELAY_MS=0 bun run scripts/sim.ts -``` +# Authentication +envsync login +envsync whoami -## Auth +# Project management +envsync init +envsync app list -Keycloak is the only supported identity provider in the current stack. +# Secrets +envsync pull --env development +envsync push --env staging +envsync push --env production --strict -Canonical local browser auth flow: -- app: `http://app.lvh.me:8001` -- api callback: `http://api.lvh.me:4000/api/access/web/callback` -- auth: `http://auth.lvh.me:8080` +# Export +envsync export --format dotenv +envsync export --format json -Canonical local env vars: -- `KEYCLOAK_URL` -- `KEYCLOAK_REALM` -- `KEYCLOAK_ADMIN_USER` -- `KEYCLOAK_ADMIN_PASSWORD` -- `KEYCLOAK_WEB_CLIENT_ID` -- `KEYCLOAK_WEB_CLIENT_SECRET` -- `KEYCLOAK_CLI_CLIENT_ID` -- `KEYCLOAK_API_CLIENT_ID` -- `KEYCLOAK_API_CLIENT_SECRET` +# Run with secrets +envsync run -- npm start +``` + +--- -The local realm import lives under [docker/keycloak/realm-import](./docker/keycloak/realm-import). Self-hosted deploys generate a derived realm config during setup. -The local and E2E Keycloak container image is built from [packages/envsync-keycloak-theme](./packages/envsync-keycloak-theme), not pulled from GHCR. +## SDKs -## Observability +### TypeScript +```typescript +import { EnvSyncAPISDK } from '@envsync-cloud/envsync-ts-sdk'; -Local observability is ClickStack / HyperDX plus one OTEL collector: -- API, CLI, and browser telemetry flow into OTLP -- ClickStack stores traces, logs, and metrics -- local dashboards are seeded automatically +const sdk = new EnvSyncAPISDK({ BASE: 'https://api.envsync.cloud' }); +const secrets = await sdk.secrets.getSecrets({ app_id: 'my-app' }); +``` -If you recreate ClickStack state: +### Go +```go +import "github.com/EnvSync-Cloud/envsync/sdks/envsync-go-sdk/sdk" -```bash -bun run clickstack:sync +client := sdk.NewClient("https://api.envsync.cloud") +secrets, err := client.Secrets.GetSecrets(ctx, "my-app") ``` +--- + ## Self-Hosting -Self-hosted deployment now targets: +EnvSync supports self-hosted deployment with: - Docker Swarm - Traefik - Keycloak - ClickStack / HyperDX -- single-host Ubuntu/Debian in v1 - -See [SELFHOSTING.md](./SELFHOSTING.md). -## SDKs +See [SELFHOSTING.md](./SELFHOSTING.md) for details. -- [sdks/envsync-ts-sdk/README.md](./sdks/envsync-ts-sdk/README.md) -- [sdks/envsync-go-sdk/sdk/README.md](./sdks/envsync-go-sdk/sdk/README.md) +--- ## Contributing -1. Create a branch. -2. Make changes. -3. Run relevant tests. -4. Open a PR. +See [CONTRIBUTING.md](./CONTRIBUTING.md) for guidelines. + +--- ## Support -- Docs: [docs.envsync.cloud](https://docs.envsync.cloud) -- Issues: [GitHub Issues](https://github.com/EnvSync-Cloud/envsync/issues) +- 📖 Docs: [docs.envsync.cloud](https://docs.envsync.cloud) +- 🐛 Issues: [GitHub Issues](https://github.com/EnvSync-Cloud/envsync/issues) +- 💬 Discussions: [GitHub Discussions](https://github.com/EnvSync-Cloud/envsync/discussions) + +--- + +

+ Made with ❤️ by the EnvSync team +

diff --git a/apps/envsync-landing/index.html b/apps/envsync-landing/index.html index 30965922..cc755ccc 100644 --- a/apps/envsync-landing/index.html +++ b/apps/envsync-landing/index.html @@ -1,6 +1,6 @@ - + @@ -16,6 +16,28 @@ + + + + + + + + diff --git a/apps/envsync-landing/package.json b/apps/envsync-landing/package.json index 3c87c518..2d8ee8bc 100644 --- a/apps/envsync-landing/package.json +++ b/apps/envsync-landing/package.json @@ -1,7 +1,7 @@ { "name": "envsync-landing", "private": true, - "version": "0.9.1", + "version": "0.10.0", "type": "module", "scripts": { "dev": "vite", @@ -70,7 +70,6 @@ "framer-motion": "^12.35.1", "input-otp": "^1.2.4", "lucide-react": "^0.462.0", - "motion": "^12.35.1", "next-themes": "^0.3.0", "react": "^18.3.1", "react-day-picker": "^8.10.1", diff --git a/apps/envsync-landing/src/components/CTA.tsx b/apps/envsync-landing/src/components/CTA.tsx index 44a7d45a..0e222327 100644 --- a/apps/envsync-landing/src/components/CTA.tsx +++ b/apps/envsync-landing/src/components/CTA.tsx @@ -1,20 +1,13 @@ import { Button } from "@/components/ui/button"; import { ArrowRight, Github } from "lucide-react"; import { Link } from "react-router-dom"; -import { motion } from "framer-motion"; import { runtimeConfig } from "@/utils/runtime-config"; const CTA = () => { return ( -
+
- +
-
+
); diff --git a/apps/envsync-landing/src/components/Compare.tsx b/apps/envsync-landing/src/components/Compare.tsx index 074fc742..d54364c1 100644 --- a/apps/envsync-landing/src/components/Compare.tsx +++ b/apps/envsync-landing/src/components/Compare.tsx @@ -1,6 +1,5 @@ import { Check, Minus, X } from "lucide-react"; import { Badge } from "@/components/ui/badge"; -import { motion } from "framer-motion"; import { Table, TableBody, @@ -24,11 +23,7 @@ const rows: ComparisonRow[] = [ { feature: "Environment promotion flow", envsync: "check", doppler: "minus", vault: "minus", dotenv: "x" }, { feature: "Approval gates before prod", envsync: "check", doppler: "minus", vault: "minus", dotenv: "x" }, { feature: "Versioned rollback context", envsync: "check", doppler: "check", vault: "minus", dotenv: "x" }, - { feature: "CLI-first workflow", envsync: "check", doppler: "check", vault: "check", dotenv: "minus" }, - { feature: "CI injection path", envsync: "check", doppler: "check", vault: "minus", dotenv: "x" }, { feature: "Self-host option", envsync: "check", doppler: "x", vault: "check", dotenv: "check" }, - { feature: "Certificates and key ops", envsync: "check", doppler: "x", vault: "minus", dotenv: "x" }, - { feature: "Team-scoped access control", envsync: "check", doppler: "check", vault: "check", dotenv: "x" }, { feature: "Audit history on changes", envsync: "check", doppler: "check", vault: "check", dotenv: "x" }, ]; @@ -45,15 +40,9 @@ const CellIcon = ({ value }: { value: CellValue }) => { const Compare = () => { return ( -
+
- +
Compare
@@ -63,15 +52,9 @@ const Compare = () => {

The difference shows up when config moves through approvals, CI, and rollback.

- +
- +
@@ -109,7 +92,7 @@ const Compare = () => { ))}
- +
); diff --git a/apps/envsync-landing/src/components/FeatureVisuals.tsx b/apps/envsync-landing/src/components/FeatureVisuals.tsx index d769f789..143b208b 100644 --- a/apps/envsync-landing/src/components/FeatureVisuals.tsx +++ b/apps/envsync-landing/src/components/FeatureVisuals.tsx @@ -248,9 +248,9 @@ export const AccessVisual = () => (
{row.role}
- {row.values.map((value) => ( + {row.values.map((value, index) => (
{ return ( -
+
{

Workflow

diff --git a/apps/envsync-landing/src/components/Header.tsx b/apps/envsync-landing/src/components/Header.tsx index b55b8b53..496843e6 100644 --- a/apps/envsync-landing/src/components/Header.tsx +++ b/apps/envsync-landing/src/components/Header.tsx @@ -1,13 +1,39 @@ import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; -import { Menu, X } from "lucide-react"; -import { useState } from "react"; +import { Menu, Moon, Sun, X } from "lucide-react"; +import { useEffect, useRef, useState } from "react"; import { Link, useLocation } from "react-router-dom"; import { runtimeConfig } from "@/utils/runtime-config"; const Header = () => { const [isMenuOpen, setIsMenuOpen] = useState(false); const location = useLocation(); + const headerRef = useRef(null); + const [theme, setTheme] = useState<"light" | "dark">(() => { + if (typeof window !== "undefined") { + return document.documentElement.classList.contains("dark") ? "dark" : "light"; + } + return "dark"; + }); + + // Close mobile menu when clicking outside the header + useEffect(() => { + if (!isMenuOpen) return; + + const handleClickOutside = (event: MouseEvent) => { + if (headerRef.current && !headerRef.current.contains(event.target as Node)) { + setIsMenuOpen(false); + } + }; + + document.addEventListener("mousedown", handleClickOutside); + return () => document.removeEventListener("mousedown", handleClickOutside); + }, [isMenuOpen]); + + useEffect(() => { + document.documentElement.classList.toggle("dark", theme === "dark"); + localStorage.setItem("theme", theme); + }, [theme]); const navLinks = [ { label: "Integrations", href: "/integrations", external: false }, @@ -16,7 +42,7 @@ const Header = () => { ]; return ( -
+
@@ -51,6 +77,13 @@ const Header = () => { ), )} + @@ -104,6 +139,14 @@ const Header = () => { ), )}
+ - - - API Reference - -
-
- {[ - { icon: Terminal, title: "pull and push" }, - { icon: ShieldCheck, title: "approve before prod" }, - { icon: Github, title: "inject in CI" }, - ].map((item) => ( -
-
- - {item.title} -
-
- ))} -
+ + View API Reference → +
diff --git a/apps/envsync-landing/src/components/HowItWorks.tsx b/apps/envsync-landing/src/components/HowItWorks.tsx index 53f69367..5e6a8618 100644 --- a/apps/envsync-landing/src/components/HowItWorks.tsx +++ b/apps/envsync-landing/src/components/HowItWorks.tsx @@ -1,5 +1,4 @@ import { GitPullRequestArrow, ShieldCheck, Terminal, Workflow } from "lucide-react"; -import { motion } from "framer-motion"; const steps = [ { @@ -34,15 +33,9 @@ const steps = [ const HowItWorks = () => { return ( -
+
- +
Workflow
@@ -52,15 +45,9 @@ const HowItWorks = () => {

Connect, push, approve, and inject without inventing a new team ritual.

- +
- +
-
+
); diff --git a/apps/envsync-landing/src/components/NewCTA.tsx b/apps/envsync-landing/src/components/NewCTA.tsx new file mode 100644 index 00000000..4e29d907 --- /dev/null +++ b/apps/envsync-landing/src/components/NewCTA.tsx @@ -0,0 +1,36 @@ +import { Button } from "@/components/ui/button"; +import { ArrowRight, Github } from "lucide-react"; +import { Link } from "react-router-dom"; + +const NewCTA = () => { + return ( +
+
+
+

+ Ready to stop the .env chaos? +

+

+ Start for free. No credit card required. Self-host or use our managed service. +

+
+ + + + + + +
+
+
+
+ ); +}; + +export default NewCTA; diff --git a/apps/envsync-landing/src/components/NewCompare.tsx b/apps/envsync-landing/src/components/NewCompare.tsx new file mode 100644 index 00000000..5ee09720 --- /dev/null +++ b/apps/envsync-landing/src/components/NewCompare.tsx @@ -0,0 +1,58 @@ +import { Check, Minus, X } from "lucide-react"; + +const features = [ + { name: "Environment promotion", envsync: true, dotenv: false, vault: "partial" }, + { name: "Approval gates", envsync: true, dotenv: false, vault: "partial" }, + { name: "Version history", envsync: true, dotenv: false, vault: true }, + { name: "CLI-first workflow", envsync: true, dotenv: true, vault: true }, + { name: "Self-host option", envsync: true, dotenv: true, vault: true }, + { name: "Zero-knowledge encryption", envsync: true, dotenv: false, vault: true }, +]; + +const StatusIcon = ({ value }: { value: boolean | string }) => { + if (value === true) return ; + if (value === "partial") return ; + return ; +}; + +const NewCompare = () => { + return ( +
+
+
+

+ How EnvSync compares +

+

+ Built for teams who need more than a secret store. +

+
+ +
+ + + + + + + + + + + {features.map((feature) => ( + + + + + + + ))} + +
FeatureEnvSync.env filesVault
{feature.name}
+
+
+
+ ); +}; + +export default NewCompare; diff --git a/apps/envsync-landing/src/components/NewFeatures.tsx b/apps/envsync-landing/src/components/NewFeatures.tsx new file mode 100644 index 00000000..cd13f107 --- /dev/null +++ b/apps/envsync-landing/src/components/NewFeatures.tsx @@ -0,0 +1,124 @@ +import { Shield, GitBranch, Zap, Lock, History, GitPullRequest, Eye, RotateCcw } from "lucide-react"; + +const features = [ + { + icon: GitBranch, + title: "Environment promotion", + description: "Move config through dev → staging → production with approval gates. No more copy-pasting .env files.", + }, + { + icon: Shield, + title: "Approval gates", + description: "Require review before production changes. Track who approved what and when.", + }, + { + icon: Zap, + title: "Instant sync", + description: "Propagate changes across environments in seconds. CLI-first workflow that fits your existing tools.", + }, + { + icon: Lock, + title: "End-to-end encryption", + description: "AES-256 encryption at rest and in transit. Zero-knowledge architecture keeps secrets safe.", + }, +]; + +const uniqueFeatures = [ + { + icon: RotateCcw, + title: "Point-in-time rollback", + description: "Undo any secret change to any previous state. Full history with diff visibility.", + badge: "Unique", + }, + { + icon: GitPullRequest, + title: "Change request workflows", + description: "Protected environments require approval before changes go live. Self-approval blocked.", + badge: "Unique", + }, + { + icon: Eye, + title: "Audit trail", + description: "Track every change with who, what, when. Export to Datadog, Splunk, or Sumo Logic.", + badge: "Enterprise", + }, + { + icon: History, + title: "Secret rotation", + description: "Auto-rotate database credentials, AWS IAM keys, and Azure service principals.", + badge: "Enterprise", + }, +]; + +const NewFeatures = () => { + return ( +
+
+ {/* Core features */} +
+

+ Why teams switch to EnvSync +

+

+ Stop emailing .env files. Start shipping config with confidence. +

+
+ +
+ {features.map((feature) => ( +
+ +

+ {feature.title} +

+

+ {feature.description} +

+
+ ))} +
+ + {/* Unique features */} +
+
+

+ What makes EnvSync different +

+

+ Features no other secrets manager has. +

+
+ +
+ {uniqueFeatures.map((feature) => ( +
+
+ + {feature.badge && ( + + {feature.badge} + + )} +
+

+ {feature.title} +

+

+ {feature.description} +

+
+ ))} +
+
+
+
+ ); +}; + +export default NewFeatures; diff --git a/apps/envsync-landing/src/components/NewHero.tsx b/apps/envsync-landing/src/components/NewHero.tsx new file mode 100644 index 00000000..e9236f13 --- /dev/null +++ b/apps/envsync-landing/src/components/NewHero.tsx @@ -0,0 +1,62 @@ +import { Button } from "@/components/ui/button"; +import { ArrowRight, Github } from "lucide-react"; +import { Link } from "react-router-dom"; + +const NewHero = () => { + return ( +
+
+
+
+ + Now in public beta +
+ +

+ Ship environment variables{" "} + without the drift. +

+ +

+ CLI-first secrets and config delivery for dev, staging, CI, and production. + Pull, push, approve — no more .env file chaos. +

+ +
+ + + + + + +
+ +
+
+
+
+
+ Terminal +
+
+
$ envsync pull --env staging
+
✓ Synced 12 values from staging
+
$ envsync push --env production --strict
+
⏳ Approval required before prod sync
+
$ envsync approve --change-set cs_abc123
+
✓ Approved and deployed to production
+
+
+
+
+
+ ); +}; + +export default NewHero; diff --git a/apps/envsync-landing/src/components/NewHowItWorks.tsx b/apps/envsync-landing/src/components/NewHowItWorks.tsx new file mode 100644 index 00000000..6a780b83 --- /dev/null +++ b/apps/envsync-landing/src/components/NewHowItWorks.tsx @@ -0,0 +1,52 @@ +const steps = [ + { + number: "01", + title: "Connect", + command: "envsync login && envsync init", + description: "Link your repo to EnvSync. One command to set up your project.", + }, + { + number: "02", + title: "Push", + command: "envsync push --env staging", + description: "Send config to an environment. Changes are versioned and reviewable.", + }, + { + number: "03", + title: "Deploy", + command: "envsync pull --env production", + description: "Pull approved config into CI or runtime. Safe, audited, instant.", + }, +]; + +const NewHowItWorks = () => { + return ( +
+
+
+

+ Three commands. Zero drift. +

+

+ From local dev to production in three steps. No GUI required. +

+
+ +
+ {steps.map((step) => ( +
+
{step.number}
+

{step.title}

+

{step.description}

+
+ $ {step.command} +
+
+ ))} +
+
+
+ ); +}; + +export default NewHowItWorks; diff --git a/apps/envsync-landing/src/components/NewIntegrations.tsx b/apps/envsync-landing/src/components/NewIntegrations.tsx new file mode 100644 index 00000000..bffab51f --- /dev/null +++ b/apps/envsync-landing/src/components/NewIntegrations.tsx @@ -0,0 +1,40 @@ +import { Cloud, Container, GitBranch, Globe, Server, Workflow } from "lucide-react"; + +const integrations = [ + { icon: Workflow, name: "GitHub Actions", description: "Inject secrets into CI/CD pipelines" }, + { icon: Container, name: "Docker", description: "Sync env vars to containers at runtime" }, + { icon: Globe, name: "Vercel", description: "Deploy with env vars automatically" }, + { icon: Cloud, name: "Cloudflare", description: "Workers and Pages integration" }, + { icon: Server, name: "AWS", description: "Lambda and ECS environment sync" }, + { icon: GitBranch, name: "GitLab CI", description: "Pipeline secret injection" }, +]; + +const NewIntegrations = () => { + return ( +
+
+
+

+ Works with your stack +

+

+ Plug EnvSync into the tools you already use. No vendor lock-in. +

+
+
+ {integrations.map((integration) => ( +
+ +
+

{integration.name}

+

{integration.description}

+
+
+ ))} +
+
+
+ ); +}; + +export default NewIntegrations; diff --git a/apps/envsync-landing/src/components/NewTestimonial.tsx b/apps/envsync-landing/src/components/NewTestimonial.tsx new file mode 100644 index 00000000..6e7494b9 --- /dev/null +++ b/apps/envsync-landing/src/components/NewTestimonial.tsx @@ -0,0 +1,24 @@ +const NewTestimonial = () => { + return ( +
+
+
+
+ "EnvSync eliminated our .env file chaos overnight. We went from 3-hour config debugging sessions to zero incidents. The approval gates alone saved us from two production outages." +
+
+
+ SK +
+
+
Sarah Kim
+
Platform Lead, Streamline
+
+
+
+
+
+ ); +}; + +export default NewTestimonial; diff --git a/apps/envsync-landing/src/index.css b/apps/envsync-landing/src/index.css index 9a469970..badb8d6c 100644 --- a/apps/envsync-landing/src/index.css +++ b/apps/envsync-landing/src/index.css @@ -8,6 +8,54 @@ @layer base { :root { + --background: 0 0% 100%; + --foreground: 0 0% 10%; + + --card: 0 0% 98%; + --card-foreground: 0 0% 10%; + + --popover: 0 0% 98%; + --popover-foreground: 0 0% 10%; + + --primary: 142 71% 45%; + --primary-foreground: 0 0% 100%; + + --secondary: 0 0% 96%; + --secondary-foreground: 0 0% 10%; + + --muted: 0 0% 95%; + --muted-foreground: 0 0% 40%; + + --accent: 0 0% 95%; + --accent-foreground: 0 0% 10%; + + --destructive: 0 63% 39%; + --destructive-foreground: 0 0% 100%; + + --border: 0 0% 90%; + --input: 0 0% 90%; + --ring: 142 71% 45%; + + --radius: 0.125rem; + + --sidebar-background: 0 0% 100%; + --sidebar-foreground: 0 0% 10%; + --sidebar-primary: 142 71% 45%; + --sidebar-primary-foreground: 0 0% 100%; + --sidebar-accent: 0 0% 95%; + --sidebar-accent-foreground: 0 0% 10%; + --sidebar-border: 0 0% 90%; + --sidebar-ring: 142 71% 45%; + + --surface-1: 0 0% 98%; + --surface-2: 0 0% 96%; + --surface-3: 0 0% 94%; + --border-strong: 0 0% 85%; + + --cta-color: "#1DB954"; + } + + .dark { --background: 220 22% 6%; --foreground: 210 20% 96%; @@ -95,3 +143,14 @@ body { .hero-workflow-dot { animation: hero-workflow-glide 2.8s linear infinite; } + +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + scroll-behavior: auto !important; + } +} diff --git a/apps/envsync-landing/src/pages/Index.tsx b/apps/envsync-landing/src/pages/Index.tsx index 4663eaaf..3c8f44be 100644 --- a/apps/envsync-landing/src/pages/Index.tsx +++ b/apps/envsync-landing/src/pages/Index.tsx @@ -1,27 +1,29 @@ - import Header from "@/components/Header"; -import Hero from "@/components/Hero"; -import HowItWorks from "@/components/HowItWorks"; -import Features from "@/components/Features"; -import CLIShowcase from "@/components/CLIShowcase"; -import Compare from "@/components/Compare"; -import SocialProof from "@/components/SocialProof"; -import CTA from "@/components/CTA"; import Footer from "@/components/Footer"; -import IntegrationWorkflowStrip from "@/components/IntegrationWorkflowStrip"; +import NewHero from "@/components/NewHero"; +import NewFeatures from "@/components/NewFeatures"; +import NewHowItWorks from "@/components/NewHowItWorks"; +import NewIntegrations from "@/components/NewIntegrations"; +import NewTestimonial from "@/components/NewTestimonial"; +import NewCompare from "@/components/NewCompare"; +import NewCTA from "@/components/NewCTA"; const Index = () => { return ( -
window.scrollTo(0, 0)}> +
+ + Skip to main content +
- - - - - - - - +
+ + + + + + + +
); diff --git a/apps/envsync-landing/vite.config.ts b/apps/envsync-landing/vite.config.ts index 40102b78..61bce5e3 100644 --- a/apps/envsync-landing/vite.config.ts +++ b/apps/envsync-landing/vite.config.ts @@ -23,5 +23,6 @@ export default defineConfig(({ mode }) => ({ alias: { "@": path.resolve(__dirname, "./src"), }, + dedupe: ['react', 'react-dom'], }, })); diff --git a/apps/envsync-management-web/package.json b/apps/envsync-management-web/package.json index 55d6031d..06a79636 100644 --- a/apps/envsync-management-web/package.json +++ b/apps/envsync-management-web/package.json @@ -1,7 +1,7 @@ { "name": "envsync-management-web", "private": true, - "version": "0.8.7", + "version": "0.10.0", "type": "module", "scripts": { "dev": "vite", diff --git a/apps/envsync-web/e2e/helpers/project-flows.ts b/apps/envsync-web/e2e/helpers/project-flows.ts index 2b89bc46..9de9ec29 100644 --- a/apps/envsync-web/e2e/helpers/project-flows.ts +++ b/apps/envsync-web/e2e/helpers/project-flows.ts @@ -108,7 +108,8 @@ async function ensureEnvironmentTypeExists(page: Page, appId: string, envName: s await page.goto(`/applications/${appId}/manage-environments`, { waitUntil: "domcontentloaded" }); await expect( page.getByRole("heading", { name: "Manage Environment Types" }) - .or(page.getByRole("heading", { name: "Manage Environments" })), + .or(page.getByRole("heading", { name: "Manage Environments" })) + .or(page.getByRole("heading", { name: "Environments" })), ).toBeVisible(); await expect(page.getByTestId(`env-type-card-${matchingEnvTypes[0]!.id}`)).toBeVisible(); return; @@ -117,14 +118,15 @@ async function ensureEnvironmentTypeExists(page: Page, appId: string, envName: s await page.goto(`/applications/${appId}/manage-environments`, { waitUntil: "domcontentloaded" }); await expect( page.getByRole("heading", { name: "Manage Environment Types" }) - .or(page.getByRole("heading", { name: "Manage Environments" })), + .or(page.getByRole("heading", { name: "Manage Environments" })) + .or(page.getByRole("heading", { name: "Environments" })), ).toBeVisible(); - await page.getByRole("button", { name: "Add Environment Type" }).click(); + await page.getByRole("button", { name: /Add Environment|Add Environment Type/ }).click(); const dialog = page.getByRole("dialog").last(); - await expect(dialog.getByRole("heading", { name: "Create Environment Type" })).toBeVisible(); + await expect(dialog.getByRole("heading", { name: /Create Environment|Add Environment|Create Environment Type/ })).toBeVisible(); await dialog.locator("#create-env-name").fill(envName); - await dialog.getByRole("button", { name: "Create Environment Type" }).click(); + await dialog.getByRole("button", { name: /Create|Create Environment Type/ }).click(); const envTypeId = await waitForEnvironmentTypeId(page, appId, envName); await expect(page.getByTestId(`env-type-card-${envTypeId}`)).toBeVisible(); } @@ -250,7 +252,7 @@ export async function createProject(page: Page, projectName: string) { } export async function openProjectCardActions(page: Page, projectName: string) { - const card = page.locator('[class*="group cursor-pointer"]').filter({ hasText: projectName }).first(); + const card = page.locator('[class*="cursor-pointer"][class*="group"]').filter({ hasText: projectName }).first(); await card.waitFor({ state: "visible" }); await card.hover(); const actionButton = card.getByRole("button").last(); @@ -291,7 +293,7 @@ export async function setEnvironmentProtected(page: Page, appId: string, envName await page.getByTestId(`env-type-edit-${envType!.id}`).click(); const dialog = page.getByRole("dialog").last(); - await expect(dialog.getByRole("heading", { name: "Edit Environment Type" })).toBeVisible(); + await expect(dialog.getByRole("heading", { name: /Edit Environment|Edit Environment Type/ })).toBeVisible(); const checkbox = dialog.getByTestId("env-type-protected-checkbox"); const checked = await checkbox.isChecked(); const updateResponse = waitForTrackedResponse(page, { @@ -303,7 +305,7 @@ export async function setEnvironmentProtected(page: Page, appId: string, envName if (checked !== isProtected) { await checkbox.click(); } - await dialog.getByRole("button", { name: "Update Environment Type" }).click(); + await dialog.getByRole("button", { name: /Update|Save|Update Environment Type/ }).click(); const trackedResponse = await updateResponse; expectObjectBody(trackedResponse.requestBody, "Update environment type"); expect(trackedResponse.requestBody.id).toBeTruthy(); @@ -329,11 +331,11 @@ export async function setEnvironmentProtected(page: Page, appId: string, envName export async function createEnvironmentType(page: Page, appId: string, envName: string) { await page.goto(`/applications/${appId}/manage-environments`, { waitUntil: "domcontentloaded" }); - await page.getByRole("button", { name: "Add Environment Type" }).click(); + await page.getByRole("button", { name: /Add Environment|Add Environment Type/ }).click(); const dialog = page.getByRole("dialog").last(); - await expect(dialog.getByRole("heading", { name: "Create Environment Type" })).toBeVisible(); + await expect(dialog.getByRole("heading", { name: /Create Environment|Add Environment|Create Environment Type/ })).toBeVisible(); await dialog.locator("#create-env-name").fill(envName); - await dialog.getByRole("button", { name: "Create Environment Type" }).click(); + await dialog.getByRole("button", { name: /Create|Create Environment Type/ }).click(); const envTypeId = await waitForEnvironmentTypeId(page, appId, envName); await expect(page.getByTestId(`env-type-card-${envTypeId}`)).toBeVisible(); } @@ -346,9 +348,9 @@ export async function deleteEnvironmentType(page: Page, appId: string, envName: await page.getByTestId(`env-type-card-${envType!.id}`).waitFor({ state: "visible" }); await page.getByTestId(`env-type-delete-${envType!.id}`).click(); const dialog = page.getByRole("dialog").last(); - await expect(dialog.getByRole("heading", { name: "Delete Environment Type" })).toBeVisible(); + await expect(dialog.getByRole("heading", { name: /Delete Environment|Delete Environment Type/ })).toBeVisible(); await dialog.locator("#delete-confirm-text").fill(envName); - await dialog.getByRole("button", { name: "Delete Environment Type" }).click(); + await dialog.getByRole("button", { name: /Delete|Delete Environment Type/ }).click(); await expect(page.getByText(envName)).toHaveCount(0); } diff --git a/apps/envsync-web/e2e/specs/regression/routes.spec.ts b/apps/envsync-web/e2e/specs/regression/routes.spec.ts index 82b025ff..a62843fe 100644 --- a/apps/envsync-web/e2e/specs/regression/routes.spec.ts +++ b/apps/envsync-web/e2e/specs/regression/routes.spec.ts @@ -12,7 +12,7 @@ test.describe("route surface", () => { { path: "/applications/create", heading: "Create New Project" }, { path: `/applications/${seededApp!.id}`, heading: "Core Platform" }, { path: `/applications/${seededApp!.id}/secrets`, heading: "Core Platform" }, - { path: `/applications/${seededApp!.id}/manage-environments`, heading: "Manage Environments" }, + { path: `/applications/${seededApp!.id}/manage-environments`, heading: /Manage Environments|Environments/ }, { path: `/applications/${seededApp!.id}/access`, heading: "Project Access" }, { path: `/applications/pit/${seededApp!.id}`, heading: "Core Platform" }, { path: "/roles", heading: "Roles" }, diff --git a/apps/envsync-web/e2e/specs/smoke/core.spec.ts b/apps/envsync-web/e2e/specs/smoke/core.spec.ts index 3e716364..643f2dd5 100644 --- a/apps/envsync-web/e2e/specs/smoke/core.spec.ts +++ b/apps/envsync-web/e2e/specs/smoke/core.spec.ts @@ -14,8 +14,8 @@ test.describe("UI smoke", () => { test("covers dashboard, project create, variable CRUD, secret CRUD, certificates and settings", async ({ page, makeName }) => { await page.goto("/dashboard", { waitUntil: "domcontentloaded" }); await expect(page.getByRole("heading", { name: "Dashboard" })).toBeVisible(); - await expect(page.getByText("Quick Actions")).toBeVisible(); - await expect(page.getByTestId("dashboard-stat-config-items")).toBeVisible(); + await expect(page.getByText("Projects").first()).toBeVisible(); + await expect(page.getByText("Security").first()).toBeVisible(); const projectName = makeName("UI_SMOKE_APP"); const { appId } = await createProject(page, projectName); diff --git a/apps/envsync-web/package.json b/apps/envsync-web/package.json index 6454b4be..fc14cd72 100644 --- a/apps/envsync-web/package.json +++ b/apps/envsync-web/package.json @@ -1,7 +1,7 @@ { "name": "envsync-web", "private": true, - "version": "0.9.1", + "version": "0.10.0", "type": "module", "scripts": { "dev": "vite", diff --git a/apps/envsync-web/public/runtime-config.js b/apps/envsync-web/public/runtime-config.js index 7a24aeb4..60d1ce7c 100644 --- a/apps/envsync-web/public/runtime-config.js +++ b/apps/envsync-web/public/runtime-config.js @@ -2,7 +2,7 @@ window.__ENVSYNC_RUNTIME_CONFIG__ = { apiBaseUrl: "http://api.lvh.me:4000", appBaseUrl: "http://app.lvh.me:8001", authBaseUrl: "http://auth.lvh.me:8080", - managementApiUrl: "http://localhost:4001", + managementApiUrl: "http://manage-api.lvh.me:4001", keycloakRealm: "envsync", webClientId: "envsync-web", apiDocsUrl: "http://api.lvh.me:4000/docs", diff --git a/apps/envsync-web/src/api/enterprise/types.ts b/apps/envsync-web/src/api/enterprise/types.ts index e33b7d41..be2d22ca 100644 --- a/apps/envsync-web/src/api/enterprise/types.ts +++ b/apps/envsync-web/src/api/enterprise/types.ts @@ -12,7 +12,30 @@ export type EnterpriseProvider = | "gitlab" | "aws-ssm" | "vercel" - | "google-secret-manager"; + | "google-secret-manager" + | "circleci" + | "jenkins" + | "azure-devops" + | "bitbucket" + | "travisci" + | "netlify" + | "railway" + | "fly-io" + | "render" + | "supabase" + | "digitalocean-app-platform" + | "azure-key-vault" + | "aws-secrets-manager" + | "cloudflare-workers" + | "azure-app-service" + | "codefresh" + | "deno-deploy" + | "harness" + | "hasura-cloud" + | "heroku" + | "laravel-forge" + | "qovery" + | "terraform-cloud"; export type { EnvTypeMapping, diff --git a/apps/envsync-web/src/components/ProjectEnvironments.tsx b/apps/envsync-web/src/components/ProjectEnvironments.tsx index 1f58a99b..c91a2e12 100644 --- a/apps/envsync-web/src/components/ProjectEnvironments.tsx +++ b/apps/envsync-web/src/components/ProjectEnvironments.tsx @@ -162,7 +162,6 @@ export const ProjectEnvironments = ({ // Set initial selected environment when project data loads useEffect(() => { - console.log("Project data loaded:", projectData); if (projectData?.environments.length > 0 && !selectedEnv) { setSelectedEnv(projectData.environments[0].id); } diff --git a/apps/envsync-web/src/components/applications/ApplicationCard.tsx b/apps/envsync-web/src/components/applications/ApplicationCard.tsx index f3d03907..6315022a 100644 --- a/apps/envsync-web/src/components/applications/ApplicationCard.tsx +++ b/apps/envsync-web/src/components/applications/ApplicationCard.tsx @@ -1,6 +1,5 @@ -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; -import { MoreHorizontal, Eye, Edit, Trash2, Key } from "lucide-react"; +import { MoreHorizontal, Eye, Edit, Trash2, ChevronRight, Database } from "lucide-react"; import { DropdownMenu, DropdownMenuContent, @@ -49,98 +48,73 @@ export const ApplicationCard = ({ }; return ( - - -
navigate(appDetailPath(app.id))} - className="flex items-start justify-between" - > -
-
- - {app.name.charAt(0).toUpperCase()} - -
-
- - {app.name} - -
-
- - {canEdit && ( - - - - - - { - e.stopPropagation(); - onView(app); - }} - > - - View Details - - { - e.stopPropagation(); - onEdit(app); - }} - > - - Edit Project - - { - e.stopPropagation(); - onDelete(app); - }} - > - - Delete Project - - - - )} +
navigate(appDetailPath(app.id))} + > +
+
+
- - - navigate(appDetailPath(app.id))} - > -

- {app.description || "No description provided"} -

- -
-
-
- - {configItemCount} vars / secrets -
-
- - - {getRelativeTime(app.updated_at)} - +
+

+ {app.name} +

+

+ {app.description || "No description"} · {configItemCount} config items · Updated {getRelativeTime(app.updated_at)} +

- - +
+ +
+ {canEdit && ( + + + + + + { + e.stopPropagation(); + onView(app); + }} + > + + View Details + + { + e.stopPropagation(); + onEdit(app); + }} + > + + Edit Project + + { + e.stopPropagation(); + onDelete(app); + }} + > + + Delete Project + + + + )} + +
+
); }; diff --git a/apps/envsync-web/src/components/applications/ApplicationsGrid.tsx b/apps/envsync-web/src/components/applications/ApplicationsGrid.tsx index 16c4a6f0..587580d3 100644 --- a/apps/envsync-web/src/components/applications/ApplicationsGrid.tsx +++ b/apps/envsync-web/src/components/applications/ApplicationsGrid.tsx @@ -43,7 +43,7 @@ export const ApplicationsGrid = ({ } return ( -
+
{apps.map((app) => ( void; + providerFilter?: EnterpriseProvider; +} + +export function CreateOrgSecretModal({ + open, + onOpenChange, + providerFilter, +}: CreateOrgSecretModalProps) { + const createOrgSecret = useCreateOrgSecret(); + + const [form, setForm] = useState({ + key: "", + value: "", + description: "", + providerRefs: providerFilter ?? "", + rotationPolicy: "manual", + metadataRaw: "{}", + }); + + useEffect(() => { + if (!open) return; + setForm({ + key: "", + value: "", + description: "", + providerRefs: providerFilter ?? "", + rotationPolicy: "manual", + metadataRaw: "{}", + }); + }, [open, providerFilter]); + + const handleSubmit = async (event: React.FormEvent) => { + event.preventDefault(); + try { + const providerRefs = form.providerRefs + .split(",") + .map((value) => value.trim()) + .filter(Boolean); + await createOrgSecret.mutateAsync({ + key: form.key, + value: form.value, + description: form.description || null, + metadata: { + ...JSON.parse(form.metadataRaw) as Record, + ...(providerRefs.length > 0 ? { provider_refs: providerRefs } : {}), + ...(form.rotationPolicy ? { rotation_policy: form.rotationPolicy } : {}), + }, + }); + toast.success("Org secret created."); + onOpenChange(false); + } catch (error) { + toast.error(error instanceof Error ? error.message : "Failed to create org secret."); + } + }; + + return ( + + + + Create Org Secret + + Create reusable secret material once, then reference it from provider connections and project mappings. + + + +
+ + +