mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 06:05:24 +00:00
feat: redesign user host defaults system
This commit is contained in:
1 parent
3028ab1176
commit
a4a4d4f27b
144 files changed
+24609
-2555
No files matched your search
@@ -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;
|
||||
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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 }> =
|
||||
|
||||
Reference in new issue
Block a user