@valentinkolb/cloud 0.1.0
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.
- package/package.json +69 -0
- package/public/logo.svg +1 -0
- package/scripts/build.ts +113 -0
- package/scripts/preload.ts +73 -0
- package/src/_internal/define-app.ts +399 -0
- package/src/_internal/heartbeat.ts +33 -0
- package/src/_internal/registry.ts +100 -0
- package/src/_internal/runtime-context.ts +38 -0
- package/src/api/accounts-entities.ts +134 -0
- package/src/api/admin-lifecycle.ts +210 -0
- package/src/api/auth/schemas.ts +28 -0
- package/src/api/auth.ts +230 -0
- package/src/api/index.ts +66 -0
- package/src/api/me.ts +206 -0
- package/src/api/search/schemas.ts +43 -0
- package/src/api/search.ts +130 -0
- package/src/clients/core.ts +19 -0
- package/src/config/env.ts +23 -0
- package/src/config/index.ts +6 -0
- package/src/config/ssr.ts +58 -0
- package/src/contracts/app.ts +140 -0
- package/src/contracts/index.ts +5 -0
- package/src/contracts/profile.ts +67 -0
- package/src/contracts/registry.ts +50 -0
- package/src/contracts/settings-types.ts +84 -0
- package/src/contracts/shared.ts +258 -0
- package/src/contracts/widgets.ts +121 -0
- package/src/index.ts +6 -0
- package/src/server/api/index.ts +1 -0
- package/src/server/api/respond.ts +55 -0
- package/src/server/api-client.ts +54 -0
- package/src/server/app-context.ts +39 -0
- package/src/server/index.ts +62 -0
- package/src/server/middleware/auth.ts +168 -0
- package/src/server/middleware/index.ts +7 -0
- package/src/server/middleware/middleware.ts +47 -0
- package/src/server/middleware/openapi.ts +126 -0
- package/src/server/middleware/rate-limit.ts +126 -0
- package/src/server/middleware/request-logger.ts +41 -0
- package/src/server/middleware/validator.ts +35 -0
- package/src/server/services/access.ts +294 -0
- package/src/server/services/freeipa/client.ts +100 -0
- package/src/server/services/freeipa/index.ts +9 -0
- package/src/server/services/freeipa/session.ts +78 -0
- package/src/server/services/freeipa/tls.ts +48 -0
- package/src/server/services/freeipa/util.ts +60 -0
- package/src/server/services/geo.ts +154 -0
- package/src/server/services/index.ts +28 -0
- package/src/server/services/services.ts +13 -0
- package/src/services/account-lifecycle/audit.ts +41 -0
- package/src/services/account-lifecycle/index.ts +907 -0
- package/src/services/account-lifecycle/scheduler.ts +347 -0
- package/src/services/account-model.ts +21 -0
- package/src/services/accounts/app.ts +966 -0
- package/src/services/accounts/authz.ts +22 -0
- package/src/services/accounts/base-group.ts +11 -0
- package/src/services/accounts/base-user.ts +45 -0
- package/src/services/accounts/entities.ts +529 -0
- package/src/services/accounts/group-sql.ts +106 -0
- package/src/services/accounts/groups.ts +246 -0
- package/src/services/accounts/index.ts +14 -0
- package/src/services/accounts/ipa-data.ts +64 -0
- package/src/services/accounts/lifecycle.ts +2 -0
- package/src/services/accounts/local-groups.ts +491 -0
- package/src/services/accounts/model.ts +135 -0
- package/src/services/accounts/switching.ts +117 -0
- package/src/services/accounts/users.ts +714 -0
- package/src/services/auth-flows/index.ts +6 -0
- package/src/services/auth-flows/ipa.ts +128 -0
- package/src/services/auth-flows/magic-link.ts +119 -0
- package/src/services/freeipa-config.ts +89 -0
- package/src/services/index.ts +46 -0
- package/src/services/ipa/auth.ts +122 -0
- package/src/services/ipa/groups.ts +684 -0
- package/src/services/ipa/guard.ts +17 -0
- package/src/services/ipa/index.ts +17 -0
- package/src/services/ipa/profile.ts +90 -0
- package/src/services/ipa/search.ts +154 -0
- package/src/services/ipa/sync.ts +740 -0
- package/src/services/ipa/users.ts +794 -0
- package/src/services/logging/index.ts +294 -0
- package/src/services/notifications/email.ts +123 -0
- package/src/services/notifications/index.ts +413 -0
- package/src/services/postgres.ts +51 -0
- package/src/services/providers/index.ts +27 -0
- package/src/services/providers/local/auth.ts +13 -0
- package/src/services/providers/local/index.ts +4 -0
- package/src/services/providers/local/users.ts +255 -0
- package/src/services/session/index.ts +137 -0
- package/src/services/settings/api.ts +61 -0
- package/src/services/settings/app.ts +101 -0
- package/src/services/settings/crypto.ts +69 -0
- package/src/services/settings/defaults.ts +824 -0
- package/src/services/settings/index.ts +203 -0
- package/src/services/settings/namespace.ts +9 -0
- package/src/services/settings/snapshot.ts +49 -0
- package/src/services/settings/store.ts +179 -0
- package/src/services/settings/templates.ts +10 -0
- package/src/services/weather/forecast.ts +287 -0
- package/src/services/weather/geo.ts +110 -0
- package/src/services/weather/index.ts +99 -0
- package/src/services/weather/location.ts +24 -0
- package/src/services/weather/locations.ts +125 -0
- package/src/services/weather/migrate.ts +22 -0
- package/src/services/weather/types.ts +61 -0
- package/src/services/weather/ui.ts +50 -0
- package/src/shared/account-display.ts +17 -0
- package/src/shared/account-session.ts +15 -0
- package/src/shared/icons.ts +109 -0
- package/src/shared/index.ts +10 -0
- package/src/shared/markdown/client.ts +130 -0
- package/src/shared/markdown/extensions/code.ts +58 -0
- package/src/shared/markdown/extensions/images.ts +43 -0
- package/src/shared/markdown/extensions/info-blocks.ts +93 -0
- package/src/shared/markdown/extensions/katex.ts +120 -0
- package/src/shared/markdown/extensions/links.ts +34 -0
- package/src/shared/markdown/extensions/tables.ts +88 -0
- package/src/shared/markdown/extensions/task-list.ts +53 -0
- package/src/shared/markdown/index.ts +97 -0
- package/src/shared/markdown/shared.ts +36 -0
- package/src/ssr/AdminLayout.tsx +42 -0
- package/src/ssr/AdminSidebar.tsx +95 -0
- package/src/ssr/Footer.island.tsx +62 -0
- package/src/ssr/GlobalSearchDialog.tsx +389 -0
- package/src/ssr/GlobalSearchHelpDialog.tsx +106 -0
- package/src/ssr/GlobalSearchTrigger.island.tsx +42 -0
- package/src/ssr/HotkeysHelpRail.island.tsx +99 -0
- package/src/ssr/Layout.tsx +326 -0
- package/src/ssr/MoreAppsDropdown.island.tsx +61 -0
- package/src/ssr/NavMenu.island.tsx +108 -0
- package/src/ssr/ThemeToggleRail.island.tsx +27 -0
- package/src/ssr/index.ts +5 -0
- package/src/ssr/islands/SearchBar.island.tsx +77 -0
- package/src/ssr/islands/index.ts +1 -0
- package/src/ssr/runtime.ts +22 -0
- package/src/styles/base-popover.css +28 -0
- package/src/styles/effects.css +65 -0
- package/src/styles/global.css +133 -0
- package/src/styles/input.css +54 -0
- package/src/styles/tokens.css +35 -0
- package/src/styles/utilities-buttons.css +125 -0
- package/src/styles/utilities-feedback.css +65 -0
- package/src/styles/utilities-layout.css +122 -0
- package/src/styles/utilities-navigation.css +196 -0
- package/src/types/ambient.d.ts +8 -0
- package/src/ui/admin-settings.tsx +148 -0
- package/src/ui/dialog-core.ts +146 -0
- package/src/ui/filter/FilterChip.tsx +196 -0
- package/src/ui/filter/index.ts +2 -0
- package/src/ui/index.ts +19 -0
- package/src/ui/input/Checkbox.tsx +55 -0
- package/src/ui/input/ColorInput.tsx +122 -0
- package/src/ui/input/DateTimeInput.tsx +86 -0
- package/src/ui/input/ImageInput.tsx +170 -0
- package/src/ui/input/NumberInput.tsx +113 -0
- package/src/ui/input/PinInput.tsx +169 -0
- package/src/ui/input/SegmentedControl.tsx +99 -0
- package/src/ui/input/Select.tsx +288 -0
- package/src/ui/input/SelectChip.tsx +61 -0
- package/src/ui/input/Slider.tsx +118 -0
- package/src/ui/input/Switch.tsx +62 -0
- package/src/ui/input/TagsInput.tsx +115 -0
- package/src/ui/input/TextInput.tsx +160 -0
- package/src/ui/input/index.ts +13 -0
- package/src/ui/input/types.ts +42 -0
- package/src/ui/input/util.tsx +105 -0
- package/src/ui/ipa/Avatar.tsx +28 -0
- package/src/ui/ipa/GroupView.tsx +36 -0
- package/src/ui/ipa/LoginBtn.tsx +16 -0
- package/src/ui/ipa/UserView.tsx +58 -0
- package/src/ui/ipa/index.ts +4 -0
- package/src/ui/misc/ContextMenu.tsx +211 -0
- package/src/ui/misc/CopyButton.tsx +28 -0
- package/src/ui/misc/Dropdown.tsx +194 -0
- package/src/ui/misc/EntitySearch.tsx +213 -0
- package/src/ui/misc/Lightbox.tsx +194 -0
- package/src/ui/misc/LinkCard.tsx +34 -0
- package/src/ui/misc/LogEntriesTable.tsx +61 -0
- package/src/ui/misc/MarkdownView.tsx +65 -0
- package/src/ui/misc/Pagination.tsx +51 -0
- package/src/ui/misc/PermissionEditor.tsx +379 -0
- package/src/ui/misc/ProgressBar.tsx +47 -0
- package/src/ui/misc/RemoveBtn.tsx +27 -0
- package/src/ui/misc/StatCell.tsx +90 -0
- package/src/ui/misc/index.ts +18 -0
- package/src/ui/navigation.ts +32 -0
- package/src/ui/prompts.tsx +854 -0
- package/src/ui/sidebar.tsx +468 -0
- package/src/ui/widgets/Widget.tsx +62 -0
- package/src/ui/widgets/WidgetCard.tsx +19 -0
- package/src/ui/widgets/WidgetHero.tsx +39 -0
- package/src/ui/widgets/WidgetList.tsx +84 -0
- package/src/ui/widgets/WidgetPills.tsx +68 -0
- package/src/ui/widgets/WidgetStat.tsx +67 -0
- package/src/ui/widgets/WidgetStatus.tsx +62 -0
- package/src/ui/widgets/index.ts +9 -0
|
@@ -0,0 +1,824 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Central settings registry.
|
|
3
|
+
* Single source of truth for configurable settings, their value kinds, defaults,
|
|
4
|
+
* validation, UI metadata, and temporary env bootstrap behavior.
|
|
5
|
+
*
|
|
6
|
+
* Resolution order: DB value -> env fallback -> code default.
|
|
7
|
+
*
|
|
8
|
+
* `SettingKind` and `SettingOption` are re-exported from `contracts/shared` to
|
|
9
|
+
* keep a single source of truth (browser-safe types live there).
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { SettingKind, SettingOption } from "../../contracts/shared";
|
|
13
|
+
export type { SettingKind, SettingOption };
|
|
14
|
+
|
|
15
|
+
type SettingEnvResolver = () => unknown;
|
|
16
|
+
|
|
17
|
+
type SettingCommon = {
|
|
18
|
+
key: string;
|
|
19
|
+
label?: string;
|
|
20
|
+
description: string;
|
|
21
|
+
placeholder?: string;
|
|
22
|
+
group: string;
|
|
23
|
+
envFallback?: SettingEnvResolver;
|
|
24
|
+
envBootstrap?: SettingEnvResolver;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
type SettingStringLikeKind =
|
|
28
|
+
| "string"
|
|
29
|
+
| "text"
|
|
30
|
+
| "email"
|
|
31
|
+
| "url"
|
|
32
|
+
| "secret"
|
|
33
|
+
| "image"
|
|
34
|
+
| "cron"
|
|
35
|
+
| "timezone"
|
|
36
|
+
| "template";
|
|
37
|
+
|
|
38
|
+
type StringLikeSettingDef = SettingCommon & {
|
|
39
|
+
kind: SettingStringLikeKind;
|
|
40
|
+
default: string;
|
|
41
|
+
templateVars?: string[];
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
type BooleanSettingDef = SettingCommon & {
|
|
45
|
+
kind: "boolean";
|
|
46
|
+
default: boolean;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
type NumberSettingDef = SettingCommon & {
|
|
50
|
+
kind: "number";
|
|
51
|
+
default: number;
|
|
52
|
+
min?: number;
|
|
53
|
+
max?: number;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
type EnumSettingDef = SettingCommon & {
|
|
57
|
+
kind: "enum";
|
|
58
|
+
default: string;
|
|
59
|
+
options: SettingOption[];
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
type StringListSettingDef = SettingCommon & {
|
|
63
|
+
kind: "string_list";
|
|
64
|
+
default: string[];
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
type NumberListSettingDef = SettingCommon & {
|
|
68
|
+
kind: "number_list";
|
|
69
|
+
default: number[];
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
export type SettingDef =
|
|
73
|
+
| StringLikeSettingDef
|
|
74
|
+
| BooleanSettingDef
|
|
75
|
+
| NumberSettingDef
|
|
76
|
+
| EnumSettingDef
|
|
77
|
+
| StringListSettingDef
|
|
78
|
+
| NumberListSettingDef;
|
|
79
|
+
|
|
80
|
+
export type SettingValidationResult =
|
|
81
|
+
| { ok: true; value: SettingDef["default"] }
|
|
82
|
+
| { ok: false; error: string };
|
|
83
|
+
|
|
84
|
+
const envString = (key: string): string | undefined => {
|
|
85
|
+
const value = process.env[key]?.trim();
|
|
86
|
+
return value && value.length > 0 ? value : undefined;
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
const envCsv = (key: string): string | undefined => {
|
|
90
|
+
const value = process.env[key]
|
|
91
|
+
?.split(",")
|
|
92
|
+
.map((part) => part.trim())
|
|
93
|
+
.filter(Boolean)
|
|
94
|
+
.join(",");
|
|
95
|
+
return value && value.length > 0 ? value : undefined;
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
const hasRequiredFreeIpaEnv = (): boolean =>
|
|
99
|
+
Boolean(envString("FREEIPA_URL") && envString("FREEIPA_SVC_USER") && envString("FREEIPA_SVC_PASSWORD"));
|
|
100
|
+
|
|
101
|
+
const IPA_MATCH_MODE_OPTIONS = [
|
|
102
|
+
{ value: "ignore", label: "Ignore local match" },
|
|
103
|
+
{ value: "migrate", label: "Migrate matching local account" },
|
|
104
|
+
] as const satisfies readonly SettingOption[];
|
|
105
|
+
|
|
106
|
+
const IPA_ACCOUNT_TRANSITION_OPTIONS = [
|
|
107
|
+
{ value: "delete", label: "Delete account" },
|
|
108
|
+
{ value: "demote_to_local", label: "Make local (keep profile)" },
|
|
109
|
+
{ value: "demote_to_local_guest", label: "Make local guest" },
|
|
110
|
+
{ value: "demote_to_local_user", label: "Make local user" },
|
|
111
|
+
] as const satisfies readonly SettingOption[];
|
|
112
|
+
|
|
113
|
+
export const SETTINGS: SettingDef[] = [
|
|
114
|
+
{
|
|
115
|
+
key: "app.url",
|
|
116
|
+
label: "URL",
|
|
117
|
+
kind: "string",
|
|
118
|
+
default: "localhost:3000",
|
|
119
|
+
description: "Public-facing application URL used for links in emails, OAuth redirects, and WebSocket connections (with or without scheme)",
|
|
120
|
+
placeholder: "e.g. https://cloud.example.org",
|
|
121
|
+
group: "app",
|
|
122
|
+
envFallback: () => envString("APP_URL"),
|
|
123
|
+
envBootstrap: () => envString("APP_URL"),
|
|
124
|
+
},
|
|
125
|
+
{
|
|
126
|
+
key: "app.name",
|
|
127
|
+
label: "Name",
|
|
128
|
+
kind: "string",
|
|
129
|
+
default: "My App",
|
|
130
|
+
description: "Application display name",
|
|
131
|
+
placeholder: "e.g. MyCloud",
|
|
132
|
+
group: "app",
|
|
133
|
+
},
|
|
134
|
+
{
|
|
135
|
+
key: "app.contact_email",
|
|
136
|
+
label: "Contact Email",
|
|
137
|
+
kind: "email",
|
|
138
|
+
default: "",
|
|
139
|
+
description: "Support contact email",
|
|
140
|
+
placeholder: "e.g. support@example.org",
|
|
141
|
+
group: "app",
|
|
142
|
+
},
|
|
143
|
+
{
|
|
144
|
+
key: "app.copyright",
|
|
145
|
+
label: "Copyright",
|
|
146
|
+
kind: "string",
|
|
147
|
+
default: "",
|
|
148
|
+
description: "Copyright holder name shown in footer",
|
|
149
|
+
placeholder: "e.g. MyCompany",
|
|
150
|
+
group: "app",
|
|
151
|
+
},
|
|
152
|
+
{
|
|
153
|
+
key: "app.logo",
|
|
154
|
+
label: "Logo",
|
|
155
|
+
kind: "image",
|
|
156
|
+
default: "",
|
|
157
|
+
description: "Logo image",
|
|
158
|
+
group: "app",
|
|
159
|
+
},
|
|
160
|
+
{
|
|
161
|
+
key: "app.favicon",
|
|
162
|
+
label: "Favicon",
|
|
163
|
+
kind: "image",
|
|
164
|
+
default: "",
|
|
165
|
+
description: "Favicon",
|
|
166
|
+
group: "app",
|
|
167
|
+
},
|
|
168
|
+
{
|
|
169
|
+
key: "app.timezone",
|
|
170
|
+
label: "Timezone",
|
|
171
|
+
kind: "timezone",
|
|
172
|
+
default: "Europe/Berlin",
|
|
173
|
+
description: "IANA timezone used for all scheduler-based jobs and time-based operations",
|
|
174
|
+
placeholder: "e.g. Europe/Berlin",
|
|
175
|
+
group: "app",
|
|
176
|
+
},
|
|
177
|
+
{
|
|
178
|
+
key: "app.cleanup_schedule",
|
|
179
|
+
label: "Cleanup Schedule",
|
|
180
|
+
kind: "cron",
|
|
181
|
+
default: "0 4 * * *",
|
|
182
|
+
description: "Five-field cron schedule used by all automatic cleanup jobs in app.timezone",
|
|
183
|
+
group: "app",
|
|
184
|
+
},
|
|
185
|
+
|
|
186
|
+
{
|
|
187
|
+
key: "freeipa.enable",
|
|
188
|
+
label: "Enable FreeIPA",
|
|
189
|
+
kind: "boolean",
|
|
190
|
+
default: false,
|
|
191
|
+
description: "Enable FreeIPA-backed login, sync, account management, and IPA groups.",
|
|
192
|
+
group: "freeipa",
|
|
193
|
+
envFallback: () => hasRequiredFreeIpaEnv(),
|
|
194
|
+
envBootstrap: () => (hasRequiredFreeIpaEnv() ? true : undefined),
|
|
195
|
+
},
|
|
196
|
+
{
|
|
197
|
+
key: "freeipa.url",
|
|
198
|
+
label: "Server Host",
|
|
199
|
+
kind: "string",
|
|
200
|
+
default: "freeipa.ipa.example.com",
|
|
201
|
+
description: "FreeIPA host name used for RPC and login requests (without protocol).",
|
|
202
|
+
placeholder: "e.g. ipa.example.org",
|
|
203
|
+
group: "freeipa",
|
|
204
|
+
envFallback: () => envString("FREEIPA_URL"),
|
|
205
|
+
envBootstrap: () => envString("FREEIPA_URL"),
|
|
206
|
+
},
|
|
207
|
+
{
|
|
208
|
+
key: "freeipa.ca_cert",
|
|
209
|
+
label: "CA Certificate (PEM)",
|
|
210
|
+
kind: "text",
|
|
211
|
+
default: "",
|
|
212
|
+
description: "Paste the FreeIPA root CA in PEM format to trust self-signed/private-CA servers without disabling validation. Preferred over allow_insecure.",
|
|
213
|
+
placeholder: "-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----",
|
|
214
|
+
group: "freeipa",
|
|
215
|
+
},
|
|
216
|
+
{
|
|
217
|
+
key: "freeipa.allow_insecure",
|
|
218
|
+
label: "Allow Insecure TLS",
|
|
219
|
+
kind: "boolean",
|
|
220
|
+
default: false,
|
|
221
|
+
description: "Skip TLS certificate validation entirely. Use only for local dev — disables MITM protection. Ignored when ca_cert is set.",
|
|
222
|
+
group: "freeipa",
|
|
223
|
+
},
|
|
224
|
+
{
|
|
225
|
+
key: "freeipa.service_user",
|
|
226
|
+
label: "Service User",
|
|
227
|
+
kind: "string",
|
|
228
|
+
default: "svc-cloud",
|
|
229
|
+
description: "FreeIPA service account username used for internal admin operations.",
|
|
230
|
+
placeholder: "e.g. svc-cloud",
|
|
231
|
+
group: "freeipa",
|
|
232
|
+
envFallback: () => envString("FREEIPA_SVC_USER"),
|
|
233
|
+
envBootstrap: () => envString("FREEIPA_SVC_USER"),
|
|
234
|
+
},
|
|
235
|
+
{
|
|
236
|
+
key: "freeipa.service_password",
|
|
237
|
+
label: "Service Password",
|
|
238
|
+
kind: "secret",
|
|
239
|
+
default: "",
|
|
240
|
+
description: "FreeIPA service account password used for internal admin operations.",
|
|
241
|
+
group: "freeipa",
|
|
242
|
+
envFallback: () => envString("FREEIPA_SVC_PASSWORD"),
|
|
243
|
+
envBootstrap: () => envString("FREEIPA_SVC_PASSWORD"),
|
|
244
|
+
},
|
|
245
|
+
{
|
|
246
|
+
key: "user.allow_self_registration",
|
|
247
|
+
label: "Allow Self-Registration",
|
|
248
|
+
kind: "boolean",
|
|
249
|
+
default: false,
|
|
250
|
+
description: "Allow creating a local guest account automatically during first email sign-in when no local account exists yet.",
|
|
251
|
+
group: "user",
|
|
252
|
+
},
|
|
253
|
+
{
|
|
254
|
+
key: "freeipa.groups.admin",
|
|
255
|
+
label: "Admin Groups",
|
|
256
|
+
kind: "string_list",
|
|
257
|
+
default: ["admins"],
|
|
258
|
+
description: "FreeIPA groups that imply app admin access.",
|
|
259
|
+
placeholder: "admins,cloud-admins",
|
|
260
|
+
group: "freeipa",
|
|
261
|
+
envFallback: () => envCsv("GROUPS_ADMIN"),
|
|
262
|
+
envBootstrap: () => envCsv("GROUPS_ADMIN"),
|
|
263
|
+
},
|
|
264
|
+
{
|
|
265
|
+
key: "freeipa.groups.base_sync",
|
|
266
|
+
label: "Base Sync Groups",
|
|
267
|
+
kind: "string_list",
|
|
268
|
+
default: ["users"],
|
|
269
|
+
description: "FreeIPA groups that define the in-sync account scope.",
|
|
270
|
+
placeholder: "users,cloud",
|
|
271
|
+
group: "freeipa",
|
|
272
|
+
envFallback: () => envCsv("GROUPS_BASE_SYNC"),
|
|
273
|
+
envBootstrap: () => envCsv("GROUPS_BASE_SYNC"),
|
|
274
|
+
},
|
|
275
|
+
{
|
|
276
|
+
key: "freeipa.groups.base_ipa_realm",
|
|
277
|
+
label: "Base Realm Groups",
|
|
278
|
+
kind: "string_list",
|
|
279
|
+
default: ["cloud"],
|
|
280
|
+
description: "FreeIPA groups that imply canonical full-user profile.",
|
|
281
|
+
placeholder: "cloud,staff",
|
|
282
|
+
group: "freeipa",
|
|
283
|
+
envFallback: () => envCsv("GROUPS_BASE_IPA_REALM"),
|
|
284
|
+
envBootstrap: () => envCsv("GROUPS_BASE_IPA_REALM"),
|
|
285
|
+
},
|
|
286
|
+
{
|
|
287
|
+
key: "freeipa.groups.excluded",
|
|
288
|
+
label: "Excluded Groups",
|
|
289
|
+
kind: "string_list",
|
|
290
|
+
default: ["editors", "trust admins", "admins"],
|
|
291
|
+
description: "FreeIPA groups excluded from mirrored memberships and hierarchy logic.",
|
|
292
|
+
placeholder: "editors,trust admins,admins",
|
|
293
|
+
group: "freeipa",
|
|
294
|
+
envFallback: () => envCsv("GROUPS_EXCLUDED"),
|
|
295
|
+
envBootstrap: () => envCsv("GROUPS_EXCLUDED"),
|
|
296
|
+
},
|
|
297
|
+
{
|
|
298
|
+
key: "freeipa.user_match_mode",
|
|
299
|
+
label: "User Match Mode",
|
|
300
|
+
kind: "enum",
|
|
301
|
+
default: "ignore",
|
|
302
|
+
description: "How IPA sync handles a unique local account match by email.",
|
|
303
|
+
options: [...IPA_MATCH_MODE_OPTIONS],
|
|
304
|
+
group: "freeipa",
|
|
305
|
+
},
|
|
306
|
+
{
|
|
307
|
+
key: "freeipa.account_transition_policy",
|
|
308
|
+
label: "Account Transition Policy",
|
|
309
|
+
kind: "enum",
|
|
310
|
+
default: "demote_to_local_guest",
|
|
311
|
+
description: "What happens when an IPA-backed account expires or disappears from sync scope.",
|
|
312
|
+
options: [...IPA_ACCOUNT_TRANSITION_OPTIONS],
|
|
313
|
+
group: "freeipa",
|
|
314
|
+
},
|
|
315
|
+
{
|
|
316
|
+
key: "freeipa.sync_cron",
|
|
317
|
+
label: "Sync Cron",
|
|
318
|
+
kind: "cron",
|
|
319
|
+
default: "*/5 * * * *",
|
|
320
|
+
description: "Five-field cron schedule for the FreeIPA sync job in app.timezone.",
|
|
321
|
+
group: "freeipa",
|
|
322
|
+
},
|
|
323
|
+
|
|
324
|
+
{
|
|
325
|
+
key: "user.abbr_length",
|
|
326
|
+
label: "Username Abbreviation Length",
|
|
327
|
+
kind: "number",
|
|
328
|
+
default: 5,
|
|
329
|
+
min: 1,
|
|
330
|
+
description: "Length of randomly generated username abbreviations for new accounts",
|
|
331
|
+
group: "user",
|
|
332
|
+
},
|
|
333
|
+
{
|
|
334
|
+
key: "user.session.expiry_hours",
|
|
335
|
+
label: "Session Expiry Hours",
|
|
336
|
+
kind: "number",
|
|
337
|
+
default: 8,
|
|
338
|
+
min: 1,
|
|
339
|
+
description: "How long a login session stays valid (in hours)",
|
|
340
|
+
group: "user",
|
|
341
|
+
},
|
|
342
|
+
{
|
|
343
|
+
key: "user.account.ipa_expires_days",
|
|
344
|
+
label: "IPA Account Expiry Days",
|
|
345
|
+
kind: "number",
|
|
346
|
+
default: 365,
|
|
347
|
+
min: 0,
|
|
348
|
+
description: "IPA accounts expire after this many days (0 = never expires)",
|
|
349
|
+
group: "user",
|
|
350
|
+
},
|
|
351
|
+
{
|
|
352
|
+
key: "user.account.local_user_expires_days",
|
|
353
|
+
label: "Local User Expiry Days",
|
|
354
|
+
kind: "number",
|
|
355
|
+
default: 0,
|
|
356
|
+
min: 0,
|
|
357
|
+
description: "Local user accounts expire after this many days (0 = never expires)",
|
|
358
|
+
group: "user",
|
|
359
|
+
},
|
|
360
|
+
{
|
|
361
|
+
key: "user.account.local_guest_expires_days",
|
|
362
|
+
label: "Local Guest Expiry Days",
|
|
363
|
+
kind: "number",
|
|
364
|
+
default: 365,
|
|
365
|
+
min: 0,
|
|
366
|
+
description: "Local guest accounts expire after this many days (0 = never expires)",
|
|
367
|
+
group: "user",
|
|
368
|
+
},
|
|
369
|
+
{
|
|
370
|
+
key: "user.account.reminder_days",
|
|
371
|
+
label: "Reminder Days",
|
|
372
|
+
kind: "number_list",
|
|
373
|
+
default: [30, 7],
|
|
374
|
+
description: "Days before expiry to send reminder emails.",
|
|
375
|
+
group: "user",
|
|
376
|
+
},
|
|
377
|
+
{
|
|
378
|
+
key: "user.account.reminder_cron",
|
|
379
|
+
label: "Reminder Cron",
|
|
380
|
+
kind: "cron",
|
|
381
|
+
default: "0 9 * * *",
|
|
382
|
+
description: "Five-field cron schedule for account expiry reminder runs in app.timezone",
|
|
383
|
+
group: "user",
|
|
384
|
+
},
|
|
385
|
+
{
|
|
386
|
+
key: "user.account.deleted_accounts_retention_days",
|
|
387
|
+
label: "Deleted Accounts Retention Days",
|
|
388
|
+
kind: "number",
|
|
389
|
+
default: 365,
|
|
390
|
+
min: 0,
|
|
391
|
+
description: "How many days deleted account history is kept before cleanup (0 = keep forever)",
|
|
392
|
+
group: "user",
|
|
393
|
+
},
|
|
394
|
+
{
|
|
395
|
+
key: "user.account.reminder_history_retention_days",
|
|
396
|
+
label: "Reminder History Retention Days",
|
|
397
|
+
kind: "number",
|
|
398
|
+
default: 365,
|
|
399
|
+
min: 0,
|
|
400
|
+
description: "How many days reminder history is kept before cleanup (0 = keep forever)",
|
|
401
|
+
group: "user",
|
|
402
|
+
},
|
|
403
|
+
|
|
404
|
+
{
|
|
405
|
+
key: "mail.user_welcome_freeipa",
|
|
406
|
+
label: "FreeIPA Welcome Template",
|
|
407
|
+
kind: "template",
|
|
408
|
+
default: `<p>Your account has been created.</p>
|
|
409
|
+
<p><strong>Login credentials:</strong></p>
|
|
410
|
+
<p>Username: <code style="background:#f4f4f5;padding:2px 6px;border-radius:4px;">{{USERNAME}}</code></p>
|
|
411
|
+
<p>Temporary password: <code style="background:#f4f4f5;padding:2px 6px;border-radius:4px;">{{PASSWORD}}</code></p>
|
|
412
|
+
{{#EXPIRY}}<p>Your account is valid until: <strong>{{EXPIRY}}</strong></p>{{/EXPIRY}}
|
|
413
|
+
<p><a href="{{LOGIN_URL}}">Click here to login</a></p>
|
|
414
|
+
<p style="margin-top:24px;padding:12px;background:#f4f4f5;border-radius:6px;"><strong>Your username:</strong> <code style="font-size:16px;">{{USERNAME}}</code></p>
|
|
415
|
+
{{#CONTACT_EMAIL}}<p>If you have any questions, please contact us at <a href="mailto:{{CONTACT_EMAIL}}">{{CONTACT_EMAIL}}</a>.</p>{{/CONTACT_EMAIL}}`,
|
|
416
|
+
description: "FreeIPA welcome email template (HTML). Subject: Welcome to {{APP_NAME}}",
|
|
417
|
+
group: "mail",
|
|
418
|
+
templateVars: ["USERNAME", "PASSWORD", "EXPIRY", "LOGIN_URL", "CONTACT_EMAIL", "APP_NAME"],
|
|
419
|
+
},
|
|
420
|
+
{
|
|
421
|
+
key: "mail.user_welcome_local",
|
|
422
|
+
label: "Local Welcome Template",
|
|
423
|
+
kind: "template",
|
|
424
|
+
default: `<p>Your account has been created.</p>
|
|
425
|
+
<p>Sign in with your email address: <code style="background:#f4f4f5;padding:2px 6px;border-radius:4px;">{{EMAIL}}</code></p>
|
|
426
|
+
{{#EXPIRY}}<p>Your account is valid until: <strong>{{EXPIRY}}</strong></p>{{/EXPIRY}}
|
|
427
|
+
<p><a href="{{LOGIN_URL}}">Open the login page</a> and choose email sign-in.</p>
|
|
428
|
+
{{#CONTACT_EMAIL}}<p>If you have any questions, please contact us at <a href="mailto:{{CONTACT_EMAIL}}">{{CONTACT_EMAIL}}</a>.</p>{{/CONTACT_EMAIL}}`,
|
|
429
|
+
description: "Local welcome email template (HTML). Subject: Welcome to {{APP_NAME}}",
|
|
430
|
+
group: "mail",
|
|
431
|
+
templateVars: ["EMAIL", "EXPIRY", "LOGIN_URL", "CONTACT_EMAIL", "APP_NAME"],
|
|
432
|
+
},
|
|
433
|
+
{
|
|
434
|
+
key: "mail.magic_link_login",
|
|
435
|
+
label: "Magic Link Template",
|
|
436
|
+
kind: "template",
|
|
437
|
+
default: `<p style="text-align:center;margin:0 0 24px 0;">
|
|
438
|
+
<code style="background:#f4f4f5;padding:8px 16px;border-radius:8px;letter-spacing:2px;font-weight:600;">{{TOKEN}}</code>
|
|
439
|
+
</p>
|
|
440
|
+
<p style="text-align:center;margin:0 0 24px 0;">
|
|
441
|
+
<a href="{{MAGIC_LINK}}" target="_blank" style="color:#3b82f6;text-decoration:underline;">Click here to log in directly</a>
|
|
442
|
+
</p>
|
|
443
|
+
<p style="text-align:center;color:#71717a;font-size:12px;margin:0 0 8px 0;">This code expires in 5 minutes. Never share this code or link with anyone. If you didn't request this, please ignore this email.</p>`,
|
|
444
|
+
description: "Magic link login email template (HTML). Subject: {{APP_NAME}} Login Code",
|
|
445
|
+
group: "mail",
|
|
446
|
+
templateVars: ["TOKEN", "MAGIC_LINK", "APP_NAME"],
|
|
447
|
+
},
|
|
448
|
+
{
|
|
449
|
+
key: "mail.account_expiry_reminder",
|
|
450
|
+
label: "Account Expiry Reminder Template",
|
|
451
|
+
kind: "template",
|
|
452
|
+
default: `<p>Hi {{FIRST_NAME}},</p>
|
|
453
|
+
<p>Your {{APP_NAME}} account ({{ACCOUNT_KIND}}) will expire on <strong>{{EXPIRY}}</strong>.</p>
|
|
454
|
+
<p>You can extend your account here: <a href="{{EXTEND_URL}}">{{EXTEND_URL}}</a></p>
|
|
455
|
+
{{#CONTACT_EMAIL}}<p>If you need help, contact <a href="mailto:{{CONTACT_EMAIL}}">{{CONTACT_EMAIL}}</a>.</p>{{/CONTACT_EMAIL}}`,
|
|
456
|
+
description: "Account expiry reminder email template (HTML). Subject: {{APP_NAME}} Account Expiry",
|
|
457
|
+
group: "mail",
|
|
458
|
+
templateVars: ["FIRST_NAME", "DISPLAY_NAME", "EXPIRY", "EXTEND_URL", "APP_NAME", "CONTACT_EMAIL", "ACCOUNT_KIND"],
|
|
459
|
+
},
|
|
460
|
+
{
|
|
461
|
+
key: "mail.account_request_denial",
|
|
462
|
+
label: "Account Request Denial Template",
|
|
463
|
+
kind: "template",
|
|
464
|
+
default: `<p>Hi {{FIRST_NAME}},</p>
|
|
465
|
+
<p>Your request for an account has been reviewed and unfortunately cannot be approved at this time.</p>
|
|
466
|
+
<p><strong>Reason:</strong> {{REASON}}</p>
|
|
467
|
+
{{#CONTACT_EMAIL}}<p>If you have questions, please contact <a href="mailto:{{CONTACT_EMAIL}}">{{CONTACT_EMAIL}}</a>.</p>{{/CONTACT_EMAIL}}`,
|
|
468
|
+
description: "Account request denial email template (HTML). Subject: Account Request Update",
|
|
469
|
+
group: "mail",
|
|
470
|
+
templateVars: ["FIRST_NAME", "REASON", "CONTACT_EMAIL", "APP_NAME"],
|
|
471
|
+
},
|
|
472
|
+
{
|
|
473
|
+
key: "mail.noreply.smtp_host",
|
|
474
|
+
label: "SMTP Host",
|
|
475
|
+
kind: "string",
|
|
476
|
+
default: "",
|
|
477
|
+
description: "SMTP server hostname",
|
|
478
|
+
placeholder: "e.g. smtp.example.org",
|
|
479
|
+
group: "mail",
|
|
480
|
+
},
|
|
481
|
+
{
|
|
482
|
+
key: "mail.noreply.smtp_port",
|
|
483
|
+
label: "SMTP Port",
|
|
484
|
+
kind: "number",
|
|
485
|
+
default: 587,
|
|
486
|
+
min: 1,
|
|
487
|
+
max: 65535,
|
|
488
|
+
description: "SMTP server port (587 for STARTTLS, 465 for SSL)",
|
|
489
|
+
group: "mail",
|
|
490
|
+
},
|
|
491
|
+
{
|
|
492
|
+
key: "mail.noreply.from",
|
|
493
|
+
label: "From Address",
|
|
494
|
+
kind: "email",
|
|
495
|
+
default: "",
|
|
496
|
+
description: "From email address",
|
|
497
|
+
placeholder: "e.g. noreply@example.org",
|
|
498
|
+
group: "mail",
|
|
499
|
+
},
|
|
500
|
+
{
|
|
501
|
+
key: "mail.noreply.user",
|
|
502
|
+
label: "SMTP User",
|
|
503
|
+
kind: "string",
|
|
504
|
+
default: "",
|
|
505
|
+
description: "SMTP username",
|
|
506
|
+
placeholder: "e.g. noreply@example.org",
|
|
507
|
+
group: "mail",
|
|
508
|
+
},
|
|
509
|
+
{
|
|
510
|
+
key: "mail.noreply.password",
|
|
511
|
+
label: "SMTP Password",
|
|
512
|
+
kind: "secret",
|
|
513
|
+
default: "",
|
|
514
|
+
description: "SMTP password",
|
|
515
|
+
placeholder: "SMTP password",
|
|
516
|
+
group: "mail",
|
|
517
|
+
},
|
|
518
|
+
|
|
519
|
+
{
|
|
520
|
+
key: "security.rate_limit_per_second",
|
|
521
|
+
label: "Requests Per Second",
|
|
522
|
+
kind: "number",
|
|
523
|
+
default: 60,
|
|
524
|
+
min: 1,
|
|
525
|
+
description: "Maximum API requests per second per IP address",
|
|
526
|
+
group: "security",
|
|
527
|
+
},
|
|
528
|
+
|
|
529
|
+
// ── Legal documents (Imprint, Privacy, Terms) ──────────────────────────
|
|
530
|
+
// Three pages, three modes each:
|
|
531
|
+
// mode = "local" → render markdown from `legal.<kind>.content`
|
|
532
|
+
// mode = "external" → 302-redirect to `legal.<kind>.url`
|
|
533
|
+
// All three pages live in the settings app (mounts: /impressum,
|
|
534
|
+
// /legal/privacy, /legal/terms).
|
|
535
|
+
{
|
|
536
|
+
key: "legal.terms.mode",
|
|
537
|
+
label: "Terms of Service Source",
|
|
538
|
+
kind: "enum",
|
|
539
|
+
default: "local",
|
|
540
|
+
options: [
|
|
541
|
+
{ value: "local", label: "Local content (markdown)" },
|
|
542
|
+
{ value: "external", label: "External URL (redirect)" },
|
|
543
|
+
],
|
|
544
|
+
description: "How the Terms of Service page (/legal/terms) is served.",
|
|
545
|
+
group: "legal",
|
|
546
|
+
},
|
|
547
|
+
{
|
|
548
|
+
key: "legal.terms.content",
|
|
549
|
+
label: "Terms of Service Content",
|
|
550
|
+
kind: "text",
|
|
551
|
+
default: "",
|
|
552
|
+
description: "Markdown rendered at /legal/terms when source = local.",
|
|
553
|
+
placeholder: "# Terms of Service\n\nYour terms here…",
|
|
554
|
+
group: "legal",
|
|
555
|
+
},
|
|
556
|
+
{
|
|
557
|
+
key: "legal.terms.url",
|
|
558
|
+
label: "Terms of Service URL",
|
|
559
|
+
kind: "url",
|
|
560
|
+
default: "",
|
|
561
|
+
description: "External URL redirected to from /legal/terms when source = external.",
|
|
562
|
+
placeholder: "https://example.org/terms",
|
|
563
|
+
group: "legal",
|
|
564
|
+
},
|
|
565
|
+
{
|
|
566
|
+
key: "legal.privacy.mode",
|
|
567
|
+
label: "Privacy Policy Source",
|
|
568
|
+
kind: "enum",
|
|
569
|
+
default: "local",
|
|
570
|
+
options: [
|
|
571
|
+
{ value: "local", label: "Local content (markdown)" },
|
|
572
|
+
{ value: "external", label: "External URL (redirect)" },
|
|
573
|
+
],
|
|
574
|
+
description: "How the Privacy Policy page (/legal/privacy) is served.",
|
|
575
|
+
group: "legal",
|
|
576
|
+
},
|
|
577
|
+
{
|
|
578
|
+
key: "legal.privacy.content",
|
|
579
|
+
label: "Privacy Policy Content",
|
|
580
|
+
kind: "text",
|
|
581
|
+
default: "",
|
|
582
|
+
description: "Markdown rendered at /legal/privacy when source = local.",
|
|
583
|
+
placeholder: "# Privacy Policy\n\nYour privacy policy here…",
|
|
584
|
+
group: "legal",
|
|
585
|
+
},
|
|
586
|
+
{
|
|
587
|
+
key: "legal.privacy.url",
|
|
588
|
+
label: "Privacy Policy URL",
|
|
589
|
+
kind: "url",
|
|
590
|
+
default: "",
|
|
591
|
+
description: "External URL redirected to from /legal/privacy when source = external.",
|
|
592
|
+
placeholder: "https://example.org/privacy",
|
|
593
|
+
group: "legal",
|
|
594
|
+
},
|
|
595
|
+
{
|
|
596
|
+
key: "legal.imprint.mode",
|
|
597
|
+
label: "Imprint Source",
|
|
598
|
+
kind: "enum",
|
|
599
|
+
default: "local",
|
|
600
|
+
options: [
|
|
601
|
+
{ value: "local", label: "Local content (markdown)" },
|
|
602
|
+
{ value: "external", label: "External URL (redirect)" },
|
|
603
|
+
],
|
|
604
|
+
description: "How the Imprint page (/impressum) is served. Required by §5 TMG (German law).",
|
|
605
|
+
group: "legal",
|
|
606
|
+
},
|
|
607
|
+
{
|
|
608
|
+
key: "legal.imprint.content",
|
|
609
|
+
label: "Imprint Content",
|
|
610
|
+
kind: "text",
|
|
611
|
+
default: "",
|
|
612
|
+
description: "Markdown rendered at /impressum when source = local.",
|
|
613
|
+
placeholder: "# Imprint\n\n**Operator**: Example Org\n\nAddress, contact, …",
|
|
614
|
+
group: "legal",
|
|
615
|
+
},
|
|
616
|
+
{
|
|
617
|
+
key: "legal.imprint.url",
|
|
618
|
+
label: "Imprint URL",
|
|
619
|
+
kind: "url",
|
|
620
|
+
default: "",
|
|
621
|
+
description: "External URL redirected to from /impressum when source = external.",
|
|
622
|
+
placeholder: "https://example.org/imprint",
|
|
623
|
+
group: "legal",
|
|
624
|
+
},
|
|
625
|
+
];
|
|
626
|
+
|
|
627
|
+
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
628
|
+
|
|
629
|
+
const toStringValue = (value: unknown): string | null => {
|
|
630
|
+
if (typeof value === "string") return value;
|
|
631
|
+
if (typeof value === "number" || typeof value === "boolean") return String(value);
|
|
632
|
+
return null;
|
|
633
|
+
};
|
|
634
|
+
|
|
635
|
+
const parseStringList = (value: unknown): string[] | null => {
|
|
636
|
+
const rawValues = Array.isArray(value)
|
|
637
|
+
? value.flatMap((entry) => (typeof entry === "string" ? entry.split(/[,\n]/) : typeof entry === "number" ? [String(entry)] : []))
|
|
638
|
+
: typeof value === "string"
|
|
639
|
+
? value.split(/[,\n]/)
|
|
640
|
+
: null;
|
|
641
|
+
|
|
642
|
+
if (!rawValues) return null;
|
|
643
|
+
|
|
644
|
+
return [...new Set(rawValues.map((entry) => entry.trim()).filter(Boolean))];
|
|
645
|
+
};
|
|
646
|
+
|
|
647
|
+
const parseNumberList = (value: unknown): number[] | null => {
|
|
648
|
+
const rawValues = Array.isArray(value)
|
|
649
|
+
? value
|
|
650
|
+
: typeof value === "string"
|
|
651
|
+
? value.split(/[,\n]/).map((entry) => entry.trim())
|
|
652
|
+
: null;
|
|
653
|
+
|
|
654
|
+
if (!rawValues) return null;
|
|
655
|
+
|
|
656
|
+
const parsed = rawValues
|
|
657
|
+
.map((entry) => (typeof entry === "number" ? entry : Number(entry)))
|
|
658
|
+
.filter((entry) => Number.isInteger(entry) && entry > 0);
|
|
659
|
+
|
|
660
|
+
return [...new Set(parsed)].sort((a, b) => b - a);
|
|
661
|
+
};
|
|
662
|
+
|
|
663
|
+
const isValidCron = (value: string): boolean => {
|
|
664
|
+
const trimmed = value.trim();
|
|
665
|
+
if (!trimmed) return false;
|
|
666
|
+
return trimmed.split(/\s+/).length === 5;
|
|
667
|
+
};
|
|
668
|
+
|
|
669
|
+
const isValidTimezone = (value: string): boolean => {
|
|
670
|
+
try {
|
|
671
|
+
new Intl.DateTimeFormat(undefined, { timeZone: value });
|
|
672
|
+
return true;
|
|
673
|
+
} catch {
|
|
674
|
+
return false;
|
|
675
|
+
}
|
|
676
|
+
};
|
|
677
|
+
|
|
678
|
+
const isNonEmptyStringKind = (kind: SettingKind): kind is Exclude<SettingKind, "boolean" | "number" | "string_list" | "number_list"> =>
|
|
679
|
+
kind !== "boolean" && kind !== "number" && kind !== "string_list" && kind !== "number_list";
|
|
680
|
+
|
|
681
|
+
export const getSettingLabel = (def: SettingDef): string => {
|
|
682
|
+
if (def.label) return def.label;
|
|
683
|
+
return def.key
|
|
684
|
+
.split(".")
|
|
685
|
+
.slice(1)
|
|
686
|
+
.join(" ")
|
|
687
|
+
.replaceAll("_", " ")
|
|
688
|
+
.replace(/\b\w/g, (match) => match.toUpperCase());
|
|
689
|
+
};
|
|
690
|
+
|
|
691
|
+
export const normalizeSettingValue = (def: SettingDef, raw: unknown): unknown => {
|
|
692
|
+
switch (def.kind) {
|
|
693
|
+
case "boolean":
|
|
694
|
+
if (typeof raw === "boolean") return raw;
|
|
695
|
+
if (typeof raw === "string") {
|
|
696
|
+
const trimmed = raw.trim().toLowerCase();
|
|
697
|
+
if (trimmed === "true") return true;
|
|
698
|
+
if (trimmed === "false") return false;
|
|
699
|
+
}
|
|
700
|
+
return raw;
|
|
701
|
+
case "number":
|
|
702
|
+
if (typeof raw === "number" && Number.isFinite(raw)) return raw;
|
|
703
|
+
if (typeof raw === "string" && raw.trim()) {
|
|
704
|
+
const parsed = Number(raw.trim());
|
|
705
|
+
return Number.isFinite(parsed) ? parsed : raw;
|
|
706
|
+
}
|
|
707
|
+
return raw;
|
|
708
|
+
case "string_list":
|
|
709
|
+
return parseStringList(raw) ?? raw;
|
|
710
|
+
case "number_list":
|
|
711
|
+
return parseNumberList(raw) ?? raw;
|
|
712
|
+
case "enum": {
|
|
713
|
+
const value = toStringValue(raw);
|
|
714
|
+
return value === null ? raw : value.trim();
|
|
715
|
+
}
|
|
716
|
+
default: {
|
|
717
|
+
const value = toStringValue(raw);
|
|
718
|
+
if (value === null) return raw;
|
|
719
|
+
return def.kind === "text" || def.kind === "template" ? value : value.trim();
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
};
|
|
723
|
+
|
|
724
|
+
export const validateSettingValue = (def: SettingDef, raw: unknown): SettingValidationResult => {
|
|
725
|
+
const value = normalizeSettingValue(def, raw);
|
|
726
|
+
|
|
727
|
+
switch (def.kind) {
|
|
728
|
+
case "boolean":
|
|
729
|
+
return typeof value === "boolean" ? { ok: true, value } : { ok: false, error: `${getSettingLabel(def)} must be true or false` };
|
|
730
|
+
case "number":
|
|
731
|
+
if (typeof value !== "number" || !Number.isFinite(value)) {
|
|
732
|
+
return { ok: false, error: `${getSettingLabel(def)} must be a valid number` };
|
|
733
|
+
}
|
|
734
|
+
if (def.min !== undefined && value < def.min) {
|
|
735
|
+
return { ok: false, error: `${getSettingLabel(def)} must be at least ${def.min}` };
|
|
736
|
+
}
|
|
737
|
+
if (def.max !== undefined && value > def.max) {
|
|
738
|
+
return { ok: false, error: `${getSettingLabel(def)} must be at most ${def.max}` };
|
|
739
|
+
}
|
|
740
|
+
return { ok: true, value };
|
|
741
|
+
case "string_list":
|
|
742
|
+
return Array.isArray(value) && value.every((entry) => typeof entry === "string")
|
|
743
|
+
? { ok: true, value }
|
|
744
|
+
: { ok: false, error: `${getSettingLabel(def)} must be a list of strings` };
|
|
745
|
+
case "number_list":
|
|
746
|
+
return Array.isArray(value) && value.every((entry) => typeof entry === "number" && Number.isInteger(entry) && entry > 0)
|
|
747
|
+
? { ok: true, value }
|
|
748
|
+
: { ok: false, error: `${getSettingLabel(def)} must be a list of positive whole numbers` };
|
|
749
|
+
case "enum":
|
|
750
|
+
if (typeof value !== "string") {
|
|
751
|
+
return { ok: false, error: `${getSettingLabel(def)} must be a valid option` };
|
|
752
|
+
}
|
|
753
|
+
return def.options.some((option) => option.value === value)
|
|
754
|
+
? { ok: true, value }
|
|
755
|
+
: { ok: false, error: `${getSettingLabel(def)} must be one of: ${def.options.map((option) => option.value).join(", ")}` };
|
|
756
|
+
case "email":
|
|
757
|
+
if (typeof value !== "string") return { ok: false, error: `${getSettingLabel(def)} must be a valid email address` };
|
|
758
|
+
return value.length === 0 || EMAIL_RE.test(value)
|
|
759
|
+
? { ok: true, value }
|
|
760
|
+
: { ok: false, error: `${getSettingLabel(def)} must be a valid email address` };
|
|
761
|
+
case "url":
|
|
762
|
+
case "image":
|
|
763
|
+
if (typeof value !== "string") return { ok: false, error: `${getSettingLabel(def)} must be a valid URL` };
|
|
764
|
+
if (!value.length) return { ok: true, value };
|
|
765
|
+
try {
|
|
766
|
+
new URL(value);
|
|
767
|
+
return { ok: true, value };
|
|
768
|
+
} catch {
|
|
769
|
+
return { ok: false, error: `${getSettingLabel(def)} must be a valid URL` };
|
|
770
|
+
}
|
|
771
|
+
case "cron":
|
|
772
|
+
return typeof value === "string" && isValidCron(value)
|
|
773
|
+
? { ok: true, value }
|
|
774
|
+
: { ok: false, error: `${getSettingLabel(def)} must be a valid five-field cron expression` };
|
|
775
|
+
case "timezone":
|
|
776
|
+
if (typeof value !== "string") return { ok: false, error: `${getSettingLabel(def)} must be a valid IANA timezone` };
|
|
777
|
+
return value.length === 0 || isValidTimezone(value)
|
|
778
|
+
? { ok: true, value }
|
|
779
|
+
: { ok: false, error: `${getSettingLabel(def)} must be a valid IANA timezone` };
|
|
780
|
+
default:
|
|
781
|
+
if (!isNonEmptyStringKind(def.kind)) {
|
|
782
|
+
return { ok: false, error: `${getSettingLabel(def)} is invalid` };
|
|
783
|
+
}
|
|
784
|
+
return typeof value === "string" ? { ok: true, value } : { ok: false, error: `${getSettingLabel(def)} must be text` };
|
|
785
|
+
}
|
|
786
|
+
};
|
|
787
|
+
|
|
788
|
+
/** Lookup map for quick access by key */
|
|
789
|
+
export const SETTINGS_MAP = new Map(SETTINGS.map((setting) => [setting.key, setting] as const));
|
|
790
|
+
|
|
791
|
+
/** All group names (ordered by first occurrence) */
|
|
792
|
+
export const SETTING_GROUPS: string[] = [];
|
|
793
|
+
|
|
794
|
+
const ensureGroup = (group: string): void => {
|
|
795
|
+
if (!SETTING_GROUPS.includes(group)) SETTING_GROUPS.push(group);
|
|
796
|
+
};
|
|
797
|
+
|
|
798
|
+
for (const setting of SETTINGS) {
|
|
799
|
+
ensureGroup(setting.group);
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
/** Group display labels */
|
|
803
|
+
export const GROUP_LABELS: Record<string, string> = {
|
|
804
|
+
app: "Application",
|
|
805
|
+
freeipa: "FreeIPA",
|
|
806
|
+
user: "User Management",
|
|
807
|
+
mail: "Mail",
|
|
808
|
+
security: "Security",
|
|
809
|
+
legal: "Legal",
|
|
810
|
+
};
|
|
811
|
+
|
|
812
|
+
/** Register additional settings (used by apps to add their own defaults). */
|
|
813
|
+
export function registerSettings(defs: SettingDef[]): void {
|
|
814
|
+
for (const def of defs) {
|
|
815
|
+
SETTINGS.push(def);
|
|
816
|
+
SETTINGS_MAP.set(def.key, def);
|
|
817
|
+
ensureGroup(def.group);
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
/** Register a group display label (used by apps alongside registerSettings). */
|
|
822
|
+
export function registerGroupLabel(group: string, label: string): void {
|
|
823
|
+
GROUP_LABELS[group] = label;
|
|
824
|
+
}
|