feat: redesign user host defaults system

This commit is contained in:
LukeGus committed 2026-09-30 06:19:56 -05:00
1 parent 3028ab1176
commit a4a4d4f27b
144 files changed
+24609 -2555

No files matched your search

+38 -2
View File
@@ -500,15 +500,44 @@ export interface PluginSettings {
getUser: <T = unknown>(userId: string, key: string) => Promise<T | undefined>;
setUser: (userId: string, key: string, value: unknown) => Promise<void>;
/** Per-host settings. */
/**
* Per-host settings. A host follows its host defaults (server, user,
* folder) for every key it has not set itself, and the value read here is
* already the resolved one.
*/
getHost: <T = unknown>(
hostId: number | string,
key: string,
) => Promise<T | undefined>;
/**
* The value as one user sees it. Only a `personal` field differs from
* getHost: on a host shared with that user and following its defaults, it
* resolves against the user's own defaults rather than the owner's.
*/
getHostFor: <T = unknown>(
hostId: number | string,
userId: string,
key: string,
) => Promise<T | undefined>;
/**
* What a user's hosts get for a host field when nothing more specific
* sets it: their own defaults, then the server's, then the manifest
* default. For something with no host, such as a quick connect.
*/
getHostDefault: <T = unknown>(
userId: string,
key: string,
) => Promise<T | undefined>;
/**
* Writes a host value, which makes it the host's own rather than
* following its defaults. `{ inherit: true }` hands the key back to the
* defaults instead and ignores `value`.
*/
setHost: (
hostId: number | string,
key: string,
value: unknown,
options?: { inherit?: boolean },
) => Promise<void>;
/**
@@ -542,7 +571,14 @@ export interface PluginSettings {
scope: "admin" | "user" | "host",
validator: (
values: Record<string, unknown>,
context: { hostId?: number },
/**
* `defaults` is set when the values are a host defaults level being
* saved rather than one host, so there is no hostId.
*/
context: {
hostId?: number;
defaults?: { level: "admin" | "user" | "folder" };
},
) => Record<string, string> | void | Promise<Record<string, string> | void>,
) => () => void;
+14
View File
@@ -403,6 +403,13 @@ export interface HostEditorSectionProps {
adminTargetUserId?: string;
/** Which connection protocols are switched on for this host. */
protocols: Record<string, boolean>;
/**
* "defaults" while the editor sets a level of host defaults (server, user
* or folder) rather than one host. There is no `host` then, and the form
* holds the defaults. Only a section registered with `defaults: true` is
* shown in that mode.
*/
mode?: "host" | "defaults";
}
export interface HostEditorSectionContribution {
@@ -418,6 +425,13 @@ export interface HostEditorSectionContribution {
order?: number;
/** Whether to offer the tab, from the host's enabled protocols. */
visible?: (protocols: Record<string, boolean>) => boolean;
/**
* Also shown in the host defaults editor. Only for a section whose fields
* are this plugin's host settings kept on the form, and that makes no
* calls about one particular host. Off by default. "only" shows it in the
* defaults editor and never for a host.
*/
defaults?: boolean | "only";
component: ComponentType<HostEditorSectionProps>;
}
+57 -15
View File
@@ -276,11 +276,30 @@ export interface PluginSettingsField {
/** Registered component id. Required when type is "custom". */
component?: string;
/**
* Host scope only: the admin field whose value a new host starts with,
* instead of `default`. Hosts that never saved the field still read
* `default`.
* Deprecated and ignored: host defaults cover every host field now. Still
* accepted so an older manifest loads. Removed in 3.0.0.
*/
defaultFrom?: string;
/**
* Host scope only: whether the field can have a host default (server,
* user or folder) that hosts follow until they set their own. On by
* default for every field except secrets and json fields with
* `secretKeys`. Turn it off for a value that only makes sense per host,
* such as a MAC address or an API endpoint.
*/
defaultable?: boolean;
/**
* Host scope only: the levels that may set a default for it. Defaults to
* all three. Leave "admin" out for a value that points at one user's own
* rows (a snippet id, a profile id).
*/
defaultLevels?: Array<"admin" | "user" | "folder">;
/**
* Host scope only: a look-and-feel value. When the host follows its
* defaults, a user it is shared with gets their own defaults for it
* rather than the owner's.
*/
personal?: boolean;
/**
* Host scope only: the lowest share level whose recipients see this value
* in the host payload. Defaults to "connect", everyone who sees the host.
@@ -626,6 +645,9 @@ const ALLOWED_SETTINGS_FIELD = [
"component",
"hidden",
"defaultFrom",
"defaultable",
"defaultLevels",
"personal",
"shareRead",
"ownerOnly",
"secretKeys",
@@ -1589,20 +1611,33 @@ function validateSettings(settings: unknown, errors: string[]): void {
typeof host.enableKey === "string" ? host.enableKey : undefined,
);
const adminKeys = new Set(
(Array.isArray(settings.admin) ? settings.admin : [])
.filter(isPlainObject)
// A secret never becomes a default the host editor can read.
.filter((field) => field.type !== "secret")
.map((field) => field.key),
);
host.fields.forEach((raw, index) => {
if (!isPlainObject(raw)) return;
const fieldAt = `${at}.fields[${index}]`;
if ("defaultFrom" in raw && !adminKeys.has(raw.defaultFrom as string)) {
errors.push(
`${fieldAt}.defaultFrom must name one of this plugin's non-secret admin fields`,
);
if ("defaultFrom" in raw && typeof raw.defaultFrom !== "string") {
errors.push(`${fieldAt}.defaultFrom must be a string`);
}
for (const flag of ["defaultable", "personal"]) {
if (flag in raw && typeof raw[flag] !== "boolean") {
errors.push(`${fieldAt}.${flag} must be a boolean`);
}
}
if ("defaultLevels" in raw) {
const levels = raw.defaultLevels;
if (
!Array.isArray(levels) ||
levels.length === 0 ||
levels.some(
(level) => !["admin", "user", "folder"].includes(level as string),
)
) {
errors.push(
`${fieldAt}.defaultLevels must be a non-empty array of "admin", "user" or "folder"`,
);
}
}
if (raw.type === "secret" && raw.defaultable === true) {
errors.push(`${fieldAt} is a secret and cannot be defaultable`);
}
if (
"shareRead" in raw &&
@@ -1634,7 +1669,14 @@ function validateSettings(settings: unknown, errors: string[]): void {
if (!Array.isArray(fields)) continue;
fields.forEach((raw, index) => {
if (!isPlainObject(raw)) return;
for (const key of ["defaultFrom", "shareRead", "ownerOnly"]) {
for (const key of [
"defaultFrom",
"defaultable",
"defaultLevels",
"personal",
"shareRead",
"ownerOnly",
]) {
if (key in raw) {
errors.push(
`${where}.${scope}[${index}].${key} is only valid on host fields`,
+10 -2
View File
@@ -826,8 +826,16 @@ export function createFakeContext(
getHost: async (hostId, key) =>
settings.get(settingsKey("host", String(hostId), key)) as never,
setHost: async (hostId, key, value) =>
writeSetting("host", String(hostId), key, value),
getHostFor: async (hostId, _userId, key) =>
settings.get(settingsKey("host", String(hostId), key)) as never,
// Host defaults live in core; the mock stands in the user's own
// setting of the same key, so a test can set one with setUser.
getHostDefault: async (userId, key) =>
settings.get(settingsKey("user", userId, key)) as never,
setHost: async (hostId, key, value, options) =>
options?.inherit
? void settings.delete(settingsKey("host", String(hostId), key))
: writeSetting("host", String(hostId), key, value),
listHostValues: async (key) => {
const found: Array<{ hostId: number; userId: string; value: never }> =