simple-module-settings 0.0.1__py3-none-any.whl

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.
settings/env_vars.py ADDED
@@ -0,0 +1,18 @@
1
+ """Map package names to their historical ``SM_*`` env-var prefix.
2
+
3
+ Used by ``_module_settings`` to label fields in the admin UI and by the
4
+ ``sm-settings import-from-env`` CLI to locate legacy env values. Most
5
+ packages follow ``SM_<PACKAGE_UPPER>_``; the exceptions are listed below.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ _PACKAGE_ENV_PREFIX: dict[str, str] = {
11
+ "background_tasks": "SM_BG_TASKS_",
12
+ "file_storage": "SM_FILE_STORAGE_",
13
+ "host": "SM_",
14
+ }
15
+
16
+
17
+ def env_prefix_for(package: str) -> str:
18
+ return _PACKAGE_ENV_PREFIX.get(package, f"SM_{package.upper()}_")
settings/hydrate.py ADDED
@@ -0,0 +1,63 @@
1
+ """Resolve a module's BaseSettings from DB overrides + pydantic defaults.
2
+
3
+ A field's declared Python type maps to one of the five ``value_type`` labels
4
+ understood by SettingsStore (``string | bool | int | float | json``). The
5
+ hydrator reads overrides, parses each according to its stored ``value_type``,
6
+ and constructs the BaseSettings — pydantic enforces field validators and any
7
+ ``@model_validator`` hooks.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ from typing import get_origin
14
+
15
+ from pydantic_settings import BaseSettings
16
+
17
+ from settings.store import SettingsStore
18
+
19
+
20
+ def value_type_for_field(cls: type[BaseSettings], field_name: str) -> str:
21
+ """Return the ``value_type`` label for a field based on its annotation.
22
+
23
+ - ``bool`` → ``"bool"``
24
+ - ``int`` → ``"int"``
25
+ - ``float`` → ``"float"``
26
+ - ``str`` and enums → ``"string"``
27
+ - ``list``, ``dict``, and other container types → ``"json"``
28
+ """
29
+ info = cls.model_fields[field_name]
30
+ ann = info.annotation
31
+ origin = get_origin(ann)
32
+ if origin is not None:
33
+ return "json"
34
+ if ann is bool:
35
+ return "bool"
36
+ if ann is int:
37
+ return "int"
38
+ if ann is float:
39
+ return "float"
40
+ return "string"
41
+
42
+
43
+ def _parse(raw: str, value_type: str):
44
+ if value_type == "bool":
45
+ return raw.lower() in ("1", "true", "yes", "on")
46
+ if value_type == "int":
47
+ return int(raw)
48
+ if value_type == "float":
49
+ return float(raw)
50
+ if value_type == "json":
51
+ return json.loads(raw)
52
+ return raw
53
+
54
+
55
+ async def hydrate_settings[T: BaseSettings](cls: type[T], store: SettingsStore, package: str) -> T:
56
+ """Construct ``cls`` with DB overrides merged over pydantic defaults."""
57
+ raw_overrides = await store.get_overrides(package)
58
+ parsed: dict[str, object] = {}
59
+ for field_name, (raw, vtype) in raw_overrides.items():
60
+ if field_name not in cls.model_fields:
61
+ continue
62
+ parsed[field_name] = _parse(raw, vtype)
63
+ return cls(**parsed)
@@ -0,0 +1,71 @@
1
+ {
2
+ "browse": {
3
+ "title": "Settings",
4
+ "new_button": "New Setting",
5
+ "empty_title": "No settings yet",
6
+ "empty_description": "Get started by creating your first setting.",
7
+ "edit_link": "Edit",
8
+ "delete_link": "Delete",
9
+ "delete_confirm": "Delete setting \"{key}\"? This cannot be undone."
10
+ },
11
+ "table": {
12
+ "scope": "Scope",
13
+ "scope_id": "Scope ID",
14
+ "key": "Key",
15
+ "value": "Value",
16
+ "value_type": "Type",
17
+ "description": "Description",
18
+ "actions": "Actions"
19
+ },
20
+ "scopes": {
21
+ "system": "System",
22
+ "tenant": "Tenant",
23
+ "user": "User"
24
+ },
25
+ "value_types": {
26
+ "string": "String",
27
+ "bool": "Boolean",
28
+ "int": "Integer",
29
+ "float": "Float",
30
+ "json": "JSON"
31
+ },
32
+ "form": {
33
+ "scope_label": "Scope",
34
+ "scope_id_label": "Scope ID",
35
+ "scope_id_placeholder": "Tenant or user id (empty for system)",
36
+ "key_label": "Key",
37
+ "key_placeholder": "e.g. feature.enabled",
38
+ "value_type_label": "Type",
39
+ "value_label": "Value",
40
+ "value_placeholder": "Enter a value",
41
+ "description_label": "Description",
42
+ "description_placeholder": "Optional description"
43
+ },
44
+ "create": {
45
+ "title": "New Setting",
46
+ "submit_button": "Create"
47
+ },
48
+ "edit": {
49
+ "title": "Edit Setting",
50
+ "submit_button": "Save"
51
+ },
52
+ "modules": {
53
+ "title": "Module Settings",
54
+ "description": "Read-only view of every module's runtime configuration, autodiscovered from the app. Secrets are masked.",
55
+ "back_link": "Back to overrides",
56
+ "empty_title": "No modules with settings",
57
+ "no_fields": "No fields declared.",
58
+ "secret_badge": "secret",
59
+ "browse_link": "View module settings",
60
+ "search_placeholder": "Search modules or fields…",
61
+ "field_count_suffix": "fields",
62
+ "browse_free_form_link": "View free-form settings",
63
+ "table": {
64
+ "field": "Field",
65
+ "env_var": "Env var",
66
+ "value": "Value",
67
+ "default": "Default",
68
+ "description": "Description"
69
+ }
70
+ }
71
+ }
settings/models.py ADDED
@@ -0,0 +1,50 @@
1
+ """SQLModel tables for the Settings module."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from simple_module_db.base import create_module_base
6
+ from simple_module_db.mixins import AuditMixin
7
+ from sqlalchemy import UniqueConstraint
8
+ from sqlmodel import Field
9
+
10
+ from settings.constants import (
11
+ DESCRIPTION_MAX_LENGTH,
12
+ KEY_MAX_LENGTH,
13
+ MODULE_PACKAGE,
14
+ SCOPE_ID_MAX_LENGTH,
15
+ SCOPE_MAX_LENGTH,
16
+ SCOPE_SYSTEM,
17
+ SYSTEM_SCOPE_ID,
18
+ TABLE_SETTING,
19
+ UQ_SCOPE_KEY,
20
+ VALUE_MAX_LENGTH,
21
+ VALUE_TYPE_MAX_LENGTH,
22
+ VALUE_TYPE_STRING,
23
+ )
24
+
25
+ Base = create_module_base(MODULE_PACKAGE)
26
+
27
+
28
+ class Setting(Base, AuditMixin, table=True): # ty: ignore[unsupported-base]
29
+ """A typed key/value configuration entry scoped to system, tenant, or user.
30
+
31
+ Resolution precedence when a consumer asks for a key: USER > TENANT >
32
+ SYSTEM. Uniqueness is enforced across the (scope, scope_id, key) tuple
33
+ so the same key can live at different scopes without collision.
34
+
35
+ ``value`` is always stored as a string. ``value_type`` advertises how
36
+ the bytes should be interpreted ("string", "bool", "int", "float",
37
+ "json") so UIs pick the right input control and pydantic can reject
38
+ inputs that don't parse.
39
+ """
40
+
41
+ __tablename__ = TABLE_SETTING
42
+ __table_args__ = (UniqueConstraint("scope", "scope_id", "key", name=UQ_SCOPE_KEY),)
43
+
44
+ id: int | None = Field(default=None, primary_key=True)
45
+ scope: str = Field(default=SCOPE_SYSTEM, max_length=SCOPE_MAX_LENGTH, index=True)
46
+ scope_id: str = Field(default=SYSTEM_SCOPE_ID, max_length=SCOPE_ID_MAX_LENGTH, index=True)
47
+ key: str = Field(max_length=KEY_MAX_LENGTH, index=True)
48
+ value: str = Field(max_length=VALUE_MAX_LENGTH)
49
+ value_type: str = Field(default=VALUE_TYPE_STRING, max_length=VALUE_TYPE_MAX_LENGTH)
50
+ description: str | None = Field(default=None, max_length=DESCRIPTION_MAX_LENGTH)
settings/module.py ADDED
@@ -0,0 +1,76 @@
1
+ """Settings module definition."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import importlib.resources
6
+ from pathlib import Path
7
+
8
+ from fastapi import APIRouter, FastAPI
9
+ from simple_module_core.menu import MenuItem, MenuRegistry, MenuSection
10
+ from simple_module_core.module import ModuleBase, ModuleMeta
11
+ from simple_module_core.permissions import PermissionRegistry
12
+
13
+ from settings.constants import (
14
+ ALL_PERMISSIONS,
15
+ API_PREFIX,
16
+ LOCALE_NAMESPACE,
17
+ MENU_ICON,
18
+ MENU_LABEL,
19
+ MENU_ORDER,
20
+ MENU_URL,
21
+ MODULE_NAME,
22
+ MODULE_PACKAGE,
23
+ PERM_GROUP,
24
+ VIEW_PREFIX,
25
+ )
26
+
27
+
28
+ class SettingsModule(ModuleBase):
29
+ meta = ModuleMeta(
30
+ name=MODULE_NAME,
31
+ route_prefix=API_PREFIX,
32
+ view_prefix=VIEW_PREFIX,
33
+ )
34
+
35
+ def register_settings(self, app: FastAPI) -> None:
36
+ from settings.contracts.registry import SettingsRegistry
37
+ from settings.module_registry import ModuleSettingsRegistry
38
+ from settings.services import SettingsServices
39
+ from settings.settings import SettingsSettings
40
+
41
+ services = SettingsServices(
42
+ settings=SettingsSettings(),
43
+ registry=SettingsRegistry(),
44
+ module_registry=ModuleSettingsRegistry(),
45
+ )
46
+ setattr(app.state, MODULE_PACKAGE, services)
47
+
48
+ # Self-register so the UI lists our own settings alongside other modules.
49
+ services.module_registry.register("settings", SettingsSettings)
50
+
51
+ def register_routes(self, api_router: APIRouter, view_router: APIRouter) -> None:
52
+ from settings.endpoints.api import router as api
53
+ from settings.endpoints.module_api import router as module_api
54
+ from settings.endpoints.views import router as views
55
+
56
+ api_router.include_router(module_api)
57
+ api_router.include_router(api)
58
+ view_router.include_router(views)
59
+
60
+ def register_menu_items(self, registry: MenuRegistry) -> None:
61
+ registry.add(
62
+ MenuItem(
63
+ label=MENU_LABEL,
64
+ url=MENU_URL,
65
+ icon=MENU_ICON,
66
+ order=MENU_ORDER,
67
+ section=MenuSection.SIDEBAR,
68
+ )
69
+ )
70
+
71
+ def register_permissions(self, registry: PermissionRegistry) -> None:
72
+ registry.add_group(PERM_GROUP, list(ALL_PERMISSIONS))
73
+
74
+ def locale_dirs(self) -> dict[str, Path]:
75
+ base = Path(str(importlib.resources.files(__package__) / "locales"))
76
+ return {LOCALE_NAMESPACE: base}
@@ -0,0 +1,33 @@
1
+ """Registry of per-module pydantic BaseSettings classes.
2
+
3
+ Populated during each module's ``register_settings`` via
4
+ ``register_module_settings``. The hosting lifespan reads this at startup
5
+ to hydrate every module's effective settings from the DB.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass, field
11
+
12
+ from pydantic_settings import BaseSettings
13
+
14
+
15
+ @dataclass(slots=True)
16
+ class ModuleSettingsRegistry:
17
+ """In-memory map of ``package`` → ``BaseSettings`` subclass."""
18
+
19
+ _classes: dict[str, type[BaseSettings]] = field(default_factory=dict)
20
+
21
+ def register(self, package: str, cls: type[BaseSettings]) -> None:
22
+ if package in self._classes:
23
+ raise ValueError(f"{package!r} already registered")
24
+ self._classes[package] = cls
25
+
26
+ def get(self, package: str) -> type[BaseSettings] | None:
27
+ return self._classes.get(package)
28
+
29
+ def all_packages(self) -> list[str]:
30
+ return sorted(self._classes)
31
+
32
+ def items(self) -> list[tuple[str, type[BaseSettings]]]:
33
+ return sorted(self._classes.items())
settings/package.json ADDED
@@ -0,0 +1,16 @@
1
+ {
2
+ "name": "@simple-module-py/settings",
3
+ "version": "0.1.0",
4
+ "private": true,
5
+ "description": "Frontend assets for the Settings module",
6
+ "peerDependencies": {
7
+ "react": "^19.0.0",
8
+ "react-dom": "^19.0.0",
9
+ "@inertiajs/react": "^2.0.0",
10
+ "@simple-module-py/ui": "*"
11
+ },
12
+ "devDependencies": {
13
+ "@simple-module-py/tsconfig": "*"
14
+ },
15
+ "dependencies": {}
16
+ }
@@ -0,0 +1,103 @@
1
+ import { router } from '@inertiajs/react';
2
+ import { keys, useT } from '@simple-module-py/i18n';
3
+ import { AuthenticatedLayout } from '@simple-module-py/ui/layouts/AuthenticatedLayout';
4
+ import type React from 'react';
5
+ import type { ValueType } from './components/ValueInput';
6
+ import { ROUTES } from './routes';
7
+
8
+ type Scope = 'system' | 'tenant' | 'user';
9
+
10
+ type Setting = {
11
+ id: number;
12
+ scope: Scope;
13
+ scope_id: string;
14
+ key: string;
15
+ value: string;
16
+ value_type: ValueType;
17
+ description: string | null;
18
+ };
19
+
20
+ type Props = { settings: Setting[] };
21
+
22
+ function Browse({ settings }: Props) {
23
+ const { t } = useT();
24
+
25
+ function handleDelete(setting: Setting) {
26
+ if (!window.confirm(t(keys.settings.browse.delete_confirm, { key: setting.key }))) return;
27
+ router.delete(ROUTES.byId(setting.id));
28
+ }
29
+
30
+ return (
31
+ <div className="p-6">
32
+ <div className="flex items-center justify-between mb-4">
33
+ <h1 className="text-2xl font-semibold">{t(keys.settings.browse.title)}</h1>
34
+ <div className="flex items-center gap-3">
35
+ <a href={ROUTES.modules} className="text-sm text-primary hover:underline">
36
+ {t(keys.settings.modules.browse_link)}
37
+ </a>
38
+ <a
39
+ href={ROUTES.create}
40
+ className="rounded bg-primary px-3 py-1.5 text-primary-foreground"
41
+ >
42
+ {t(keys.settings.browse.new_button)}
43
+ </a>
44
+ </div>
45
+ </div>
46
+ {settings.length === 0 ? (
47
+ <div className="py-12 text-center">
48
+ <h2 className="text-lg font-medium">{t(keys.settings.browse.empty_title)}</h2>
49
+ <p className="text-sm text-muted-foreground mt-1">
50
+ {t(keys.settings.browse.empty_description)}
51
+ </p>
52
+ </div>
53
+ ) : (
54
+ <table className="w-full text-left border-collapse">
55
+ <thead>
56
+ <tr className="border-b">
57
+ <th className="py-2 pr-4">{t(keys.settings.table.scope)}</th>
58
+ <th className="py-2 pr-4">{t(keys.settings.table.scope_id)}</th>
59
+ <th className="py-2 pr-4">{t(keys.settings.table.key)}</th>
60
+ <th className="py-2 pr-4">{t(keys.settings.table.value_type)}</th>
61
+ <th className="py-2 pr-4">{t(keys.settings.table.value)}</th>
62
+ <th className="py-2 pr-4">{t(keys.settings.table.description)}</th>
63
+ <th className="py-2">{t(keys.settings.table.actions)}</th>
64
+ </tr>
65
+ </thead>
66
+ <tbody>
67
+ {settings.map((setting) => (
68
+ <tr key={setting.id} className="border-b">
69
+ <td className="py-2 pr-4">{t(keys.settings.scopes[setting.scope])}</td>
70
+ <td className="py-2 pr-4 font-mono text-sm text-muted-foreground">
71
+ {setting.scope_id || '—'}
72
+ </td>
73
+ <td className="py-2 pr-4 font-mono text-sm">{setting.key}</td>
74
+ <td className="py-2 pr-4 text-xs uppercase text-muted-foreground">
75
+ {t(keys.settings.value_types[setting.value_type])}
76
+ </td>
77
+ <td className="py-2 pr-4 font-mono text-sm">{setting.value}</td>
78
+ <td className="py-2 pr-4 text-muted-foreground">{setting.description ?? ''}</td>
79
+ <td className="py-2">
80
+ <div className="flex gap-3">
81
+ <a href={ROUTES.edit(setting.id)} className="text-primary hover:underline">
82
+ {t(keys.settings.browse.edit_link)}
83
+ </a>
84
+ <button
85
+ type="button"
86
+ onClick={() => handleDelete(setting)}
87
+ className="text-destructive hover:underline"
88
+ >
89
+ {t(keys.settings.browse.delete_link)}
90
+ </button>
91
+ </div>
92
+ </td>
93
+ </tr>
94
+ ))}
95
+ </tbody>
96
+ </table>
97
+ )}
98
+ </div>
99
+ );
100
+ }
101
+
102
+ Browse.layout = (page: React.ReactNode) => <AuthenticatedLayout>{page}</AuthenticatedLayout>;
103
+ export default Browse;
@@ -0,0 +1,113 @@
1
+ import { useForm } from '@inertiajs/react';
2
+ import { keys, useT } from '@simple-module-py/i18n';
3
+ import { AuthenticatedLayout } from '@simple-module-py/ui/layouts/AuthenticatedLayout';
4
+ import type React from 'react';
5
+ import ValueInput, { VALUE_TYPES, type ValueType } from './components/ValueInput';
6
+ import { ROUTES } from './routes';
7
+
8
+ const SCOPES = ['system', 'tenant', 'user'] as const;
9
+ type Scope = (typeof SCOPES)[number];
10
+
11
+ function Create() {
12
+ const { t } = useT();
13
+ const { data, setData, post, processing, errors } = useForm({
14
+ scope: 'system' as Scope,
15
+ scope_id: '',
16
+ key: '',
17
+ value_type: 'string' as ValueType,
18
+ value: '',
19
+ description: '',
20
+ });
21
+
22
+ function handleSubmit(e: React.FormEvent) {
23
+ e.preventDefault();
24
+ post(ROUTES.browse);
25
+ }
26
+
27
+ return (
28
+ <div className="p-6 max-w-xl">
29
+ <h1 className="text-2xl font-semibold mb-4">{t(keys.settings.create.title)}</h1>
30
+ <form onSubmit={handleSubmit} className="space-y-3">
31
+ <label className="block">
32
+ <span className="block text-sm">{t(keys.settings.form.scope_label)}</span>
33
+ <select
34
+ value={data.scope}
35
+ onChange={(e) => setData('scope', e.target.value as Scope)}
36
+ className="border rounded w-full p-2"
37
+ >
38
+ {SCOPES.map((s) => (
39
+ <option key={s} value={s}>
40
+ {t(keys.settings.scopes[s])}
41
+ </option>
42
+ ))}
43
+ </select>
44
+ {errors.scope && <p className="text-sm text-destructive">{errors.scope}</p>}
45
+ </label>
46
+ <label className="block">
47
+ <span className="block text-sm">{t(keys.settings.form.scope_id_label)}</span>
48
+ <input
49
+ value={data.scope_id}
50
+ onChange={(e) => setData('scope_id', e.target.value)}
51
+ placeholder={t(keys.settings.form.scope_id_placeholder)}
52
+ className="border rounded w-full p-2 font-mono"
53
+ />
54
+ {errors.scope_id && <p className="text-sm text-destructive">{errors.scope_id}</p>}
55
+ </label>
56
+ <label className="block">
57
+ <span className="block text-sm">{t(keys.settings.form.key_label)}</span>
58
+ <input
59
+ value={data.key}
60
+ onChange={(e) => setData('key', e.target.value)}
61
+ required
62
+ placeholder={t(keys.settings.form.key_placeholder)}
63
+ className="border rounded w-full p-2 font-mono"
64
+ />
65
+ {errors.key && <p className="text-sm text-destructive">{errors.key}</p>}
66
+ </label>
67
+ <label className="block">
68
+ <span className="block text-sm">{t(keys.settings.form.value_type_label)}</span>
69
+ <select
70
+ value={data.value_type}
71
+ onChange={(e) => setData('value_type', e.target.value as ValueType)}
72
+ className="border rounded w-full p-2"
73
+ >
74
+ {VALUE_TYPES.map((vt) => (
75
+ <option key={vt} value={vt}>
76
+ {t(keys.settings.value_types[vt])}
77
+ </option>
78
+ ))}
79
+ </select>
80
+ </label>
81
+ <div className="block">
82
+ <span className="block text-sm">{t(keys.settings.form.value_label)}</span>
83
+ <ValueInput
84
+ valueType={data.value_type}
85
+ value={data.value}
86
+ onValueChange={(v) => setData('value', v)}
87
+ required
88
+ />
89
+ {errors.value && <p className="text-sm text-destructive">{errors.value}</p>}
90
+ </div>
91
+ <label className="block">
92
+ <span className="block text-sm">{t(keys.settings.form.description_label)}</span>
93
+ <textarea
94
+ value={data.description}
95
+ onChange={(e) => setData('description', e.target.value)}
96
+ placeholder={t(keys.settings.form.description_placeholder)}
97
+ className="border rounded w-full p-2"
98
+ />
99
+ </label>
100
+ <button
101
+ type="submit"
102
+ disabled={processing}
103
+ className="rounded bg-primary px-3 py-1.5 text-primary-foreground disabled:opacity-50"
104
+ >
105
+ {t(keys.settings.create.submit_button)}
106
+ </button>
107
+ </form>
108
+ </div>
109
+ );
110
+ }
111
+
112
+ Create.layout = (page: React.ReactNode) => <AuthenticatedLayout>{page}</AuthenticatedLayout>;
113
+ export default Create;
@@ -0,0 +1,102 @@
1
+ import { useForm } from '@inertiajs/react';
2
+ import { keys, useT } from '@simple-module-py/i18n';
3
+ import { AuthenticatedLayout } from '@simple-module-py/ui/layouts/AuthenticatedLayout';
4
+ import type React from 'react';
5
+ import ValueInput, { type ValueType } from './components/ValueInput';
6
+ import { ROUTES } from './routes';
7
+
8
+ type Scope = 'system' | 'tenant' | 'user';
9
+
10
+ type Setting = {
11
+ id: number;
12
+ scope: Scope;
13
+ scope_id: string;
14
+ key: string;
15
+ value: string;
16
+ value_type: ValueType;
17
+ description: string | null;
18
+ };
19
+
20
+ type Props = { setting: Setting };
21
+
22
+ function Edit({ setting }: Props) {
23
+ const { t } = useT();
24
+ const { data, setData, put, processing, errors } = useForm({
25
+ value: setting.value,
26
+ value_type: setting.value_type,
27
+ description: setting.description ?? '',
28
+ });
29
+
30
+ function handleSubmit(e: React.FormEvent) {
31
+ e.preventDefault();
32
+ put(ROUTES.byId(setting.id));
33
+ }
34
+
35
+ return (
36
+ <div className="p-6 max-w-xl">
37
+ <h1 className="text-2xl font-semibold mb-4">{t(keys.settings.edit.title)}</h1>
38
+ <form onSubmit={handleSubmit} className="space-y-3">
39
+ <label className="block">
40
+ <span className="block text-sm">{t(keys.settings.form.scope_label)}</span>
41
+ <input
42
+ value={t(keys.settings.scopes[setting.scope])}
43
+ disabled
44
+ className="border rounded w-full p-2 bg-muted"
45
+ />
46
+ </label>
47
+ <label className="block">
48
+ <span className="block text-sm">{t(keys.settings.form.scope_id_label)}</span>
49
+ <input
50
+ value={setting.scope_id}
51
+ disabled
52
+ className="border rounded w-full p-2 font-mono bg-muted"
53
+ />
54
+ </label>
55
+ <label className="block">
56
+ <span className="block text-sm">{t(keys.settings.form.key_label)}</span>
57
+ <input
58
+ defaultValue={setting.key}
59
+ disabled
60
+ className="border rounded w-full p-2 font-mono bg-muted"
61
+ />
62
+ </label>
63
+ <label className="block">
64
+ <span className="block text-sm">{t(keys.settings.form.value_type_label)}</span>
65
+ <input
66
+ value={t(keys.settings.value_types[setting.value_type])}
67
+ disabled
68
+ className="border rounded w-full p-2 bg-muted"
69
+ />
70
+ </label>
71
+ <div className="block">
72
+ <span className="block text-sm">{t(keys.settings.form.value_label)}</span>
73
+ <ValueInput
74
+ valueType={data.value_type}
75
+ value={data.value}
76
+ onValueChange={(v) => setData('value', v)}
77
+ required
78
+ />
79
+ {errors.value && <p className="text-sm text-destructive">{errors.value}</p>}
80
+ </div>
81
+ <label className="block">
82
+ <span className="block text-sm">{t(keys.settings.form.description_label)}</span>
83
+ <textarea
84
+ value={data.description}
85
+ onChange={(e) => setData('description', e.target.value)}
86
+ className="border rounded w-full p-2"
87
+ />
88
+ </label>
89
+ <button
90
+ type="submit"
91
+ disabled={processing}
92
+ className="rounded bg-primary px-3 py-1.5 text-primary-foreground disabled:opacity-50"
93
+ >
94
+ {t(keys.settings.edit.submit_button)}
95
+ </button>
96
+ </form>
97
+ </div>
98
+ );
99
+ }
100
+
101
+ Edit.layout = (page: React.ReactNode) => <AuthenticatedLayout>{page}</AuthenticatedLayout>;
102
+ export default Edit;