simple-module-feature-flags 0.0.32__tar.gz → 0.0.33__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. {simple_module_feature_flags-0.0.32 → simple_module_feature_flags-0.0.33}/PKG-INFO +4 -4
  2. simple_module_feature_flags-0.0.33/feature_flags/constants.py +50 -0
  3. {simple_module_feature_flags-0.0.32 → simple_module_feature_flags-0.0.33}/feature_flags/endpoints/views.py +20 -0
  4. simple_module_feature_flags-0.0.33/feature_flags/locales/en.json +55 -0
  5. {simple_module_feature_flags-0.0.32 → simple_module_feature_flags-0.0.33}/feature_flags/module.py +65 -4
  6. simple_module_feature_flags-0.0.33/feature_flags/pages/Browse.tsx +111 -0
  7. simple_module_feature_flags-0.0.33/feature_flags/pages/components/FlagTable.tsx +162 -0
  8. simple_module_feature_flags-0.0.33/feature_flags/pages/components/ScopeCard.tsx +51 -0
  9. simple_module_feature_flags-0.0.33/feature_flags/pages/components/TenantPicker.tsx +91 -0
  10. {simple_module_feature_flags-0.0.32 → simple_module_feature_flags-0.0.33}/feature_flags/pages/components/ToggleConfirmDialog.tsx +21 -31
  11. simple_module_feature_flags-0.0.33/feature_flags/pages/types.ts +12 -0
  12. {simple_module_feature_flags-0.0.32 → simple_module_feature_flags-0.0.33}/pyproject.toml +4 -4
  13. simple_module_feature_flags-0.0.33/tests/test_feature_flags_audit_links.py +117 -0
  14. simple_module_feature_flags-0.0.33/tests/test_feature_flags_menu_section.py +28 -0
  15. simple_module_feature_flags-0.0.32/feature_flags/constants.py +0 -33
  16. simple_module_feature_flags-0.0.32/feature_flags/locales/en.json +0 -48
  17. simple_module_feature_flags-0.0.32/feature_flags/pages/Browse.tsx +0 -257
  18. simple_module_feature_flags-0.0.32/feature_flags/pages/components/TenantPicker.tsx +0 -112
  19. {simple_module_feature_flags-0.0.32 → simple_module_feature_flags-0.0.33}/.gitignore +0 -0
  20. {simple_module_feature_flags-0.0.32 → simple_module_feature_flags-0.0.33}/LICENSE +0 -0
  21. {simple_module_feature_flags-0.0.32 → simple_module_feature_flags-0.0.33}/README.md +0 -0
  22. {simple_module_feature_flags-0.0.32 → simple_module_feature_flags-0.0.33}/feature_flags/__init__.py +0 -0
  23. {simple_module_feature_flags-0.0.32 → simple_module_feature_flags-0.0.33}/feature_flags/contracts/__init__.py +0 -0
  24. {simple_module_feature_flags-0.0.32 → simple_module_feature_flags-0.0.33}/feature_flags/contracts/schemas.py +0 -0
  25. {simple_module_feature_flags-0.0.32 → simple_module_feature_flags-0.0.33}/feature_flags/deps.py +0 -0
  26. {simple_module_feature_flags-0.0.32 → simple_module_feature_flags-0.0.33}/feature_flags/endpoints/__init__.py +0 -0
  27. {simple_module_feature_flags-0.0.32 → simple_module_feature_flags-0.0.33}/feature_flags/endpoints/api.py +0 -0
  28. {simple_module_feature_flags-0.0.32 → simple_module_feature_flags-0.0.33}/feature_flags/models.py +0 -0
  29. {simple_module_feature_flags-0.0.32 → simple_module_feature_flags-0.0.33}/feature_flags/py.typed +0 -0
  30. {simple_module_feature_flags-0.0.32 → simple_module_feature_flags-0.0.33}/feature_flags/service.py +0 -0
  31. {simple_module_feature_flags-0.0.32 → simple_module_feature_flags-0.0.33}/package.json +0 -0
  32. {simple_module_feature_flags-0.0.32 → simple_module_feature_flags-0.0.33}/tests/test_feature_flags_api.py +0 -0
  33. {simple_module_feature_flags-0.0.32 → simple_module_feature_flags-0.0.33}/tests/test_feature_flags_service.py +0 -0
  34. {simple_module_feature_flags-0.0.32 → simple_module_feature_flags-0.0.33}/tests/test_store_outage_fallback.py +0 -0
  35. {simple_module_feature_flags-0.0.32 → simple_module_feature_flags-0.0.33}/tsconfig.json +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: simple_module_feature_flags
3
- Version: 0.0.32
3
+ Version: 0.0.33
4
4
  Summary: Simple feature-flag module with per-tenant overrides and a consumer API for simple_module
5
5
  Project-URL: Homepage, https://github.com/antosubash/simple_module_python
6
6
  Project-URL: Repository, https://github.com/antosubash/simple_module_python
@@ -21,9 +21,9 @@ Classifier: Topic :: Internet :: WWW/HTTP
21
21
  Classifier: Topic :: Software Development :: Libraries :: Application Frameworks
22
22
  Classifier: Typing :: Typed
23
23
  Requires-Python: >=3.12
24
- Requires-Dist: simple-module-core==0.0.32
25
- Requires-Dist: simple-module-db==0.0.32
26
- Requires-Dist: simple-module-hosting==0.0.32
24
+ Requires-Dist: simple-module-core==0.0.33
25
+ Requires-Dist: simple-module-db==0.0.33
26
+ Requires-Dist: simple-module-hosting==0.0.33
27
27
  Description-Content-Type: text/markdown
28
28
 
29
29
  # simple_module_feature_flags
@@ -0,0 +1,50 @@
1
+ """Stable identifiers for the feature_flags module."""
2
+
3
+ PERM_FEATURE_FLAGS_VIEW = "feature_flags.view"
4
+ PERM_FEATURE_FLAGS_MANAGE = "feature_flags.manage"
5
+ # Shown as a heading in the role editor, so it follows the same sentence case
6
+ # as the menu label and the page title rather than reading "Feature Flags"
7
+ # beside them.
8
+ PERM_GROUP = "Feature flags"
9
+
10
+ # Prefix is load-bearing on SQLite, which has no per-module schemas.
11
+ TABLE_OVERRIDE = "feature_flags_override"
12
+ UQ_OVERRIDE_SCOPE_NAME = "uq_feature_flags_override_scope_scope_id_name"
13
+
14
+ SCOPE_SYSTEM = "system"
15
+ SCOPE_TENANT = "tenant"
16
+ ALL_SCOPES = (SCOPE_SYSTEM, SCOPE_TENANT)
17
+ # Empty string, not NULL: PG treats NULLs as distinct in unique indexes,
18
+ # which would let two "system" rows for the same flag coexist.
19
+ SYSTEM_SCOPE_ID = ""
20
+
21
+ SCOPE_MAX_LENGTH = 10
22
+ SCOPE_ID_MAX_LENGTH = 64
23
+
24
+ LOCALE_NAMESPACE = "feature_flags"
25
+
26
+ MENU_LABEL = "Feature flags"
27
+ # Trailing slash: the browse route is registered at "/" under the view
28
+ # prefix, so linking to the bare prefix costs a 307 on every navigation.
29
+ MENU_URL = "/admin/feature-flags/"
30
+ MENU_ICON = "flag"
31
+ MENU_ORDER = 110
32
+
33
+ PAGE_BROWSE = "FeatureFlags/Browse"
34
+
35
+ API_PREFIX = "/api/feature_flags"
36
+ VIEW_PREFIX = "/admin/feature-flags"
37
+ QP_TENANT_ID = "tenant_id"
38
+ # Audit rows carry an override's primary key and nothing else. There is no
39
+ # per-override page to land on, so the link opens the flags screen and names
40
+ # the row it came from; an AuditLink without an "{id}" slot is rejected at
41
+ # boot because every row would otherwise resolve to the same URL.
42
+ QP_OVERRIDE = "override"
43
+ AUDIT_LINK_LABEL = "Feature flag override"
44
+ AUDIT_LINK_LABEL_KEY = "feature_flags.audit.override"
45
+
46
+ # Module name and browse URL of the audit log, used to decide whether the
47
+ # "View change history" link has anywhere to go and where. Not a dependency:
48
+ # flags work perfectly well without an audit log installed.
49
+ AUDIT_LOG_MODULE = "AuditLog"
50
+ AUDIT_LOG_VIEW_URL = "/admin/audit-log/"
@@ -15,6 +15,8 @@ from simple_module_hosting.permissions import RequiresPermission
15
15
  from starlette.responses import RedirectResponse
16
16
 
17
17
  from feature_flags.constants import (
18
+ AUDIT_LOG_MODULE,
19
+ AUDIT_LOG_VIEW_URL,
18
20
  MENU_URL,
19
21
  PAGE_BROWSE,
20
22
  PERM_FEATURE_FLAGS_MANAGE,
@@ -34,6 +36,22 @@ def _redirect_for_tenant(tenant_id: str | None) -> RedirectResponse:
34
36
  return RedirectResponse(target, status_code=303)
35
37
 
36
38
 
39
+ def _audit_log_url(request: Request) -> str | None:
40
+ """Where "View change history" points, or ``None`` when nothing is there.
41
+
42
+ The audit log is an ordinary installable module, not a dependency of this
43
+ one — offering a link to a screen the install does not have is a 404 with
44
+ extra steps. Built from the model class name because that is the key the
45
+ trail is written under.
46
+ """
47
+ from feature_flags.models import FeatureFlagOverride
48
+
49
+ installed = any(m.meta.name == AUDIT_LOG_MODULE for m in request.app.state.sm.modules)
50
+ if not installed:
51
+ return None
52
+ return f"{AUDIT_LOG_VIEW_URL}?entity_type={FeatureFlagOverride.__name__}"
53
+
54
+
37
55
  def _scope_args(tenant_id: str | None) -> dict[str, str]:
38
56
  if tenant_id:
39
57
  return {"scope": SCOPE_TENANT, "scope_id": tenant_id}
@@ -46,6 +64,7 @@ def _scope_args(tenant_id: str | None) -> dict[str, str]:
46
64
  dependencies=[Depends(RequiresPermission(PERM_FEATURE_FLAGS_VIEW))],
47
65
  )
48
66
  async def browse(
67
+ request: Request,
49
68
  inertia: InertiaDep,
50
69
  service: FeatureFlagServiceDep,
51
70
  registry: FeatureFlagRegistryDep,
@@ -59,6 +78,7 @@ async def browse(
59
78
  "flags": [f.model_dump(mode="json") for f in flags],
60
79
  "tenant_id": tenant_id,
61
80
  "tenants": tenants,
81
+ "audit_log_url": _audit_log_url(request),
62
82
  },
63
83
  )
64
84
 
@@ -0,0 +1,55 @@
1
+ {
2
+ "browse": {
3
+ "title": "Feature flags",
4
+ "description": "Runtime toggles. A tenant override wins over the system value.",
5
+ "empty_title": "No feature flags registered",
6
+ "empty_description": "Modules declare flags via register_feature_flags(); install one to see it here.",
7
+ "scope_label": "Scope",
8
+ "viewing_system": "Viewing system-level flags — every tenant follows these unless it overrides them.",
9
+ "viewing_tenant": "Viewing overrides for tenant {tenant_id} — unset flags follow the system value.",
10
+ "view_history": "View change history →",
11
+ "audit_note": "Every toggle is written to the audit log with the actor and the previous value.",
12
+ "tenant_id_label": "Tenant ID",
13
+ "tenant_id_placeholder": "e.g. acme",
14
+ "scope_system": "system",
15
+ "scope_custom": "Other…",
16
+ "go": "View",
17
+ "cancel": "Cancel"
18
+ },
19
+ "table": {
20
+ "name": "Name",
21
+ "description": "Description",
22
+ "system": "System",
23
+ "effective": "Effective",
24
+ "actions": "Actions",
25
+ "on": "on",
26
+ "off": "off",
27
+ "enabled": "Enabled",
28
+ "disabled": "Disabled",
29
+ "overridden": "override",
30
+ "following_default": "following default",
31
+ "following_system": "following system",
32
+ "clear_override": "Clear override"
33
+ },
34
+ "confirm": {
35
+ "enable_title": "Enable “{name}”?",
36
+ "disable_title": "Disable “{name}”?",
37
+ "scope_system": "This changes the value for every tenant, immediately.",
38
+ "scope_tenant": "This sets an override for tenant “{tenant_id}” only. Other tenants keep following the system value.",
39
+ "enable_action": "Enable",
40
+ "disable_action": "Disable",
41
+ "cancel": "Cancel"
42
+ },
43
+ "toasts": {
44
+ "enabled": "\"{name}\" enabled",
45
+ "disabled": "\"{name}\" disabled",
46
+ "cleared": "Override cleared for \"{name}\"",
47
+ "toggle_failed": "Failed to update flag"
48
+ },
49
+ "audit": {
50
+ "override": "Feature flag override"
51
+ },
52
+ "nav": {
53
+ "feature_flags": "Feature flags"
54
+ }
55
+ }
@@ -5,13 +5,17 @@ from __future__ import annotations
5
5
  import importlib.resources
6
6
  import logging
7
7
  from pathlib import Path
8
+ from typing import TYPE_CHECKING
8
9
 
9
10
  from fastapi import APIRouter, FastAPI
11
+ from simple_module_core.audit_links import AuditLink, AuditLinkRegistry
10
12
  from simple_module_core.menu import MenuItem, MenuRegistry, MenuSection
11
13
  from simple_module_core.module import ModuleBase, ModuleMeta
12
14
  from simple_module_core.permissions import PermissionRegistry
13
15
 
14
16
  from feature_flags.constants import (
17
+ AUDIT_LINK_LABEL,
18
+ AUDIT_LINK_LABEL_KEY,
15
19
  LOCALE_NAMESPACE,
16
20
  MENU_ICON,
17
21
  MENU_LABEL,
@@ -19,16 +23,47 @@ from feature_flags.constants import (
19
23
  MENU_URL,
20
24
  PERM_FEATURE_FLAGS_MANAGE,
21
25
  PERM_FEATURE_FLAGS_VIEW,
26
+ PERM_GROUP,
27
+ QP_OVERRIDE,
28
+ VIEW_PREFIX,
22
29
  )
23
30
 
31
+ if TYPE_CHECKING:
32
+ from sqlalchemy.ext.asyncio import AsyncSession
33
+
24
34
  _logger = logging.getLogger(__name__)
25
35
 
26
36
 
37
+ async def _resolve_override_labels(db: AsyncSession, ids: list[str]) -> dict[str, str]:
38
+ """Name an override row by the flag it overrides.
39
+
40
+ The row's primary key is an autoincrementing integer with no meaning
41
+ outside the table. What a reader of "someone changed override 12" wants to
42
+ know is *which flag* — so the label is the flag name, not the id, and not
43
+ the row's scope (which the flags screen shows once the link is followed).
44
+ """
45
+ from sqlalchemy import select
46
+
47
+ from feature_flags.models import FeatureFlagOverride
48
+
49
+ numeric = {int(raw): raw for raw in ids if raw.lstrip("-").isdigit()}
50
+ if not numeric:
51
+ return {}
52
+ rows = (
53
+ await db.execute(
54
+ select(FeatureFlagOverride.id, FeatureFlagOverride.name).where(
55
+ FeatureFlagOverride.id.in_(list(numeric))
56
+ )
57
+ )
58
+ ).all()
59
+ return {numeric.get(row_id, str(row_id)): name for row_id, name in rows}
60
+
61
+
27
62
  class FeatureFlagsModule(ModuleBase):
28
63
  meta = ModuleMeta(
29
64
  name="FeatureFlags",
30
65
  route_prefix="/api/feature_flags",
31
- view_prefix="/feature_flags",
66
+ view_prefix=VIEW_PREFIX,
32
67
  i18n_audience="admin",
33
68
  )
34
69
 
@@ -43,20 +78,46 @@ class FeatureFlagsModule(ModuleBase):
43
78
  registry.add(
44
79
  MenuItem(
45
80
  label=MENU_LABEL,
81
+ label_key="feature_flags.nav.feature_flags",
46
82
  url=MENU_URL,
47
83
  icon=MENU_ICON,
48
84
  order=MENU_ORDER,
49
- section=MenuSection.SIDEBAR,
50
- group="Administration",
85
+ section=MenuSection.ADMIN_SIDEBAR,
86
+ group="System",
87
+ group_key="ui.nav_groups.system",
51
88
  # Mirrors the view router's guard. Without it the entry shows
52
89
  # for every signed-in account and 403s on click.
53
90
  permissions=[PERM_FEATURE_FLAGS_VIEW],
54
91
  )
55
92
  )
56
93
 
94
+ def register_audit_links(self, registry: AuditLinkRegistry) -> None:
95
+ """Point audit rows for an override back at the flags screen.
96
+
97
+ The browse footer promises every toggle is written to the audit log;
98
+ the reverse trip is what makes that promise useful. There is no
99
+ per-override page, so the link lands on the table with the row's id in
100
+ the query string rather than inventing a detail screen for a two-column
101
+ record.
102
+ """
103
+ from feature_flags.models import FeatureFlagOverride
104
+
105
+ registry.register(
106
+ AuditLink(
107
+ # Class name, not __tablename__ — see AuditLink.entity_type.
108
+ # The table name travels alongside as the audit log's type tag.
109
+ entity_type=FeatureFlagOverride.__name__,
110
+ url_template=f"{MENU_URL}?{QP_OVERRIDE}={{id}}",
111
+ label=AUDIT_LINK_LABEL,
112
+ label_key=AUDIT_LINK_LABEL_KEY,
113
+ table_name=FeatureFlagOverride.__tablename__,
114
+ label_resolver=_resolve_override_labels,
115
+ )
116
+ )
117
+
57
118
  def register_permissions(self, registry: PermissionRegistry) -> None:
58
119
  registry.add_group(
59
- "Feature Flags",
120
+ PERM_GROUP,
60
121
  [
61
122
  PERM_FEATURE_FLAGS_VIEW,
62
123
  PERM_FEATURE_FLAGS_MANAGE,
@@ -0,0 +1,111 @@
1
+ import { Head, router, usePage } from '@inertiajs/react';
2
+ import { keys, useT } from '@simple-module-py/i18n';
3
+ import { PageShell } from '@simple-module-py/ui/components/PageShell';
4
+ import { usePermissions } from '@simple-module-py/ui/hooks/use-permissions';
5
+ import { AdminLayout } from '@simple-module-py/ui/layouts/AdminLayout';
6
+ import { useState } from 'react';
7
+ import { toast } from 'sonner';
8
+ import { FlagTable } from './components/FlagTable';
9
+ import { ScopeCard } from './components/ScopeCard';
10
+ import { type PendingToggle, ToggleConfirmDialog } from './components/ToggleConfirmDialog';
11
+ import type { FeatureFlag } from './types';
12
+
13
+ interface Props {
14
+ flags: FeatureFlag[];
15
+ tenant_id: string | null;
16
+ tenants: string[];
17
+ audit_log_url: string | null;
18
+ }
19
+
20
+ function buildPath(tenantId: string | null) {
21
+ // Trailing slash: the browse route is registered at "/" under the module's
22
+ // view prefix, and `_clone_bare_prefix_route` cannot alias a bare prefix for
23
+ // routes contributed via `include_router` — so the bare form costs a 307 on
24
+ // every navigation. Matches MENU_URL in constants.py.
25
+ return tenantId
26
+ ? `/admin/feature-flags/?tenant_id=${encodeURIComponent(tenantId)}`
27
+ : '/admin/feature-flags/';
28
+ }
29
+
30
+ function actionUrl(name: string, action: 'toggle' | 'clear', tenantId: string | null) {
31
+ const base = `/admin/feature-flags/${name}/${action}`;
32
+ return tenantId ? `${base}?tenant_id=${encodeURIComponent(tenantId)}` : base;
33
+ }
34
+
35
+ function Browse() {
36
+ const { flags, tenant_id, tenants, audit_log_url } = usePage<{ props: Props }>()
37
+ .props as unknown as Props;
38
+ const { t } = useT();
39
+ const { can } = usePermissions();
40
+ const canManage = can('feature_flags.manage');
41
+
42
+ // The switch stages the change; the confirm is what writes it. The Switch
43
+ // stays bound to the server value, so cancelling leaves it where it was
44
+ // without needing to undo anything.
45
+ const [pending, setPending] = useState<PendingToggle | null>(null);
46
+
47
+ function handleToggle({ name, next }: PendingToggle) {
48
+ setPending(null);
49
+ router.post(
50
+ actionUrl(name, 'toggle', tenant_id),
51
+ { enabled: next },
52
+ {
53
+ preserveScroll: true,
54
+ onSuccess: () =>
55
+ toast.success(
56
+ next
57
+ ? t(keys.feature_flags.toasts.enabled, { name })
58
+ : t(keys.feature_flags.toasts.disabled, { name }),
59
+ ),
60
+ onError: () => toast.error(t(keys.feature_flags.toasts.toggle_failed)),
61
+ },
62
+ );
63
+ }
64
+
65
+ function handleClear(flag: FeatureFlag) {
66
+ router.post(
67
+ actionUrl(flag.name, 'clear', tenant_id),
68
+ {},
69
+ {
70
+ preserveScroll: true,
71
+ onSuccess: () => toast.success(t(keys.feature_flags.toasts.cleared, { name: flag.name })),
72
+ onError: () => toast.error(t(keys.feature_flags.toasts.toggle_failed)),
73
+ },
74
+ );
75
+ }
76
+
77
+ return (
78
+ <>
79
+ <Head title={t(keys.feature_flags.browse.title)} />
80
+ <PageShell
81
+ title={t(keys.feature_flags.browse.title)}
82
+ description={t(keys.feature_flags.browse.description)}
83
+ >
84
+ <ScopeCard
85
+ tenantId={tenant_id}
86
+ tenants={tenants}
87
+ auditLogUrl={audit_log_url}
88
+ onSelect={(next) => router.visit(buildPath(next))}
89
+ />
90
+
91
+ <FlagTable
92
+ flags={flags}
93
+ tenantId={tenant_id}
94
+ canManage={canManage}
95
+ onToggle={(flag, next) => setPending({ name: flag.name, next })}
96
+ onClear={handleClear}
97
+ />
98
+
99
+ <ToggleConfirmDialog
100
+ pending={pending}
101
+ tenantId={tenant_id}
102
+ onConfirm={handleToggle}
103
+ onCancel={() => setPending(null)}
104
+ />
105
+ </PageShell>
106
+ </>
107
+ );
108
+ }
109
+
110
+ Browse.layout = (page: React.ReactNode) => <AdminLayout>{page}</AdminLayout>;
111
+ export default Browse;
@@ -0,0 +1,162 @@
1
+ import { keys, useT } from '@simple-module-py/i18n';
2
+ import { Badge } from '@simple-module-py/ui/components/ui/badge';
3
+ import { Button } from '@simple-module-py/ui/components/ui/button';
4
+ import { Card } from '@simple-module-py/ui/components/ui/card';
5
+ import {
6
+ Empty,
7
+ EmptyDescription,
8
+ EmptyMedia,
9
+ EmptyTitle,
10
+ } from '@simple-module-py/ui/components/ui/empty';
11
+ import { Switch } from '@simple-module-py/ui/components/ui/switch';
12
+ import {
13
+ Table,
14
+ TableBody,
15
+ TableCell,
16
+ TableHead,
17
+ TableHeader,
18
+ TableRow,
19
+ } from '@simple-module-py/ui/components/ui/table';
20
+ import { cn } from '@simple-module-py/ui/lib/utils';
21
+ import { Flag } from 'lucide-react';
22
+ import type { FeatureFlag } from '../types';
23
+
24
+ interface Props {
25
+ flags: FeatureFlag[];
26
+ /** Non-null while a tenant scope is selected; changes the fallback copy. */
27
+ tenantId: string | null;
28
+ canManage: boolean;
29
+ onToggle: (flag: FeatureFlag, next: boolean) => void;
30
+ onClear: (flag: FeatureFlag) => void;
31
+ }
32
+
33
+ const HEAD =
34
+ 'bg-secondary/40 text-[11px] font-semibold uppercase tracking-[0.08em] ' +
35
+ 'text-muted-foreground sm:px-6';
36
+
37
+ export function FlagTable({ flags, tenantId, canManage, onToggle, onClear }: Props) {
38
+ const { t } = useT();
39
+ const columnCount = canManage ? 5 : 4;
40
+
41
+ /** The override control, or the sentence explaining why there is none. */
42
+ const clearAction = (flag: FeatureFlag) =>
43
+ flag.overridden ? (
44
+ <Button
45
+ variant="link"
46
+ size="sm"
47
+ className="h-auto p-0 text-primary-700 max-sm:min-h-11"
48
+ onClick={() => onClear(flag)}
49
+ >
50
+ {t(keys.feature_flags.table.clear_override)}
51
+ </Button>
52
+ ) : (
53
+ <span className="text-xs text-muted-foreground">
54
+ {tenantId
55
+ ? t(keys.feature_flags.table.following_system)
56
+ : t(keys.feature_flags.table.following_default)}
57
+ </span>
58
+ );
59
+
60
+ return (
61
+ // Fills the viewport with the audit note pinned to the bottom, as the deck
62
+ // and the settings store both do. A min-height, not a fixed one, so a long
63
+ // flag list grows rather than scrolling inside the card.
64
+ <Card className="flex flex-col gap-0 overflow-hidden border-border p-0 lg:min-h-[calc(100vh-var(--app-chrome-h)-15rem)]">
65
+ <Table>
66
+ <TableHeader>
67
+ <TableRow>
68
+ <TableHead className={HEAD}>{t(keys.feature_flags.table.name)}</TableHead>
69
+ <TableHead className={cn(HEAD, 'hidden md:table-cell')}>
70
+ {t(keys.feature_flags.table.description)}
71
+ </TableHead>
72
+ <TableHead className={cn(HEAD, 'hidden sm:table-cell')}>
73
+ {t(keys.feature_flags.table.system)}
74
+ </TableHead>
75
+ <TableHead className={HEAD}>{t(keys.feature_flags.table.effective)}</TableHead>
76
+ {canManage && (
77
+ // Hidden below `sm`: at 390px this column pushed the table wider
78
+ // than the card. Its control moves under the switch instead.
79
+ <TableHead className={cn(HEAD, 'hidden text-right sm:table-cell')}>
80
+ {t(keys.feature_flags.table.actions)}
81
+ </TableHead>
82
+ )}
83
+ </TableRow>
84
+ </TableHeader>
85
+ <TableBody>
86
+ {flags.map((flag) => (
87
+ <TableRow key={flag.name} className={cn(flag.overridden && 'bg-primary-600/10')}>
88
+ <TableCell className="sm:px-6">
89
+ <div className="flex flex-wrap items-center gap-2">
90
+ <code className="font-mono text-sm font-medium">{flag.name}</code>
91
+ {flag.overridden && (
92
+ <Badge variant="outline" className="border-primary text-primary-700">
93
+ {t(keys.feature_flags.table.overridden)}
94
+ </Badge>
95
+ )}
96
+ </div>
97
+ </TableCell>
98
+ <TableCell className="hidden md:table-cell sm:px-6">
99
+ <span className="line-clamp-2 text-sm text-muted-foreground">
100
+ {flag.description || '—'}
101
+ </span>
102
+ </TableCell>
103
+ {/* The value every tenant inherits: the system override when one
104
+ exists, otherwise the default the module shipped. */}
105
+ <TableCell className="hidden sm:table-cell sm:px-6 text-sm text-muted-foreground">
106
+ {(flag.system_enabled ?? flag.default_enabled)
107
+ ? t(keys.feature_flags.table.on)
108
+ : t(keys.feature_flags.table.off)}
109
+ </TableCell>
110
+ <TableCell className="sm:px-6">
111
+ <div className="flex items-center gap-3">
112
+ <Switch
113
+ checked={flag.enabled}
114
+ onCheckedChange={(checked) => onToggle(flag, checked === true)}
115
+ disabled={!canManage}
116
+ aria-label={flag.name}
117
+ />
118
+ <span
119
+ className={cn(
120
+ 'text-sm',
121
+ flag.enabled ? 'font-medium' : 'text-muted-foreground',
122
+ )}
123
+ >
124
+ {flag.enabled
125
+ ? t(keys.feature_flags.table.enabled)
126
+ : t(keys.feature_flags.table.disabled)}
127
+ </span>
128
+ </div>
129
+ {canManage && <div className="mt-1.5 sm:hidden">{clearAction(flag)}</div>}
130
+ </TableCell>
131
+ {canManage && (
132
+ <TableCell className="hidden text-right sm:table-cell sm:px-6">
133
+ {clearAction(flag)}
134
+ </TableCell>
135
+ )}
136
+ </TableRow>
137
+ ))}
138
+ {/* The deck has no empty state — it never shows an install with no
139
+ modules registering flags. This one does exist. */}
140
+ {flags.length === 0 && (
141
+ <TableRow>
142
+ <TableCell colSpan={columnCount} className="h-40">
143
+ <Empty>
144
+ <EmptyMedia variant="icon">
145
+ <Flag className="size-5 text-primary-300" />
146
+ </EmptyMedia>
147
+ <EmptyTitle>{t(keys.feature_flags.browse.empty_title)}</EmptyTitle>
148
+ <EmptyDescription>
149
+ {t(keys.feature_flags.browse.empty_description)}
150
+ </EmptyDescription>
151
+ </Empty>
152
+ </TableCell>
153
+ </TableRow>
154
+ )}
155
+ </TableBody>
156
+ </Table>
157
+ <p className="mt-auto border-t px-4 py-3 text-sm text-muted-foreground">
158
+ {t(keys.feature_flags.browse.audit_note)}
159
+ </p>
160
+ </Card>
161
+ );
162
+ }
@@ -0,0 +1,51 @@
1
+ import { Link } from '@inertiajs/react';
2
+ import { keys, useT } from '@simple-module-py/i18n';
3
+ import { Card } from '@simple-module-py/ui/components/ui/card';
4
+ import { TenantPicker } from './TenantPicker';
5
+
6
+ interface Props {
7
+ tenantId: string | null;
8
+ tenants: string[];
9
+ /** Audit log filtered to override rows, or null when none is installed. */
10
+ auditLogUrl: string | null;
11
+ onSelect: (tenantId: string | null) => void;
12
+ }
13
+
14
+ // Word Joiner: a zero-width character that cannot appear in real copy, used to
15
+ // find where the translator put the tenant id so it can be rendered in the mono
16
+ // face. Interpolating twice and splitting keeps word order the translator's
17
+ // choice, which pinning the id to the end of the sentence would not.
18
+ const SLOT = '\u2060';
19
+
20
+ /**
21
+ * The scope row above the table: which tenant is being looked at, what that
22
+ * means for unset flags, and a way into the history of every change.
23
+ */
24
+ export function ScopeCard({ tenantId, tenants, auditLogUrl, onSelect }: Props) {
25
+ const { t } = useT();
26
+ const [hintBefore, hintAfter] = tenantId
27
+ ? t(keys.feature_flags.browse.viewing_tenant, { tenant_id: SLOT }).split(SLOT)
28
+ : [t(keys.feature_flags.browse.viewing_system), ''];
29
+
30
+ return (
31
+ <Card className="mb-4 flex flex-row flex-wrap items-center gap-x-3 gap-y-2 p-4">
32
+ <span className="text-sm font-medium text-muted-foreground">
33
+ {t(keys.feature_flags.browse.scope_label)}
34
+ </span>
35
+ <TenantPicker tenantId={tenantId} tenants={tenants} onSelect={onSelect} />
36
+ <p className="text-sm text-muted-foreground">
37
+ {hintBefore}
38
+ {tenantId && <code className="font-mono text-xs">{tenantId}</code>}
39
+ {hintAfter}
40
+ </p>
41
+ {auditLogUrl && (
42
+ <Link
43
+ href={auditLogUrl}
44
+ className="text-xs font-medium text-primary-700 hover:underline sm:ml-auto"
45
+ >
46
+ {t(keys.feature_flags.browse.view_history)}
47
+ </Link>
48
+ )}
49
+ </Card>
50
+ );
51
+ }