diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index 7194459..395ab6f 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -16,7 +16,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v5 with: - python-version: "3.11" + python-version: "3.13" - name: Install uv run: pip install uv diff --git a/AGENTS.md b/AGENTS.md index d94ed3c..775f82f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -7,6 +7,16 @@ This is a monorepo with: - Backend API at repository root. - Frontend Admin Console at `frontend/`. +## Instruction hierarchy (authoritative) + +- This root `AGENTS.md` is mandatory baseline guidance for the entire monorepo. +- Subproject `AGENTS.md` files are also mandatory when working inside their directory tree. +- Always load and follow the nearest `AGENTS.md` for every file you touch, in addition to this root file. +- If guidance conflicts, the nearest (most specific) `AGENTS.md` for the changed file wins, and root rules still apply where they do not conflict. +- Continuously capture non-trivial lessons from implementation work and document the most important ones in the appropriate `AGENTS.md` (root and/or subproject) so future sessions accelerate delivery and workflow quality. +- Known subproject instruction files in this repo: + - `frontend/AGENTS.md` for all work under `frontend/`. + ## Backend working rules - Run backend quality checks with `./scripts/quality.sh check`. @@ -36,6 +46,16 @@ This is a monorepo with: - Playwright MCP tool, when accessible, can be used for testing front-end components and changes. - Always scan related components to ensure consistency. Keep things stupid simple (KISS) and don't repeat yourself (DRY). This prevents code bloat. We need to avoid overengineering. +## Session lessons (non-trivial) + +- `git push` can be blocked by a pre-push hook when the working tree is dirty, even if the dirty files are unrelated to the commit being pushed. + - Practical workflow: temporarily stash unrelated local edits, push, then restore with `git stash pop`. +- Keep React Query derived views under the same key prefix when possible (for example, `['secrets', projectSlug, configSlug, ...]`) so existing prefix invalidation still refreshes all related views. +- Secret icon persistence now distinguishes `icon_source` as `auto` or `manual`; project-wide icon recompute should only rewrite `auto` entries so explicit manual overrides remain stable. +- When preparing legacy API removal, deprecate in layers first: code-level `@warnings.deprecated`, OpenAPI `deprecated=true`, and API response deprecation headers, then remove only in a later major release. +- For complexity-heavy logic in engines, prefer a module-private per-use-case service class (for example, comparison or icon resolution) with small single-purpose methods, while keeping the public engine/API method signature and response shape unchanged. +- DeepSource `PYL-R0201` maps to pylint's optional extension check `R6301/no-self-use`; local verification requires `--load-plugins=pylint.extensions.no_self_use`. + ## Conventional Commit Guidelines @@ -128,4 +148,3 @@ git commit -m "type(scope): description" Replace with your constructed message. Include body and footer if needed. ``` - diff --git a/Access/tokens.py b/Access/tokens.py index ed3a1df..c18bb31 100644 --- a/Access/tokens.py +++ b/Access/tokens.py @@ -139,7 +139,8 @@ def revoke(self, token=None, username=None, token_id=None): ) return {"status": "OK"}, 200 - def _serialize_token_metadata(self, doc): + @staticmethod + def _serialize_token_metadata(doc): return { "token_id": oid_to_str(doc.get("_id")), "type": doc.get("type"), diff --git a/Api/api.py b/Api/api.py index 30a4047..bb094b0 100644 --- a/Api/api.py +++ b/Api/api.py @@ -22,6 +22,9 @@ SecretExportResource, SecretItemResource, ) +from Api.resources.secrets.project_icons_resource import ( # noqa: F401 + ProjectSecretIconsRecomputeResource, +) from Api.resources.compare.compare_secret_resource import ( # noqa: F401 CompareSecretResource, ) diff --git a/Api/resources/auth/onboarding_resource.py b/Api/resources/auth/onboarding_resource.py index 308146b..7b9f1fd 100644 --- a/Api/resources/auth/onboarding_resource.py +++ b/Api/resources/auth/onboarding_resource.py @@ -16,14 +16,16 @@ @onboarding_ns.route("/status") class OnboardingStatusResource(Resource): - def get(self): + @staticmethod + def get(): return {"status": "OK", "onboarding": conn.onboarding.get_state()}, 200 @onboarding_ns.route("/bootstrap") class OnboardingBootstrapResource(Resource): + @staticmethod @api.doc(parser=bootstrap_parser) - def post(self): + def post(): args = bootstrap_parser.parse_args() result, code = conn.onboarding.bootstrap( username=args["username"], diff --git a/Api/resources/meta/version_resource.py b/Api/resources/meta/version_resource.py index 0b91f7e..f3d3175 100644 --- a/Api/resources/meta/version_resource.py +++ b/Api/resources/meta/version_resource.py @@ -9,5 +9,6 @@ @meta_ns.route("") class VersionResource(Resource): - def get(self): + @staticmethod + def get(): return {"status": "OK", "version": get_application_version()}, 200 diff --git a/Api/resources/secrets/kv_resource.py b/Api/resources/secrets/kv_resource.py index c3ccce8..8fcd73f 100644 --- a/Api/resources/secrets/kv_resource.py +++ b/Api/resources/secrets/kv_resource.py @@ -1,9 +1,30 @@ #!/usr/bin/env python3 # Key-Value (KV) Secrets Engines API Resource +from warnings import deprecated + from flask_restx import fields, Resource from Api.core import api, conn from Access.is_auth import require_token +KV_DEPRECATION_MESSAGE = ( + "The /api/secrets/kv endpoints are deprecated and will be removed in the " + "next major release. " + "Use /api/projects/{project}/configs/{config}/secrets/{key}." +) +KV_DEPRECATION_HEADERS = { + "Deprecation": "true", + "Warning": f'299 - "{KV_DEPRECATION_MESSAGE}"', + "Link": ( + "; rel="deprecation"' + ), +} + + +def _with_deprecation_headers(payload, code=200): + return payload, code, KV_DEPRECATION_HEADERS + + # KV Namespace kv_ns = api.namespace( name="secrets/kv", @@ -47,19 +68,25 @@ @kv_ns.route("//") @api.doc( + deprecated=True, responses={401: "Unauthorized", 404: "Path or KV not found"}, params={ "path": "Path to a KV store", "key": "Key (index) in path where a secret (value) is stored", }, ) +@deprecated(KV_DEPRECATION_MESSAGE) class Engine_KV(Resource): """Key-Value API operations""" @api.doc( - description="Update a kv in a path", security="Token", parser=kv_parser + description="Update a kv in a path", + security="Token", + parser=kv_parser, + deprecated=True, ) @api.marshal_with(kv_model) + @deprecated(KV_DEPRECATION_MESSAGE) def put(self, path, key): """Update a given resource""" args = kv_parser.parse_args() @@ -68,13 +95,15 @@ def put(self, path, key): if code != 200: api.abort(code, status) return None - return status + return _with_deprecation_headers(status, code) @api.doc( description="Delete a KV from a path", security="Token", responses={200: "Secrets deleted"}, + deprecated=True, ) + @deprecated(KV_DEPRECATION_MESSAGE) def delete(self, path, key): """Delete a given kv""" require_token() @@ -82,12 +111,16 @@ def delete(self, path, key): if code != 200: api.abort(code, status) return None - return status + return _with_deprecation_headers(status, code) @api.doc( - description="Add a KV to a path", security="Token", parser=kv_parser + description="Add a KV to a path", + security="Token", + parser=kv_parser, + deprecated=True, ) @api.marshal_with(kv_model) + @deprecated(KV_DEPRECATION_MESSAGE) def post(self, path, key): """Add a new kv to a path""" args = kv_parser.parse_args() @@ -96,14 +129,19 @@ def post(self, path, key): if code != 200: api.abort(code, status) return None - return status + return _with_deprecation_headers(status, code) - @api.doc(description="Return a KV from a path", security="Token") + @api.doc( + description="Return a KV from a path", + security="Token", + deprecated=True, + ) @api.marshal_with(kv_model) + @deprecated(KV_DEPRECATION_MESSAGE) def get(self, path, key): """Fetch a given KV from a path""" require_token() status, code = conn.kv.get(path, key) if code != 200: api.abort(code, str(status)) - return status + return _with_deprecation_headers(status, code) diff --git a/Api/resources/secrets/project_icons_resource.py b/Api/resources/secrets/project_icons_resource.py new file mode 100644 index 0000000..c9c5e25 --- /dev/null +++ b/Api/resources/secrets/project_icons_resource.py @@ -0,0 +1,32 @@ +#!/usr/bin/env python3 +from flask_restx import Resource + +from Api.core import api, conn +from Api.resources.helpers import resolve_project_config +from Access.is_auth import with_token, require_scope, audit_event + +project_icons_ns = api.namespace( + "projects//secrets/icons", + description="Project secret icon maintenance", +) + + +@project_icons_ns.route("/recompute") +class ProjectSecretIconsRecomputeResource(Resource): + @api.doc(security=["Bearer", "Token"]) + @with_token + def post(self, project_slug): + project, _ = resolve_project_config(project_slug) + require_scope("secrets:write", project_id=project["_id"]) + + summary, msg, code = conn.secrets_v2.recompute_project_icon_slugs( + project["_id"] + ) + audit_event( + "secrets.icons.recompute", + project_slug=project_slug, + status_code=code, + ) + if code >= 400: + api.abort(code, msg) + return {"status": "OK", "summary": summary}, 200 diff --git a/Api/resources/secrets/references.py b/Api/resources/secrets/references.py index 4f684bf..c1bcc85 100644 --- a/Api/resources/secrets/references.py +++ b/Api/resources/secrets/references.py @@ -186,7 +186,8 @@ def _resolve_key( self._resolved_cache[node] = resolved return resolved - def _parse_reference(self, token: str, current: _Context) -> _Node | None: + @staticmethod + def _parse_reference(token: str, current: _Context) -> _Node | None: parts = token.split(".") if len(parts) == 1: key = parts[0] diff --git a/Api/resources/secrets/secrets_resource.py b/Api/resources/secrets/secrets_resource.py index 4b8b524..7d44190 100644 --- a/Api/resources/secrets/secrets_resource.py +++ b/Api/resources/secrets/secrets_resource.py @@ -109,6 +109,8 @@ def put(self, project_slug, config_slug, key): ) args = secret_parser.parse_args() value = args["value"] + if not isinstance(value, str): + api.abort(400, "value must be a string") raw_payload = request.get_json(silent=True) payload = raw_payload if isinstance(raw_payload, dict) else {} icon_slug_provided = "icon_slug" in payload diff --git a/Engines/audit.py b/Engines/audit.py index 9bf5fc3..ee2b4f5 100644 --- a/Engines/audit.py +++ b/Engines/audit.py @@ -21,8 +21,8 @@ def write_event(self, event: dict): } self._events.insert_one(payload) + @staticmethod def _build_query( - self, project_slug=None, config_slug=None, since=None, diff --git a/Engines/kv.py b/Engines/kv.py index ead4a63..6c3a6a9 100644 --- a/Engines/kv.py +++ b/Engines/kv.py @@ -2,8 +2,15 @@ """KV (Key-Value) Secret Engine for Secrets Manager""" import re +from warnings import deprecated +@deprecated( + "Key_Value_Secrets is deprecated and will be removed in the next major " + "release. " + "Use config-scoped secrets via " + "/api/projects/{project}/configs/{config}/secrets/{key}." +) class Key_Value_Secrets: def __init__(self, kv_col): """KV stands for Key-Value collection @@ -14,6 +21,12 @@ def __init__(self, kv_col): # * db.kv.createIndex( { "path": 1 }, { unique: true } ) self._kv = kv_col + @deprecated( + "Key_Value_Secrets.get is deprecated and will be removed in the next " + "major release. " + "Use config-scoped secrets via " + "/api/projects/{project}/configs/{config}/secrets/{key}." + ) def get(self, path, key): finder = self._kv.find_one({"path": path}) if not finder: @@ -28,6 +41,12 @@ def get(self, path, key): } return result, 200 + @deprecated( + "Key_Value_Secrets.add is deprecated and will be removed in the next " + "major release. " + "Use config-scoped secrets via " + "/api/projects/{project}/configs/{config}/secrets/{key}." + ) def add(self, path, key, value): pattern = "[a-zA-Z0-9_]+" if not (re.fullmatch(pattern, key) and re.fullmatch(pattern, path)): @@ -54,6 +73,12 @@ def add(self, path, key, value): return {"status": f"Key already exist in '{path}'"}, 400 return result, 200 + @deprecated( + "Key_Value_Secrets.delete is deprecated and will be removed in the " + "next major release. " + "Use config-scoped secrets via " + "/api/projects/{project}/configs/{config}/secrets/{key}." + ) def delete(self, path, key): finder = self._kv.find_one({"path": path}) if not finder: @@ -65,6 +90,12 @@ def delete(self, path, key): result = {"status": "OK", "path": path, "key": key} return result, 200 + @deprecated( + "Key_Value_Secrets.update is deprecated and will be removed in the " + "next major release. " + "Use config-scoped secrets via " + "/api/projects/{project}/configs/{config}/secrets/{key}." + ) def update(self, path, key, value): finder = self._kv.find_one({"path": path}) if not finder: diff --git a/Engines/secret_icons.py b/Engines/secret_icons.py index ddbb5df..48a4af7 100644 --- a/Engines/secret_icons.py +++ b/Engines/secret_icons.py @@ -57,64 +57,158 @@ def _load_index() -> Dict[str, Dict[str, object]]: return {} -def _candidate_terms(tokens: Iterable[str]) -> Iterable[Tuple[str, int]]: - normalized_tokens = [ - token for token in tokens if token and token not in STOP_TOKENS - ] - for token in normalized_tokens: - if len(token) >= 3: +class _IconSlugResolutionService: + def __init__(self, index: Dict[str, Dict[str, object]]): + self._index = index + + def guess(self, key: str) -> str: + if not self._index: + return DEFAULT_ICON_SLUG + + tokens = self._tokens_for_key(key) + if not tokens: + return DEFAULT_ICON_SLUG + + best_slug = self._best_slug_for_terms(self._first_pass_terms(tokens)) + if best_slug: + return best_slug + + best_slug = self._best_slug_for_terms(self._fallback_terms(tokens)) + return best_slug or DEFAULT_ICON_SLUG + + @staticmethod + def _tokens_for_key(key: str) -> list[str]: + raw_tokens = TOKEN_SPLIT_PATTERN.split(key.lower()) + return [ + token for token in raw_tokens if token and token not in STOP_TOKENS + ] + + @staticmethod + def _push_term( + yielded: set[str], term: str, size: int + ) -> Iterable[Tuple[str, int]]: + if term in yielded: + return + yielded.add(term) + yield term, size + + def _first_pass_terms( + self, tokens: list[str] + ) -> Iterable[Tuple[str, int]]: + if not tokens: + return + if len(tokens[0]) < 3: + return + + yielded: set[str] = set() + yield from self._push_term(yielded, tokens[0], 1) + if self._has_min_length(tokens, 2): + yield from self._joined_terms(yielded, tokens[0:2], 2) + if self._has_min_length(tokens, 3): + yield from self._joined_terms(yielded, tokens[0:3], 3) + + @staticmethod + def _has_min_length(tokens: list[str], size: int) -> bool: + if len(tokens) < size: + return False + return all(len(token) >= 3 for token in tokens[0:size]) + + def _joined_terms( + self, yielded: set[str], window: list[str], size: int + ) -> Iterable[Tuple[str, int]]: + yield from self._push_term(yielded, "-".join(window), size) + yield from self._push_term(yielded, "".join(window), size) + + def _fallback_terms(self, tokens: list[str]) -> Iterable[Tuple[str, int]]: + yield from self._fallback_single_terms(tokens) + yield from self._fallback_window_terms(tokens) + + @staticmethod + def _fallback_single_terms( + tokens: list[str], + ) -> Iterable[Tuple[str, int]]: + yielded: set[str] = set() + for token in tokens[1:]: + if len(token) < 3: + continue + if token in yielded: + continue + yielded.add(token) yield token, 1 - for size in (3, 2): - if len(normalized_tokens) < size: - continue - for index in range(0, len(normalized_tokens) - size + 1): - window = normalized_tokens[index : index + size] + def _fallback_window_terms( + self, tokens: list[str] + ) -> Iterable[Tuple[str, int]]: + yielded: set[str] = set() + for size in (3, 2): + yield from self._window_terms(tokens, size, yielded) + + def _window_terms( + self, tokens: list[str], size: int, yielded: set[str] + ) -> Iterable[Tuple[str, int]]: + if len(tokens) < size: + return + for index in range(1, len(tokens) - size + 1): + window = tokens[index : index + size] if any(len(token) < 3 for token in window): continue - yield "-".join(window), size - yield "".join(window), size - + yield from self._joined_terms(yielded, window, size) -def guess_icon_slug(key: str) -> str: - index = _load_index() - if not index: - return DEFAULT_ICON_SLUG - - tokens = [ - token for token in TOKEN_SPLIT_PATTERN.split(key.lower()) if token - ] - if not tokens: - return DEFAULT_ICON_SLUG - - best_score: Optional[Tuple[float, int, str]] = None - best_slug = DEFAULT_ICON_SLUG - for term, term_size in _candidate_terms(tokens): - entry = index.get(term) + def _best_slug_for_terms(self, terms: Iterable[Tuple[str, int]]) -> str: + best_score: Optional[Tuple[float, int, str]] = None + for term, term_size in terms: + candidate = self._candidate_for_term(term, term_size) + if candidate is None: + continue + if best_score is None or candidate > best_score: + best_score = candidate + return best_score[2] if best_score else "" + + def _candidate_for_term( + self, term: str, term_size: int + ) -> Optional[Tuple[float, int, str]]: + entry = self._index.get(term) if not isinstance(entry, dict): - continue + return None slug = entry.get("slug") if not isinstance(slug, str): - continue + return None + + count = self._count(entry.get("count")) + if self._is_filtered_short_term(term, count): + return None - count_value = entry.get("count") - count = count_value if isinstance(count_value, int) else 0 + score = self._score(term, term_size, count) + return (score, self._simple_icons_bonus(slug), slug) + + @staticmethod + def _count(raw_count: object) -> int: + return raw_count if isinstance(raw_count, int) else 0 + + @staticmethod + def _is_filtered_short_term(term: str, count: int) -> bool: if len(term) <= 4 and count > 250: - continue + return True if len(term) <= 3 and count > 40: - continue + return True + return False + @staticmethod + def _score(term: str, term_size: int, count: int) -> float: score = float(term_size * 10 + min(len(term), 30)) if count > 1: score -= min(count / 50.0, 6.0) - simple_icons_bonus = 1 if slug.startswith("simple-icons:") else 0 - candidate = (score, simple_icons_bonus, slug) - if best_score is None or candidate > best_score: - best_score = candidate - best_slug = slug + return score + + @staticmethod + def _simple_icons_bonus(slug: str) -> int: + return 1 if slug.startswith("simple-icons:") else 0 - return best_slug + +def guess_icon_slug(key: str) -> str: + resolver = _IconSlugResolutionService(_load_index()) + return resolver.guess(key) def resolve_icon_slug(key: str, icon_slug_override: Optional[str]) -> str: diff --git a/Engines/secrets_v2.py b/Engines/secrets_v2.py index d223817..0d9b28f 100644 --- a/Engines/secrets_v2.py +++ b/Engines/secrets_v2.py @@ -22,19 +22,185 @@ def decrypt(value_enc: str) -> str: return value_enc +class _ConfigKeyComparisonService: + def __init__( + self, + secrets_col, + *, + include_parent, + include_metadata, + include_empty, + ): + self._secrets = secrets_col + self._include_parent = include_parent + self._include_metadata = include_metadata + self._include_empty = include_empty + + def compare(self, configs, key): + normalized_configs = self._normalize_configs(configs) + if not normalized_configs: + return [], "OK", 200 + + config_by_id = {cfg["_id"]: cfg for cfg in normalized_configs} + direct_by_config_id = self._direct_by_config_id(config_by_id, key) + + rows = [] + for config in normalized_configs: + row, err, code = self._build_row( + config, config_by_id, direct_by_config_id + ) + if err: + return None, err, code + if row is not None: + rows.append(row) + return rows, "OK", 200 + + @staticmethod + def _normalize_configs(configs): + normalized = [] + for cfg in configs: + config_id = cfg.get("_id") + slug = cfg.get("slug") + if config_id is None or not isinstance(slug, str): + continue + normalized.append( + { + "_id": config_id, + "slug": slug, + "parent_config_id": cfg.get("parent_config_id"), + } + ) + return normalized + + def _direct_by_config_id(self, config_by_id, key): + config_ids = list(config_by_id.keys()) + direct_docs = list( + self._secrets.find({"config_id": {"$in": config_ids}, "key": key}) + ) + return {doc["config_id"]: doc for doc in direct_docs} + + def _build_row(self, config, config_by_id, direct_by_config_id): + direct_doc = direct_by_config_id.get(config["_id"]) + ( + source_config, + effective_doc, + is_inherited, + err, + code, + ) = self._resolve_effective_doc( + config, config_by_id, direct_by_config_id + ) + if err: + return None, err, code + + if effective_doc is None and not self._include_empty: + return None, None, None + + row = { + "configId": str(config["_id"]), + "configSlug": config["slug"], + "effective": self._effective_payload( + effective_doc, source_config, is_inherited + ), + "direct": self._direct_payload(direct_doc), + } + if self._include_metadata: + row["meta"] = self._meta_payload(effective_doc) + return row, None, None + + def _resolve_effective_doc( + self, config, config_by_id, direct_by_config_id + ): + direct_doc = direct_by_config_id.get(config["_id"]) + if direct_doc is not None or not self._include_parent: + return config, direct_doc, False, None, None + + source_config, inherited_doc, err, code = ( + self._find_effective_for_config( + config, config_by_id, direct_by_config_id + ) + ) + if err or inherited_doc is None: + return config, None, False, err, code + return source_config, inherited_doc, True, None, None + + @staticmethod + def _find_effective_for_config(config, config_by_id, direct_by_config_id): + visited = {str(config["_id"])} + current = config.get("parent_config_id") + while current is not None: + current_key = str(current) + if current_key in visited: + return None, None, "Config inheritance cycle detected", 400 + visited.add(current_key) + + parent = config_by_id.get(current) + if parent is None: + return None, None, None, None + direct_doc = direct_by_config_id.get(parent["_id"]) + if direct_doc is not None: + return parent, direct_doc, None, None + current = parent.get("parent_config_id") + return None, None, None, None + + @staticmethod + def _effective_payload(effective_doc, source_config, is_inherited): + if effective_doc is None: + return {"value": None, "source": None, "isInherited": False} + return { + "value": SecretCodec.decrypt(effective_doc["value_enc"]), + "source": source_config["slug"], + "isInherited": is_inherited, + } + + @staticmethod + def _direct_payload(direct_doc): + if direct_doc is None: + return {"exists": False, "value": None} + return { + "exists": True, + "value": SecretCodec.decrypt(direct_doc["value_enc"]), + } + + @staticmethod + def _meta_payload(effective_doc): + if effective_doc is None: + return {"updatedAt": None, "updatedBy": None, "iconSlug": ""} + return { + "updatedAt": to_iso(effective_doc.get("updated_at")), + "updatedBy": effective_doc.get("updated_by"), + "iconSlug": normalize_icon_slug(effective_doc.get("icon_slug")), + } + + class SecretsV2: + ICON_SOURCE_AUTO = "auto" + ICON_SOURCE_MANUAL = "manual" + def __init__(self, secrets_col, configs_engine): self._secrets = secrets_col self._configs = configs_engine self._secrets.create_index([("config_id", 1), ("key", 1)], unique=True) - def _existing_icon_slug(self, config_id, key): + @classmethod + def _normalize_icon_source(cls, value): + return ( + cls.ICON_SOURCE_MANUAL + if value == cls.ICON_SOURCE_MANUAL + else cls.ICON_SOURCE_AUTO + ) + + def _existing_icon_entry(self, config_id, key): existing = self._secrets.find_one( - {"config_id": config_id, "key": key}, {"icon_slug": 1} + {"config_id": config_id, "key": key}, + {"icon_slug": 1, "icon_source": 1}, ) if not existing: - return "" - return normalize_icon_slug(existing.get("icon_slug")) + return "", self.ICON_SOURCE_AUTO + return ( + normalize_icon_slug(existing.get("icon_slug")), + self._normalize_icon_source(existing.get("icon_source")), + ) def _project_config_ids_for_config(self, config_id): config = self._configs.get_by_id(config_id) @@ -54,32 +220,38 @@ def _project_config_ids_for_config(self, config_id): return [config_id_value] - def _existing_project_icon_slug(self, config_id, key): + def _existing_project_icon_entry(self, config_id, key): for current_config_id in self._project_config_ids_for_config( config_id ): - icon_slug = self._existing_icon_slug(current_config_id, key) + icon_slug, icon_source = self._existing_icon_entry( + current_config_id, key + ) if is_valid_icon_slug(icon_slug): - return icon_slug - return "" + return icon_slug, icon_source + return "", self.ICON_SOURCE_AUTO - def _sync_project_icon_slug(self, config_id, key, icon_slug): + def _sync_project_icon_slug(self, config_id, key, icon_slug, icon_source): config_ids = self._project_config_ids_for_config(config_id) if not config_ids: return + set_doc = { + "icon_slug": icon_slug, + "icon_source": self._normalize_icon_source(icon_source), + } update_many = getattr(self._secrets, "update_many", None) if callable(update_many): update_many( {"config_id": {"$in": config_ids}, "key": key}, - {"$set": {"icon_slug": icon_slug}}, + {"$set": set_doc}, ) return for current_config_id in config_ids: self._secrets.update_one( {"config_id": current_config_id, "key": key}, - {"$set": {"icon_slug": icon_slug}}, + {"$set": set_doc}, ) def _resolve_icon_slug_for_put( @@ -87,22 +259,116 @@ def _resolve_icon_slug_for_put( ): if icon_slug_provided: if icon_slug is not None and not isinstance(icon_slug, str): - return None, "Invalid icon slug", 400 + return None, None, "Invalid icon slug", 400 normalized_icon_slug = normalize_icon_slug(icon_slug) if normalized_icon_slug and not is_valid_icon_slug( normalized_icon_slug ): - return None, "Invalid icon slug", 400 + return None, None, "Invalid icon slug", 400 if normalized_icon_slug: - return normalized_icon_slug, None, None - return resolve_icon_slug(key, None), None, None + return ( + normalized_icon_slug, + self.ICON_SOURCE_MANUAL, + None, + None, + ) + return ( + resolve_icon_slug(key, None), + self.ICON_SOURCE_AUTO, + None, + None, + ) - existing_project_icon_slug = self._existing_project_icon_slug( - config_id, key + existing_project_icon_slug, existing_icon_source = ( + self._existing_project_icon_entry(config_id, key) ) if is_valid_icon_slug(existing_project_icon_slug): - return existing_project_icon_slug, None, None - return resolve_icon_slug(key, None), None, None + return existing_project_icon_slug, existing_icon_source, None, None + return ( + resolve_icon_slug(key, None), + self.ICON_SOURCE_AUTO, + None, + None, + ) + + def recompute_project_icon_slugs(self, project_id): + list_ids = getattr(self._configs, "list_ids", None) + if not callable(list_ids): + return None, "Config list lookup is unavailable", 500 + + config_ids = list_ids(project_id) or [] + summary = { + "configsScanned": len(config_ids), + "keysScanned": 0, + "keysUpdated": 0, + "secretsUpdated": 0, + "keysSkippedManual": 0, + } + if not config_ids: + return summary, "OK", 200 + + docs = list( + self._secrets.find( + {"config_id": {"$in": config_ids}}, + {"config_id": 1, "key": 1, "icon_slug": 1, "icon_source": 1}, + ) + ) + docs_by_key = {} + for doc in docs: + key = doc.get("key") + if not isinstance(key, str): + continue + docs_by_key.setdefault(key, []).append(doc) + + for key, key_docs in docs_by_key.items(): + summary["keysScanned"] += 1 + has_manual = any( + self._normalize_icon_source(doc.get("icon_source")) + == self.ICON_SOURCE_MANUAL + for doc in key_docs + ) + if has_manual: + summary["keysSkippedManual"] += 1 + continue + + desired_slug = resolve_icon_slug(key, None) + needs_update = any( + normalize_icon_slug(doc.get("icon_slug")) != desired_slug + or self._normalize_icon_source(doc.get("icon_source")) + != self.ICON_SOURCE_AUTO + for doc in key_docs + ) + if not needs_update: + continue + + summary["keysUpdated"] += 1 + summary["secretsUpdated"] += len(key_docs) + + update_many = getattr(self._secrets, "update_many", None) + if callable(update_many): + update_many( + {"config_id": {"$in": config_ids}, "key": key}, + { + "$set": { + "icon_slug": desired_slug, + "icon_source": self.ICON_SOURCE_AUTO, + } + }, + ) + continue + + for config_id in config_ids: + self._secrets.update_one( + {"config_id": config_id, "key": key}, + { + "$set": { + "icon_slug": desired_slug, + "icon_source": self.ICON_SOURCE_AUTO, + } + }, + ) + + return summary, "OK", 200 def put( self, @@ -115,7 +381,14 @@ def put( ): if not is_valid_env_key(key): return "Invalid secret key", 400 - resolved_icon_slug, err, code = self._resolve_icon_slug_for_put( + if not isinstance(value, str): + return "Secret value must be a string", 400 + ( + resolved_icon_slug, + resolved_icon_source, + err, + code, + ) = self._resolve_icon_slug_for_put( config_id, key, icon_slug, icon_slug_provided ) if err: @@ -127,13 +400,16 @@ def put( "updated_at": datetime.now(timezone.utc), "updated_by": actor, "icon_slug": resolved_icon_slug, + "icon_source": resolved_icon_source, } } self._secrets.update_one( {"config_id": config_id, "key": key}, update_doc, upsert=True ) - self._sync_project_icon_slug(config_id, key, resolved_icon_slug) + self._sync_project_icon_slug( + config_id, key, resolved_icon_slug, resolved_icon_source + ) return {"status": "OK", "key": key}, 200 def get(self, config_id, key): @@ -156,42 +432,6 @@ def delete(self, config_id, key): return "Secret not found", 404 return {"status": "OK", "key": key}, 200 - @staticmethod - def _normalize_compare_configs(configs): - normalized = [] - for cfg in configs: - config_id = cfg.get("_id") - slug = cfg.get("slug") - if config_id is None or not isinstance(slug, str): - continue - normalized.append( - { - "_id": config_id, - "slug": slug, - "parent_config_id": cfg.get("parent_config_id"), - } - ) - return normalized - - @staticmethod - def _find_effective_for_config(config, config_by_id, direct_by_config_id): - visited = {str(config["_id"])} - current = config.get("parent_config_id") - while current is not None: - current_key = str(current) - if current_key in visited: - return None, None, "Config inheritance cycle detected", 400 - visited.add(current_key) - - parent = config_by_id.get(current) - if parent is None: - return None, None, None, None - direct_doc = direct_by_config_id.get(parent["_id"]) - if direct_doc is not None: - return parent, direct_doc, None, None - current = parent.get("parent_config_id") - return None, None, None, None - def compare_key_across_configs( self, configs, @@ -202,79 +442,13 @@ def compare_key_across_configs( ): if not is_valid_env_key(key): return None, "Invalid secret key", 400 - - normalized_configs = self._normalize_compare_configs(configs) - if not normalized_configs: - return [], "OK", 200 - - config_by_id = {cfg["_id"]: cfg for cfg in normalized_configs} - config_ids = list(config_by_id.keys()) - direct_docs = list( - self._secrets.find({"config_id": {"$in": config_ids}, "key": key}) + comparator = _ConfigKeyComparisonService( + self._secrets, + include_parent=include_parent, + include_metadata=include_metadata, + include_empty=include_empty, ) - direct_by_config_id = {doc["config_id"]: doc for doc in direct_docs} - - rows = [] - for config in normalized_configs: - config_id = config["_id"] - direct_doc = direct_by_config_id.get(config_id) - effective_doc = direct_doc - source_config = config - is_inherited = False - - if effective_doc is None and include_parent: - inherited_source, inherited_doc, err, code = ( - self._find_effective_for_config( - config, config_by_id, direct_by_config_id - ) - ) - if err: - return None, err, code - if inherited_doc is not None: - source_config = inherited_source - effective_doc = inherited_doc - is_inherited = True - - if effective_doc is None and not include_empty: - continue - - row = { - "configId": str(config_id), - "configSlug": config["slug"], - "effective": { - "value": SecretCodec.decrypt(effective_doc["value_enc"]) - if effective_doc is not None - else None, - "source": source_config["slug"] - if effective_doc is not None - else None, - "isInherited": is_inherited - if effective_doc is not None - else False, - }, - "direct": { - "exists": direct_doc is not None, - "value": SecretCodec.decrypt(direct_doc["value_enc"]) - if direct_doc is not None - else None, - }, - } - if include_metadata: - row["meta"] = { - "updatedAt": to_iso(effective_doc.get("updated_at")) - if effective_doc is not None - else None, - "updatedBy": effective_doc.get("updated_by") - if effective_doc is not None - else None, - "iconSlug": ( - normalize_icon_slug(effective_doc.get("icon_slug")) - if effective_doc is not None - else "" - ), - } - rows.append(row) - return rows, "OK", 200 + return comparator.compare(configs, key) def _resolve_chain(self, config_id): chain = [] @@ -330,7 +504,10 @@ def export_config( for key in keys_needing_sync: self._sync_project_icon_slug( - config_id, key, project_icon_by_key[key] + config_id, + key, + project_icon_by_key[key], + self.ICON_SOURCE_AUTO, ) return merged, meta if include_metadata else None, "OK", 200 diff --git a/README.md b/README.md index cf92de4..9f8a423 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,7 @@ API-only bootstrap steps are in [`docs/FIRST_TIME_SETUP.md`](docs/FIRST_TIME_SET --- -### Workspace RBAC and Groups (v1.4.0) +### Workspace RBAC and Groups (v1.4.1) Simple Secrets Manager now uses token-scoped RBAC for app APIs. diff --git a/VERSION b/VERSION index 88c5fb8..347f583 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.4.0 +1.4.1 diff --git a/docs/CLI.md b/docs/CLI.md index ef8ff33..dc171e5 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -47,7 +47,7 @@ uvx --from git+https://github.com/bearlike/Simple-Secrets-Manager.git ssm-cli -- Pin to tag: ```bash -uvx --from git+https://github.com/bearlike/Simple-Secrets-Manager.git@v1.4.0 ssm-cli --help +uvx --from git+https://github.com/bearlike/Simple-Secrets-Manager.git@v1.4.1 ssm-cli --help ``` ## 3) Quick Start @@ -180,7 +180,7 @@ Test overrides via env vars: - `SSM_CREDENTIALS_FILE` - `SSM_CACHE_DIR` -## Workspace Commands (v1.4.0) +## Workspace Commands (v1.4.1) `ssm-cli workspace ...` covers user/group/RBAC management: diff --git a/docs/DEPRECATIONS.md b/docs/DEPRECATIONS.md new file mode 100644 index 0000000..3d1b153 --- /dev/null +++ b/docs/DEPRECATIONS.md @@ -0,0 +1,27 @@ +# Deprecations + +This file tracks APIs and modules that are deprecated and scheduled for +removal in a future release. + +## Deprecated now + +### Legacy KV API + +- Status: Deprecated +- Removal target: Next major release +- Endpoints: + - `GET /api/secrets/kv//` + - `POST /api/secrets/kv//` + - `PUT /api/secrets/kv//` + - `DELETE /api/secrets/kv//` +- Legacy backend engine: `Engines.kv.Key_Value_Secrets` +- Replacement: + - `PUT /api/projects//configs//secrets/` + - `GET /api/projects//configs//secrets/` + - `DELETE /api/projects//configs//secrets/` + - `GET /api/projects//configs//secrets?format=json|env` + +## Not deprecated in this pass + +- `/api/auth/tokens/` remains in use by CLI login flow and is not part of + this removal candidate set yet. diff --git a/docs/DEVELOPER_GUIDE.md b/docs/DEVELOPER_GUIDE.md index 36a9e37..c74ad05 100644 --- a/docs/DEVELOPER_GUIDE.md +++ b/docs/DEVELOPER_GUIDE.md @@ -11,7 +11,7 @@ This document contains developer-facing setup and maintenance workflows. ## Prerequisites - Docker + Docker Compose -- Python + `uv` +- Python 3.13+ + `uv` - Node.js + npm ## Clone and bootstrap diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 5aa4f1d..545ec3a 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -4,6 +4,10 @@ This document is a deeper engineering reference. For day-to-day developer onboarding, use [`docs/DEVELOPER_GUIDE.md`](DEVELOPER_GUIDE.md) first. +## Runtime baseline + +- Python 3.13+ is required. + ## Monorepo overview - Backend API: repository root (`server.py`, `Api/`, `Engines/`, `Access/`) @@ -103,9 +107,9 @@ Detailed CLI usage is documented in [`docs/CLI.md`](CLI.md). - `scripts/version_sync.py --check` validates wiring and is used in CI before Docker builds/publish. - `scripts/deploy_stack.sh` is the local source-build deploy path and exports `APP_VERSION` from `VERSION` before running compose. - Docker images receive `APP_VERSION` build arg from CI, and `org.opencontainers.image.version` is labeled from that arg. -- Release tags must match `VERSION` (for example `v1.4.0` for `VERSION=1.4.0`). +- Release tags must match `VERSION` (for example `v1.4.1` for `VERSION=1.4.1`). -## Workspace RBAC model (v1.4.0) +## Workspace RBAC model (v1.4.1) Authorization model: diff --git a/docs/FIRST_TIME_SETUP.md b/docs/FIRST_TIME_SETUP.md index 23233f3..2c4c25c 100644 --- a/docs/FIRST_TIME_SETUP.md +++ b/docs/FIRST_TIME_SETUP.md @@ -84,7 +84,7 @@ curl -sS http://localhost:5000/api/workspace/members \ -H "Authorization: Bearer " ``` -## Workspace RBAC quick notes (v1.4.0) +## Workspace RBAC quick notes (v1.4.1) - Default bootstrap user is created as workspace `owner`. - Workspace roles: `owner`, `admin`, `collaborator`, `viewer`. diff --git a/frontend/AGENTS.md b/frontend/AGENTS.md index 31378e2..cd58ffc 100644 --- a/frontend/AGENTS.md +++ b/frontend/AGENTS.md @@ -17,3 +17,38 @@ This folder contains the SSM Admin Console (Vite + React). - API base URL is controlled by `VITE_API_BASE_URL`. - Default API base is `/api`. - Auth header expected by backend: `Authorization: Bearer `. + +## KISS/DRY Reuse Policy (Frontend) + +- Keep frontend implementation intentionally simple: prefer straightforward composition over new abstractions. +- DRY first: before adding code, check whether the same UI behavior already exists in `src/components` and reuse it. +- Reuse order is mandatory: existing local component -> existing shadcn primitive/pattern already in repo -> package component already installed -> new component as last resort. +- If a feature can be built by composing existing shadcn components (`Dialog`, `DropdownMenu`, `Tabs`, `Select`, `Button`, etc.), do that instead of creating a new custom primitive. +- Avoid frontend sprawl: do not add new reusable components unless the same pattern is needed in multiple places or materially improves maintainability. +- When a new component is unavoidable, keep API and internal logic minimal, colocate it near usage, and avoid speculative/generalized props. +- Prefer extending existing components with `className`, small props, and composition rather than forking or duplicating markup. +- We are not a frontend-heavy project: optimize for low ownership and low maintenance by maximizing out-of-the-box component reuse. + +### Planning/Research order for new UI work (do this first) + +1. Check official shadcn docs + registry for a native primitive/pattern that matches the need (`ui.shadcn.com` docs + registry JSON). +2. Check `src/components/ui` for that primitive already present before adding anything. +3. If missing, add the official shadcn registry primitive (and only required companion deps/files) before designing custom UI. +4. Compose feature UI from those primitives first; only build a new custom component when composition is clearly insufficient. +5. Keep implementation minimal and local: small props, no speculative APIs, no duplicate wrappers around existing shadcn behavior. + +## Session lessons (frontend) + +- Forked config grouping can be implemented without backend contract changes by combining read-time calls: + - child effective secrets (`include_parent=true`) + - child direct secrets (`include_parent=false`) + - parent effective secrets (`include_parent=true`) +- Fork diff display rule: if a child has a direct key with the same value as parent effective, classify it as `Inherited` in UI (no effective divergence). +- Current UI kit in this repo does not include accordion/collapsible primitives by default; section toggles should be built with existing shadcn-styled `Button` + lucide chevrons unless a shared primitive is intentionally introduced. +- If parent comparison data cannot be loaded, degrade gracefully in UI (show a small note and keep table usable) instead of blocking the entire secrets view. +- Prefer the native shadcn `SidebarProvider` + `Sidebar` + `SidebarInset` + `SidebarTrigger` stack for app navigation; avoid custom `Dialog`-as-drawer rewiring because it tends to cause brittle styling and transparency regressions. +- For any new component request, research should start with official shadcn registry primitives and their required companion files/dependencies before considering external UI libraries or custom implementations. +- For responsive top bars, keep only core actions always visible on small viewports (navigation trigger, breadcrumb context, account menu) and move secondary actions (export/settings/repo links/config switching) into a native shadcn `DropdownMenu` overflow. +- Keep theme mode controls out of crowded top bars on mobile-first layouts; place them in stable navigation surfaces (for example sidebar footer) to reduce header collisions and keep interaction targets accessible. +- For dense tables on compact widths, prioritize search/input width by switching secondary action buttons to icon-only with `Tooltip` labels and use the same row action definition to render desktop icon actions plus mobile `DropdownMenu` actions without duplicating behavior logic. +- For project-wide secret mutations (like icon recompute), invalidate shared query prefixes (for example `['secrets', projectSlug]` and `['compare-secret', projectSlug]`) so all config views refresh consistently. diff --git a/frontend/package-lock.json b/frontend/package-lock.json index cb30419..6bbec54 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "ssm-admin-console", - "version": "1.4.0", + "version": "1.4.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ssm-admin-console", - "version": "1.4.0", + "version": "1.4.1", "dependencies": { "@codemirror/autocomplete": "^6.20.0", "@codemirror/state": "^6.5.4", @@ -19,8 +19,10 @@ "@radix-ui/react-label": "^2.1.2", "@radix-ui/react-popover": "^1.1.6", "@radix-ui/react-select": "^2.1.6", + "@radix-ui/react-separator": "^1.1.8", "@radix-ui/react-slot": "latest", "@radix-ui/react-tabs": "^1.1.3", + "@radix-ui/react-tooltip": "^1.2.8", "@tanstack/react-query": "^5.56.2", "@tanstack/react-table": "^8.20.5", "@uiw/react-codemirror": "^4.25.5", @@ -1799,6 +1801,50 @@ } } }, + "node_modules/@radix-ui/react-separator": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.8.tgz", + "integrity": "sha512-sDvqVY4itsKwwSMEe0jtKgfTh+72Sy3gPmQpjqcQneqQ4PFmr/1I0YA+2/puilhggCe2gJcx5EBAYFkWkdpa5g==", + "dependencies": { + "@radix-ui/react-primitive": "2.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-separator/node_modules/@radix-ui/react-primitive": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.4.tgz", + "integrity": "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg==", + "dependencies": { + "@radix-ui/react-slot": "1.2.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-slot": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.4.tgz", @@ -1845,6 +1891,56 @@ } } }, + "node_modules/@radix-ui/react-tooltip": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.2.8.tgz", + "integrity": "sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg==", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-popper": "1.2.8", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-visually-hidden": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-use-callback-ref": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index 1f3dd97..10ea978 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "ssm-admin-console", - "version": "1.4.0", + "version": "1.4.1", "private": true, "type": "module", "scripts": { @@ -21,8 +21,10 @@ "@radix-ui/react-label": "^2.1.2", "@radix-ui/react-popover": "^1.1.6", "@radix-ui/react-select": "^2.1.6", + "@radix-ui/react-separator": "^1.1.8", "@radix-ui/react-slot": "latest", "@radix-ui/react-tabs": "^1.1.3", + "@radix-ui/react-tooltip": "^1.2.8", "@tanstack/react-query": "^5.56.2", "@tanstack/react-table": "^8.20.5", "@uiw/react-codemirror": "^4.25.5", diff --git a/frontend/src/components/layout/AppShell.tsx b/frontend/src/components/layout/AppShell.tsx index 397b1d1..ef4786a 100644 --- a/frontend/src/components/layout/AppShell.tsx +++ b/frontend/src/components/layout/AppShell.tsx @@ -1,16 +1,21 @@ import { Outlet } from 'react-router-dom'; +import { SidebarInset, SidebarProvider } from '@/components/ui/sidebar'; + import { Sidebar } from './Sidebar'; import { TopBar } from './TopBar'; + export function AppShell() { return ( -
- -
- -
- -
-
-
); - -} \ No newline at end of file +
+ + + + +
+ +
+
+
+
+ ); +} diff --git a/frontend/src/components/layout/Sidebar.tsx b/frontend/src/components/layout/Sidebar.tsx index cc64a6b..da4cbd4 100644 --- a/frontend/src/components/layout/Sidebar.tsx +++ b/frontend/src/components/layout/Sidebar.tsx @@ -1,146 +1,191 @@ -import { NavLink, useNavigate } from 'react-router-dom'; +import { type LucideIcon, FolderIcon, GroupIcon, KeyRoundIcon, LockIcon, LogOutIcon, MoonIcon, ScrollTextIcon, SunIcon, UserIcon, UsersIcon } from 'lucide-react'; import { useQuery } from '@tanstack/react-query'; +import { NavLink, useLocation, useNavigate } from 'react-router-dom'; + import { - LockIcon, - KeyRoundIcon, - ScrollTextIcon, - UserIcon, - UsersIcon, - GroupIcon, - LogOutIcon, - FolderIcon } from -'lucide-react'; -import { Skeleton } from '@/components/ui/skeleton'; -import { Button } from '@/components/ui/button'; + Sidebar as SidebarRoot, + SidebarContent, + SidebarFooter, + SidebarGroup, + SidebarGroupContent, + SidebarGroupLabel, + SidebarHeader, + SidebarMenu, + SidebarMenuButton, + SidebarMenuItem, + SidebarMenuSkeleton, + SidebarRail, + useSidebar +} from '@/components/ui/sidebar'; + import { getProjects } from '../../lib/api/projects'; import { queryKeys } from '../../lib/api/queryKeys'; import { useAuth } from '../../lib/auth'; -function ProjectNavItem({ slug, name }: {slug: string;name: string;}) { - const to = `/projects/${slug}/settings`; - return ( - - `flex items-center gap-2 px-2.5 py-1.5 rounded-md text-sm transition-colors truncate ${isActive ? 'bg-accent text-accent-foreground font-medium' : 'text-muted-foreground hover:bg-accent/50 hover:text-foreground'}` - }> +import { useTheme } from '../../lib/theme'; + +function isProjectRouteActive(pathname: string, slug: string): boolean { + return pathname.startsWith(`/projects/${slug}/`); +} - - {name} - ); +interface NavItemProps { + icon: LucideIcon; + isActive: boolean; + label: string; + onClick?: () => void; + to: string; +} +function NavItem({ icon: Icon, isActive, label, onClick, to }: NavItemProps) { + return ( + + + + + {label} + + + + ); } + export function Sidebar() { const { logout } = useAuth(); + const { theme, toggleTheme } = useTheme(); const navigate = useNavigate(); + const location = useLocation(); + const { isMobile, setOpenMobile } = useSidebar(); + const { data: projects = [], isLoading } = useQuery({ queryKey: queryKeys.projects(), queryFn: getProjects }); + + const closeOnMobile = () => { + if (isMobile) { + setOpenMobile(false); + } + }; + const handleLogout = () => { + closeOnMobile(); logout(); navigate('/login'); }; - return ( - ); + + + {project.name} + + + + ) + } + + + + + + + + + + + + + + {theme === 'dark' ? ( + + ) : ( + + )} + {theme === 'dark' ? 'Light mode' : 'Dark mode'} + + + + + + Sign Out + + + + + + + ); } diff --git a/frontend/src/components/layout/TopBar.tsx b/frontend/src/components/layout/TopBar.tsx index f52fea5..a2b9868 100644 --- a/frontend/src/components/layout/TopBar.tsx +++ b/frontend/src/components/layout/TopBar.tsx @@ -1,25 +1,36 @@ -import { useParams, useNavigate, useLocation, Link } from 'react-router-dom'; +import { Link, useLocation, useNavigate, useParams } from 'react-router-dom'; import { useQuery } from '@tanstack/react-query'; import { - ChevronRightIcon, DownloadIcon, + EllipsisIcon, GithubIcon, + GroupIcon, LogOutIcon, - MoonIcon, SettingsIcon, - SunIcon, UserCircle2Icon, - UsersIcon, - GroupIcon + UsersIcon } from 'lucide-react'; import { toast } from 'sonner'; +import { + Breadcrumb, + BreadcrumbItem, + BreadcrumbLink, + BreadcrumbList, + BreadcrumbPage, + BreadcrumbSeparator +} from '@/components/ui/breadcrumb'; import { Button } from '@/components/ui/button'; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'; +import { SidebarTrigger } from '@/components/ui/sidebar'; import { Select, SelectContent, @@ -33,7 +44,6 @@ import { bulkExport } from '../../lib/api/secrets'; import { getAppVersion } from '../../lib/api/version'; import { getMe } from '../../lib/api/me'; import { queryKeys } from '../../lib/api/queryKeys'; -import { useTheme } from '../../lib/theme'; import { useAuth } from '../../lib/auth'; import { notifyApiError } from '../../lib/api/errorToast'; @@ -56,7 +66,6 @@ export function TopBar() { }>(); const navigate = useNavigate(); const location = useLocation(); - const { theme, toggleTheme } = useTheme(); const { logout } = useAuth(); const isCompareBySecretPage = Boolean(projectSlug) && location.pathname.endsWith('/compare/secret'); @@ -112,142 +121,210 @@ export function TopBar() { } }; + const renderExportMenuItems = () => ( + <> + handleExport('json')}>Export as JSON (resolved) + handleExport('env')}>Export as .env (resolved) + handleExport('json', true)}>Export as JSON (raw) + handleExport('env', true)}>Export as .env (raw) + + ); + return ( -
- - - {projectSlug && configs.length > 0 && ( - - )} - - - - - - {projectSlug && ( - - )} - - {projectSlug && configSlug && ( - - - - - - handleExport('json')}>Export as JSON (resolved) - handleExport('env')}>Export as .env (resolved) - handleExport('json', true)}>Export as JSON (raw) - handleExport('env', true)}>Export as .env (raw) - - - )} - - - - - - - navigate('/account')}> - - Account - - navigate('/team')}> - - Team - - navigate('/groups')}> - - Groups - - { - logout(); - navigate('/login'); - }} - > - - Sign Out - - - + + {projectSlug && ( + + )} + + {projectSlug && configSlug && ( + + + + + {renderExportMenuItems()} + + )} + + + + + + + window.open(REPOSITORY_URL, '_blank', 'noopener,noreferrer')}> + + {`Open GitHub (v${appVersion})`} + + + {projectSlug && ( + navigate(`/projects/${projectSlug}/settings`)}> + + Project settings + + )} + + {projectSlug && configs.length > 0 && ( + + Switch config + + {configs.map((config) => ( + handleConfigChange(config.slug)}> + + {config.slug} + + + ))} + + + )} + + {projectSlug && configSlug && ( + <> + + + Export + {renderExportMenuItems()} + + + )} + + + + + + + + + navigate('/account')}> + + Account + + navigate('/team')}> + + Team + + navigate('/groups')}> + + Groups + + { + logout(); + navigate('/login'); + }} + > + + Sign Out + + + + +
); } diff --git a/frontend/src/components/secrets/AddSecretDialog.tsx b/frontend/src/components/secrets/AddSecretDialog.tsx index 3ba13e2..e1a5e38 100644 --- a/frontend/src/components/secrets/AddSecretDialog.tsx +++ b/frontend/src/components/secrets/AddSecretDialog.tsx @@ -1,3 +1,4 @@ +import { useState } from 'react'; import { Controller, useForm } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; import { z } from 'zod'; @@ -18,6 +19,11 @@ import { createSecret } from '../../lib/api/secrets'; import { queryKeys } from '../../lib/api/queryKeys'; import { SecretValueEditor } from './SecretValueEditor'; import { useReferenceSuggestions } from './useReferenceSuggestions'; +import { ConfirmDialog } from '../common/ConfirmDialog'; +import { + normalizeSecretValueForSubmit, + requiresEmptyValueConfirmation +} from './secretValueSubmit'; const ICON_SLUG_PATTERN = /^[a-z0-9-]+:[a-z0-9][a-z0-9-]*$/; @@ -29,7 +35,7 @@ const schema = z.object({ /^[A-Z0-9_]+$/, 'Key must be uppercase letters, numbers, and underscores only' ), - value: z.string().min(1, 'Value is required'), + value: z.string(), iconSlug: z .string() .optional() @@ -53,6 +59,7 @@ export function AddSecretDialog({ }: AddSecretDialogProps) { const queryClient = useQueryClient(); const referenceSuggestions = useReferenceSuggestions({ projectSlug, configSlug }); + const [pendingSubmit, setPendingSubmit] = useState(null); const { control, register, @@ -60,7 +67,12 @@ export function AddSecretDialog({ reset, formState: { errors } } = useForm({ - resolver: zodResolver(schema) + resolver: zodResolver(schema), + defaultValues: { + key: '', + value: '', + iconSlug: '' + } }); const mutation = useMutation({ mutationFn: (data: FormValues) => @@ -75,6 +87,7 @@ export function AddSecretDialog({ }); toast.success('Secret created'); reset(); + setPendingSubmit(null); onOpenChange(false); }, onError: (error) => { @@ -89,87 +102,125 @@ export function AddSecretDialog({ toast.error('Failed to create secret'); } }); - const onSubmit = (data: FormValues) => mutation.mutate(data); + const submitValue = (data: FormValues) => mutation.mutate(data); + const onSubmit = (data: FormValues) => { + const normalizedValue = normalizeSecretValueForSubmit(data.value); + const normalized = { + ...data, + value: normalizedValue + }; + if (requiresEmptyValueConfirmation(data.value)) { + setPendingSubmit(normalized); + return; + } + submitValue(normalized); + }; return ( - { - onOpenChange(v); - if (!v) reset(); - }}> + <> + { + onOpenChange(v); + if (!v) { + reset(); + setPendingSubmit(null); + } + }}> - - - Add Secret - -
-
- - + + + Add Secret + + +
+ + - {errors.key && -

{errors.key.message}

- } -

- Uppercase letters, numbers, and underscores only -

-
-
- - - {errors.iconSlug &&

{errors.iconSlug.message}

} -

Leave blank to auto-detect icon

-
-
- - - {errors.key.message}

+ } +

+ Uppercase letters, numbers, and underscores only +

+
+
+ + + {errors.iconSlug &&

{errors.iconSlug.message}

} +

Leave blank to auto-detect icon

+
+
+ + + + } /> + + {errors.value && +

{errors.value.message}

} - /> +

+ References: ${'{KEY}'}, ${'{config.KEY}'},{' '} + ${'{project.config.KEY}'} +

+

+ Whitespace-only input is saved as an empty string after confirmation. +

+
+ +
- - + + +
+
+
- Cancel - - - - - -
); + { + if (!next) setPendingSubmit(null); + }} + title="Create Empty Value?" + description={ + pendingSubmit ? + `This will create "${pendingSubmit.key}" with an empty string value.` : + 'This will create the secret with an empty string value.' + } + onConfirm={() => { + if (!pendingSubmit) return; + submitValue(pendingSubmit); + }} + loading={mutation.isPending} + /> + ); } diff --git a/frontend/src/components/secrets/EditSecretDialog.tsx b/frontend/src/components/secrets/EditSecretDialog.tsx index 02b5070..f12be0b 100644 --- a/frontend/src/components/secrets/EditSecretDialog.tsx +++ b/frontend/src/components/secrets/EditSecretDialog.tsx @@ -1,4 +1,4 @@ -import { useEffect } from 'react'; +import { useEffect, useState } from 'react'; import { Controller, useForm } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; import { z } from 'zod'; @@ -20,9 +20,14 @@ import { queryKeys } from '../../lib/api/queryKeys'; import type { Secret } from '../../lib/api/types'; import { SecretValueEditor } from './SecretValueEditor'; import { useReferenceSuggestions } from './useReferenceSuggestions'; +import { ConfirmDialog } from '../common/ConfirmDialog'; +import { + normalizeSecretValueForSubmit, + requiresEmptyValueConfirmation +} from './secretValueSubmit'; const schema = z.object({ - value: z.string().min(1, 'Value is required'), + value: z.string(), iconSlug: z .string() .optional() @@ -51,6 +56,7 @@ export function EditSecretDialog({ }: EditSecretDialogProps) { const queryClient = useQueryClient(); const referenceSuggestions = useReferenceSuggestions({ projectSlug, configSlug }); + const [pendingSubmit, setPendingSubmit] = useState(null); const { control, register, @@ -85,6 +91,7 @@ export function EditSecretDialog({ queryKey: queryKeys.secrets(projectSlug, configSlug) }); toast.success('Secret updated'); + setPendingSubmit(null); onOpenChange(false); }, onError: (error) => { @@ -100,76 +107,112 @@ export function EditSecretDialog({ } }); - const onSubmit = (data: FormValues) => mutation.mutate(data); + const submitValue = (data: FormValues) => mutation.mutate(data); + const onSubmit = (data: FormValues) => { + const normalizedValue = normalizeSecretValueForSubmit(data.value); + const normalized = { + ...data, + value: normalizedValue + }; + if (requiresEmptyValueConfirmation(data.value)) { + setPendingSubmit(normalized); + return; + } + submitValue(normalized); + }; return ( - { - onOpenChange(next); - if (!next) { - mutation.reset(); - } - }} - > - - - Edit Secret - -
-
- -

{secret?.key ?? '—'}

-
+ <> + { + onOpenChange(next); + if (!next) { + mutation.reset(); + setPendingSubmit(null); + } + }} + > + + + Edit Secret + + +
+ +

{secret?.key ?? '—'}

+
-
- - - {errors.iconSlug &&

{errors.iconSlug.message}

} -

Clear this field to reset back to auto-detected icon

-
+
+ + + {errors.iconSlug &&

{errors.iconSlug.message}

} +

Clear this field to reset back to auto-detected icon

+
-
- - ( - - )} - /> +
+ + ( + + )} + /> - {errors.value &&

{errors.value.message}

} -

- References: ${'{KEY}'},{' '} - ${'{config.KEY}'},{' '} - ${'{project.config.KEY}'} -

-
+ {errors.value &&

{errors.value.message}

} +

+ References: ${'{KEY}'},{' '} + ${'{config.KEY}'},{' '} + ${'{project.config.KEY}'} +

+

+ Whitespace-only input is saved as an empty string after confirmation. +

+
- - - - - -
-
+ + + + + +
+
+ + { + if (!next) setPendingSubmit(null); + }} + title="Save Empty Value?" + description={ + secret ? + `This will update "${secret.key}" to an empty string value.` : + 'This will update the secret to an empty string value.' + } + onConfirm={() => { + if (!pendingSubmit) return; + submitValue(pendingSubmit); + }} + loading={mutation.isPending} + /> + ); } diff --git a/frontend/src/components/secrets/SecretsTable.tsx b/frontend/src/components/secrets/SecretsTable.tsx index a712d68..2a83255 100644 --- a/frontend/src/components/secrets/SecretsTable.tsx +++ b/frontend/src/components/secrets/SecretsTable.tsx @@ -1,15 +1,19 @@ -import { useMemo, useRef, useState, type ChangeEventHandler } from 'react'; +import { useEffect, useMemo, useRef, useState, type ChangeEventHandler } from 'react'; import { useReactTable, getCoreRowModel, getFilteredRowModel, flexRender, - type ColumnDef + type ColumnDef, + type Row } from '@tanstack/react-table'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { toast } from 'sonner'; import { useNavigate } from 'react-router-dom'; import { + ChevronDownIcon, + ChevronRightIcon, + EllipsisIcon, EyeIcon, EyeOffIcon, GitCompareArrowsIcon, @@ -23,6 +27,14 @@ import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Badge } from '@/components/ui/badge'; import { Skeleton } from '@/components/ui/skeleton'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger +} from '@/components/ui/dropdown-menu'; +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; +import { Table, TableBody, TableCaption, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'; import { deleteSecret, getSecrets, @@ -66,31 +78,95 @@ function hasOwnKey(record: Record, key: string): boolean { return Object.prototype.hasOwnProperty.call(record, key); } +type ForkBucket = 'override' | 'inherited'; + +interface SecretRow extends Secret { + forkBucket: ForkBucket; + isDirectInConfig: boolean; +} + +export interface ForkSecretsSummary { + isFork: boolean; + overrides: number; + inherited: number; + parentComparisonDegraded: boolean; +} + +interface SecretsQueryData { + rows: SecretRow[]; + summary: ForkSecretsSummary; +} + interface SecretsTableProps { projectSlug: string; configSlug: string; + parentSlug?: string; + onForkSummaryChange?: (summary: ForkSecretsSummary) => void; } -function columnClass(columnId: string, header = false): string { - if (columnId === 'icon') { - return header ? 'w-14' : 'w-14 align-top'; - } - if (columnId === 'key') { - return header ? 'w-56 max-w-[14rem]' : 'w-56 max-w-[14rem] align-top'; - } - if (columnId === 'value') { - return header ? '' : 'min-w-0 align-top'; - } - if (columnId === 'updatedAt') { - return header ? 'w-28' : 'w-28 align-top'; +interface RowAction { + key: string; + label: string; + onSelect: () => void; + icon: typeof EyeIcon; + destructive?: boolean; +} + +const COLUMN_CLASS: Record = { + icon: { header: 'w-10 sm:w-12', cell: 'w-10 sm:w-12 align-top' }, + key: { + header: 'min-w-[10rem] sm:min-w-[12rem]', + cell: 'min-w-[10rem] sm:min-w-[12rem] align-top' + }, + value: { + header: 'min-w-[14rem] sm:min-w-[18rem]', + cell: 'min-w-[14rem] sm:min-w-[18rem] align-top' + }, + updatedAt: { + header: 'w-[6.5rem] whitespace-nowrap', + cell: 'w-[6.5rem] whitespace-nowrap align-top' + }, + actions: { + header: 'w-10 sm:w-12 lg:w-40 text-right', + cell: 'w-10 sm:w-12 lg:w-40 align-top' } - if (columnId === 'actions') { - return header ? 'w-44 text-right' : 'w-44 align-top'; +}; + +function columnClass(columnId: string, header = false): string { + const classes = COLUMN_CLASS[columnId]; + if (!classes) return header ? '' : 'align-top'; + return header ? classes.header : classes.cell; +} + +function summarizeForkRows( + rows: SecretRow[], + isFork: boolean, + parentComparisonDegraded = false +): ForkSecretsSummary { + if (!isFork) { + return { + isFork: false, + overrides: rows.length, + inherited: 0, + parentComparisonDegraded: false + }; } - return header ? '' : 'align-top'; + + const overrides = rows.filter((row) => row.forkBucket === 'override').length; + return { + isFork: true, + overrides, + inherited: rows.length - overrides, + parentComparisonDegraded + }; } -export function SecretsTable({ projectSlug, configSlug }: SecretsTableProps) { +export function SecretsTable({ + projectSlug, + configSlug, + parentSlug, + onForkSummaryChange +}: SecretsTableProps) { const queryClient = useQueryClient(); const navigate = useNavigate(); const fileInputRef = useRef(null); @@ -102,13 +178,95 @@ export function SecretsTable({ projectSlug, configSlug }: SecretsTableProps) { const [globalFilter, setGlobalFilter] = useState(''); const [importOpen, setImportOpen] = useState(false); const [importPreview, setImportPreview] = useState(null); + const [overridesOpen, setOverridesOpen] = useState(true); + const [inheritedOpen, setInheritedOpen] = useState(false); + + const isFork = Boolean(parentSlug); + + useEffect(() => { + setOverridesOpen(true); + setInheritedOpen(false); + }, [projectSlug, configSlug, parentSlug]); + + const { data: secretsData, isLoading } = useQuery({ + queryKey: queryKeys.secretsView(projectSlug, configSlug, parentSlug), + queryFn: async () => { + const effectiveSecrets = await getSecrets(projectSlug, configSlug, { + includeParent: true, + includeMeta: true, + resolveReferences: false, + raw: false + }); + + if (!parentSlug) { + const rows: SecretRow[] = effectiveSecrets.map((secret) => ({ + ...secret, + forkBucket: 'override', + isDirectInConfig: true + })); + return { + rows, + summary: summarizeForkRows(rows, false) + }; + } - const { data: secrets = [], isLoading } = useQuery({ - queryKey: queryKeys.secrets(projectSlug, configSlug), - queryFn: () => getSecrets(projectSlug, configSlug), + const [directSecrets, parentSecretsResult] = await Promise.all([ + getSecretsKeyMap(projectSlug, configSlug, false, { + includeParent: false, + includeMeta: false, + resolveReferences: false, + raw: false + }), + getSecretsKeyMap(projectSlug, parentSlug, true, { + includeParent: true, + includeMeta: false, + resolveReferences: false, + raw: false + }) + .then((data) => ({ data, failed: false })) + .catch(() => ({ data: {} as Record, failed: true })) + ]); + + const parentSecrets = parentSecretsResult.data; + const parentComparisonDegraded = parentSecretsResult.failed; + + const rows: SecretRow[] = effectiveSecrets.map((secret) => { + const isDirectInConfig = hasOwnKey(directSecrets, secret.key); + + let forkBucket: ForkBucket = 'inherited'; + if (isDirectInConfig) { + if (parentComparisonDegraded) { + forkBucket = 'override'; + } else if (!hasOwnKey(parentSecrets, secret.key)) { + forkBucket = 'override'; + } else { + forkBucket = directSecrets[secret.key] === parentSecrets[secret.key] ? 'inherited' : 'override'; + } + } + + return { + ...secret, + forkBucket, + isDirectInConfig + }; + }); + + return { + rows, + summary: summarizeForkRows(rows, true, parentComparisonDegraded) + }; + }, enabled: !!projectSlug && !!configSlug }); + useEffect(() => { + if (!onForkSummaryChange || !secretsData) return; + onForkSummaryChange(secretsData.summary); + }, [onForkSummaryChange, secretsData]); + + const secrets = useMemo(() => secretsData?.rows ?? [], [secretsData?.rows]); + const summary = secretsData?.summary; + const deleteMutation = useMutation({ mutationFn: (key: string) => deleteSecret(projectSlug, configSlug, key), onSuccess: () => { @@ -248,7 +406,7 @@ export function SecretsTable({ projectSlug, configSlug }: SecretsTableProps) { } }; - const columns = useMemo[]>( + const columns = useMemo[]>( () => [ { id: 'icon', @@ -262,22 +420,34 @@ export function SecretsTable({ projectSlug, configSlug }: SecretsTableProps) { { accessorKey: 'key', header: 'KEY', - cell: ({ row }) => ( - - {row.original.key} - - ) + cell: ({ row }) => { + const inherited = row.original.forkBucket === 'inherited'; + return ( + + {row.original.key} + + ); + } }, { accessorKey: 'value', header: 'VALUE', cell: ({ row }) => { const revealed = revealedKeys.has(row.original.key); - return ( - revealed ? -
- -
: + const inherited = row.original.forkBucket === 'inherited'; + return revealed ? ( +
+ +
+ ) : ( •••••••••••• ); } @@ -292,53 +462,84 @@ export function SecretsTable({ projectSlug, configSlug }: SecretsTableProps) { { id: 'actions', header: '', - cell: ({ row }) => ( -
- - - - -
- ) + cell: ({ row }) => { + const isInherited = row.original.forkBucket === 'inherited'; + const editLabel = isInherited ? 'Override inherited secret' : 'Edit secret'; + const isRevealed = revealedKeys.has(row.original.key); + const rowActions: RowAction[] = [ + { + key: 'toggle-visibility', + label: isRevealed ? 'Hide value' : 'Reveal value', + onSelect: () => toggleReveal(row.original.key), + icon: isRevealed ? EyeOffIcon : EyeIcon + }, + { + key: 'compare', + label: 'Compare secret', + onSelect: () => + navigate(`/projects/${projectSlug}/compare/secret?key=${encodeURIComponent(row.original.key)}`), + icon: GitCompareArrowsIcon + }, + { + key: 'edit', + label: editLabel, + onSelect: () => setEditingSecret(row.original), + icon: PencilIcon + }, + { + key: 'delete', + label: 'Delete secret', + onSelect: () => setDeletingKey(row.original.key), + icon: Trash2Icon, + destructive: true + } + ]; + + return ( + <> +
+ {rowActions.map((action) => ( + + ))} +
+
+ + + + + + {rowActions.map((action) => ( + + + {action.label} + + ))} + + +
+ + ); + } } ], [navigate, projectSlug, revealedKeys] @@ -357,38 +558,90 @@ export function SecretsTable({ projectSlug, configSlug }: SecretsTableProps) { row.original.key.toLowerCase().includes(filterValue.toLowerCase()) }); + const filteredRows = table.getRowModel().rows; + const overrideRows = isFork ? filteredRows.filter((row) => row.original.forkBucket === 'override') : filteredRows; + const inheritedRows = isFork ? filteredRows.filter((row) => row.original.forkBucket === 'inherited') : []; + + const renderDataRow = (row: Row) => { + const inherited = row.original.forkBucket === 'inherited'; + return ( + + {row.getVisibleCells().map((cell) => ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ))} + + ); + }; + return (
-
-
+
+
setGlobalFilter(event.target.value)} - className="pl-8 h-8 text-sm" + className="h-8 pl-8 text-sm" />
-
- - {configSlug} - - - + {configSlug} + + + + + + Import .env + + + + + + Add Secret +
+ {isFork && summary?.parentComparisonDegraded && ( +

+ Parent diff comparison is partially unavailable; direct keys are treated as overrides. +

+ )} + -
- - - +

+ Swipe or scroll horizontally to view all columns. +

+
+
+ + Environment secrets table with columns key, value, updated time, and actions. + + + {table.getHeaderGroups().map((headerGroup) => headerGroup.headers.map((header) => ( - + )) )} - - - - {isLoading - ? Array.from({ length: 5 }).map((_, index) => ( - - - - - - - - )) - : table.getRowModel().rows.length === 0 - ? ( - - - - ) - : table.getRowModel().rows.map((row) => ( - + + + {isLoading ? ( + Array.from({ length: 5 }).map((_, index) => ( + + + + + + + + + + + + + + + + + + )) + ) : filteredRows.length === 0 ? ( + + + setAddOpen(true)}> + + Add Secret + + ) : undefined + } + /> + + + ) : isFork ? ( + <> + + + + + + {overridesOpen && + (overrideRows.length > 0 ? ( + overrideRows.map((row) => renderDataRow(row)) + ) : ( + + + No overrides in this view. + + + ))} + + + + - ))} - + {inheritedOpen ? : } + Inherited + + {inheritedRows.length} + + + + + {inheritedOpen && + (inheritedRows.length > 0 ? ( + inheritedRows.map((row) => renderDataRow(row)) + ) : ( + + + No inherited secrets in this view. + + ))} - -
{flexRender(header.column.columnDef.header, header.getContext())} -
- - - - - - - - - -
- setAddOpen(true)}> - - Add Secret - - ) : undefined - } - /> -
- {flexRender(cell.column.columnDef.cell, cell.getContext())} -
+ + ) : ( + filteredRows.map((row) => renderDataRow(row)) + )} + +
& { + separator?: React.ReactNode + } +>(({ ...props }, ref) =>