mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-31 07:35: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
@@ -0,0 +1,20 @@
|
|||||||
|
CREATE TABLE `host_defaults` (
|
||||||
|
`id` int AUTO_INCREMENT NOT NULL,
|
||||||
|
`level` enum('admin','user','folder') NOT NULL,
|
||||||
|
`scope_key` varchar(255) NOT NULL,
|
||||||
|
`user_id` varchar(255),
|
||||||
|
`folder_id` int,
|
||||||
|
`namespace` varchar(255) NOT NULL,
|
||||||
|
`key` varchar(255) NOT NULL,
|
||||||
|
`value` text,
|
||||||
|
`updated_by` text,
|
||||||
|
`updated_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP),
|
||||||
|
CONSTRAINT `host_defaults_id` PRIMARY KEY(`id`),
|
||||||
|
CONSTRAINT `idx_host_defaults_scope_key` UNIQUE(`scope_key`,`namespace`,`key`)
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE `ssh_data` ADD `default_overrides` text;--> statement-breakpoint
|
||||||
|
ALTER TABLE `host_defaults` ADD CONSTRAINT `host_defaults_user_id_users_id_fk` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE `host_defaults` ADD CONSTRAINT `host_defaults_folder_id_ssh_folders_id_fk` FOREIGN KEY (`folder_id`) REFERENCES `ssh_folders`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
CREATE INDEX `idx_host_defaults_user` ON `host_defaults` (`user_id`);--> statement-breakpoint
|
||||||
|
CREATE INDEX `idx_host_defaults_folder` ON `host_defaults` (`folder_id`);
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -456,6 +456,13 @@
|
|||||||
"when": 1790704531888,
|
"when": 1790704531888,
|
||||||
"tag": "0064_flippant_killer_shrike",
|
"tag": "0064_flippant_killer_shrike",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 65,
|
||||||
|
"version": "5",
|
||||||
|
"when": 1790742086934,
|
||||||
|
"tag": "0065_ambiguous_agent_zero",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
CREATE TABLE "host_defaults" (
|
||||||
|
"id" serial PRIMARY KEY NOT NULL,
|
||||||
|
"level" text NOT NULL,
|
||||||
|
"scope_key" varchar(255) NOT NULL,
|
||||||
|
"user_id" varchar(255),
|
||||||
|
"folder_id" integer,
|
||||||
|
"namespace" varchar(255) NOT NULL,
|
||||||
|
"key" varchar(255) NOT NULL,
|
||||||
|
"value" text,
|
||||||
|
"updated_by" text,
|
||||||
|
"updated_at" text DEFAULT CURRENT_TIMESTAMP NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "ssh_data" ADD COLUMN "default_overrides" text;--> statement-breakpoint
|
||||||
|
ALTER TABLE "host_defaults" ADD CONSTRAINT "host_defaults_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "host_defaults" ADD CONSTRAINT "host_defaults_folder_id_ssh_folders_id_fk" FOREIGN KEY ("folder_id") REFERENCES "public"."ssh_folders"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "idx_host_defaults_scope_key" ON "host_defaults" USING btree ("scope_key","namespace","key");--> statement-breakpoint
|
||||||
|
CREATE INDEX "idx_host_defaults_user" ON "host_defaults" USING btree ("user_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "idx_host_defaults_folder" ON "host_defaults" USING btree ("folder_id");
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -456,6 +456,13 @@
|
|||||||
"when": 1790704531255,
|
"when": 1790704531255,
|
||||||
"tag": "0064_sticky_pestilence",
|
"tag": "0064_sticky_pestilence",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 65,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1790742086298,
|
||||||
|
"tag": "0065_regular_joystick",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
CREATE TABLE `host_defaults` (
|
||||||
|
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||||
|
`level` text NOT NULL,
|
||||||
|
`scope_key` text NOT NULL,
|
||||||
|
`user_id` text,
|
||||||
|
`folder_id` integer,
|
||||||
|
`namespace` text NOT NULL,
|
||||||
|
`key` text NOT NULL,
|
||||||
|
`value` text,
|
||||||
|
`updated_by` text,
|
||||||
|
`updated_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||||
|
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade,
|
||||||
|
FOREIGN KEY (`folder_id`) REFERENCES `ssh_folders`(`id`) ON UPDATE no action ON DELETE cascade
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX `idx_host_defaults_scope_key` ON `host_defaults` (`scope_key`,`namespace`,`key`);--> statement-breakpoint
|
||||||
|
CREATE INDEX `idx_host_defaults_user` ON `host_defaults` (`user_id`);--> statement-breakpoint
|
||||||
|
CREATE INDEX `idx_host_defaults_folder` ON `host_defaults` (`folder_id`);--> statement-breakpoint
|
||||||
|
ALTER TABLE `ssh_data` ADD `default_overrides` text;
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -421,6 +421,13 @@
|
|||||||
"when": 1790704530590,
|
"when": 1790704530590,
|
||||||
"tag": "0059_adorable_wrecking_crew",
|
"tag": "0059_adorable_wrecking_crew",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 60,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1790742085632,
|
||||||
|
"tag": "0060_fine_cerise",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -903,7 +903,21 @@
|
|||||||
"defaultFrom": {
|
"defaultFrom": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"minLength": 1,
|
"minLength": 1,
|
||||||
"description": "A non-secret admin field whose value a new host starts with."
|
"description": "Deprecated and ignored. Host defaults cover every host field."
|
||||||
|
},
|
||||||
|
"defaultable": {
|
||||||
|
"type": "boolean",
|
||||||
|
"description": "Whether the field can have a host default. On by default for non-secret fields."
|
||||||
|
},
|
||||||
|
"defaultLevels": {
|
||||||
|
"type": "array",
|
||||||
|
"minItems": 1,
|
||||||
|
"items": { "enum": ["admin", "user", "folder"] },
|
||||||
|
"description": "Levels that may set a default for the field. All three by default."
|
||||||
|
},
|
||||||
|
"personal": {
|
||||||
|
"type": "boolean",
|
||||||
|
"description": "A look-and-feel value: a user a host is shared with gets their own default for it."
|
||||||
},
|
},
|
||||||
"shareRead": {
|
"shareRead": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
|
|||||||
@@ -500,15 +500,44 @@ export interface PluginSettings {
|
|||||||
getUser: <T = unknown>(userId: string, key: string) => Promise<T | undefined>;
|
getUser: <T = unknown>(userId: string, key: string) => Promise<T | undefined>;
|
||||||
setUser: (userId: string, key: string, value: unknown) => Promise<void>;
|
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>(
|
getHost: <T = unknown>(
|
||||||
hostId: number | string,
|
hostId: number | string,
|
||||||
key: string,
|
key: string,
|
||||||
) => Promise<T | undefined>;
|
) => 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: (
|
setHost: (
|
||||||
hostId: number | string,
|
hostId: number | string,
|
||||||
key: string,
|
key: string,
|
||||||
value: unknown,
|
value: unknown,
|
||||||
|
options?: { inherit?: boolean },
|
||||||
) => Promise<void>;
|
) => Promise<void>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -542,7 +571,14 @@ export interface PluginSettings {
|
|||||||
scope: "admin" | "user" | "host",
|
scope: "admin" | "user" | "host",
|
||||||
validator: (
|
validator: (
|
||||||
values: Record<string, unknown>,
|
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>,
|
) => Record<string, string> | void | Promise<Record<string, string> | void>,
|
||||||
) => () => void;
|
) => () => void;
|
||||||
|
|
||||||
|
|||||||
@@ -403,6 +403,13 @@ export interface HostEditorSectionProps {
|
|||||||
adminTargetUserId?: string;
|
adminTargetUserId?: string;
|
||||||
/** Which connection protocols are switched on for this host. */
|
/** Which connection protocols are switched on for this host. */
|
||||||
protocols: Record<string, boolean>;
|
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 {
|
export interface HostEditorSectionContribution {
|
||||||
@@ -418,6 +425,13 @@ export interface HostEditorSectionContribution {
|
|||||||
order?: number;
|
order?: number;
|
||||||
/** Whether to offer the tab, from the host's enabled protocols. */
|
/** Whether to offer the tab, from the host's enabled protocols. */
|
||||||
visible?: (protocols: Record<string, boolean>) => boolean;
|
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>;
|
component: ComponentType<HostEditorSectionProps>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -276,11 +276,30 @@ export interface PluginSettingsField {
|
|||||||
/** Registered component id. Required when type is "custom". */
|
/** Registered component id. Required when type is "custom". */
|
||||||
component?: string;
|
component?: string;
|
||||||
/**
|
/**
|
||||||
* Host scope only: the admin field whose value a new host starts with,
|
* Deprecated and ignored: host defaults cover every host field now. Still
|
||||||
* instead of `default`. Hosts that never saved the field still read
|
* accepted so an older manifest loads. Removed in 3.0.0.
|
||||||
* `default`.
|
|
||||||
*/
|
*/
|
||||||
defaultFrom?: string;
|
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
|
* Host scope only: the lowest share level whose recipients see this value
|
||||||
* in the host payload. Defaults to "connect", everyone who sees the host.
|
* in the host payload. Defaults to "connect", everyone who sees the host.
|
||||||
@@ -626,6 +645,9 @@ const ALLOWED_SETTINGS_FIELD = [
|
|||||||
"component",
|
"component",
|
||||||
"hidden",
|
"hidden",
|
||||||
"defaultFrom",
|
"defaultFrom",
|
||||||
|
"defaultable",
|
||||||
|
"defaultLevels",
|
||||||
|
"personal",
|
||||||
"shareRead",
|
"shareRead",
|
||||||
"ownerOnly",
|
"ownerOnly",
|
||||||
"secretKeys",
|
"secretKeys",
|
||||||
@@ -1589,20 +1611,33 @@ function validateSettings(settings: unknown, errors: string[]): void {
|
|||||||
typeof host.enableKey === "string" ? host.enableKey : undefined,
|
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) => {
|
host.fields.forEach((raw, index) => {
|
||||||
if (!isPlainObject(raw)) return;
|
if (!isPlainObject(raw)) return;
|
||||||
const fieldAt = `${at}.fields[${index}]`;
|
const fieldAt = `${at}.fields[${index}]`;
|
||||||
if ("defaultFrom" in raw && !adminKeys.has(raw.defaultFrom as string)) {
|
if ("defaultFrom" in raw && typeof raw.defaultFrom !== "string") {
|
||||||
errors.push(
|
errors.push(`${fieldAt}.defaultFrom must be a string`);
|
||||||
`${fieldAt}.defaultFrom must name one of this plugin's non-secret admin fields`,
|
}
|
||||||
);
|
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 (
|
if (
|
||||||
"shareRead" in raw &&
|
"shareRead" in raw &&
|
||||||
@@ -1634,7 +1669,14 @@ function validateSettings(settings: unknown, errors: string[]): void {
|
|||||||
if (!Array.isArray(fields)) continue;
|
if (!Array.isArray(fields)) continue;
|
||||||
fields.forEach((raw, index) => {
|
fields.forEach((raw, index) => {
|
||||||
if (!isPlainObject(raw)) return;
|
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) {
|
if (key in raw) {
|
||||||
errors.push(
|
errors.push(
|
||||||
`${where}.${scope}[${index}].${key} is only valid on host fields`,
|
`${where}.${scope}[${index}].${key} is only valid on host fields`,
|
||||||
|
|||||||
@@ -826,8 +826,16 @@ export function createFakeContext(
|
|||||||
|
|
||||||
getHost: async (hostId, key) =>
|
getHost: async (hostId, key) =>
|
||||||
settings.get(settingsKey("host", String(hostId), key)) as never,
|
settings.get(settingsKey("host", String(hostId), key)) as never,
|
||||||
setHost: async (hostId, key, value) =>
|
getHostFor: async (hostId, _userId, key) =>
|
||||||
writeSetting("host", String(hostId), key, value),
|
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) => {
|
listHostValues: async (key) => {
|
||||||
const found: Array<{ hostId: number; userId: string; value: never }> =
|
const found: Array<{ hostId: number; userId: string; value: never }> =
|
||||||
|
|||||||
@@ -72,6 +72,8 @@ GridCardCatalogEntry (type): GridCardCatalogEntry
|
|||||||
GridColSpan (type): GridColSpan
|
GridColSpan (type): GridColSpan
|
||||||
GridLayout (type): GridLayout
|
GridLayout (type): GridLayout
|
||||||
GridSlot (type): GridSlot
|
GridSlot (type): GridSlot
|
||||||
|
HostDefaultBadge (value): ({ settingKey }: { settingKey: string; }) => Element
|
||||||
|
HostDefaultField (value): ({ settingKey, children, }: { settingKey: string; children: ReactNode; }) => Element
|
||||||
HostFeatureFields (value): ({ form, updateForm, }: HostFeatureFieldsProps) => Element
|
HostFeatureFields (value): ({ form, updateForm, }: HostFeatureFieldsProps) => Element
|
||||||
HostFeatureFieldsProps (type): HostFeatureFieldsProps
|
HostFeatureFieldsProps (type): HostFeatureFieldsProps
|
||||||
HostKeyVerificationDialog (value): ({ isOpen, scenario, ip, port, hostname, fingerprint, oldFingerprint, algorithm, onAccept, onReject, backgroundColor, }: HostKeyVerificationDialogProps) => Element
|
HostKeyVerificationDialog (value): ({ isOpen, scenario, ip, port, hostname, fingerprint, oldFingerprint, algorithm, onAccept, onReject, backgroundColor, }: HostKeyVerificationDialogProps) => Element
|
||||||
@@ -121,7 +123,7 @@ SelectSeparator (value): ({ className, ...props }: SelectSeparatorProps & RefAtt
|
|||||||
SelectTrigger (value): ({ className, size, children, ...props }: SelectTriggerProps & RefAttributes<HTMLButtonElement> & { size?: "default" | "sm"; }) => Element
|
SelectTrigger (value): ({ className, size, children, ...props }: SelectTriggerProps & RefAttributes<HTMLButtonElement> & { size?: "default" | "sm"; }) => Element
|
||||||
SelectValue (value): ({ ...props }: SelectValueProps & RefAttributes<HTMLSpanElement>) => Element
|
SelectValue (value): ({ ...props }: SelectValueProps & RefAttributes<HTMLSpanElement>) => Element
|
||||||
Separator (value): ({ className, orientation, decorative, ...props }: SeparatorProps & RefAttributes<HTMLDivElement>) => Element
|
Separator (value): ({ className, orientation, decorative, ...props }: SeparatorProps & RefAttributes<HTMLDivElement>) => Element
|
||||||
SettingRow (value): ({ label, badge, description, children, }: { label: string; badge?: string; description?: ReactNode; children: ReactNode; }) => Element
|
SettingRow (value): ({ label, badge, description, defaultKey, children, }: { label: string; badge?: string; description?: ReactNode; defaultKey?: string; children: ReactNode; }) => Element
|
||||||
Sheet (value): ({ ...props }: DialogProps) => Element
|
Sheet (value): ({ ...props }: DialogProps) => Element
|
||||||
SheetClose (value): ({ ...props }: DialogCloseProps & RefAttributes<HTMLButtonElement>) => Element
|
SheetClose (value): ({ ...props }: DialogCloseProps & RefAttributes<HTMLButtonElement>) => Element
|
||||||
SheetContent (value): ({ className, children, side, showCloseButton, ...props }: DialogContentProps & RefAttributes<HTMLDivElement> & { side?: "top" | "right" | "bottom" | "left"; showCloseButton?: boolean; }) => Element
|
SheetContent (value): ({ className, children, side, showCloseButton, ...props }: DialogContentProps & RefAttributes<HTMLDivElement> & { side?: "top" | "right" | "bottom" | "left"; showCloseButton?: boolean; }) => Element
|
||||||
@@ -179,6 +181,7 @@ useAdaptivePolling (value): (poll: () => AdaptivePollResult | Promise<AdaptivePo
|
|||||||
useAppTheme (value): () => ThemeProviderState
|
useAppTheme (value): () => ThemeProviderState
|
||||||
useConfirmation (value): () => { isOpen: boolean; options: ConfirmationOptions; confirm: (opts: ConfirmationOptions, callback: () => void) => void; handleConfirm: () => void; handleCancel: () => void; confirmWithToast: (opts: string | ConfirmationOptions, callback?: () => void, variantOrConfirmLabel?: string, cancelLabel?: string, toastOptions?: ToastConfirmOptions) => Promise<boolean>; }
|
useConfirmation (value): () => { isOpen: boolean; options: ConfirmationOptions; confirm: (opts: ConfirmationOptions, callback: () => void) => void; handleConfirm: () => void; handleCancel: () => void; confirmWithToast: (opts: string | ConfirmationOptions, callback?: () => void, variantOrConfirmLabel?: string, cancelLabel?: string, toastOptions?: ToastConfirmOptions) => Promise<boolean>; }
|
||||||
useConnectionLog (value): () => ConnectionLogContextType
|
useConnectionLog (value): () => ConnectionLogContextType
|
||||||
|
useIsDefaultsEditor (value): () => boolean
|
||||||
useIsMobile (value): () => boolean
|
useIsMobile (value): () => boolean
|
||||||
useOptionalConnectionLog (value): () => ConnectionLogContextType
|
useOptionalConnectionLog (value): () => ConnectionLogContextType
|
||||||
useTabs (value): () => TabContextType
|
useTabs (value): () => TabContextType
|
||||||
|
|||||||
@@ -102,25 +102,6 @@ export function registerRoutes(router: Router, deps: RouteDeps): void {
|
|||||||
poller.registerViewer(hostId, sessionId, userId),
|
poller.registerViewer(hostId, sessionId, userId),
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* @openapi
|
|
||||||
* /plugin-api/host-metrics/defaults:
|
|
||||||
* get:
|
|
||||||
* summary: Get defaults for new hosts
|
|
||||||
* description: Whether a new host starts with metrics on, an admin setting any user creating a host needs.
|
|
||||||
* tags:
|
|
||||||
* - Host Metrics
|
|
||||||
* responses:
|
|
||||||
* 200:
|
|
||||||
* description: The defaults.
|
|
||||||
*/
|
|
||||||
router.get("/defaults", async (_req, res) => {
|
|
||||||
res.json({
|
|
||||||
enabledForNewHosts:
|
|
||||||
(await ctx.settings.get<boolean>("enabledForNewHosts")) !== false,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @openapi
|
* @openapi
|
||||||
* /plugin-api/host-metrics/metrics/{id}:
|
* /plugin-api/host-metrics/metrics/{id}:
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
|
HostDefaultBadge,
|
||||||
Input,
|
Input,
|
||||||
SectionCard,
|
SectionCard,
|
||||||
SettingRow,
|
SettingRow,
|
||||||
FakeSwitch,
|
FakeSwitch,
|
||||||
|
useIsDefaultsEditor,
|
||||||
} from "@termix/plugin-sdk/ui";
|
} from "@termix/plugin-sdk/ui";
|
||||||
import { useEffect, useState } from "react";
|
import { useState } from "react";
|
||||||
import {
|
import {
|
||||||
usePluginApi,
|
|
||||||
useTranslation,
|
useTranslation,
|
||||||
type HostEditorSectionProps,
|
type HostEditorSectionProps,
|
||||||
} from "@termix/plugin-sdk/frontend";
|
} from "@termix/plugin-sdk/frontend";
|
||||||
@@ -24,11 +25,7 @@ type PluginSettingsBag = Record<string, Record<string, unknown>>;
|
|||||||
* host settings, carried on form.pluginSettings and saved by the editor after
|
* host settings, carried on form.pluginSettings and saved by the editor after
|
||||||
* the host itself.
|
* the host itself.
|
||||||
*/
|
*/
|
||||||
export function HostStatsTab({
|
export function HostStatsTab({ form, updateForm }: HostEditorSectionProps) {
|
||||||
form,
|
|
||||||
updateForm,
|
|
||||||
host,
|
|
||||||
}: HostEditorSectionProps) {
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [newMount, setNewMount] = useState("");
|
const [newMount, setNewMount] = useState("");
|
||||||
const [newMonitoredPath, setNewMonitoredPath] = useState("");
|
const [newMonitoredPath, setNewMonitoredPath] = useState("");
|
||||||
@@ -38,7 +35,7 @@ export function HostStatsTab({
|
|||||||
];
|
];
|
||||||
const settings = readHostMetricsSettings(bag);
|
const settings = readHostMetricsSettings(bag);
|
||||||
const { excludedMounts, monitoredMounts } = settings;
|
const { excludedMounts, monitoredMounts } = settings;
|
||||||
const api = usePluginApi();
|
const defaultsEditor = useIsDefaultsEditor();
|
||||||
|
|
||||||
const patch = (values: Record<string, unknown>) =>
|
const patch = (values: Record<string, unknown>) =>
|
||||||
updateForm((current) => {
|
updateForm((current) => {
|
||||||
@@ -52,23 +49,6 @@ export function HostStatsTab({
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
// A new host starts with the admin's default for metrics.
|
|
||||||
useEffect(() => {
|
|
||||||
if (host || bag?.metricsEnabled !== undefined) return;
|
|
||||||
let cancelled = false;
|
|
||||||
api
|
|
||||||
.get<{ enabledForNewHosts: boolean }>("/defaults")
|
|
||||||
.then(({ data }) => {
|
|
||||||
if (!cancelled && data.enabledForNewHosts === false) {
|
|
||||||
patch({ metricsEnabled: false });
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(() => {});
|
|
||||||
return () => {
|
|
||||||
cancelled = true;
|
|
||||||
};
|
|
||||||
}, [host]);
|
|
||||||
|
|
||||||
const addExcludedMount = () => {
|
const addExcludedMount = () => {
|
||||||
const value = newMount.trim();
|
const value = newMount.trim();
|
||||||
if (!value || excludedMounts.includes(value)) {
|
if (!value || excludedMounts.includes(value)) {
|
||||||
@@ -102,6 +82,7 @@ export function HostStatsTab({
|
|||||||
<SettingRow
|
<SettingRow
|
||||||
label={t("hosts.enableMetricsLabel")}
|
label={t("hosts.enableMetricsLabel")}
|
||||||
description={t("hosts.enableMetricsDesc")}
|
description={t("hosts.enableMetricsDesc")}
|
||||||
|
defaultKey="metricsEnabled"
|
||||||
>
|
>
|
||||||
<FakeSwitch
|
<FakeSwitch
|
||||||
checked={settings.metricsEnabled}
|
checked={settings.metricsEnabled}
|
||||||
@@ -112,6 +93,7 @@ export function HostStatsTab({
|
|||||||
<SettingRow
|
<SettingRow
|
||||||
label={t("hosts.useGlobalMetrics")}
|
label={t("hosts.useGlobalMetrics")}
|
||||||
description={t("hosts.useGlobalMetricsDesc")}
|
description={t("hosts.useGlobalMetricsDesc")}
|
||||||
|
defaultKey="metricsInterval"
|
||||||
>
|
>
|
||||||
<FakeSwitch
|
<FakeSwitch
|
||||||
checked={settings.metricsInterval === null}
|
checked={settings.metricsInterval === null}
|
||||||
@@ -141,8 +123,9 @@ export function HostStatsTab({
|
|||||||
icon={<HardDrive className="size-3.5" />}
|
icon={<HardDrive className="size-3.5" />}
|
||||||
>
|
>
|
||||||
<div className="flex flex-col gap-3 py-3">
|
<div className="flex flex-col gap-3 py-3">
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||||
{t("hosts.monitoredMountsDesc")}
|
{t("hosts.monitoredMountsDesc")}
|
||||||
|
<HostDefaultBadge settingKey="monitoredMounts" />
|
||||||
</p>
|
</p>
|
||||||
<div className="grid grid-cols-[1fr_0.7fr_auto] gap-2">
|
<div className="grid grid-cols-[1fr_0.7fr_auto] gap-2">
|
||||||
<Input
|
<Input
|
||||||
@@ -212,8 +195,9 @@ export function HostStatsTab({
|
|||||||
icon={<HardDrive className="size-3.5" />}
|
icon={<HardDrive className="size-3.5" />}
|
||||||
>
|
>
|
||||||
<div className="flex flex-col gap-3 py-3">
|
<div className="flex flex-col gap-3 py-3">
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||||
{t("hosts.excludedMountsDesc")}
|
{t("hosts.excludedMountsDesc")}
|
||||||
|
<HostDefaultBadge settingKey="excludedMounts" />
|
||||||
</p>
|
</p>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Input
|
<Input
|
||||||
@@ -265,16 +249,18 @@ export function HostStatsTab({
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</SectionCard>
|
</SectionCard>
|
||||||
<SectionCard
|
{!defaultsEditor && (
|
||||||
title={t("hosts.visibleWidgets")}
|
<SectionCard
|
||||||
icon={<LayoutDashboard className="size-3.5" />}
|
title={t("hosts.visibleWidgets")}
|
||||||
>
|
icon={<LayoutDashboard className="size-3.5" />}
|
||||||
<div className="flex flex-col gap-2 py-3">
|
>
|
||||||
<p className="text-xs text-muted-foreground">
|
<div className="flex flex-col gap-2 py-3">
|
||||||
{t("hosts.widgetsMovedToHostMetrics")}
|
<p className="text-xs text-muted-foreground">
|
||||||
</p>
|
{t("hosts.widgetsMovedToHostMetrics")}
|
||||||
</div>
|
</p>
|
||||||
</SectionCard>
|
</div>
|
||||||
|
</SectionCard>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -96,6 +96,7 @@ export async function activate(app: TermixApp): Promise<void> {
|
|||||||
titleKey: "hosts.tabHostMetrics",
|
titleKey: "hosts.tabHostMetrics",
|
||||||
icon: Activity,
|
icon: Activity,
|
||||||
order: 70,
|
order: 70,
|
||||||
|
defaults: true,
|
||||||
component: MetricsHostSection,
|
component: MetricsHostSection,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -35,19 +35,6 @@ describe("permissions", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("defaults", () => {
|
|
||||||
it("tells any user whether new hosts start with metrics on", async () => {
|
|
||||||
server = await startServer();
|
|
||||||
expect((await server.request("GET", "/defaults")).body).toEqual({
|
|
||||||
enabledForNewHosts: true,
|
|
||||||
});
|
|
||||||
await server.mock.ctx.settings.set("enabledForNewHosts", false);
|
|
||||||
expect((await server.request("GET", "/defaults")).body).toEqual({
|
|
||||||
enabledForNewHosts: false,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("metrics", () => {
|
describe("metrics", () => {
|
||||||
it("answers 404 until a sample exists", async () => {
|
it("answers 404 until a sample exists", async () => {
|
||||||
server = await startServer();
|
server = await startServer();
|
||||||
|
|||||||
@@ -39,22 +39,26 @@
|
|||||||
{
|
{
|
||||||
"key": "enableProxmox",
|
"key": "enableProxmox",
|
||||||
"type": "custom",
|
"type": "custom",
|
||||||
"component": "proxmoxHostSettings"
|
"component": "proxmoxHostSettings",
|
||||||
|
"defaultable": false
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"key": "proxmoxConfig",
|
"key": "proxmoxConfig",
|
||||||
"type": "custom",
|
"type": "custom",
|
||||||
"component": "proxmoxHostSettings"
|
"component": "proxmoxHostSettings",
|
||||||
|
"defaultable": false
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"key": "enableProxmoxStats",
|
"key": "enableProxmoxStats",
|
||||||
"type": "custom",
|
"type": "custom",
|
||||||
"component": "proxmoxHostSettings"
|
"component": "proxmoxHostSettings",
|
||||||
|
"defaultable": false
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"key": "proxmoxStatsConfig",
|
"key": "proxmoxStatsConfig",
|
||||||
"type": "custom",
|
"type": "custom",
|
||||||
"component": "proxmoxHostSettings"
|
"component": "proxmoxHostSettings",
|
||||||
|
"defaultable": false
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -323,7 +323,9 @@
|
|||||||
"enableToolbar": {
|
"enableToolbar": {
|
||||||
"label": "Remote desktop toolbar",
|
"label": "Remote desktop toolbar",
|
||||||
"description": "Show the toolbar in RDP, VNC and Telnet sessions."
|
"description": "Show the toolbar in RDP, VNC and Telnet sessions."
|
||||||
}
|
},
|
||||||
|
"displayGroup": "Display",
|
||||||
|
"displayDefaultsTab": "Remote Desktop"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"palette": {
|
"palette": {
|
||||||
|
|||||||
@@ -150,7 +150,8 @@
|
|||||||
"labelKey": "settings.user.colorDepth32"
|
"labelKey": "settings.user.colorDepth32"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"default": "inherit"
|
"default": "inherit",
|
||||||
|
"hidden": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"key": "resizeMethod",
|
"key": "resizeMethod",
|
||||||
@@ -172,7 +173,8 @@
|
|||||||
"labelKey": "settings.user.resizeReconnect"
|
"labelKey": "settings.user.resizeReconnect"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"default": "inherit"
|
"default": "inherit",
|
||||||
|
"hidden": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"key": "forceLossless",
|
"key": "forceLossless",
|
||||||
@@ -194,7 +196,8 @@
|
|||||||
"labelKey": "settings.user.off"
|
"labelKey": "settings.user.off"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"default": "inherit"
|
"default": "inherit",
|
||||||
|
"hidden": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"key": "enableWallpaper",
|
"key": "enableWallpaper",
|
||||||
@@ -216,7 +219,8 @@
|
|||||||
"labelKey": "settings.user.off"
|
"labelKey": "settings.user.off"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"default": "inherit"
|
"default": "inherit",
|
||||||
|
"hidden": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"key": "enableFontSmoothing",
|
"key": "enableFontSmoothing",
|
||||||
@@ -238,7 +242,8 @@
|
|||||||
"labelKey": "settings.user.off"
|
"labelKey": "settings.user.off"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"default": "inherit"
|
"default": "inherit",
|
||||||
|
"hidden": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"key": "enableDesktopComposition",
|
"key": "enableDesktopComposition",
|
||||||
@@ -260,7 +265,8 @@
|
|||||||
"labelKey": "settings.user.off"
|
"labelKey": "settings.user.off"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"default": "inherit"
|
"default": "inherit",
|
||||||
|
"hidden": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"key": "disableAudio",
|
"key": "disableAudio",
|
||||||
@@ -282,7 +288,8 @@
|
|||||||
"labelKey": "settings.user.off"
|
"labelKey": "settings.user.off"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"default": "inherit"
|
"default": "inherit",
|
||||||
|
"hidden": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"key": "enablePrinting",
|
"key": "enablePrinting",
|
||||||
@@ -304,7 +311,8 @@
|
|||||||
"labelKey": "settings.user.off"
|
"labelKey": "settings.user.off"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"default": "inherit"
|
"default": "inherit",
|
||||||
|
"hidden": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"key": "enableDrive",
|
"key": "enableDrive",
|
||||||
@@ -326,7 +334,8 @@
|
|||||||
"labelKey": "settings.user.off"
|
"labelKey": "settings.user.off"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"default": "inherit"
|
"default": "inherit",
|
||||||
|
"hidden": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"key": "disableCopy",
|
"key": "disableCopy",
|
||||||
@@ -348,7 +357,8 @@
|
|||||||
"labelKey": "settings.user.off"
|
"labelKey": "settings.user.off"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"default": "inherit"
|
"default": "inherit",
|
||||||
|
"hidden": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"key": "disablePaste",
|
"key": "disablePaste",
|
||||||
@@ -370,7 +380,8 @@
|
|||||||
"labelKey": "settings.user.off"
|
"labelKey": "settings.user.off"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"default": "inherit"
|
"default": "inherit",
|
||||||
|
"hidden": true
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"host": {
|
"host": {
|
||||||
@@ -380,21 +391,24 @@
|
|||||||
"type": "boolean",
|
"type": "boolean",
|
||||||
"labelKey": "settings.host.enableRdp.label",
|
"labelKey": "settings.host.enableRdp.label",
|
||||||
"default": false,
|
"default": false,
|
||||||
"hidden": true
|
"hidden": true,
|
||||||
|
"defaultable": false
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"key": "enableVnc",
|
"key": "enableVnc",
|
||||||
"type": "boolean",
|
"type": "boolean",
|
||||||
"labelKey": "settings.host.enableVnc.label",
|
"labelKey": "settings.host.enableVnc.label",
|
||||||
"default": false,
|
"default": false,
|
||||||
"hidden": true
|
"hidden": true,
|
||||||
|
"defaultable": false
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"key": "enableTelnet",
|
"key": "enableTelnet",
|
||||||
"type": "boolean",
|
"type": "boolean",
|
||||||
"labelKey": "settings.host.enableTelnet.label",
|
"labelKey": "settings.host.enableTelnet.label",
|
||||||
"default": false,
|
"default": false,
|
||||||
"hidden": true
|
"hidden": true,
|
||||||
|
"defaultable": false
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"key": "rdpPort",
|
"key": "rdpPort",
|
||||||
@@ -403,7 +417,8 @@
|
|||||||
"min": 1,
|
"min": 1,
|
||||||
"max": 65535,
|
"max": 65535,
|
||||||
"default": 3389,
|
"default": 3389,
|
||||||
"hidden": true
|
"hidden": true,
|
||||||
|
"defaultable": false
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"key": "vncPort",
|
"key": "vncPort",
|
||||||
@@ -412,7 +427,8 @@
|
|||||||
"min": 1,
|
"min": 1,
|
||||||
"max": 65535,
|
"max": 65535,
|
||||||
"default": 5900,
|
"default": 5900,
|
||||||
"hidden": true
|
"hidden": true,
|
||||||
|
"defaultable": false
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"key": "telnetPort",
|
"key": "telnetPort",
|
||||||
@@ -421,7 +437,8 @@
|
|||||||
"min": 1,
|
"min": 1,
|
||||||
"max": 65535,
|
"max": 65535,
|
||||||
"default": 23,
|
"default": 23,
|
||||||
"hidden": true
|
"hidden": true,
|
||||||
|
"defaultable": false
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"key": "rdpSecurity",
|
"key": "rdpSecurity",
|
||||||
@@ -444,7 +461,8 @@
|
|||||||
"default": {},
|
"default": {},
|
||||||
"hidden": true,
|
"hidden": true,
|
||||||
"shareRead": "edit",
|
"shareRead": "edit",
|
||||||
"secretKeys": ["gateway-password"]
|
"secretKeys": ["gateway-password"],
|
||||||
|
"defaultable": false
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"key": "enableToolbar",
|
"key": "enableToolbar",
|
||||||
@@ -453,6 +471,263 @@
|
|||||||
"descriptionKey": "settings.host.enableToolbar.description",
|
"descriptionKey": "settings.host.enableToolbar.description",
|
||||||
"default": true,
|
"default": true,
|
||||||
"hidden": true
|
"hidden": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "colorDepth",
|
||||||
|
"type": "select",
|
||||||
|
"labelKey": "settings.user.colorDepth.label",
|
||||||
|
"descriptionKey": "settings.user.colorDepth.description",
|
||||||
|
"group": "settings.host.displayGroup",
|
||||||
|
"options": [
|
||||||
|
{
|
||||||
|
"value": "inherit",
|
||||||
|
"labelKey": "settings.user.inherit"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"value": "16",
|
||||||
|
"labelKey": "settings.user.colorDepth16"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"value": "24",
|
||||||
|
"labelKey": "settings.user.colorDepth24"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"value": "32",
|
||||||
|
"labelKey": "settings.user.colorDepth32"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"default": "inherit",
|
||||||
|
"personal": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "resizeMethod",
|
||||||
|
"type": "select",
|
||||||
|
"labelKey": "settings.user.resizeMethod.label",
|
||||||
|
"descriptionKey": "settings.user.resizeMethod.description",
|
||||||
|
"group": "settings.host.displayGroup",
|
||||||
|
"options": [
|
||||||
|
{
|
||||||
|
"value": "inherit",
|
||||||
|
"labelKey": "settings.user.inherit"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"value": "display-update",
|
||||||
|
"labelKey": "settings.user.resizeDisplayUpdate"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"value": "reconnect",
|
||||||
|
"labelKey": "settings.user.resizeReconnect"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"default": "inherit",
|
||||||
|
"personal": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "forceLossless",
|
||||||
|
"type": "select",
|
||||||
|
"labelKey": "settings.user.forceLossless.label",
|
||||||
|
"descriptionKey": "settings.user.forceLossless.description",
|
||||||
|
"group": "settings.host.displayGroup",
|
||||||
|
"options": [
|
||||||
|
{
|
||||||
|
"value": "inherit",
|
||||||
|
"labelKey": "settings.user.inherit"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"value": "on",
|
||||||
|
"labelKey": "settings.user.on"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"value": "off",
|
||||||
|
"labelKey": "settings.user.off"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"default": "inherit",
|
||||||
|
"personal": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "enableWallpaper",
|
||||||
|
"type": "select",
|
||||||
|
"labelKey": "settings.user.enableWallpaper.label",
|
||||||
|
"descriptionKey": "settings.user.enableWallpaper.description",
|
||||||
|
"group": "settings.host.displayGroup",
|
||||||
|
"options": [
|
||||||
|
{
|
||||||
|
"value": "inherit",
|
||||||
|
"labelKey": "settings.user.inherit"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"value": "on",
|
||||||
|
"labelKey": "settings.user.on"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"value": "off",
|
||||||
|
"labelKey": "settings.user.off"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"default": "inherit",
|
||||||
|
"personal": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "enableFontSmoothing",
|
||||||
|
"type": "select",
|
||||||
|
"labelKey": "settings.user.enableFontSmoothing.label",
|
||||||
|
"descriptionKey": "settings.user.enableFontSmoothing.description",
|
||||||
|
"group": "settings.host.displayGroup",
|
||||||
|
"options": [
|
||||||
|
{
|
||||||
|
"value": "inherit",
|
||||||
|
"labelKey": "settings.user.inherit"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"value": "on",
|
||||||
|
"labelKey": "settings.user.on"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"value": "off",
|
||||||
|
"labelKey": "settings.user.off"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"default": "inherit",
|
||||||
|
"personal": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "enableDesktopComposition",
|
||||||
|
"type": "select",
|
||||||
|
"labelKey": "settings.user.enableDesktopComposition.label",
|
||||||
|
"descriptionKey": "settings.user.enableDesktopComposition.description",
|
||||||
|
"group": "settings.host.displayGroup",
|
||||||
|
"options": [
|
||||||
|
{
|
||||||
|
"value": "inherit",
|
||||||
|
"labelKey": "settings.user.inherit"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"value": "on",
|
||||||
|
"labelKey": "settings.user.on"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"value": "off",
|
||||||
|
"labelKey": "settings.user.off"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"default": "inherit",
|
||||||
|
"personal": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "disableAudio",
|
||||||
|
"type": "select",
|
||||||
|
"labelKey": "settings.user.disableAudio.label",
|
||||||
|
"descriptionKey": "settings.user.disableAudio.description",
|
||||||
|
"group": "settings.host.displayGroup",
|
||||||
|
"options": [
|
||||||
|
{
|
||||||
|
"value": "inherit",
|
||||||
|
"labelKey": "settings.user.inherit"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"value": "on",
|
||||||
|
"labelKey": "settings.user.on"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"value": "off",
|
||||||
|
"labelKey": "settings.user.off"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"default": "inherit",
|
||||||
|
"personal": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "enablePrinting",
|
||||||
|
"type": "select",
|
||||||
|
"labelKey": "settings.user.enablePrinting.label",
|
||||||
|
"descriptionKey": "settings.user.enablePrinting.description",
|
||||||
|
"group": "settings.host.displayGroup",
|
||||||
|
"options": [
|
||||||
|
{
|
||||||
|
"value": "inherit",
|
||||||
|
"labelKey": "settings.user.inherit"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"value": "on",
|
||||||
|
"labelKey": "settings.user.on"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"value": "off",
|
||||||
|
"labelKey": "settings.user.off"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"default": "inherit",
|
||||||
|
"personal": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "enableDrive",
|
||||||
|
"type": "select",
|
||||||
|
"labelKey": "settings.user.enableDrive.label",
|
||||||
|
"descriptionKey": "settings.user.enableDrive.description",
|
||||||
|
"group": "settings.host.displayGroup",
|
||||||
|
"options": [
|
||||||
|
{
|
||||||
|
"value": "inherit",
|
||||||
|
"labelKey": "settings.user.inherit"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"value": "on",
|
||||||
|
"labelKey": "settings.user.on"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"value": "off",
|
||||||
|
"labelKey": "settings.user.off"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"default": "inherit",
|
||||||
|
"personal": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "disableCopy",
|
||||||
|
"type": "select",
|
||||||
|
"labelKey": "settings.user.disableCopy.label",
|
||||||
|
"descriptionKey": "settings.user.disableCopy.description",
|
||||||
|
"group": "settings.host.displayGroup",
|
||||||
|
"options": [
|
||||||
|
{
|
||||||
|
"value": "inherit",
|
||||||
|
"labelKey": "settings.user.inherit"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"value": "on",
|
||||||
|
"labelKey": "settings.user.on"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"value": "off",
|
||||||
|
"labelKey": "settings.user.off"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"default": "inherit",
|
||||||
|
"personal": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "disablePaste",
|
||||||
|
"type": "select",
|
||||||
|
"labelKey": "settings.user.disablePaste.label",
|
||||||
|
"descriptionKey": "settings.user.disablePaste.description",
|
||||||
|
"group": "settings.host.displayGroup",
|
||||||
|
"options": [
|
||||||
|
{
|
||||||
|
"value": "inherit",
|
||||||
|
"labelKey": "settings.user.inherit"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"value": "on",
|
||||||
|
"labelKey": "settings.user.on"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"value": "off",
|
||||||
|
"labelKey": "settings.user.off"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"default": "inherit",
|
||||||
|
"personal": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -177,7 +177,7 @@ export function hostPayloadLegacy(
|
|||||||
return Object.keys(out).length > 0 ? out : null;
|
return Object.keys(out).length > 0 ? out : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** User setting key to the guacd parameter it sets. */
|
/** Display field key to the guacd parameter it sets. */
|
||||||
const USER_DEFAULT_PARAMS: Record<string, string> = {
|
const USER_DEFAULT_PARAMS: Record<string, string> = {
|
||||||
colorDepth: "color-depth",
|
colorDepth: "color-depth",
|
||||||
resizeMethod: "resize-method",
|
resizeMethod: "resize-method",
|
||||||
@@ -193,8 +193,8 @@ const USER_DEFAULT_PARAMS: Record<string, string> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The user's RDP defaults as guacd parameters. "inherit" leaves a parameter
|
* Display settings as guacd parameters. "inherit" leaves a parameter to the
|
||||||
* to the host and guacd; on and off become booleans.
|
* host and guacd; on and off become booleans.
|
||||||
*/
|
*/
|
||||||
export function userDefaultParams(
|
export function userDefaultParams(
|
||||||
values: Record<string, unknown>,
|
values: Record<string, unknown>,
|
||||||
@@ -215,9 +215,22 @@ export function userDefaultParams(
|
|||||||
return params;
|
return params;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function readUserDefaults(
|
/**
|
||||||
|
* The display settings a session runs with, as guacd parameters: the host's
|
||||||
|
* as this user sees them (their own defaults while the host follows its
|
||||||
|
* defaults), or with no host, this user's defaults.
|
||||||
|
*/
|
||||||
|
export async function readDisplayDefaults(
|
||||||
ctx: PluginContext,
|
ctx: PluginContext,
|
||||||
userId: string,
|
userId: string,
|
||||||
|
hostId?: number,
|
||||||
): Promise<Record<string, unknown>> {
|
): Promise<Record<string, unknown>> {
|
||||||
return userDefaultParams(await ctx.settings.getAll("user", userId));
|
const values: Record<string, unknown> = {};
|
||||||
|
for (const key of Object.keys(USER_DEFAULT_PARAMS)) {
|
||||||
|
values[key] =
|
||||||
|
hostId === undefined
|
||||||
|
? await ctx.settings.getHostDefault(userId, key)
|
||||||
|
: await ctx.settings.getHostFor(hostId, userId, key);
|
||||||
|
}
|
||||||
|
return userDefaultParams(values);
|
||||||
}
|
}
|
||||||
@@ -23,7 +23,7 @@ import {
|
|||||||
PORT_KEY,
|
PORT_KEY,
|
||||||
isRemoteProtocol,
|
isRemoteProtocol,
|
||||||
readHostSettings,
|
readHostSettings,
|
||||||
readUserDefaults,
|
readDisplayDefaults,
|
||||||
type RemoteProtocol,
|
type RemoteProtocol,
|
||||||
} from "./host-settings.js";
|
} from "./host-settings.js";
|
||||||
import { errorMessage, type RemoteDesktopLogger } from "./log.js";
|
import { errorMessage, type RemoteDesktopLogger } from "./log.js";
|
||||||
@@ -203,7 +203,7 @@ export function registerRoutes(router: Router, deps: RouteDeps): void {
|
|||||||
const userId = ctx.currentActor();
|
const userId = ctx.currentActor();
|
||||||
if (type === "rdp" && userId) {
|
if (type === "rdp" && userId) {
|
||||||
for (const [key, value] of Object.entries(
|
for (const [key, value] of Object.entries(
|
||||||
await readUserDefaults(ctx, userId),
|
await readDisplayDefaults(ctx, userId),
|
||||||
)) {
|
)) {
|
||||||
if (isServerOwnedSetting(key)) continue;
|
if (isServerOwnedSetting(key)) continue;
|
||||||
if (options[key] === undefined) options[key] = value;
|
if (options[key] === undefined) options[key] = value;
|
||||||
@@ -319,9 +319,11 @@ export function registerRoutes(router: Router, deps: RouteDeps): void {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// The user's RDP defaults sit under whatever the host sets itself.
|
// The display settings sit under whatever the host's guacd settings say.
|
||||||
const { config, guacdOverrides } = cleanGuacConfig({
|
const { config, guacdOverrides } = cleanGuacConfig({
|
||||||
...(protocol === "rdp" ? await readUserDefaults(ctx, userId) : {}),
|
...(protocol === "rdp"
|
||||||
|
? await readDisplayDefaults(ctx, userId, hostId)
|
||||||
|
: {}),
|
||||||
...settings.guacamoleConfig,
|
...settings.guacamoleConfig,
|
||||||
});
|
});
|
||||||
let guacConfig = config;
|
let guacConfig = config;
|
||||||
@@ -545,7 +547,9 @@ export function registerRoutes(router: Router, deps: RouteDeps): void {
|
|||||||
.json({ error: "Protocol is not enabled for this host" });
|
.json({ error: "Protocol is not enabled for this host" });
|
||||||
}
|
}
|
||||||
const { guacdOverrides } = cleanGuacConfig({
|
const { guacdOverrides } = cleanGuacConfig({
|
||||||
...(protocol === "rdp" ? await readUserDefaults(ctx, userId) : {}),
|
...(protocol === "rdp"
|
||||||
|
? await readDisplayDefaults(ctx, userId, hostId)
|
||||||
|
: {}),
|
||||||
...settings.guacamoleConfig,
|
...settings.guacamoleConfig,
|
||||||
});
|
});
|
||||||
guacd = {
|
guacd = {
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
} from "@termix/plugin-sdk/frontend";
|
} from "@termix/plugin-sdk/frontend";
|
||||||
import {
|
import {
|
||||||
FakeSwitch,
|
FakeSwitch,
|
||||||
|
HostFeatureFields,
|
||||||
SectionCard,
|
SectionCard,
|
||||||
SettingRow,
|
SettingRow,
|
||||||
isElectron,
|
isElectron,
|
||||||
@@ -166,6 +167,7 @@ function ToolbarCard({
|
|||||||
<SettingRow
|
<SettingRow
|
||||||
label={t("settings.host.enableToolbar.label")}
|
label={t("settings.host.enableToolbar.label")}
|
||||||
description={t("settings.host.enableToolbar.description")}
|
description={t("settings.host.enableToolbar.description")}
|
||||||
|
defaultKey="enableToolbar"
|
||||||
>
|
>
|
||||||
<FakeSwitch
|
<FakeSwitch
|
||||||
checked={form.enableToolbar}
|
checked={form.enableToolbar}
|
||||||
@@ -254,6 +256,27 @@ function TelnetSection(props: HostEditorSectionProps) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The defaults editor's remote desktop section: the display settings and the
|
||||||
|
* toolbar, which every host follows unless its own guacd settings say
|
||||||
|
* otherwise. A host edits these in its protocol tabs instead.
|
||||||
|
*/
|
||||||
|
function DisplayDefaultsSection(props: HostEditorSectionProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { form, setField } = remoteDesktopForm(props);
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<SectionCard
|
||||||
|
title={t("settings.host.displayGroup")}
|
||||||
|
icon={<Monitor className="size-3.5" />}
|
||||||
|
>
|
||||||
|
<HostFeatureFields form={props.form} updateForm={props.updateForm} />
|
||||||
|
</SectionCard>
|
||||||
|
<ToolbarCard form={form} setField={setField} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const SECTIONS: Record<Protocol, ComponentType<HostEditorSectionProps>> = {
|
const SECTIONS: Record<Protocol, ComponentType<HostEditorSectionProps>> = {
|
||||||
rdp: RdpSection,
|
rdp: RdpSection,
|
||||||
vnc: VncSection,
|
vnc: VncSection,
|
||||||
@@ -343,6 +366,16 @@ function registerHostSurfaces(app: TermixApp): void {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
app.registerHostEditorSection({
|
||||||
|
id: "remote-desktop-display",
|
||||||
|
group: "top",
|
||||||
|
titleKey: "settings.host.displayDefaultsTab",
|
||||||
|
icon: Monitor,
|
||||||
|
order: 40,
|
||||||
|
defaults: "only",
|
||||||
|
component: DisplayDefaultsSection,
|
||||||
|
});
|
||||||
|
|
||||||
if (isElectron()) registerNativeRdp(app);
|
if (isElectron()) registerNativeRdp(app);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -149,14 +149,15 @@ describe("POST /connect-host/:hostId", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("puts the user's RDP defaults under the host's own values", async () => {
|
it("puts the host's display settings under its own guacd settings", async () => {
|
||||||
server = await start({ protocolTargets: { "7:rdp": target() } });
|
server = await start({ protocolTargets: { "7:rdp": target() } });
|
||||||
await server.mock.ctx.settings.setHost(7, "enableRdp", true);
|
await server.mock.ctx.settings.setHost(7, "enableRdp", true);
|
||||||
await server.mock.ctx.settings.setHost(7, "guacamoleConfig", {
|
await server.mock.ctx.settings.setHost(7, "guacamoleConfig", {
|
||||||
"color-depth": 32,
|
"color-depth": 32,
|
||||||
});
|
});
|
||||||
await server.mock.ctx.settings.setUser("user-1", "colorDepth", "16");
|
// What the host follows from its defaults.
|
||||||
await server.mock.ctx.settings.setUser("user-1", "disableCopy", "on");
|
await server.mock.ctx.settings.setHost(7, "colorDepth", "16");
|
||||||
|
await server.mock.ctx.settings.setHost(7, "disableCopy", "on");
|
||||||
|
|
||||||
const response = await server.request("POST", "/connect-host/7", {
|
const response = await server.request("POST", "/connect-host/7", {
|
||||||
protocol: "rdp",
|
protocol: "rdp",
|
||||||
|
|||||||
@@ -108,7 +108,8 @@
|
|||||||
"descriptionKey": "host.startupDescription",
|
"descriptionKey": "host.startupDescription",
|
||||||
"hidden": true,
|
"hidden": true,
|
||||||
"min": 1,
|
"min": 1,
|
||||||
"shareRead": "view"
|
"shareRead": "view",
|
||||||
|
"defaultLevels": ["user", "folder"]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"key": "quickActions",
|
"key": "quickActions",
|
||||||
@@ -117,7 +118,8 @@
|
|||||||
"descriptionKey": "host.quickActionsDescription",
|
"descriptionKey": "host.quickActionsDescription",
|
||||||
"hidden": true,
|
"hidden": true,
|
||||||
"default": [],
|
"default": [],
|
||||||
"shareRead": "view"
|
"shareRead": "view",
|
||||||
|
"defaultLevels": ["user", "folder"]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,14 @@ import {
|
|||||||
useTranslation,
|
useTranslation,
|
||||||
type HostEditorSectionProps,
|
type HostEditorSectionProps,
|
||||||
} from "@termix/plugin-sdk/frontend";
|
} from "@termix/plugin-sdk/frontend";
|
||||||
import { Button, Input, SectionCard, Select2 } from "@termix/plugin-sdk/ui";
|
import {
|
||||||
|
Button,
|
||||||
|
HostDefaultBadge,
|
||||||
|
HostDefaultField,
|
||||||
|
Input,
|
||||||
|
SectionCard,
|
||||||
|
Select2,
|
||||||
|
} from "@termix/plugin-sdk/ui";
|
||||||
import { PLUGIN_ID, readStartupSnippetId } from "../shared/host-settings.js";
|
import { PLUGIN_ID, readStartupSnippetId } from "../shared/host-settings.js";
|
||||||
import { useSnippetOptions } from "./use-snippet-options";
|
import { useSnippetOptions } from "./use-snippet-options";
|
||||||
|
|
||||||
@@ -62,110 +69,119 @@ export function HostSnippetsSection({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<SectionCard
|
<HostDefaultField settingKey="startupSnippetId">
|
||||||
title={t("host.startupTitle")}
|
<SectionCard
|
||||||
icon={<Play className="size-3.5" />}
|
title={t("host.startupTitle")}
|
||||||
>
|
icon={<Play className="size-3.5" />}
|
||||||
<div className="flex flex-col gap-1.5 py-3">
|
>
|
||||||
<p className="text-xs text-muted-foreground">
|
<div className="flex flex-col gap-1.5 py-3">
|
||||||
{t("host.startupDescription")}
|
<p className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||||
</p>
|
{t("host.startupDescription")}
|
||||||
<Select2
|
<HostDefaultBadge settingKey="startupSnippetId" />
|
||||||
aria-label={t("host.startupTitle")}
|
</p>
|
||||||
value={startupSnippetId ?? ""}
|
<Select2
|
||||||
onChange={(e) =>
|
aria-label={t("host.startupTitle")}
|
||||||
write({
|
value={startupSnippetId ?? ""}
|
||||||
startupSnippetId: e.target.value
|
onChange={(e) =>
|
||||||
? Number(e.target.value)
|
write({
|
||||||
: null,
|
startupSnippetId: e.target.value
|
||||||
})
|
? Number(e.target.value)
|
||||||
}
|
: null,
|
||||||
className="flex h-9 w-full border border-border bg-background px-3 py-1 text-xs outline-none focus:ring-1 focus:ring-ring"
|
})
|
||||||
>
|
}
|
||||||
<option value="">{t("host.none")}</option>
|
className="flex h-9 w-full border border-border bg-background px-3 py-1 text-xs outline-none focus:ring-1 focus:ring-ring"
|
||||||
{options.map((snippet) => (
|
|
||||||
<option key={snippet.id} value={snippet.id}>
|
|
||||||
{snippet.name}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</Select2>
|
|
||||||
</div>
|
|
||||||
</SectionCard>
|
|
||||||
<SectionCard
|
|
||||||
title={t("host.quickActionsTitle")}
|
|
||||||
icon={<Zap className="size-3.5" />}
|
|
||||||
action={
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="h-6 text-[10px] px-2 border-accent-brand/40 text-accent-brand"
|
|
||||||
onClick={() =>
|
|
||||||
setQuickActions([...quickActions, { name: "", snippetId: null }])
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Plus className="size-3 mr-1" /> {t("host.addQuickAction")}
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<div className="flex flex-col gap-3 py-3">
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
{t("host.quickActionsDescription")}
|
|
||||||
</p>
|
|
||||||
{quickActions.length === 0 && (
|
|
||||||
<div className="flex flex-col items-center justify-center py-4 text-muted-foreground/40 gap-1.5">
|
|
||||||
<Zap className="size-6" />
|
|
||||||
<span className="text-xs">{t("host.noQuickActions")}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{quickActions.map((row, index) => (
|
|
||||||
<div
|
|
||||||
key={index}
|
|
||||||
className="flex items-center gap-2 p-2 bg-muted/20 border border-border group"
|
|
||||||
>
|
>
|
||||||
<Input
|
<option value="">{t("host.none")}</option>
|
||||||
className="h-7 text-xs flex-1"
|
{options.map((snippet) => (
|
||||||
placeholder={t("host.buttonLabel")}
|
<option key={snippet.id} value={snippet.id}>
|
||||||
value={row.name}
|
{snippet.name}
|
||||||
onChange={(e) => {
|
</option>
|
||||||
const next = [...quickActions];
|
))}
|
||||||
next[index] = { ...row, name: e.target.value };
|
</Select2>
|
||||||
setQuickActions(next);
|
</div>
|
||||||
}}
|
</SectionCard>
|
||||||
/>
|
</HostDefaultField>
|
||||||
<Select2
|
<HostDefaultField settingKey="quickActions">
|
||||||
aria-label={t("host.selectSnippet")}
|
<SectionCard
|
||||||
className="h-7 text-xs flex-1 border border-border bg-background px-2 outline-none focus:ring-1 focus:ring-ring"
|
title={t("host.quickActionsTitle")}
|
||||||
value={row.snippetId ?? ""}
|
icon={<Zap className="size-3.5" />}
|
||||||
onChange={(e) => {
|
action={
|
||||||
const next = [...quickActions];
|
<Button
|
||||||
next[index] = {
|
variant="outline"
|
||||||
...row,
|
size="sm"
|
||||||
snippetId: e.target.value ? Number(e.target.value) : null,
|
className="h-6 text-[10px] px-2 border-accent-brand/40 text-accent-brand"
|
||||||
};
|
onClick={() =>
|
||||||
setQuickActions(next);
|
setQuickActions([
|
||||||
}}
|
...quickActions,
|
||||||
|
{ name: "", snippetId: null },
|
||||||
|
])
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Plus className="size-3 mr-1" /> {t("host.addQuickAction")}
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-3 py-3">
|
||||||
|
<p className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||||
|
{t("host.quickActionsDescription")}
|
||||||
|
<HostDefaultBadge settingKey="quickActions" />
|
||||||
|
</p>
|
||||||
|
{quickActions.length === 0 && (
|
||||||
|
<div className="flex flex-col items-center justify-center py-4 text-muted-foreground/40 gap-1.5">
|
||||||
|
<Zap className="size-6" />
|
||||||
|
<span className="text-xs">{t("host.noQuickActions")}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{quickActions.map((row, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className="flex items-center gap-2 p-2 bg-muted/20 border border-border group"
|
||||||
>
|
>
|
||||||
<option value="">{t("host.selectSnippet")}</option>
|
<Input
|
||||||
{options.map((snippet) => (
|
className="h-7 text-xs flex-1"
|
||||||
<option key={snippet.id} value={snippet.id}>
|
placeholder={t("host.buttonLabel")}
|
||||||
{snippet.name}
|
value={row.name}
|
||||||
</option>
|
onChange={(e) => {
|
||||||
))}
|
const next = [...quickActions];
|
||||||
</Select2>
|
next[index] = { ...row, name: e.target.value };
|
||||||
<button
|
setQuickActions(next);
|
||||||
type="button"
|
}}
|
||||||
aria-label={t("host.removeQuickAction")}
|
/>
|
||||||
className="text-muted-foreground hover:text-destructive opacity-0 group-hover:opacity-100 transition-opacity"
|
<Select2
|
||||||
onClick={() =>
|
aria-label={t("host.selectSnippet")}
|
||||||
setQuickActions(quickActions.filter((_, i) => i !== index))
|
className="h-7 text-xs flex-1 border border-border bg-background px-2 outline-none focus:ring-1 focus:ring-ring"
|
||||||
}
|
value={row.snippetId ?? ""}
|
||||||
>
|
onChange={(e) => {
|
||||||
<Trash2 className="size-3.5" />
|
const next = [...quickActions];
|
||||||
</button>
|
next[index] = {
|
||||||
</div>
|
...row,
|
||||||
))}
|
snippetId: e.target.value ? Number(e.target.value) : null,
|
||||||
</div>
|
};
|
||||||
</SectionCard>
|
setQuickActions(next);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="">{t("host.selectSnippet")}</option>
|
||||||
|
{options.map((snippet) => (
|
||||||
|
<option key={snippet.id} value={snippet.id}>
|
||||||
|
{snippet.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</Select2>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={t("host.removeQuickAction")}
|
||||||
|
className="text-muted-foreground hover:text-destructive opacity-0 group-hover:opacity-100 transition-opacity"
|
||||||
|
onClick={() =>
|
||||||
|
setQuickActions(quickActions.filter((_, i) => i !== index))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Trash2 className="size-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</SectionCard>
|
||||||
|
</HostDefaultField>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -127,6 +127,7 @@ export function activate(app: TermixApp): void {
|
|||||||
titleKey: "host.tabTitle",
|
titleKey: "host.tabTitle",
|
||||||
icon: Play,
|
icon: Play,
|
||||||
order: 15,
|
order: 15,
|
||||||
|
defaults: true,
|
||||||
component: HostSnippetsSection,
|
component: HostSnippetsSection,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -461,13 +461,11 @@
|
|||||||
"lineHeight": "Line Height",
|
"lineHeight": "Line Height",
|
||||||
"lineHeightLabel": "Line Height",
|
"lineHeightLabel": "Line Height",
|
||||||
"linkClickBehaviorConfirm": "Show popup to open or copy",
|
"linkClickBehaviorConfirm": "Show popup to open or copy",
|
||||||
"linkClickBehaviorDefault": "Default (follow app setting)",
|
|
||||||
"linkClickBehaviorDesc": "Controls what happens when you click a link in the terminal. Use 'Default' to follow the app-level setting.",
|
"linkClickBehaviorDesc": "Controls what happens when you click a link in the terminal. Use 'Default' to follow the app-level setting.",
|
||||||
"linkClickBehaviorDirect": "Open directly",
|
"linkClickBehaviorDirect": "Open directly",
|
||||||
"linkClickBehaviorLabel": "Link Click Behavior",
|
"linkClickBehaviorLabel": "Link Click Behavior",
|
||||||
"loadingHost": "Loading host...",
|
"loadingHost": "Loading host...",
|
||||||
"localEchoAuto": "Automatic",
|
"localEchoAuto": "Automatic",
|
||||||
"localEchoDefault": "Use global setting",
|
|
||||||
"localEchoDesc": "Predict safe printable keystrokes locally on slow connections. Password prompts and control input are never echoed.",
|
"localEchoDesc": "Predict safe printable keystrokes locally on slow connections. Password prompts and control input are never echoed.",
|
||||||
"localEchoLabel": "Local Echo",
|
"localEchoLabel": "Local Echo",
|
||||||
"localEchoOff": "Off",
|
"localEchoOff": "Off",
|
||||||
@@ -512,8 +510,6 @@
|
|||||||
"themePreview": "Theme Preview",
|
"themePreview": "Theme Preview",
|
||||||
"useSSHTitleDesc": "Update the tab title from the shell's window title instead of the host name",
|
"useSSHTitleDesc": "Update the tab title from the shell's window title instead of the host name",
|
||||||
"useSSHTitleLabel": "Use SSH Window Title",
|
"useSSHTitleLabel": "Use SSH Window Title",
|
||||||
"useUserDefaults": "Use user defaults",
|
|
||||||
"useUserTerminalDefaultsDesc": "Keep this host in step with the terminal defaults in your profile.",
|
|
||||||
"enableTunnel": "Enable Tunnel",
|
"enableTunnel": "Enable Tunnel",
|
||||||
"enableFileManager": "Enable File Manager",
|
"enableFileManager": "Enable File Manager",
|
||||||
"enableDocker": "Enable Docker",
|
"enableDocker": "Enable Docker",
|
||||||
@@ -571,19 +567,6 @@
|
|||||||
"startRecording": "Start Recording",
|
"startRecording": "Start Recording",
|
||||||
"stopRecording": "Stop Recording"
|
"stopRecording": "Stop Recording"
|
||||||
},
|
},
|
||||||
"terminalDefaults": {
|
|
||||||
"clearAll": "Clear All",
|
|
||||||
"description": "Set values that every host inherits unless it overrides them.",
|
|
||||||
"dialogDescription": "These apply to hosts that leave a setting unset. Anything set on a host wins.",
|
|
||||||
"inherit": "Inherit",
|
|
||||||
"manage": "Manage",
|
|
||||||
"off": "Off",
|
|
||||||
"on": "On",
|
|
||||||
"savedThemeLabel": "Apply Saved Theme",
|
|
||||||
"savedThemePlaceholder": "Choose a saved theme",
|
|
||||||
"title": "Connection Defaults",
|
|
||||||
"apply": "Apply"
|
|
||||||
},
|
|
||||||
"keybindings": {
|
"keybindings": {
|
||||||
"copy": "Copy selection",
|
"copy": "Copy selection",
|
||||||
"paste": "Paste",
|
"paste": "Paste",
|
||||||
|
|||||||
@@ -151,21 +151,24 @@
|
|||||||
"type": "boolean",
|
"type": "boolean",
|
||||||
"labelKey": "settings.admin.commandHistoryForNewHosts.label",
|
"labelKey": "settings.admin.commandHistoryForNewHosts.label",
|
||||||
"descriptionKey": "settings.admin.commandHistoryForNewHosts.description",
|
"descriptionKey": "settings.admin.commandHistoryForNewHosts.description",
|
||||||
"default": true
|
"default": true,
|
||||||
|
"hidden": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"key": "newHostTheme",
|
"key": "newHostTheme",
|
||||||
"type": "custom",
|
"type": "custom",
|
||||||
"component": "newHostTheme",
|
"component": "newHostTheme",
|
||||||
"group": "settings.admin.group.newHosts",
|
"group": "settings.admin.group.newHosts",
|
||||||
"default": "termix"
|
"default": "termix",
|
||||||
|
"hidden": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"key": "newHostFontFamily",
|
"key": "newHostFontFamily",
|
||||||
"type": "custom",
|
"type": "custom",
|
||||||
"component": "newHostFontFamily",
|
"component": "newHostFontFamily",
|
||||||
"group": "settings.admin.group.newHosts",
|
"group": "settings.admin.group.newHosts",
|
||||||
"default": "Caskaydia Cove Nerd Font Mono"
|
"default": "Caskaydia Cove Nerd Font Mono",
|
||||||
|
"hidden": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"key": "newHostFontSize",
|
"key": "newHostFontSize",
|
||||||
@@ -175,7 +178,8 @@
|
|||||||
"group": "settings.admin.group.newHosts",
|
"group": "settings.admin.group.newHosts",
|
||||||
"min": 8,
|
"min": 8,
|
||||||
"max": 36,
|
"max": 36,
|
||||||
"default": 14
|
"default": 14,
|
||||||
|
"hidden": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"key": "newHostCursorStyle",
|
"key": "newHostCursorStyle",
|
||||||
@@ -196,14 +200,16 @@
|
|||||||
"value": "bar",
|
"value": "bar",
|
||||||
"labelKey": "hosts.cursorStyleBar"
|
"labelKey": "hosts.cursorStyleBar"
|
||||||
}
|
}
|
||||||
]
|
],
|
||||||
|
"hidden": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"key": "newHostCursorBlink",
|
"key": "newHostCursorBlink",
|
||||||
"type": "boolean",
|
"type": "boolean",
|
||||||
"labelKey": "settings.admin.newHostCursorBlink.label",
|
"labelKey": "settings.admin.newHostCursorBlink.label",
|
||||||
"group": "settings.admin.group.newHosts",
|
"group": "settings.admin.group.newHosts",
|
||||||
"default": true
|
"default": true,
|
||||||
|
"hidden": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"key": "newHostAutoTmux",
|
"key": "newHostAutoTmux",
|
||||||
@@ -211,7 +217,8 @@
|
|||||||
"labelKey": "settings.admin.newHostAutoTmux.label",
|
"labelKey": "settings.admin.newHostAutoTmux.label",
|
||||||
"descriptionKey": "settings.admin.newHostAutoTmux.description",
|
"descriptionKey": "settings.admin.newHostAutoTmux.description",
|
||||||
"group": "settings.admin.group.newHosts",
|
"group": "settings.admin.group.newHosts",
|
||||||
"default": false
|
"default": false,
|
||||||
|
"hidden": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"key": "touchInput",
|
"key": "touchInput",
|
||||||
@@ -399,15 +406,15 @@
|
|||||||
"type": "boolean",
|
"type": "boolean",
|
||||||
"labelKey": "settings.host.enableCommandHistory.label",
|
"labelKey": "settings.host.enableCommandHistory.label",
|
||||||
"descriptionKey": "settings.host.enableCommandHistory.description",
|
"descriptionKey": "settings.host.enableCommandHistory.description",
|
||||||
"default": true,
|
"default": true
|
||||||
"defaultFrom": "commandHistoryForNewHosts"
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"key": "inheritAppearance",
|
"key": "inheritAppearance",
|
||||||
"type": "boolean",
|
"type": "boolean",
|
||||||
"labelKey": "settings.host.inheritAppearance.label",
|
"labelKey": "settings.host.inheritAppearance.label",
|
||||||
"hidden": true,
|
"hidden": true,
|
||||||
"default": true
|
"default": true,
|
||||||
|
"defaultable": false
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"key": "theme",
|
"key": "theme",
|
||||||
@@ -415,7 +422,7 @@
|
|||||||
"labelKey": "settings.host.theme.label",
|
"labelKey": "settings.host.theme.label",
|
||||||
"hidden": true,
|
"hidden": true,
|
||||||
"default": "termix",
|
"default": "termix",
|
||||||
"defaultFrom": "newHostTheme"
|
"personal": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"key": "cursorBlink",
|
"key": "cursorBlink",
|
||||||
@@ -423,7 +430,7 @@
|
|||||||
"labelKey": "settings.host.cursorBlink.label",
|
"labelKey": "settings.host.cursorBlink.label",
|
||||||
"hidden": true,
|
"hidden": true,
|
||||||
"default": true,
|
"default": true,
|
||||||
"defaultFrom": "newHostCursorBlink"
|
"personal": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"key": "cursorStyle",
|
"key": "cursorStyle",
|
||||||
@@ -445,7 +452,7 @@
|
|||||||
"labelKey": "hosts.cursorStyleBar"
|
"labelKey": "hosts.cursorStyleBar"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"defaultFrom": "newHostCursorStyle"
|
"personal": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"key": "fontSize",
|
"key": "fontSize",
|
||||||
@@ -453,7 +460,7 @@
|
|||||||
"labelKey": "settings.host.fontSize.label",
|
"labelKey": "settings.host.fontSize.label",
|
||||||
"hidden": true,
|
"hidden": true,
|
||||||
"default": 14,
|
"default": 14,
|
||||||
"defaultFrom": "newHostFontSize"
|
"personal": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"key": "fontFamily",
|
"key": "fontFamily",
|
||||||
@@ -461,28 +468,31 @@
|
|||||||
"labelKey": "settings.host.fontFamily.label",
|
"labelKey": "settings.host.fontFamily.label",
|
||||||
"hidden": true,
|
"hidden": true,
|
||||||
"default": "Caskaydia Cove Nerd Font Mono",
|
"default": "Caskaydia Cove Nerd Font Mono",
|
||||||
"defaultFrom": "newHostFontFamily"
|
"personal": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"key": "scrollback",
|
"key": "scrollback",
|
||||||
"type": "number",
|
"type": "number",
|
||||||
"labelKey": "settings.host.scrollback.label",
|
"labelKey": "settings.host.scrollback.label",
|
||||||
"hidden": true,
|
"hidden": true,
|
||||||
"default": 10000
|
"default": 10000,
|
||||||
|
"personal": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"key": "letterSpacing",
|
"key": "letterSpacing",
|
||||||
"type": "number",
|
"type": "number",
|
||||||
"labelKey": "settings.host.letterSpacing.label",
|
"labelKey": "settings.host.letterSpacing.label",
|
||||||
"hidden": true,
|
"hidden": true,
|
||||||
"default": 0
|
"default": 0,
|
||||||
|
"personal": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"key": "lineHeight",
|
"key": "lineHeight",
|
||||||
"type": "number",
|
"type": "number",
|
||||||
"labelKey": "settings.host.lineHeight.label",
|
"labelKey": "settings.host.lineHeight.label",
|
||||||
"hidden": true,
|
"hidden": true,
|
||||||
"default": 1
|
"default": 1,
|
||||||
|
"personal": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"key": "bellStyle",
|
"key": "bellStyle",
|
||||||
@@ -507,34 +517,39 @@
|
|||||||
"value": "both",
|
"value": "both",
|
||||||
"labelKey": "hosts.bellStyleBoth"
|
"labelKey": "hosts.bellStyleBoth"
|
||||||
}
|
}
|
||||||
]
|
],
|
||||||
|
"personal": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"key": "minimumContrastRatio",
|
"key": "minimumContrastRatio",
|
||||||
"type": "number",
|
"type": "number",
|
||||||
"labelKey": "settings.host.minimumContrastRatio.label",
|
"labelKey": "settings.host.minimumContrastRatio.label",
|
||||||
"hidden": true,
|
"hidden": true,
|
||||||
"default": 1
|
"default": 1,
|
||||||
|
"personal": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"key": "backgroundImage",
|
"key": "backgroundImage",
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"labelKey": "settings.host.backgroundImage.label",
|
"labelKey": "settings.host.backgroundImage.label",
|
||||||
"hidden": true,
|
"hidden": true,
|
||||||
"default": ""
|
"default": "",
|
||||||
|
"personal": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"key": "backgroundImageOpacity",
|
"key": "backgroundImageOpacity",
|
||||||
"type": "number",
|
"type": "number",
|
||||||
"labelKey": "settings.host.backgroundImageOpacity.label",
|
"labelKey": "settings.host.backgroundImageOpacity.label",
|
||||||
"hidden": true,
|
"hidden": true,
|
||||||
"default": 0.15
|
"default": 0.15,
|
||||||
|
"personal": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"key": "customThemeColors",
|
"key": "customThemeColors",
|
||||||
"type": "json",
|
"type": "json",
|
||||||
"labelKey": "settings.host.customThemeColors.label",
|
"labelKey": "settings.host.customThemeColors.label",
|
||||||
"hidden": true
|
"hidden": true,
|
||||||
|
"personal": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"key": "rightClickSelectsWord",
|
"key": "rightClickSelectsWord",
|
||||||
@@ -614,8 +629,7 @@
|
|||||||
"type": "boolean",
|
"type": "boolean",
|
||||||
"labelKey": "settings.host.autoTmux.label",
|
"labelKey": "settings.host.autoTmux.label",
|
||||||
"hidden": true,
|
"hidden": true,
|
||||||
"default": false,
|
"default": false
|
||||||
"defaultFrom": "newHostAutoTmux"
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"key": "useSSHTitle",
|
"key": "useSSHTitle",
|
||||||
@@ -642,12 +656,8 @@
|
|||||||
"type": "select",
|
"type": "select",
|
||||||
"labelKey": "settings.host.linkClickBehavior.label",
|
"labelKey": "settings.host.linkClickBehavior.label",
|
||||||
"hidden": true,
|
"hidden": true,
|
||||||
"default": "default",
|
"default": "confirm",
|
||||||
"options": [
|
"options": [
|
||||||
{
|
|
||||||
"value": "default",
|
|
||||||
"labelKey": "hosts.linkClickBehaviorDefault"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"value": "confirm",
|
"value": "confirm",
|
||||||
"labelKey": "hosts.linkClickBehaviorConfirm"
|
"labelKey": "hosts.linkClickBehaviorConfirm"
|
||||||
@@ -663,12 +673,8 @@
|
|||||||
"type": "select",
|
"type": "select",
|
||||||
"labelKey": "settings.host.localEcho.label",
|
"labelKey": "settings.host.localEcho.label",
|
||||||
"hidden": true,
|
"hidden": true,
|
||||||
"default": "default",
|
"default": "auto",
|
||||||
"options": [
|
"options": [
|
||||||
{
|
|
||||||
"value": "default",
|
|
||||||
"labelKey": "hosts.localEchoDefault"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"value": "off",
|
"value": "off",
|
||||||
"labelKey": "hosts.localEchoOff"
|
"labelKey": "hosts.localEchoOff"
|
||||||
@@ -704,7 +710,8 @@
|
|||||||
"key": "terminalDefaults",
|
"key": "terminalDefaults",
|
||||||
"type": "custom",
|
"type": "custom",
|
||||||
"component": "terminalDefaults",
|
"component": "terminalDefaults",
|
||||||
"default": {}
|
"default": {},
|
||||||
|
"hidden": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"key": "customThemes",
|
"key": "customThemes",
|
||||||
@@ -739,7 +746,8 @@
|
|||||||
"value": "on",
|
"value": "on",
|
||||||
"labelKey": "hosts.localEchoOn"
|
"labelKey": "hosts.localEchoOn"
|
||||||
}
|
}
|
||||||
]
|
],
|
||||||
|
"hidden": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"key": "linkClickBehavior",
|
"key": "linkClickBehavior",
|
||||||
@@ -756,7 +764,8 @@
|
|||||||
"value": "direct",
|
"value": "direct",
|
||||||
"labelKey": "hosts.linkClickBehaviorDirect"
|
"labelKey": "hosts.linkClickBehaviorDirect"
|
||||||
}
|
}
|
||||||
]
|
],
|
||||||
|
"hidden": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"key": "macros",
|
"key": "macros",
|
||||||
|
|||||||
@@ -10,11 +10,7 @@ import {
|
|||||||
} from "./images/image-storage-settings.js";
|
} from "./images/image-storage-settings.js";
|
||||||
import { DEFAULT_TIMEOUT_MINUTES } from "./session-manager.js";
|
import { DEFAULT_TIMEOUT_MINUTES } from "./session-manager.js";
|
||||||
import {
|
import {
|
||||||
DEFAULT_APPEARANCE,
|
|
||||||
NEW_HOST_ADMIN_KEYS,
|
|
||||||
pickTerminalValues,
|
|
||||||
readUserSettings,
|
readUserSettings,
|
||||||
type NewHostTerminalDefaults,
|
|
||||||
type TerminalUserSettings,
|
type TerminalUserSettings,
|
||||||
} from "../shared/terminal-settings.js";
|
} from "../shared/terminal-settings.js";
|
||||||
|
|
||||||
@@ -84,8 +80,6 @@ export interface ClientSettings {
|
|||||||
sessionPersistence: boolean;
|
sessionPersistence: boolean;
|
||||||
commandHistoryEnabled: boolean;
|
commandHistoryEnabled: boolean;
|
||||||
touchInput: TouchInputSettings;
|
touchInput: TouchInputSettings;
|
||||||
/** What a new host's terminal starts with. */
|
|
||||||
newHostDefaults: NewHostTerminalDefaults;
|
|
||||||
/** The caller's own terminal settings. */
|
/** The caller's own terminal settings. */
|
||||||
user: TerminalUserSettings;
|
user: TerminalUserSettings;
|
||||||
}
|
}
|
||||||
@@ -114,26 +108,6 @@ export async function readClientSettings(
|
|||||||
sessionPersistence: all[ADMIN_KEYS.sessionPersistence] !== false,
|
sessionPersistence: all[ADMIN_KEYS.sessionPersistence] !== false,
|
||||||
commandHistoryEnabled: all[ADMIN_KEYS.commandHistoryEnabled] !== false,
|
commandHistoryEnabled: all[ADMIN_KEYS.commandHistoryEnabled] !== false,
|
||||||
touchInput: normalizeTouchInputSettings(touch),
|
touchInput: normalizeTouchInputSettings(touch),
|
||||||
newHostDefaults: readNewHostDefaults(all),
|
|
||||||
user: readUserSettings(user),
|
user: readUserSettings(user),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The admin's new-host terminal defaults, typed, with the built-in ones under them. */
|
|
||||||
export function readNewHostDefaults(
|
|
||||||
admin: Record<string, unknown>,
|
|
||||||
): NewHostTerminalDefaults {
|
|
||||||
const byHostKey: Record<string, unknown> = {};
|
|
||||||
for (const [adminKey, hostKey] of Object.entries(NEW_HOST_ADMIN_KEYS)) {
|
|
||||||
byHostKey[hostKey] = admin[adminKey];
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
fontSize: DEFAULT_APPEARANCE.fontSize,
|
|
||||||
fontFamily: DEFAULT_APPEARANCE.fontFamily,
|
|
||||||
theme: DEFAULT_APPEARANCE.theme,
|
|
||||||
cursorStyle: DEFAULT_APPEARANCE.cursorStyle,
|
|
||||||
cursorBlink: DEFAULT_APPEARANCE.cursorBlink,
|
|
||||||
autoTmux: false,
|
|
||||||
...pickTerminalValues(byHostKey, Object.values(NEW_HOST_ADMIN_KEYS)),
|
|
||||||
} as NewHostTerminalDefaults;
|
|
||||||
}
|
|
||||||
@@ -46,11 +46,6 @@ import {
|
|||||||
SendControlCodeEditor,
|
SendControlCodeEditor,
|
||||||
SendTextEditor,
|
SendTextEditor,
|
||||||
} from "./lib/keybinding-editors";
|
} from "./lib/keybinding-editors";
|
||||||
import { TerminalDefaultsSettings } from "./settings/TerminalDefaultsSettings";
|
|
||||||
import {
|
|
||||||
NewHostFontSetting,
|
|
||||||
NewHostThemeSetting,
|
|
||||||
} from "./settings/NewHostLookSettings";
|
|
||||||
import { TerminalPreview } from "./look/TerminalPreview";
|
import { TerminalPreview } from "./look/TerminalPreview";
|
||||||
import { installTerminalGlobalStyles } from "./look/terminal-global-styles";
|
import { installTerminalGlobalStyles } from "./look/terminal-global-styles";
|
||||||
import {
|
import {
|
||||||
@@ -60,7 +55,6 @@ import {
|
|||||||
} from "./look/look-actions";
|
} from "./look/look-actions";
|
||||||
import {
|
import {
|
||||||
invalidateTerminalClientSettings,
|
invalidateTerminalClientSettings,
|
||||||
loadTerminalClientSettings,
|
|
||||||
resetTerminalClientSettings,
|
resetTerminalClientSettings,
|
||||||
} from "./terminal-settings";
|
} from "./terminal-settings";
|
||||||
import { moveLocalTerminalPreferences } from "./settings/local-preferences-migration";
|
import { moveLocalTerminalPreferences } from "./settings/local-preferences-migration";
|
||||||
@@ -142,6 +136,7 @@ export function activate(app: TermixApp): void {
|
|||||||
titleKey: "hosts.tabTerminal",
|
titleKey: "hosts.tabTerminal",
|
||||||
icon: SquareTerminal,
|
icon: SquareTerminal,
|
||||||
order: 10,
|
order: 10,
|
||||||
|
defaults: true,
|
||||||
component: HostTerminalSection,
|
component: HostTerminalSection,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -305,9 +300,6 @@ export function activate(app: TermixApp): void {
|
|||||||
app.registerAction("terminal.resolveTheme", (async (
|
app.registerAction("terminal.resolveTheme", (async (
|
||||||
request: ResolveLookRequest & { hostId?: number | string } = {},
|
request: ResolveLookRequest & { hostId?: number | string } = {},
|
||||||
) => {
|
) => {
|
||||||
const settings = await loadTerminalClientSettings(app.api).catch(
|
|
||||||
() => null,
|
|
||||||
);
|
|
||||||
let host: PluginHostRecord | null | undefined = request.host;
|
let host: PluginHostRecord | null | undefined = request.host;
|
||||||
if (!host && request.hostId !== undefined) {
|
if (!host && request.hostId !== undefined) {
|
||||||
host =
|
host =
|
||||||
@@ -316,15 +308,12 @@ export function activate(app: TermixApp): void {
|
|||||||
(entry) => String(entry.id) === String(request.hostId),
|
(entry) => String(entry.id) === String(request.hostId),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return resolveTerminalLook({ ...request, host }, settings?.user);
|
return resolveTerminalLook({ ...request, host });
|
||||||
}) as never);
|
}) as never);
|
||||||
app.registerAction("terminal.themes", () => listTerminalThemes());
|
app.registerAction("terminal.themes", () => listTerminalThemes());
|
||||||
|
|
||||||
app.registerSettingsComponent("touchInput", TouchInputSettings);
|
app.registerSettingsComponent("touchInput", TouchInputSettings);
|
||||||
app.registerSettingsComponent("imageStorageTest", ImageStorageTest);
|
app.registerSettingsComponent("imageStorageTest", ImageStorageTest);
|
||||||
app.registerSettingsComponent("terminalDefaults", TerminalDefaultsSettings);
|
|
||||||
app.registerSettingsComponent("newHostTheme", NewHostThemeSetting);
|
|
||||||
app.registerSettingsComponent("newHostFontFamily", NewHostFontSetting);
|
|
||||||
|
|
||||||
app.onSettingsChanged(() => invalidateTerminalClientSettings());
|
app.onSettingsChanged(() => invalidateTerminalClientSettings());
|
||||||
if (!app.guest) void moveLocalTerminalPreferences(app);
|
if (!app.guest) void moveLocalTerminalPreferences(app);
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import { resolveForHost } from "../terminal-settings";
|
|||||||
import type {
|
import type {
|
||||||
CursorStyle,
|
CursorStyle,
|
||||||
TerminalThemeColors,
|
TerminalThemeColors,
|
||||||
TerminalUserSettings,
|
|
||||||
} from "../../shared/terminal-settings";
|
} from "../../shared/terminal-settings";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -45,9 +44,8 @@ export interface ResolveLookRequest {
|
|||||||
|
|
||||||
export function resolveTerminalLook(
|
export function resolveTerminalLook(
|
||||||
request: ResolveLookRequest,
|
request: ResolveLookRequest,
|
||||||
user: TerminalUserSettings | undefined,
|
|
||||||
): ResolvedTerminalLook {
|
): ResolvedTerminalLook {
|
||||||
const config = resolveForHost(request.host ?? null, user);
|
const config = resolveForHost(request.host ?? null);
|
||||||
const themeId = request.theme || config.theme;
|
const themeId = request.theme || config.theme;
|
||||||
ensureTerminalFontsLoaded(config.fontFamily);
|
ensureTerminalFontsLoaded(config.fontFamily);
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { toast } from "sonner";
|
|||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
FakeSwitch,
|
FakeSwitch,
|
||||||
|
HostDefaultBadge,
|
||||||
Input,
|
Input,
|
||||||
SectionCard,
|
SectionCard,
|
||||||
Select2,
|
Select2,
|
||||||
@@ -33,9 +34,6 @@ import {
|
|||||||
TERMINAL_FONT_ZOOM_MAX,
|
TERMINAL_FONT_ZOOM_MAX,
|
||||||
} from "../look/terminal-font-zoom";
|
} from "../look/terminal-font-zoom";
|
||||||
import {
|
import {
|
||||||
APPEARANCE_KEYS,
|
|
||||||
INHERIT_APPEARANCE_KEY,
|
|
||||||
pickTerminalValues,
|
|
||||||
readHostTerminalSettings,
|
readHostTerminalSettings,
|
||||||
readUserSettings,
|
readUserSettings,
|
||||||
type BackspaceMode,
|
type BackspaceMode,
|
||||||
@@ -45,14 +43,10 @@ import {
|
|||||||
type HostTerminalSettings as HostTerminalValues,
|
type HostTerminalSettings as HostTerminalValues,
|
||||||
type SavedCustomTheme,
|
type SavedCustomTheme,
|
||||||
} from "../../shared/terminal-settings";
|
} from "../../shared/terminal-settings";
|
||||||
import {
|
import { invalidateTerminalClientSettings } from "../terminal-settings";
|
||||||
invalidateTerminalClientSettings,
|
|
||||||
useTerminalClientSettings,
|
|
||||||
} from "../terminal-settings";
|
|
||||||
|
|
||||||
const PLUGIN_ID = "ssh-terminal";
|
const PLUGIN_ID = "ssh-terminal";
|
||||||
const CUSTOM_FONT_OPTION = "__custom__";
|
const CUSTOM_FONT_OPTION = "__custom__";
|
||||||
const APPEARANCE = new Set<string>(APPEARANCE_KEYS);
|
|
||||||
|
|
||||||
type HostPluginSettings = Record<string, Record<string, unknown>>;
|
type HostPluginSettings = Record<string, Record<string, unknown>>;
|
||||||
|
|
||||||
@@ -64,10 +58,8 @@ type HostPluginSettings = Record<string, Record<string, unknown>>;
|
|||||||
export function HostTerminalSettings({
|
export function HostTerminalSettings({
|
||||||
form: editorForm,
|
form: editorForm,
|
||||||
updateForm,
|
updateForm,
|
||||||
host,
|
|
||||||
}: Pick<HostEditorSectionProps, "form" | "setField" | "updateForm" | "host">) {
|
}: Pick<HostEditorSectionProps, "form" | "setField" | "updateForm" | "host">) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const client = useTerminalClientSettings();
|
|
||||||
const userSettings = useSettings("user");
|
const userSettings = useSettings("user");
|
||||||
// The preview ends when the editor section goes away.
|
// The preview ends when the editor section goes away.
|
||||||
useEffect(() => () => setThemePreview(null), []);
|
useEffect(() => () => setThemePreview(null), []);
|
||||||
@@ -75,18 +67,8 @@ export function HostTerminalSettings({
|
|||||||
const stored = ((editorForm?.pluginSettings as HostPluginSettings)?.[
|
const stored = ((editorForm?.pluginSettings as HostPluginSettings)?.[
|
||||||
PLUGIN_ID
|
PLUGIN_ID
|
||||||
] ?? {}) as Record<string, unknown>;
|
] ?? {}) as Record<string, unknown>;
|
||||||
// A new host starts from the admin's new-host defaults.
|
// The form holds what the host follows, so this is what it will look like.
|
||||||
const seed = host ? {} : (client?.newHostDefaults ?? {});
|
const form = readHostTerminalSettings(stored) as HostTerminalValues;
|
||||||
const own = readHostTerminalSettings({ ...seed, ...stored });
|
|
||||||
const userDefaults = pickTerminalValues(
|
|
||||||
client?.user.terminalDefaults ?? {},
|
|
||||||
APPEARANCE_KEYS,
|
|
||||||
);
|
|
||||||
// While the host follows the user, show the look it will actually get.
|
|
||||||
const form = {
|
|
||||||
...own,
|
|
||||||
...(own.inheritAppearance ? userDefaults : {}),
|
|
||||||
} as HostTerminalValues;
|
|
||||||
|
|
||||||
const writeValues = (values: Record<string, unknown>) =>
|
const writeValues = (values: Record<string, unknown>) =>
|
||||||
updateForm((current) => {
|
updateForm((current) => {
|
||||||
@@ -103,23 +85,7 @@ export function HostTerminalSettings({
|
|||||||
const setField = <K extends keyof HostTerminalValues>(
|
const setField = <K extends keyof HostTerminalValues>(
|
||||||
key: K,
|
key: K,
|
||||||
value: HostTerminalValues[K],
|
value: HostTerminalValues[K],
|
||||||
) =>
|
) => writeValues({ [key]: value });
|
||||||
writeValues({
|
|
||||||
[key]: value,
|
|
||||||
// Touching the look takes the host off the user's defaults.
|
|
||||||
...(APPEARANCE.has(key) ? { [INHERIT_APPEARANCE_KEY]: false } : {}),
|
|
||||||
});
|
|
||||||
|
|
||||||
const setInheritAppearance = (inherit: boolean) => {
|
|
||||||
if (inherit) {
|
|
||||||
writeValues({ [INHERIT_APPEARANCE_KEY]: true });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Keep what was shown, so switching off does not change the look.
|
|
||||||
const shown: Record<string, unknown> = {};
|
|
||||||
for (const key of APPEARANCE_KEYS) shown[key] = form[key];
|
|
||||||
writeValues({ ...shown, [INHERIT_APPEARANCE_KEY]: false });
|
|
||||||
};
|
|
||||||
|
|
||||||
const [isCustomFont, setIsCustomFont] = useState(
|
const [isCustomFont, setIsCustomFont] = useState(
|
||||||
() => !TERMINAL_FONTS.some((f) => f.value === form.fontFamily),
|
() => !TERMINAL_FONTS.some((f) => f.value === form.fontFamily),
|
||||||
@@ -183,21 +149,7 @@ export function HostTerminalSettings({
|
|||||||
icon={<Palette className="size-3.5" />}
|
icon={<Palette className="size-3.5" />}
|
||||||
>
|
>
|
||||||
<div className="flex flex-col gap-4 py-3">
|
<div className="flex flex-col gap-4 py-3">
|
||||||
<SettingRow
|
<>
|
||||||
label={t("hosts.useUserDefaults")}
|
|
||||||
description={t("hosts.useUserTerminalDefaultsDesc")}
|
|
||||||
>
|
|
||||||
<FakeSwitch
|
|
||||||
checked={form.inheritAppearance}
|
|
||||||
onChange={setInheritAppearance}
|
|
||||||
/>
|
|
||||||
</SettingRow>
|
|
||||||
<fieldset
|
|
||||||
disabled={form.inheritAppearance}
|
|
||||||
className={
|
|
||||||
form.inheritAppearance ? "contents opacity-60" : "contents"
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||||
{t("hosts.themePreview")}
|
{t("hosts.themePreview")}
|
||||||
@@ -215,8 +167,9 @@ export function HostTerminalSettings({
|
|||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
<label className="flex items-center gap-1.5 text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||||
{t("hosts.colorTheme")}
|
{t("hosts.colorTheme")}
|
||||||
|
<HostDefaultBadge settingKey="theme" />
|
||||||
</label>
|
</label>
|
||||||
<Select2
|
<Select2
|
||||||
value={form.theme}
|
value={form.theme}
|
||||||
@@ -245,8 +198,9 @@ export function HostTerminalSettings({
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
<label className="flex items-center gap-1.5 text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||||
{t("hosts.fontFamilyLabel")}
|
{t("hosts.fontFamilyLabel")}
|
||||||
|
<HostDefaultBadge settingKey="fontFamily" />
|
||||||
</label>
|
</label>
|
||||||
<TooltipProvider delayDuration={200}>
|
<TooltipProvider delayDuration={200}>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
@@ -292,8 +246,9 @@ export function HostTerminalSettings({
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
<label className="flex items-center gap-1.5 text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||||
{t("hosts.fontSizeLabel")}
|
{t("hosts.fontSizeLabel")}
|
||||||
|
<HostDefaultBadge settingKey="fontSize" />
|
||||||
</label>
|
</label>
|
||||||
<span className="text-[10px] text-muted-foreground tabular-nums">
|
<span className="text-[10px] text-muted-foreground tabular-nums">
|
||||||
{form.fontSize}px
|
{form.fontSize}px
|
||||||
@@ -308,8 +263,9 @@ export function HostTerminalSettings({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
<label className="flex items-center gap-1.5 text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||||
{t("hosts.cursorStyleLabel")}
|
{t("hosts.cursorStyleLabel")}
|
||||||
|
<HostDefaultBadge settingKey="cursorStyle" />
|
||||||
</label>
|
</label>
|
||||||
<Select2
|
<Select2
|
||||||
value={form.cursorStyle}
|
value={form.cursorStyle}
|
||||||
@@ -327,8 +283,9 @@ export function HostTerminalSettings({
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
<label className="flex items-center gap-1.5 text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||||
{t("hosts.letterSpacingPx")}
|
{t("hosts.letterSpacingPx")}
|
||||||
|
<HostDefaultBadge settingKey="letterSpacing" />
|
||||||
</label>
|
</label>
|
||||||
<span className="text-[10px] text-muted-foreground tabular-nums">
|
<span className="text-[10px] text-muted-foreground tabular-nums">
|
||||||
{form.letterSpacing}px
|
{form.letterSpacing}px
|
||||||
@@ -344,8 +301,9 @@ export function HostTerminalSettings({
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
<label className="flex items-center gap-1.5 text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||||
{t("hosts.lineHeightLabel")}
|
{t("hosts.lineHeightLabel")}
|
||||||
|
<HostDefaultBadge settingKey="lineHeight" />
|
||||||
</label>
|
</label>
|
||||||
<span className="text-[10px] text-muted-foreground tabular-nums">
|
<span className="text-[10px] text-muted-foreground tabular-nums">
|
||||||
{form.lineHeight.toFixed(1)}
|
{form.lineHeight.toFixed(1)}
|
||||||
@@ -360,8 +318,9 @@ export function HostTerminalSettings({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
<label className="flex items-center gap-1.5 text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||||
{t("hosts.bellStyleLabel")}
|
{t("hosts.bellStyleLabel")}
|
||||||
|
<HostDefaultBadge settingKey="bellStyle" />
|
||||||
</label>
|
</label>
|
||||||
<Select2
|
<Select2
|
||||||
value={form.bellStyle}
|
value={form.bellStyle}
|
||||||
@@ -378,8 +337,9 @@ export function HostTerminalSettings({
|
|||||||
</Select2>
|
</Select2>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
<label className="flex items-center gap-1.5 text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||||
{t("hosts.backspaceModeLabel")}
|
{t("hosts.backspaceModeLabel")}
|
||||||
|
<HostDefaultBadge settingKey="backspaceMode" />
|
||||||
</label>
|
</label>
|
||||||
<Select2
|
<Select2
|
||||||
value={form.backspaceMode}
|
value={form.backspaceMode}
|
||||||
@@ -397,7 +357,7 @@ export function HostTerminalSettings({
|
|||||||
</Select2>
|
</Select2>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</fieldset>
|
</>
|
||||||
{form.theme === "custom" && (
|
{form.theme === "custom" && (
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
@@ -455,8 +415,9 @@ export function HostTerminalSettings({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
<label className="flex items-center gap-1.5 text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||||
{t("hosts.customThemeColors")}
|
{t("hosts.customThemeColors")}
|
||||||
|
<HostDefaultBadge settingKey="customThemeColors" />
|
||||||
</label>
|
</label>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -569,6 +530,7 @@ export function HostTerminalSettings({
|
|||||||
)}
|
)}
|
||||||
<SettingRow
|
<SettingRow
|
||||||
label={t("hosts.cursorBlinking")}
|
label={t("hosts.cursorBlinking")}
|
||||||
|
defaultKey="cursorBlink"
|
||||||
description={t("hosts.cursorBlinkingDesc")}
|
description={t("hosts.cursorBlinkingDesc")}
|
||||||
>
|
>
|
||||||
<FakeSwitch
|
<FakeSwitch
|
||||||
@@ -578,6 +540,7 @@ export function HostTerminalSettings({
|
|||||||
</SettingRow>
|
</SettingRow>
|
||||||
<SettingRow
|
<SettingRow
|
||||||
label={t("hosts.rightClickSelectsWordLabel")}
|
label={t("hosts.rightClickSelectsWordLabel")}
|
||||||
|
defaultKey="rightClickSelectsWord"
|
||||||
description={t("hosts.rightClickSelectsWordShortDesc")}
|
description={t("hosts.rightClickSelectsWordShortDesc")}
|
||||||
>
|
>
|
||||||
<FakeSwitch
|
<FakeSwitch
|
||||||
@@ -587,6 +550,7 @@ export function HostTerminalSettings({
|
|||||||
</SettingRow>
|
</SettingRow>
|
||||||
<SettingRow
|
<SettingRow
|
||||||
label={t("hosts.macOptionIsMetaLabel")}
|
label={t("hosts.macOptionIsMetaLabel")}
|
||||||
|
defaultKey="macOptionIsMeta"
|
||||||
description={t("hosts.macOptionIsMetaShortDesc")}
|
description={t("hosts.macOptionIsMetaShortDesc")}
|
||||||
>
|
>
|
||||||
<FakeSwitch
|
<FakeSwitch
|
||||||
@@ -596,6 +560,7 @@ export function HostTerminalSettings({
|
|||||||
</SettingRow>
|
</SettingRow>
|
||||||
<SettingRow
|
<SettingRow
|
||||||
label={t("hosts.syntaxHighlightingLabel")}
|
label={t("hosts.syntaxHighlightingLabel")}
|
||||||
|
defaultKey="syntaxHighlighting"
|
||||||
description={t("hosts.syntaxHighlightingDesc")}
|
description={t("hosts.syntaxHighlightingDesc")}
|
||||||
>
|
>
|
||||||
<FakeSwitch
|
<FakeSwitch
|
||||||
@@ -633,6 +598,7 @@ export function HostTerminalSettings({
|
|||||||
).map(([key, labelKey, descKey]) => (
|
).map(([key, labelKey, descKey]) => (
|
||||||
<SettingRow
|
<SettingRow
|
||||||
key={key}
|
key={key}
|
||||||
|
defaultKey="syntaxHighlightingOptions"
|
||||||
label={t(`hosts.${labelKey}`)}
|
label={t(`hosts.${labelKey}`)}
|
||||||
description={t(`hosts.${descKey}`)}
|
description={t(`hosts.${descKey}`)}
|
||||||
>
|
>
|
||||||
@@ -650,8 +616,9 @@ export function HostTerminalSettings({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
<label className="flex items-center gap-1.5 text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||||
{t("hosts.backgroundImageLabel")}
|
{t("hosts.backgroundImageLabel")}
|
||||||
|
<HostDefaultBadge settingKey="backgroundImage" />
|
||||||
</label>
|
</label>
|
||||||
<p className="text-[10px] text-muted-foreground">
|
<p className="text-[10px] text-muted-foreground">
|
||||||
{t("hosts.backgroundImageDesc")}
|
{t("hosts.backgroundImageDesc")}
|
||||||
@@ -667,8 +634,9 @@ export function HostTerminalSettings({
|
|||||||
{form.backgroundImage && (
|
{form.backgroundImage && (
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
<label className="flex items-center gap-1.5 text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||||
{t("hosts.backgroundImageOpacityLabel")}
|
{t("hosts.backgroundImageOpacityLabel")}
|
||||||
|
<HostDefaultBadge settingKey="backgroundImageOpacity" />
|
||||||
</label>
|
</label>
|
||||||
<span className="text-[10px] text-muted-foreground tabular-nums">
|
<span className="text-[10px] text-muted-foreground tabular-nums">
|
||||||
{Math.round(form.backgroundImageOpacity * 100)}%
|
{Math.round(form.backgroundImageOpacity * 100)}%
|
||||||
@@ -693,8 +661,9 @@ export function HostTerminalSettings({
|
|||||||
<div className="flex flex-col gap-4 py-3">
|
<div className="flex flex-col gap-4 py-3">
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
<label className="flex items-center gap-1.5 text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||||
{t("hosts.scrollbackBufferLabel")}
|
{t("hosts.scrollbackBufferLabel")}
|
||||||
|
<HostDefaultBadge settingKey="scrollback" />
|
||||||
</label>
|
</label>
|
||||||
<span className="text-[10px] text-muted-foreground tabular-nums">
|
<span className="text-[10px] text-muted-foreground tabular-nums">
|
||||||
{form.scrollback.toLocaleString()}{" "}
|
{form.scrollback.toLocaleString()}{" "}
|
||||||
@@ -711,6 +680,7 @@ export function HostTerminalSettings({
|
|||||||
</div>
|
</div>
|
||||||
<SettingRow
|
<SettingRow
|
||||||
label={t("hosts.enableAutoTmux")}
|
label={t("hosts.enableAutoTmux")}
|
||||||
|
defaultKey="autoTmux"
|
||||||
description={
|
description={
|
||||||
<>
|
<>
|
||||||
{t("hosts.enableAutoTmuxDesc")}{" "}
|
{t("hosts.enableAutoTmuxDesc")}{" "}
|
||||||
@@ -732,6 +702,7 @@ export function HostTerminalSettings({
|
|||||||
</SettingRow>
|
</SettingRow>
|
||||||
<SettingRow
|
<SettingRow
|
||||||
label={t("hosts.useSSHTitleLabel")}
|
label={t("hosts.useSSHTitleLabel")}
|
||||||
|
defaultKey="useSSHTitle"
|
||||||
description={t("hosts.useSSHTitleDesc")}
|
description={t("hosts.useSSHTitleDesc")}
|
||||||
>
|
>
|
||||||
<FakeSwitch
|
<FakeSwitch
|
||||||
@@ -741,6 +712,7 @@ export function HostTerminalSettings({
|
|||||||
</SettingRow>
|
</SettingRow>
|
||||||
<SettingRow
|
<SettingRow
|
||||||
label={t("hosts.enableAutoMosh")}
|
label={t("hosts.enableAutoMosh")}
|
||||||
|
defaultKey="autoMosh"
|
||||||
description={t("hosts.enableAutoMoshDesc")}
|
description={t("hosts.enableAutoMoshDesc")}
|
||||||
>
|
>
|
||||||
<FakeSwitch
|
<FakeSwitch
|
||||||
@@ -750,6 +722,7 @@ export function HostTerminalSettings({
|
|||||||
</SettingRow>
|
</SettingRow>
|
||||||
<SettingRow
|
<SettingRow
|
||||||
label={t("hosts.passwordPromptAutoFillLabel")}
|
label={t("hosts.passwordPromptAutoFillLabel")}
|
||||||
|
defaultKey="passwordPromptAutoFill"
|
||||||
description={t("hosts.passwordPromptAutoFillDesc")}
|
description={t("hosts.passwordPromptAutoFillDesc")}
|
||||||
>
|
>
|
||||||
<FakeSwitch
|
<FakeSwitch
|
||||||
@@ -759,6 +732,7 @@ export function HostTerminalSettings({
|
|||||||
</SettingRow>
|
</SettingRow>
|
||||||
<SettingRow
|
<SettingRow
|
||||||
label={t("hosts.sudoPasswordAutoFillLabel")}
|
label={t("hosts.sudoPasswordAutoFillLabel")}
|
||||||
|
defaultKey="sudoPasswordAutoFill"
|
||||||
description={t("hosts.sudoPasswordAutoFillDesc")}
|
description={t("hosts.sudoPasswordAutoFillDesc")}
|
||||||
>
|
>
|
||||||
<FakeSwitch
|
<FakeSwitch
|
||||||
@@ -767,20 +741,17 @@ export function HostTerminalSettings({
|
|||||||
/>
|
/>
|
||||||
</SettingRow>
|
</SettingRow>
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
<label className="flex items-center gap-1.5 text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||||
{t("hosts.localEchoLabel")}
|
{t("hosts.localEchoLabel")}
|
||||||
|
<HostDefaultBadge settingKey="localEcho" />
|
||||||
</label>
|
</label>
|
||||||
<Select2
|
<Select2
|
||||||
value={form.localEcho}
|
value={form.localEcho}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setField(
|
setField("localEcho", e.target.value as "off" | "auto" | "on")
|
||||||
"localEcho",
|
|
||||||
e.target.value as "default" | "off" | "auto" | "on",
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
className="flex h-9 w-full border border-border bg-background px-3 py-1 text-xs outline-none focus:ring-1 focus:ring-ring"
|
className="flex h-9 w-full border border-border bg-background px-3 py-1 text-xs outline-none focus:ring-1 focus:ring-ring"
|
||||||
>
|
>
|
||||||
<option value="default">{t("hosts.localEchoDefault")}</option>
|
|
||||||
<option value="off">{t("hosts.localEchoOff")}</option>
|
<option value="off">{t("hosts.localEchoOff")}</option>
|
||||||
<option value="auto">{t("hosts.localEchoAuto")}</option>
|
<option value="auto">{t("hosts.localEchoAuto")}</option>
|
||||||
<option value="on">{t("hosts.localEchoOn")}</option>
|
<option value="on">{t("hosts.localEchoOn")}</option>
|
||||||
@@ -798,22 +769,20 @@ export function HostTerminalSettings({
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
<label className="flex items-center gap-1.5 text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||||
{t("hosts.linkClickBehaviorLabel")}
|
{t("hosts.linkClickBehaviorLabel")}
|
||||||
|
<HostDefaultBadge settingKey="linkClickBehavior" />
|
||||||
</label>
|
</label>
|
||||||
<Select2
|
<Select2
|
||||||
value={form.linkClickBehavior}
|
value={form.linkClickBehavior}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setField(
|
setField(
|
||||||
"linkClickBehavior",
|
"linkClickBehavior",
|
||||||
e.target.value as "default" | "confirm" | "direct",
|
e.target.value as "confirm" | "direct",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
className="flex h-9 w-full border border-border bg-background px-3 py-1 text-xs outline-none focus:ring-1 focus:ring-ring"
|
className="flex h-9 w-full border border-border bg-background px-3 py-1 text-xs outline-none focus:ring-1 focus:ring-ring"
|
||||||
>
|
>
|
||||||
<option value="default">
|
|
||||||
{t("hosts.linkClickBehaviorDefault")}
|
|
||||||
</option>
|
|
||||||
<option value="confirm">
|
<option value="confirm">
|
||||||
{t("hosts.linkClickBehaviorConfirm")}
|
{t("hosts.linkClickBehaviorConfirm")}
|
||||||
</option>
|
</option>
|
||||||
@@ -827,8 +796,9 @@ export function HostTerminalSettings({
|
|||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 border-t border-border pt-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 border-t border-border pt-4">
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
<label className="flex items-center gap-1.5 text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||||
{t("hosts.fastScrollModifierLabel")}
|
{t("hosts.fastScrollModifierLabel")}
|
||||||
|
<HostDefaultBadge settingKey="fastScrollModifier" />
|
||||||
</label>
|
</label>
|
||||||
<Select2
|
<Select2
|
||||||
value={form.fastScrollModifier}
|
value={form.fastScrollModifier}
|
||||||
@@ -849,8 +819,9 @@ export function HostTerminalSettings({
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
<label className="flex items-center gap-1.5 text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||||
{t("hosts.fastScrollSensitivityLabel")}
|
{t("hosts.fastScrollSensitivityLabel")}
|
||||||
|
<HostDefaultBadge settingKey="fastScrollSensitivity" />
|
||||||
</label>
|
</label>
|
||||||
<span className="text-[10px] text-muted-foreground tabular-nums">
|
<span className="text-[10px] text-muted-foreground tabular-nums">
|
||||||
{form.fastScrollSensitivity}
|
{form.fastScrollSensitivity}
|
||||||
@@ -867,8 +838,9 @@ export function HostTerminalSettings({
|
|||||||
</div>
|
</div>
|
||||||
{form.autoMosh && (
|
{form.autoMosh && (
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
<label className="flex items-center gap-1.5 text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||||
{t("hosts.moshCommandLabel")}
|
{t("hosts.moshCommandLabel")}
|
||||||
|
<HostDefaultBadge settingKey="moshCommand" />
|
||||||
</label>
|
</label>
|
||||||
<Input
|
<Input
|
||||||
placeholder="mosh"
|
placeholder="mosh"
|
||||||
|
|||||||
@@ -1,78 +0,0 @@
|
|||||||
import { SettingRow, Select2 } from "@termix/plugin-sdk/ui";
|
|
||||||
import {
|
|
||||||
useTranslation,
|
|
||||||
type SettingsComponentProps,
|
|
||||||
} from "@termix/plugin-sdk/frontend";
|
|
||||||
import { TERMINAL_FONTS, TERMINAL_THEMES } from "../look/terminal-themes";
|
|
||||||
import { DEFAULT_APPEARANCE } from "../../shared/terminal-settings";
|
|
||||||
|
|
||||||
const selectClass =
|
|
||||||
"flex h-8 w-48 border border-border bg-background px-2 text-xs outline-none focus:ring-1 focus:ring-ring";
|
|
||||||
|
|
||||||
/** Admin setting `newHostTheme`: the theme a new host starts with. */
|
|
||||||
export function NewHostThemeSetting({
|
|
||||||
values,
|
|
||||||
setValue,
|
|
||||||
running,
|
|
||||||
}: SettingsComponentProps) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const value =
|
|
||||||
typeof values.newHostTheme === "string"
|
|
||||||
? values.newHostTheme
|
|
||||||
: DEFAULT_APPEARANCE.theme;
|
|
||||||
return (
|
|
||||||
<SettingRow
|
|
||||||
label={t("settings.admin.newHostTheme.label")}
|
|
||||||
description={t("settings.admin.newHostTheme.description")}
|
|
||||||
>
|
|
||||||
<Select2
|
|
||||||
value={value}
|
|
||||||
disabled={!running}
|
|
||||||
onChange={(e) => setValue("newHostTheme", e.target.value)}
|
|
||||||
className={selectClass}
|
|
||||||
>
|
|
||||||
{Object.entries(TERMINAL_THEMES)
|
|
||||||
.filter(([key]) => key !== "termixDark" && key !== "termixLight")
|
|
||||||
.map(([key, theme]) => (
|
|
||||||
<option key={key} value={key}>
|
|
||||||
{theme.name}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</Select2>
|
|
||||||
</SettingRow>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Admin setting `newHostFontFamily`: the font a new host starts with. */
|
|
||||||
export function NewHostFontSetting({
|
|
||||||
values,
|
|
||||||
setValue,
|
|
||||||
running,
|
|
||||||
}: SettingsComponentProps) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const value =
|
|
||||||
typeof values.newHostFontFamily === "string"
|
|
||||||
? values.newHostFontFamily
|
|
||||||
: DEFAULT_APPEARANCE.fontFamily;
|
|
||||||
const known = TERMINAL_FONTS.some((font) => font.value === value);
|
|
||||||
return (
|
|
||||||
<SettingRow
|
|
||||||
label={t("settings.admin.newHostFontFamily.label")}
|
|
||||||
description={t("settings.admin.newHostFontFamily.description")}
|
|
||||||
>
|
|
||||||
<Select2
|
|
||||||
value={value}
|
|
||||||
disabled={!running}
|
|
||||||
onChange={(e) => setValue("newHostFontFamily", e.target.value)}
|
|
||||||
className={`${selectClass} font-mono`}
|
|
||||||
>
|
|
||||||
{!known && <option value={value}>{value}</option>}
|
|
||||||
{TERMINAL_FONTS.map((font) => (
|
|
||||||
<option key={font.value} value={font.value}>
|
|
||||||
{font.label}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</Select2>
|
|
||||||
</SettingRow>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,317 +0,0 @@
|
|||||||
import { useMemo, useState } from "react";
|
|
||||||
import type React from "react";
|
|
||||||
import {
|
|
||||||
Button,
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
SettingRow,
|
|
||||||
} from "@termix/plugin-sdk/ui";
|
|
||||||
import {
|
|
||||||
useTranslation,
|
|
||||||
type SettingsComponentProps,
|
|
||||||
} from "@termix/plugin-sdk/frontend";
|
|
||||||
import {
|
|
||||||
CURSOR_STYLES,
|
|
||||||
TERMINAL_FONTS,
|
|
||||||
TERMINAL_THEMES,
|
|
||||||
} from "../look/terminal-themes";
|
|
||||||
import { fromTriState, toTriState, type TriState } from "./tri-state";
|
|
||||||
import {
|
|
||||||
readUserSettings,
|
|
||||||
type TerminalDefaults,
|
|
||||||
} from "../../shared/terminal-settings";
|
|
||||||
|
|
||||||
const inputClass =
|
|
||||||
"h-8 w-full border border-border bg-background px-2 text-xs outline-none focus:ring-1 focus:ring-ring";
|
|
||||||
const labelClass =
|
|
||||||
"text-[10px] font-bold uppercase tracking-widest text-muted-foreground";
|
|
||||||
|
|
||||||
function Field({
|
|
||||||
label,
|
|
||||||
children,
|
|
||||||
}: {
|
|
||||||
label: string;
|
|
||||||
children: React.ReactNode;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col gap-1.5">
|
|
||||||
<span className={labelClass}>{label}</span>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function TriStateSelect({
|
|
||||||
value,
|
|
||||||
onChange,
|
|
||||||
}: {
|
|
||||||
value?: boolean;
|
|
||||||
onChange: (value: boolean | undefined) => void;
|
|
||||||
}) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
return (
|
|
||||||
<select
|
|
||||||
className="h-7 w-28 border border-border bg-background px-2 text-xs outline-none focus:ring-1 focus:ring-ring"
|
|
||||||
value={toTriState(value)}
|
|
||||||
onChange={(e) => onChange(fromTriState(e.target.value as TriState))}
|
|
||||||
>
|
|
||||||
<option value="inherit">{t("terminalDefaults.inherit")}</option>
|
|
||||||
<option value="on">{t("terminalDefaults.on")}</option>
|
|
||||||
<option value="off">{t("terminalDefaults.off")}</option>
|
|
||||||
</select>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The user's terminal defaults (the `terminalDefaults` user setting): the
|
|
||||||
* look every host that follows the user starts from. Applied to the form,
|
|
||||||
* saved with the rest of the section.
|
|
||||||
*/
|
|
||||||
export function TerminalDefaultsSettings({
|
|
||||||
values,
|
|
||||||
setValue,
|
|
||||||
running,
|
|
||||||
}: SettingsComponentProps) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const saved = useMemo(() => readUserSettings(values), [values]);
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
const [terminal, setTerminal] = useState<TerminalDefaults>({});
|
|
||||||
|
|
||||||
const configuredCount = Object.values(saved.terminalDefaults).filter(
|
|
||||||
(value) => value !== undefined,
|
|
||||||
).length;
|
|
||||||
|
|
||||||
const openDialog = () => {
|
|
||||||
setTerminal(saved.terminalDefaults);
|
|
||||||
setOpen(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const updateTerminal = <K extends keyof TerminalDefaults>(
|
|
||||||
key: K,
|
|
||||||
value: TerminalDefaults[K],
|
|
||||||
) => setTerminal((current) => ({ ...current, [key]: value }));
|
|
||||||
|
|
||||||
const applySavedTheme = (id: string) => {
|
|
||||||
const theme = saved.customThemes.find((entry) => entry.id === id);
|
|
||||||
if (!theme) return;
|
|
||||||
setTerminal((current) => ({
|
|
||||||
...current,
|
|
||||||
theme: "custom",
|
|
||||||
customThemeColors: { ...theme.colors },
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
|
|
||||||
const apply = () => {
|
|
||||||
const cleaned = Object.fromEntries(
|
|
||||||
Object.entries(terminal).filter(([, value]) => value !== undefined),
|
|
||||||
);
|
|
||||||
setValue("terminalDefaults", cleaned);
|
|
||||||
setOpen(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const inheritLabel = t("terminalDefaults.inherit");
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<SettingRow
|
|
||||||
label={t("terminalDefaults.title")}
|
|
||||||
description={t("terminalDefaults.description")}
|
|
||||||
badge={configuredCount > 0 ? String(configuredCount) : undefined}
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
disabled={!running}
|
|
||||||
onClick={openDialog}
|
|
||||||
>
|
|
||||||
{t("terminalDefaults.manage")}
|
|
||||||
</Button>
|
|
||||||
</SettingRow>
|
|
||||||
|
|
||||||
<Dialog open={open} onOpenChange={setOpen}>
|
|
||||||
<DialogContent className="sm:max-w-xl">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle className="text-base font-bold">
|
|
||||||
{t("terminalDefaults.title")}
|
|
||||||
</DialogTitle>
|
|
||||||
<DialogDescription className="text-xs text-muted-foreground">
|
|
||||||
{t("terminalDefaults.dialogDescription")}{" "}
|
|
||||||
<a
|
|
||||||
href="https://docs.termix.site/features/files-and-hosts/connection-defaults"
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
className="text-accent-brand hover:underline"
|
|
||||||
>
|
|
||||||
{t("hosts.docsLink")}
|
|
||||||
</a>
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<div className="max-h-[55vh] overflow-y-auto pr-1">
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
|
||||||
<Field label={t("hosts.fontFamilyLabel")}>
|
|
||||||
<select
|
|
||||||
className={inputClass}
|
|
||||||
value={terminal.fontFamily ?? ""}
|
|
||||||
onChange={(e) =>
|
|
||||||
updateTerminal("fontFamily", e.target.value || undefined)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<option value="">{inheritLabel}</option>
|
|
||||||
{TERMINAL_FONTS.map((font) => (
|
|
||||||
<option key={font.value} value={font.value}>
|
|
||||||
{font.label}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</Field>
|
|
||||||
|
|
||||||
<Field label={t("hosts.colorTheme")}>
|
|
||||||
<select
|
|
||||||
className={inputClass}
|
|
||||||
value={terminal.theme ?? ""}
|
|
||||||
onChange={(e) =>
|
|
||||||
updateTerminal("theme", e.target.value || undefined)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<option value="">{inheritLabel}</option>
|
|
||||||
{Object.entries(TERMINAL_THEMES)
|
|
||||||
.filter(
|
|
||||||
([key]) => key !== "termixDark" && key !== "termixLight",
|
|
||||||
)
|
|
||||||
.map(([key, theme]) => (
|
|
||||||
<option key={key} value={key}>
|
|
||||||
{theme.name}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</Field>
|
|
||||||
|
|
||||||
{saved.customThemes.length > 0 && (
|
|
||||||
<Field label={t("terminalDefaults.savedThemeLabel")}>
|
|
||||||
<select
|
|
||||||
className={inputClass}
|
|
||||||
value=""
|
|
||||||
onChange={(e) => applySavedTheme(e.target.value)}
|
|
||||||
>
|
|
||||||
<option value="">
|
|
||||||
{t("terminalDefaults.savedThemePlaceholder")}
|
|
||||||
</option>
|
|
||||||
{saved.customThemes.map((theme) => (
|
|
||||||
<option key={theme.id} value={theme.id}>
|
|
||||||
{theme.name}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</Field>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Field label={t("hosts.fontSizeLabel")}>
|
|
||||||
<input
|
|
||||||
className={inputClass}
|
|
||||||
type="number"
|
|
||||||
min={8}
|
|
||||||
max={32}
|
|
||||||
value={terminal.fontSize ?? ""}
|
|
||||||
placeholder={inheritLabel}
|
|
||||||
onChange={(e) =>
|
|
||||||
updateTerminal(
|
|
||||||
"fontSize",
|
|
||||||
e.target.value ? Number(e.target.value) : undefined,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
|
|
||||||
<Field label={t("hosts.lineHeightLabel")}>
|
|
||||||
<input
|
|
||||||
className={inputClass}
|
|
||||||
type="number"
|
|
||||||
min={0.8}
|
|
||||||
max={2}
|
|
||||||
step={0.05}
|
|
||||||
value={terminal.lineHeight ?? ""}
|
|
||||||
placeholder={inheritLabel}
|
|
||||||
onChange={(e) =>
|
|
||||||
updateTerminal(
|
|
||||||
"lineHeight",
|
|
||||||
e.target.value ? Number(e.target.value) : undefined,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
|
|
||||||
<Field label={t("hosts.cursorStyleLabel")}>
|
|
||||||
<select
|
|
||||||
className={inputClass}
|
|
||||||
value={terminal.cursorStyle ?? ""}
|
|
||||||
onChange={(e) =>
|
|
||||||
updateTerminal(
|
|
||||||
"cursorStyle",
|
|
||||||
(e.target.value || undefined) as
|
|
||||||
TerminalDefaults["cursorStyle"] | undefined,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<option value="">{inheritLabel}</option>
|
|
||||||
{CURSOR_STYLES.map((style) => (
|
|
||||||
<option key={style.value} value={style.value}>
|
|
||||||
{t(style.labelKey)}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</Field>
|
|
||||||
|
|
||||||
<Field label={t("hosts.scrollbackBufferLabel")}>
|
|
||||||
<input
|
|
||||||
className={inputClass}
|
|
||||||
type="number"
|
|
||||||
min={1000}
|
|
||||||
max={100000}
|
|
||||||
step={1000}
|
|
||||||
value={terminal.scrollback ?? ""}
|
|
||||||
placeholder={inheritLabel}
|
|
||||||
onChange={(e) =>
|
|
||||||
updateTerminal(
|
|
||||||
"scrollback",
|
|
||||||
e.target.value ? Number(e.target.value) : undefined,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
|
|
||||||
<div className="sm:col-span-2">
|
|
||||||
<SettingRow
|
|
||||||
label={t("hosts.cursorBlinking")}
|
|
||||||
description={t("hosts.cursorBlinkingDesc")}
|
|
||||||
>
|
|
||||||
<TriStateSelect
|
|
||||||
value={terminal.cursorBlink}
|
|
||||||
onChange={(value) => updateTerminal("cursorBlink", value)}
|
|
||||||
/>
|
|
||||||
</SettingRow>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center justify-between gap-2 pt-3 border-t border-border">
|
|
||||||
<Button variant="ghost" size="sm" onClick={() => setTerminal({})}>
|
|
||||||
{t("terminalDefaults.clearAll")}
|
|
||||||
</Button>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Button variant="ghost" size="sm" onClick={() => setOpen(false)}>
|
|
||||||
{t("common.cancel")}
|
|
||||||
</Button>
|
|
||||||
<Button size="sm" onClick={apply}>
|
|
||||||
{t("terminalDefaults.apply")}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
// Booleans have three states in the defaults UI: unset means the host decides.
|
|
||||||
export type TriState = "inherit" | "on" | "off";
|
|
||||||
|
|
||||||
export function toTriState(value?: boolean): TriState {
|
|
||||||
if (value === undefined) return "inherit";
|
|
||||||
return value ? "on" : "off";
|
|
||||||
}
|
|
||||||
|
|
||||||
export function fromTriState(value: TriState): boolean | undefined {
|
|
||||||
if (value === "inherit") return undefined;
|
|
||||||
return value === "on";
|
|
||||||
}
|
|
||||||
@@ -5,7 +5,6 @@ import {
|
|||||||
} from "../shared/touch-input-settings";
|
} from "../shared/touch-input-settings";
|
||||||
import {
|
import {
|
||||||
readUserSettings,
|
readUserSettings,
|
||||||
type NewHostTerminalDefaults,
|
|
||||||
type TerminalUserSettings,
|
type TerminalUserSettings,
|
||||||
} from "../shared/terminal-settings";
|
} from "../shared/terminal-settings";
|
||||||
|
|
||||||
@@ -16,7 +15,6 @@ export interface TerminalClientSettings {
|
|||||||
sessionPersistence: boolean;
|
sessionPersistence: boolean;
|
||||||
commandHistoryEnabled: boolean;
|
commandHistoryEnabled: boolean;
|
||||||
touchInput: TouchInputSettings;
|
touchInput: TouchInputSettings;
|
||||||
newHostDefaults?: NewHostTerminalDefaults;
|
|
||||||
user: TerminalUserSettings;
|
user: TerminalUserSettings;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -105,11 +105,9 @@ export function hostTerminalValues(
|
|||||||
|
|
||||||
export function resolveForHost(
|
export function resolveForHost(
|
||||||
host: object | null | undefined,
|
host: object | null | undefined,
|
||||||
user: TerminalUserSettings = DEFAULT_USER_SETTINGS,
|
|
||||||
): ResolvedTerminalConfig {
|
): ResolvedTerminalConfig {
|
||||||
const resolved = resolveTerminalSettings(
|
const resolved = resolveTerminalSettings(
|
||||||
readHostTerminalSettings(hostTerminalValues(host)),
|
readHostTerminalSettings(hostTerminalValues(host)),
|
||||||
user.terminalDefaults,
|
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
...resolved,
|
...resolved,
|
||||||
@@ -126,7 +124,7 @@ export function useTerminalSettings(
|
|||||||
const ownKey = own ? JSON.stringify(own) : "";
|
const ownKey = own ? JSON.stringify(own) : "";
|
||||||
return useMemo(() => {
|
return useMemo(() => {
|
||||||
const user = client?.user ?? DEFAULT_USER_SETTINGS;
|
const user = client?.user ?? DEFAULT_USER_SETTINGS;
|
||||||
return { config: resolveForHost(host, user), user };
|
return { config: resolveForHost(host), user };
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [ownKey, client]);
|
}, [ownKey, client]);
|
||||||
}
|
}
|
||||||
@@ -47,7 +47,7 @@ export type BackspaceMode = "normal" | "control-h";
|
|||||||
export type HostLocalEcho = "default" | "off" | "auto" | "on";
|
export type HostLocalEcho = "default" | "off" | "auto" | "on";
|
||||||
export type HostLinkClickBehavior = "default" | "confirm" | "direct";
|
export type HostLinkClickBehavior = "default" | "confirm" | "direct";
|
||||||
|
|
||||||
/** How a terminal looks. A host follows the user's defaults unless it opts out. */
|
/** How a terminal looks. Each value follows the host defaults unless the host sets it. */
|
||||||
export interface TerminalAppearance {
|
export interface TerminalAppearance {
|
||||||
theme: string;
|
theme: string;
|
||||||
cursorBlink: boolean;
|
cursorBlink: boolean;
|
||||||
@@ -64,7 +64,7 @@ export interface TerminalAppearance {
|
|||||||
customThemeColors: TerminalThemeColors | null;
|
customThemeColors: TerminalThemeColors | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** How a terminal behaves on one host. Always the host's own. */
|
/** How a terminal behaves on one host. */
|
||||||
export interface TerminalBehavior {
|
export interface TerminalBehavior {
|
||||||
rightClickSelectsWord: boolean;
|
rightClickSelectsWord: boolean;
|
||||||
macOptionIsMeta: boolean;
|
macOptionIsMeta: boolean;
|
||||||
@@ -165,8 +165,8 @@ export const DEFAULT_BEHAVIOR: TerminalBehavior = {
|
|||||||
useSSHTitle: false,
|
useSSHTitle: false,
|
||||||
syntaxHighlighting: true,
|
syntaxHighlighting: true,
|
||||||
syntaxHighlightingOptions: DEFAULT_SYNTAX_HIGHLIGHTING,
|
syntaxHighlightingOptions: DEFAULT_SYNTAX_HIGHLIGHTING,
|
||||||
linkClickBehavior: "default",
|
linkClickBehavior: "confirm",
|
||||||
localEcho: "default",
|
localEcho: "auto",
|
||||||
passwordPromptAutoFill: true,
|
passwordPromptAutoFill: true,
|
||||||
sudoPasswordAutoFill: false,
|
sudoPasswordAutoFill: false,
|
||||||
};
|
};
|
||||||
@@ -177,9 +177,6 @@ export const DEFAULT_HOST_TERMINAL_SETTINGS: HostTerminalSettings = {
|
|||||||
inheritAppearance: true,
|
inheritAppearance: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
/** The user's terminal defaults: any appearance key, unset ones inherit. */
|
|
||||||
export type TerminalDefaults = Partial<TerminalAppearance>;
|
|
||||||
|
|
||||||
export interface SavedCustomTheme {
|
export interface SavedCustomTheme {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -188,7 +185,6 @@ export interface SavedCustomTheme {
|
|||||||
|
|
||||||
/** The ssh-terminal user settings the terminal reads. */
|
/** The ssh-terminal user settings the terminal reads. */
|
||||||
export interface TerminalUserSettings {
|
export interface TerminalUserSettings {
|
||||||
terminalDefaults: TerminalDefaults;
|
|
||||||
customThemes: SavedCustomTheme[];
|
customThemes: SavedCustomTheme[];
|
||||||
commandAutocomplete: boolean;
|
commandAutocomplete: boolean;
|
||||||
localEcho: "off" | "auto" | "on";
|
localEcho: "off" | "auto" | "on";
|
||||||
@@ -196,33 +192,12 @@ export interface TerminalUserSettings {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const DEFAULT_USER_SETTINGS: TerminalUserSettings = {
|
export const DEFAULT_USER_SETTINGS: TerminalUserSettings = {
|
||||||
terminalDefaults: {},
|
|
||||||
customThemes: [],
|
customThemes: [],
|
||||||
commandAutocomplete: false,
|
commandAutocomplete: false,
|
||||||
localEcho: "auto",
|
localEcho: "auto",
|
||||||
linkClickBehavior: "confirm",
|
linkClickBehavior: "confirm",
|
||||||
};
|
};
|
||||||
|
|
||||||
/** The admin's defaults for a new host's terminal. */
|
|
||||||
export interface NewHostTerminalDefaults {
|
|
||||||
fontSize: number;
|
|
||||||
fontFamily: string;
|
|
||||||
theme: string;
|
|
||||||
cursorStyle: CursorStyle;
|
|
||||||
cursorBlink: boolean;
|
|
||||||
autoTmux: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Admin field -> the host field it seeds (manifest defaultFrom). */
|
|
||||||
export const NEW_HOST_ADMIN_KEYS = {
|
|
||||||
newHostFontSize: "fontSize",
|
|
||||||
newHostFontFamily: "fontFamily",
|
|
||||||
newHostTheme: "theme",
|
|
||||||
newHostCursorStyle: "cursorStyle",
|
|
||||||
newHostCursorBlink: "cursorBlink",
|
|
||||||
newHostAutoTmux: "autoTmux",
|
|
||||||
} as const satisfies Record<string, keyof NewHostTerminalDefaults>;
|
|
||||||
|
|
||||||
const ENUMS: Record<string, readonly string[]> = {
|
const ENUMS: Record<string, readonly string[]> = {
|
||||||
cursorStyle: ["block", "underline", "bar"],
|
cursorStyle: ["block", "underline", "bar"],
|
||||||
bellStyle: ["none", "sound", "visual", "both"],
|
bellStyle: ["none", "sound", "visual", "both"],
|
||||||
@@ -357,7 +332,6 @@ export function readUserSettings(
|
|||||||
values: Record<string, unknown> | null | undefined,
|
values: Record<string, unknown> | null | undefined,
|
||||||
): TerminalUserSettings {
|
): TerminalUserSettings {
|
||||||
const source = values ?? {};
|
const source = values ?? {};
|
||||||
const defaults = asObject(source.terminalDefaults);
|
|
||||||
const themes = Array.isArray(source.customThemes)
|
const themes = Array.isArray(source.customThemes)
|
||||||
? source.customThemes
|
? source.customThemes
|
||||||
: typeof source.customThemes === "string"
|
: typeof source.customThemes === "string"
|
||||||
@@ -371,10 +345,6 @@ export function readUserSettings(
|
|||||||
})()
|
})()
|
||||||
: [];
|
: [];
|
||||||
return {
|
return {
|
||||||
terminalDefaults: pickTerminalValues(
|
|
||||||
defaults,
|
|
||||||
APPEARANCE_KEYS,
|
|
||||||
) as TerminalDefaults,
|
|
||||||
customThemes: themes.filter(
|
customThemes: themes.filter(
|
||||||
(theme): theme is SavedCustomTheme =>
|
(theme): theme is SavedCustomTheme =>
|
||||||
isObject(theme) &&
|
isObject(theme) &&
|
||||||
@@ -393,35 +363,25 @@ export function readUserSettings(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* What a terminal on a host runs with: the defaults, then the user's
|
* What a terminal on a host runs with: the host's values over the built-in
|
||||||
* terminal defaults, then the host's own look when it does not follow the
|
* ones. The host's values already follow its host defaults, and for a host
|
||||||
* user, then the host's behavior.
|
* shared with this user its look follows theirs.
|
||||||
*/
|
*/
|
||||||
export function resolveTerminalSettings(
|
export function resolveTerminalSettings(
|
||||||
host: HostTerminalSettings | null | undefined,
|
host: HostTerminalSettings | null | undefined,
|
||||||
userDefaults: TerminalDefaults = {},
|
|
||||||
): TerminalAppearance & TerminalBehavior {
|
): TerminalAppearance & TerminalBehavior {
|
||||||
const own = host ?? DEFAULT_HOST_TERMINAL_SETTINGS;
|
const own = host ?? DEFAULT_HOST_TERMINAL_SETTINGS;
|
||||||
const appearance: TerminalAppearance = {
|
const resolved = {} as Record<string, unknown>;
|
||||||
...DEFAULT_APPEARANCE,
|
for (const key of APPEARANCE_KEYS) {
|
||||||
...pickTerminalValues(userDefaults, APPEARANCE_KEYS),
|
resolved[key] = own[key] ?? DEFAULT_APPEARANCE[key];
|
||||||
};
|
|
||||||
if (!own.inheritAppearance) {
|
|
||||||
for (const key of APPEARANCE_KEYS) {
|
|
||||||
(appearance as unknown as Record<string, unknown>)[key] = own[key];
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
const behavior = {} as TerminalBehavior;
|
for (const key of BEHAVIOR_KEYS) resolved[key] = own[key];
|
||||||
for (const key of BEHAVIOR_KEYS) {
|
return resolved as unknown as TerminalAppearance & TerminalBehavior;
|
||||||
(behavior as unknown as Record<string, unknown>)[key] = own[key];
|
|
||||||
}
|
|
||||||
return { ...appearance, ...behavior };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The host's settings in the 2.8 `terminalConfig` shape, for clients that
|
* The host's settings in the 2.8 `terminalConfig` shape, for clients that
|
||||||
* still read it (Termix-Mobile). The look is left out while the host follows
|
* still read it (Termix-Mobile).
|
||||||
* the user, the way 2.8 stored it.
|
|
||||||
*/
|
*/
|
||||||
export function terminalConfigFromHostSettings(
|
export function terminalConfigFromHostSettings(
|
||||||
values: Record<string, unknown>,
|
values: Record<string, unknown>,
|
||||||
@@ -429,9 +389,7 @@ export function terminalConfigFromHostSettings(
|
|||||||
const settings = readHostTerminalSettings(values);
|
const settings = readHostTerminalSettings(values);
|
||||||
const config: Record<string, unknown> = {};
|
const config: Record<string, unknown> = {};
|
||||||
for (const key of BEHAVIOR_KEYS) config[key] = settings[key];
|
for (const key of BEHAVIOR_KEYS) config[key] = settings[key];
|
||||||
if (!settings.inheritAppearance) {
|
for (const key of APPEARANCE_KEYS) config[key] = settings[key];
|
||||||
for (const key of APPEARANCE_KEYS) config[key] = settings[key];
|
|
||||||
}
|
|
||||||
if (config.linkClickBehavior === "default") delete config.linkClickBehavior;
|
if (config.linkClickBehavior === "default") delete config.linkClickBehavior;
|
||||||
if (config.localEcho === "default") delete config.localEcho;
|
if (config.localEcho === "default") delete config.localEcho;
|
||||||
return config;
|
return config;
|
||||||
|
|||||||
@@ -54,8 +54,9 @@ describe("hostPayloadLegacy", () => {
|
|||||||
fontSize: 20,
|
fontSize: 20,
|
||||||
}) as { terminalConfig: Record<string, unknown> };
|
}) as { terminalConfig: Record<string, unknown> };
|
||||||
expect(terminalConfig.autoTmux).toBe(true);
|
expect(terminalConfig.autoTmux).toBe(true);
|
||||||
expect(terminalConfig).not.toHaveProperty("fontSize");
|
// The values already follow the host defaults, so the look is included.
|
||||||
expect(terminalConfig).not.toHaveProperty("localEcho");
|
expect(terminalConfig.fontSize).toBe(20);
|
||||||
|
expect(terminalConfig.localEcho).toBe("auto");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("includes the look of a host that has its own", () => {
|
it("includes the look of a host that has its own", () => {
|
||||||
|
|||||||
@@ -146,26 +146,20 @@ describe("terminal settings routes", () => {
|
|||||||
authType: "password",
|
authType: "password",
|
||||||
});
|
});
|
||||||
|
|
||||||
it("hands every user their own terminal settings and the new-host defaults", async () => {
|
it("hands every user their own terminal settings", async () => {
|
||||||
server = await startServer({
|
server = await startServer();
|
||||||
settings: { newHostFontSize: 20, newHostTheme: "nord" },
|
|
||||||
});
|
|
||||||
await server.mock.ctx.settings.setUser(
|
await server.mock.ctx.settings.setUser(
|
||||||
"user-1",
|
"user-1",
|
||||||
"commandAutocomplete",
|
"commandAutocomplete",
|
||||||
true,
|
true,
|
||||||
);
|
);
|
||||||
await server.mock.ctx.settings.setUser("user-1", "terminalDefaults", {
|
|
||||||
fontSize: 18,
|
|
||||||
});
|
|
||||||
const { body } = await server.request("GET", "/client-settings");
|
const { body } = await server.request("GET", "/client-settings");
|
||||||
expect(body.user).toMatchObject({
|
expect(body.user).toMatchObject({
|
||||||
commandAutocomplete: true,
|
commandAutocomplete: true,
|
||||||
terminalDefaults: { fontSize: 18 },
|
|
||||||
localEcho: "auto",
|
localEcho: "auto",
|
||||||
linkClickBehavior: "confirm",
|
linkClickBehavior: "confirm",
|
||||||
});
|
});
|
||||||
expect(body.newHostDefaults).toMatchObject({ fontSize: 20, theme: "nord" });
|
expect(body).not.toHaveProperty("newHostDefaults");
|
||||||
const other = await server.request("GET", "/client-settings", {
|
const other = await server.request("GET", "/client-settings", {
|
||||||
user: "user-2",
|
user: "user-2",
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -30,19 +30,25 @@ describe("hostSettingsFromTerminalConfig", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("resolveTerminalSettings", () => {
|
describe("resolveTerminalSettings", () => {
|
||||||
it("uses the user's defaults while the host follows them", () => {
|
it("runs with the host's values, which already follow its defaults", () => {
|
||||||
const host = readHostTerminalSettings({ fontSize: 30, autoTmux: true });
|
const host = readHostTerminalSettings({ fontSize: 30, autoTmux: true });
|
||||||
const resolved = resolveTerminalSettings(host, { fontSize: 18 });
|
const resolved = resolveTerminalSettings(host);
|
||||||
expect(resolved.fontSize).toBe(18);
|
expect(resolved.fontSize).toBe(30);
|
||||||
expect(resolved.autoTmux).toBe(true);
|
expect(resolved.autoTmux).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("uses the host's own look once it opts out", () => {
|
it("ignores the old follow-the-user switch", () => {
|
||||||
const host = readHostTerminalSettings({
|
const host = readHostTerminalSettings({
|
||||||
inheritAppearance: false,
|
inheritAppearance: true,
|
||||||
fontSize: 30,
|
fontSize: 22,
|
||||||
});
|
});
|
||||||
expect(resolveTerminalSettings(host, { fontSize: 18 }).fontSize).toBe(30);
|
expect(resolveTerminalSettings(host).fontSize).toBe(22);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("defaults echo and link clicks to concrete modes", () => {
|
||||||
|
const resolved = resolveTerminalSettings(readHostTerminalSettings({}));
|
||||||
|
expect(resolved.localEcho).toBe("auto");
|
||||||
|
expect(resolved.linkClickBehavior).toBe("confirm");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("falls back to the built-in defaults", () => {
|
it("falls back to the built-in defaults", () => {
|
||||||
@@ -62,7 +68,6 @@ describe("readUserSettings", () => {
|
|||||||
linkClickBehavior: "direct",
|
linkClickBehavior: "direct",
|
||||||
}),
|
}),
|
||||||
).toEqual({
|
).toEqual({
|
||||||
terminalDefaults: { fontSize: 16 },
|
|
||||||
customThemes: [{ id: "a", name: "A", colors: {} }],
|
customThemes: [{ id: "a", name: "A", colors: {} }],
|
||||||
commandAutocomplete: false,
|
commandAutocomplete: false,
|
||||||
localEcho: "auto",
|
localEcho: "auto",
|
||||||
|
|||||||
@@ -116,11 +116,11 @@ describe(`${manifest.id} activate`, () => {
|
|||||||
it("registers its settings components", async () => {
|
it("registers its settings components", async () => {
|
||||||
rendered = await renderWithApp(plugin, { manifest, locales });
|
rendered = await renderWithApp(plugin, { manifest, locales });
|
||||||
expect(rendered.registered.settingsComponents()).toEqual(
|
expect(rendered.registered.settingsComponents()).toEqual(
|
||||||
expect.arrayContaining([
|
expect.arrayContaining(["touchInput", "imageStorageTest"]),
|
||||||
"terminalDefaults",
|
);
|
||||||
"newHostTheme",
|
// Terminal defaults are host defaults now, set in the host editor.
|
||||||
"newHostFontFamily",
|
expect(rendered.registered.settingsComponents()).not.toContain(
|
||||||
]),
|
"terminalDefaults",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,37 +0,0 @@
|
|||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
||||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
|
||||||
import { TerminalDefaultsSettings } from "../../../src/frontend/settings/TerminalDefaultsSettings";
|
|
||||||
|
|
||||||
afterEach(cleanup);
|
|
||||||
|
|
||||||
describe("TerminalDefaultsSettings", () => {
|
|
||||||
it("counts the defaults the user set", () => {
|
|
||||||
render(
|
|
||||||
<TerminalDefaultsSettings
|
|
||||||
pluginId="ssh-terminal"
|
|
||||||
values={{ terminalDefaults: { fontSize: 18, cursorBlink: false } }}
|
|
||||||
setValue={vi.fn()}
|
|
||||||
running
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
expect(screen.getByText("2")).toBeTruthy();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("applies the dialog's values to the terminalDefaults setting", () => {
|
|
||||||
const setValue = vi.fn();
|
|
||||||
render(
|
|
||||||
<TerminalDefaultsSettings
|
|
||||||
pluginId="ssh-terminal"
|
|
||||||
values={{ terminalDefaults: { fontSize: 18 } }}
|
|
||||||
setValue={setValue}
|
|
||||||
running
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
fireEvent.click(screen.getByText("terminalDefaults.manage"));
|
|
||||||
fireEvent.change(screen.getByDisplayValue("18"), {
|
|
||||||
target: { value: "20" },
|
|
||||||
});
|
|
||||||
fireEvent.click(screen.getByText("terminalDefaults.apply"));
|
|
||||||
expect(setValue).toHaveBeenCalledWith("terminalDefaults", { fontSize: 20 });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
|
||||||
import {
|
|
||||||
fromTriState,
|
|
||||||
toTriState,
|
|
||||||
} from "../../../src/frontend/settings/tri-state";
|
|
||||||
|
|
||||||
describe("tri-state", () => {
|
|
||||||
it("keeps unset booleans distinct from false", () => {
|
|
||||||
expect(toTriState(undefined)).toBe("inherit");
|
|
||||||
expect(toTriState(true)).toBe("on");
|
|
||||||
expect(toTriState(false)).toBe("off");
|
|
||||||
|
|
||||||
expect(fromTriState("inherit")).toBeUndefined();
|
|
||||||
expect(fromTriState("on")).toBe(true);
|
|
||||||
expect(fromTriState("off")).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -43,6 +43,7 @@ export function HostTmuxMonitorSection({
|
|||||||
<div className="flex flex-col gap-4 py-3">
|
<div className="flex flex-col gap-4 py-3">
|
||||||
<SettingRow
|
<SettingRow
|
||||||
label={t("hosts.enableTmuxMonitor")}
|
label={t("hosts.enableTmuxMonitor")}
|
||||||
|
defaultKey="enableTmuxMonitor"
|
||||||
description={
|
description={
|
||||||
<>
|
<>
|
||||||
{t("hosts.enableTmuxMonitorDesc")}{" "}
|
{t("hosts.enableTmuxMonitorDesc")}{" "}
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ export function activate(app: TermixApp): void {
|
|||||||
titleKey: "hosts.tabTmuxMonitor",
|
titleKey: "hosts.tabTmuxMonitor",
|
||||||
icon: Layers,
|
icon: Layers,
|
||||||
order: 45,
|
order: 45,
|
||||||
|
defaults: true,
|
||||||
component: HostTmuxMonitorSection,
|
component: HostTmuxMonitorSection,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -79,7 +79,8 @@
|
|||||||
"key": "tunnelConnections",
|
"key": "tunnelConnections",
|
||||||
"type": "custom",
|
"type": "custom",
|
||||||
"component": "tunnelConnections",
|
"component": "tunnelConnections",
|
||||||
"requires": "enableTunnel"
|
"requires": "enableTunnel",
|
||||||
|
"defaultable": false
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,7 +62,8 @@
|
|||||||
"type": "number",
|
"type": "number",
|
||||||
"labelKey": "settings.host.profileId.label",
|
"labelKey": "settings.host.profileId.label",
|
||||||
"hidden": true,
|
"hidden": true,
|
||||||
"ownerOnly": true
|
"ownerOnly": true,
|
||||||
|
"defaultLevels": ["user", "folder"]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,7 +46,8 @@
|
|||||||
"type": "string",
|
"type": "string",
|
||||||
"labelKey": "settings.host.macAddress.label",
|
"labelKey": "settings.host.macAddress.label",
|
||||||
"descriptionKey": "settings.host.macAddress.description",
|
"descriptionKey": "settings.host.macAddress.description",
|
||||||
"placeholderKey": "settings.host.macAddress.placeholder"
|
"placeholderKey": "settings.host.macAddress.placeholder",
|
||||||
|
"defaultable": false
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"key": "broadcastAddress",
|
"key": "broadcastAddress",
|
||||||
|
|||||||
@@ -47,7 +47,8 @@
|
|||||||
"key": "webUiConfig",
|
"key": "webUiConfig",
|
||||||
"type": "json",
|
"type": "json",
|
||||||
"labelKey": "hosts.webUiEndpointsSection",
|
"labelKey": "hosts.webUiEndpointsSection",
|
||||||
"requires": "enableWebUi"
|
"requires": "enableWebUi",
|
||||||
|
"defaultable": false
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -93,6 +93,8 @@ const ALLOWED = [
|
|||||||
"GridColSpan",
|
"GridColSpan",
|
||||||
"GridLayout",
|
"GridLayout",
|
||||||
"GridSlot",
|
"GridSlot",
|
||||||
|
"HostDefaultBadge",
|
||||||
|
"HostDefaultField",
|
||||||
"HostFeatureFields",
|
"HostFeatureFields",
|
||||||
"HostFeatureFieldsProps",
|
"HostFeatureFieldsProps",
|
||||||
"HostKeyVerificationDialog",
|
"HostKeyVerificationDialog",
|
||||||
@@ -200,6 +202,7 @@ const ALLOWED = [
|
|||||||
"useAppTheme",
|
"useAppTheme",
|
||||||
"useConfirmation",
|
"useConfirmation",
|
||||||
"useConnectionLog",
|
"useConnectionLog",
|
||||||
|
"useIsDefaultsEditor",
|
||||||
"useIsMobile",
|
"useIsMobile",
|
||||||
"useOptionalConnectionLog",
|
"useOptionalConnectionLog",
|
||||||
"useTabs",
|
"useTabs",
|
||||||
|
|||||||
@@ -1,5 +1,14 @@
|
|||||||
import { getErrorMessage } from "../utils/error-message.js";
|
import { getErrorMessage } from "../utils/error-message.js";
|
||||||
import { sshOptionsForWrite } from "../hosts/ssh-options.js";
|
import { sshOptionsForWrite } from "../hosts/ssh-options.js";
|
||||||
|
import {
|
||||||
|
applyDefaultsAfterHostWrite,
|
||||||
|
applyHostDefaultsToWrite,
|
||||||
|
} from "../hosts/defaults/index.js";
|
||||||
|
import { recompute } from "../hosts/defaults/recompute.js";
|
||||||
|
import {
|
||||||
|
importHostDefaults,
|
||||||
|
writeHostDefaultsToExport,
|
||||||
|
} from "../hosts/defaults/user-export.js";
|
||||||
import express from "express";
|
import express from "express";
|
||||||
import http from "http";
|
import http from "http";
|
||||||
import https from "https";
|
import https from "https";
|
||||||
@@ -951,6 +960,7 @@ app.post("/database/export", authenticateJWT, async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
writeSettingsToExportDatabase(exportDb, await getExportableSettings());
|
writeSettingsToExportDatabase(exportDb, await getExportableSettings());
|
||||||
|
await writeHostDefaultsToExport(exportDb, userId);
|
||||||
} finally {
|
} finally {
|
||||||
exportDb.close();
|
exportDb.close();
|
||||||
}
|
}
|
||||||
@@ -1197,6 +1207,15 @@ app.post(
|
|||||||
updatedAt: new Date().toISOString(),
|
updatedAt: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
(hostData as Record<string, unknown>).defaultOverrides =
|
||||||
|
JSON.stringify(
|
||||||
|
await applyHostDefaultsToWrite({
|
||||||
|
ownerId: userId,
|
||||||
|
hostId: null,
|
||||||
|
columns: hostData as Record<string, unknown>,
|
||||||
|
body: hostData as Record<string, unknown>,
|
||||||
|
}),
|
||||||
|
);
|
||||||
const created = await hostRepository.createEncryptedForUser(
|
const created = await hostRepository.createEncryptedForUser(
|
||||||
userId,
|
userId,
|
||||||
hostData,
|
hostData,
|
||||||
@@ -1205,6 +1224,7 @@ app.post(
|
|||||||
Number(created.id),
|
Number(created.id),
|
||||||
importedHostPluginSettings(importDb, host),
|
importedHostPluginSettings(importDb, host),
|
||||||
);
|
);
|
||||||
|
await applyDefaultsAfterHostWrite(Number(created.id));
|
||||||
await importHostProtocolLogins(
|
await importHostProtocolLogins(
|
||||||
importDb,
|
importDb,
|
||||||
host,
|
host,
|
||||||
@@ -1285,6 +1305,12 @@ app.post(
|
|||||||
result.summary.skippedItems += pluginRows.skipped;
|
result.summary.skippedItems += pluginRows.skipped;
|
||||||
result.summary.errors.push(...pluginRows.errors);
|
result.summary.errors.push(...pluginRows.errors);
|
||||||
|
|
||||||
|
const hostDefaults = await importHostDefaults(importDb, userId);
|
||||||
|
result.summary.skippedItems += hostDefaults.skipped;
|
||||||
|
if (hostDefaults.imported > 0) {
|
||||||
|
await recompute({ userIds: [userId] });
|
||||||
|
}
|
||||||
|
|
||||||
const targetUser = await userRepository.findById(userId);
|
const targetUser = await userRepository.findById(userId);
|
||||||
if (targetUser?.isAdmin) {
|
if (targetUser?.isAdmin) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1306,6 +1306,34 @@ const migrateSchema = () => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
addColumnIfNotExists("ssh_data", "default_overrides", "TEXT");
|
||||||
|
try {
|
||||||
|
sqlite.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS host_defaults (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
level TEXT NOT NULL,
|
||||||
|
scope_key TEXT NOT NULL,
|
||||||
|
user_id TEXT,
|
||||||
|
folder_id INTEGER,
|
||||||
|
namespace TEXT NOT NULL,
|
||||||
|
key TEXT NOT NULL,
|
||||||
|
value TEXT,
|
||||||
|
updated_by TEXT,
|
||||||
|
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (folder_id) REFERENCES ssh_folders (id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_host_defaults_scope_key ON host_defaults (scope_key, namespace, key);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_host_defaults_user ON host_defaults (user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_host_defaults_folder ON host_defaults (folder_id);
|
||||||
|
`);
|
||||||
|
} catch (createError) {
|
||||||
|
databaseLogger.warn("Failed to create host_defaults table", {
|
||||||
|
operation: "schema_migration",
|
||||||
|
error: createError,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (getRawSettingValue("rbac_permission_levels_v2") === null) {
|
if (getRawSettingValue("rbac_permission_levels_v2") === null) {
|
||||||
sqlite.exec(
|
sqlite.exec(
|
||||||
|
|||||||
@@ -237,6 +237,11 @@ export const hosts = mysqlTable(
|
|||||||
// Desktop only: set on the read-only copy of a host someone shared with
|
// Desktop only: set on the read-only copy of a host someone shared with
|
||||||
// the linked account. JSON with the share's owner and permission level.
|
// the linked account. JSON with the share's owner and permission level.
|
||||||
sharedSource: text("shared_source"),
|
sharedSource: text("shared_source"),
|
||||||
|
// Which host default keys this host sets itself, per namespace, as JSON:
|
||||||
|
// {"core":["sshPort"],"<pluginId>":["key"]}. Null, or a missing
|
||||||
|
// namespace, means not classified yet. Every other key follows the
|
||||||
|
// defaults and its column holds the resolved value.
|
||||||
|
defaultOverrides: text("default_overrides"),
|
||||||
|
|
||||||
createdAt: text("created_at")
|
createdAt: text("created_at")
|
||||||
.notNull()
|
.notNull()
|
||||||
@@ -390,6 +395,41 @@ export const sshFolders = mysqlTable(
|
|||||||
(table) => [index("idx_ssh_folders_user_id").on(table.userId)],
|
(table) => [index("idx_ssh_folders_user_id").on(table.userId)],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Host defaults at one level. A host follows the deepest level that sets a
|
||||||
|
* key: its folders (deepest first), then its owner, then the server, then the
|
||||||
|
* built-in value. `scope_key` is "admin", "u:<userId>" or "f:<folderId>", so
|
||||||
|
* the unique index holds for the admin level too.
|
||||||
|
*/
|
||||||
|
export const hostDefaults = mysqlTable(
|
||||||
|
"host_defaults",
|
||||||
|
{
|
||||||
|
id: int("id").autoincrement().primaryKey(),
|
||||||
|
level: mysqlEnum("level", ["admin", "user", "folder"]).notNull(),
|
||||||
|
scopeKey: varchar("scope_key", { length: 255 }).notNull(),
|
||||||
|
userId: varchar("user_id", { length: 255 }).references(() => users.id, { onDelete: "cascade" }),
|
||||||
|
folderId: int("folder_id").references(() => sshFolders.id, {
|
||||||
|
onDelete: "cascade",
|
||||||
|
}),
|
||||||
|
namespace: varchar("namespace", { length: 255 }).notNull(),
|
||||||
|
key: varchar("key", { length: 255 }).notNull(),
|
||||||
|
value: text("value"),
|
||||||
|
updatedBy: text("updated_by"),
|
||||||
|
updatedAt: text("updated_at")
|
||||||
|
.notNull()
|
||||||
|
.default(sql`(CURRENT_TIMESTAMP)`),
|
||||||
|
},
|
||||||
|
(table) => [
|
||||||
|
uniqueIndex("idx_host_defaults_scope_key").on(
|
||||||
|
table.scopeKey,
|
||||||
|
table.namespace,
|
||||||
|
table.key,
|
||||||
|
),
|
||||||
|
index("idx_host_defaults_user").on(table.userId),
|
||||||
|
index("idx_host_defaults_folder").on(table.folderId),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
export const recentActivity = mysqlTable(
|
export const recentActivity = mysqlTable(
|
||||||
"recent_activity",
|
"recent_activity",
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -237,6 +237,11 @@ export const hosts = pgTable(
|
|||||||
// Desktop only: set on the read-only copy of a host someone shared with
|
// Desktop only: set on the read-only copy of a host someone shared with
|
||||||
// the linked account. JSON with the share's owner and permission level.
|
// the linked account. JSON with the share's owner and permission level.
|
||||||
sharedSource: text("shared_source"),
|
sharedSource: text("shared_source"),
|
||||||
|
// Which host default keys this host sets itself, per namespace, as JSON:
|
||||||
|
// {"core":["sshPort"],"<pluginId>":["key"]}. Null, or a missing
|
||||||
|
// namespace, means not classified yet. Every other key follows the
|
||||||
|
// defaults and its column holds the resolved value.
|
||||||
|
defaultOverrides: text("default_overrides"),
|
||||||
|
|
||||||
createdAt: text("created_at")
|
createdAt: text("created_at")
|
||||||
.notNull()
|
.notNull()
|
||||||
@@ -390,6 +395,41 @@ export const sshFolders = pgTable(
|
|||||||
(table) => [index("idx_ssh_folders_user_id").on(table.userId)],
|
(table) => [index("idx_ssh_folders_user_id").on(table.userId)],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Host defaults at one level. A host follows the deepest level that sets a
|
||||||
|
* key: its folders (deepest first), then its owner, then the server, then the
|
||||||
|
* built-in value. `scope_key` is "admin", "u:<userId>" or "f:<folderId>", so
|
||||||
|
* the unique index holds for the admin level too.
|
||||||
|
*/
|
||||||
|
export const hostDefaults = pgTable(
|
||||||
|
"host_defaults",
|
||||||
|
{
|
||||||
|
id: serial("id").primaryKey(),
|
||||||
|
level: text("level", { enum: ["admin", "user", "folder"] }).notNull(),
|
||||||
|
scopeKey: varchar("scope_key", { length: 255 }).notNull(),
|
||||||
|
userId: varchar("user_id", { length: 255 }).references(() => users.id, { onDelete: "cascade" }),
|
||||||
|
folderId: integer("folder_id").references(() => sshFolders.id, {
|
||||||
|
onDelete: "cascade",
|
||||||
|
}),
|
||||||
|
namespace: varchar("namespace", { length: 255 }).notNull(),
|
||||||
|
key: varchar("key", { length: 255 }).notNull(),
|
||||||
|
value: text("value"),
|
||||||
|
updatedBy: text("updated_by"),
|
||||||
|
updatedAt: text("updated_at")
|
||||||
|
.notNull()
|
||||||
|
.default(sql`CURRENT_TIMESTAMP`),
|
||||||
|
},
|
||||||
|
(table) => [
|
||||||
|
uniqueIndex("idx_host_defaults_scope_key").on(
|
||||||
|
table.scopeKey,
|
||||||
|
table.namespace,
|
||||||
|
table.key,
|
||||||
|
),
|
||||||
|
index("idx_host_defaults_user").on(table.userId),
|
||||||
|
index("idx_host_defaults_folder").on(table.folderId),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
export const recentActivity = pgTable(
|
export const recentActivity = pgTable(
|
||||||
"recent_activity",
|
"recent_activity",
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -229,6 +229,11 @@ export const hosts = sqliteTable(
|
|||||||
// Desktop only: set on the read-only copy of a host someone shared with
|
// Desktop only: set on the read-only copy of a host someone shared with
|
||||||
// the linked account. JSON with the share's owner and permission level.
|
// the linked account. JSON with the share's owner and permission level.
|
||||||
sharedSource: text("shared_source"),
|
sharedSource: text("shared_source"),
|
||||||
|
// Which host default keys this host sets itself, per namespace, as JSON:
|
||||||
|
// {"core":["sshPort"],"<pluginId>":["key"]}. Null, or a missing
|
||||||
|
// namespace, means not classified yet. Every other key follows the
|
||||||
|
// defaults and its column holds the resolved value.
|
||||||
|
defaultOverrides: text("default_overrides"),
|
||||||
|
|
||||||
createdAt: text("created_at")
|
createdAt: text("created_at")
|
||||||
.notNull()
|
.notNull()
|
||||||
@@ -382,6 +387,41 @@ export const sshFolders = sqliteTable(
|
|||||||
(table) => [index("idx_ssh_folders_user_id").on(table.userId)],
|
(table) => [index("idx_ssh_folders_user_id").on(table.userId)],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Host defaults at one level. A host follows the deepest level that sets a
|
||||||
|
* key: its folders (deepest first), then its owner, then the server, then the
|
||||||
|
* built-in value. `scope_key` is "admin", "u:<userId>" or "f:<folderId>", so
|
||||||
|
* the unique index holds for the admin level too.
|
||||||
|
*/
|
||||||
|
export const hostDefaults = sqliteTable(
|
||||||
|
"host_defaults",
|
||||||
|
{
|
||||||
|
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||||
|
level: text("level", { enum: ["admin", "user", "folder"] }).notNull(),
|
||||||
|
scopeKey: text("scope_key").notNull(),
|
||||||
|
userId: text("user_id").references(() => users.id, { onDelete: "cascade" }),
|
||||||
|
folderId: integer("folder_id").references(() => sshFolders.id, {
|
||||||
|
onDelete: "cascade",
|
||||||
|
}),
|
||||||
|
namespace: text("namespace").notNull(),
|
||||||
|
key: text("key").notNull(),
|
||||||
|
value: text("value"),
|
||||||
|
updatedBy: text("updated_by"),
|
||||||
|
updatedAt: text("updated_at")
|
||||||
|
.notNull()
|
||||||
|
.default(sql`CURRENT_TIMESTAMP`),
|
||||||
|
},
|
||||||
|
(table) => [
|
||||||
|
uniqueIndex("idx_host_defaults_scope_key").on(
|
||||||
|
table.scopeKey,
|
||||||
|
table.namespace,
|
||||||
|
table.key,
|
||||||
|
),
|
||||||
|
index("idx_host_defaults_user").on(table.userId),
|
||||||
|
index("idx_host_defaults_folder").on(table.folderId),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
export const recentActivity = sqliteTable(
|
export const recentActivity = sqliteTable(
|
||||||
"recent_activity",
|
"recent_activity",
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { OpenTabRepository } from "./open-tab-repository.js";
|
|||||||
import { PluginRepository } from "./plugin-repository.js";
|
import { PluginRepository } from "./plugin-repository.js";
|
||||||
import { PluginStorageRepository } from "./plugin-storage-repository.js";
|
import { PluginStorageRepository } from "./plugin-storage-repository.js";
|
||||||
import { PluginSettingsRepository } from "./plugin-settings-repository.js";
|
import { PluginSettingsRepository } from "./plugin-settings-repository.js";
|
||||||
|
import { HostDefaultsRepository } from "./host-defaults-repository.js";
|
||||||
import { PluginMigrationRepository } from "./plugin-migration-repository.js";
|
import { PluginMigrationRepository } from "./plugin-migration-repository.js";
|
||||||
import { PluginPermissionGrantRepository } from "./plugin-permission-grant-repository.js";
|
import { PluginPermissionGrantRepository } from "./plugin-permission-grant-repository.js";
|
||||||
import { UserAuthRepository } from "./user-auth-repository.js";
|
import { UserAuthRepository } from "./user-auth-repository.js";
|
||||||
@@ -224,6 +225,13 @@ export function createCurrentPluginStorageRepository(): PluginStorageRepository
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function createCurrentHostDefaultsRepository(): HostDefaultsRepository {
|
||||||
|
return new HostDefaultsRepository(
|
||||||
|
createCurrentRepositoryContext(),
|
||||||
|
createCurrentRepositoryWriteHook("host_defaults_repository_write"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function createCurrentPluginSettingsRepository(): PluginSettingsRepository {
|
export function createCurrentPluginSettingsRepository(): PluginSettingsRepository {
|
||||||
return new PluginSettingsRepository(
|
return new PluginSettingsRepository(
|
||||||
createCurrentRepositoryContext(),
|
createCurrentRepositoryContext(),
|
||||||
|
|||||||
@@ -0,0 +1,354 @@
|
|||||||
|
import { and, eq, inArray, type SQL } from "drizzle-orm";
|
||||||
|
import { hostDefaults, hosts, sshFolders } from "../db/schema.js";
|
||||||
|
import type { DatabaseContext } from "./database-context.js";
|
||||||
|
import { rowsAffected } from "./mutation-result.js";
|
||||||
|
import type { HostDefaultsLevel } from "../../../types/host-defaults.js";
|
||||||
|
|
||||||
|
export type HostDefaultsRecord = typeof hostDefaults.$inferSelect;
|
||||||
|
export type HostRow = typeof hosts.$inferSelect;
|
||||||
|
|
||||||
|
export interface HostDefaultsScope {
|
||||||
|
level: HostDefaultsLevel;
|
||||||
|
userId?: string | null;
|
||||||
|
folderId?: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HostDefaultsEntry {
|
||||||
|
namespace: string;
|
||||||
|
key: string;
|
||||||
|
/** JSON-encoded. */
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function scopeKeyFor(scope: HostDefaultsScope): string {
|
||||||
|
if (scope.level === "admin") return "admin";
|
||||||
|
if (scope.level === "user") return `u:${scope.userId}`;
|
||||||
|
return `f:${scope.folderId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CHUNK = 500;
|
||||||
|
|
||||||
|
function chunks<T>(items: T[], size = CHUNK): T[][] {
|
||||||
|
const result: T[][] = [];
|
||||||
|
for (let i = 0; i < items.length; i += size) {
|
||||||
|
result.push(items.slice(i, i + size));
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Host defaults at every level, plus the reads and writes that apply them to
|
||||||
|
* hosts in bulk. The resolution itself lives in hosts/defaults.
|
||||||
|
*/
|
||||||
|
export class HostDefaultsRepository {
|
||||||
|
constructor(
|
||||||
|
private readonly context: DatabaseContext,
|
||||||
|
private readonly onWrite?: () => void | Promise<void>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async listScope(scope: HostDefaultsScope): Promise<HostDefaultsRecord[]> {
|
||||||
|
return this.context.drizzle
|
||||||
|
.select()
|
||||||
|
.from(hostDefaults)
|
||||||
|
.where(eq(hostDefaults.scopeKey, scopeKeyFor(scope)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The admin rows, and every user and folder row the given users own. */
|
||||||
|
async listForUsers(userIds: string[]): Promise<HostDefaultsRecord[]> {
|
||||||
|
const admin = await this.context.drizzle
|
||||||
|
.select()
|
||||||
|
.from(hostDefaults)
|
||||||
|
.where(eq(hostDefaults.level, "admin"));
|
||||||
|
const own: HostDefaultsRecord[] = [];
|
||||||
|
for (const part of chunks([...new Set(userIds)])) {
|
||||||
|
own.push(
|
||||||
|
...(await this.context.drizzle
|
||||||
|
.select()
|
||||||
|
.from(hostDefaults)
|
||||||
|
.where(inArray(hostDefaults.userId, part))),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return [...admin, ...own];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The user's own and folder rows. */
|
||||||
|
async listOwnedBy(userId: string): Promise<HostDefaultsRecord[]> {
|
||||||
|
return this.context.drizzle
|
||||||
|
.select()
|
||||||
|
.from(hostDefaults)
|
||||||
|
.where(eq(hostDefaults.userId, userId));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets and clears keys at one level in one transaction. `set` values are
|
||||||
|
* already JSON-encoded.
|
||||||
|
*/
|
||||||
|
async apply(
|
||||||
|
scope: HostDefaultsScope,
|
||||||
|
set: HostDefaultsEntry[],
|
||||||
|
unset: Array<{ namespace: string; key: string }>,
|
||||||
|
updatedBy: string | null,
|
||||||
|
): Promise<void> {
|
||||||
|
const scopeKey = scopeKeyFor(scope);
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const match = (namespace: string, key: string) =>
|
||||||
|
and(
|
||||||
|
eq(hostDefaults.scopeKey, scopeKey),
|
||||||
|
eq(hostDefaults.namespace, namespace),
|
||||||
|
eq(hostDefaults.key, key),
|
||||||
|
)!;
|
||||||
|
const touched = [...set, ...unset];
|
||||||
|
if (touched.length === 0) return;
|
||||||
|
|
||||||
|
if (this.context.dialect === "sqlite") {
|
||||||
|
// better-sqlite3 runs a transaction synchronously.
|
||||||
|
const db = this.context.drizzle;
|
||||||
|
db.transaction((tx) => {
|
||||||
|
for (const entry of unset) {
|
||||||
|
tx.delete(hostDefaults)
|
||||||
|
.where(match(entry.namespace, entry.key))
|
||||||
|
.run();
|
||||||
|
}
|
||||||
|
for (const entry of set) {
|
||||||
|
const existing = tx
|
||||||
|
.select({ id: hostDefaults.id })
|
||||||
|
.from(hostDefaults)
|
||||||
|
.where(match(entry.namespace, entry.key))
|
||||||
|
.limit(1)
|
||||||
|
.all();
|
||||||
|
if (existing[0]) {
|
||||||
|
tx.update(hostDefaults)
|
||||||
|
.set({ value: entry.value, updatedBy, updatedAt: now })
|
||||||
|
.where(eq(hostDefaults.id, existing[0].id))
|
||||||
|
.run();
|
||||||
|
} else {
|
||||||
|
tx.insert(hostDefaults)
|
||||||
|
.values({
|
||||||
|
level: scope.level,
|
||||||
|
scopeKey,
|
||||||
|
userId: scope.level === "admin" ? null : (scope.userId ?? null),
|
||||||
|
folderId:
|
||||||
|
scope.level === "folder" ? (scope.folderId ?? null) : null,
|
||||||
|
namespace: entry.namespace,
|
||||||
|
key: entry.key,
|
||||||
|
value: entry.value,
|
||||||
|
updatedBy,
|
||||||
|
updatedAt: now,
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await this.context.drizzle.transaction(async (tx) => {
|
||||||
|
for (const entry of unset) {
|
||||||
|
await tx
|
||||||
|
.delete(hostDefaults)
|
||||||
|
.where(match(entry.namespace, entry.key));
|
||||||
|
}
|
||||||
|
for (const entry of set) {
|
||||||
|
const existing = await tx
|
||||||
|
.select({ id: hostDefaults.id })
|
||||||
|
.from(hostDefaults)
|
||||||
|
.where(match(entry.namespace, entry.key))
|
||||||
|
.limit(1);
|
||||||
|
if (existing[0]) {
|
||||||
|
await tx
|
||||||
|
.update(hostDefaults)
|
||||||
|
.set({ value: entry.value, updatedBy, updatedAt: now })
|
||||||
|
.where(eq(hostDefaults.id, existing[0].id));
|
||||||
|
} else {
|
||||||
|
await tx.insert(hostDefaults).values({
|
||||||
|
level: scope.level,
|
||||||
|
scopeKey,
|
||||||
|
userId: scope.level === "admin" ? null : (scope.userId ?? null),
|
||||||
|
folderId:
|
||||||
|
scope.level === "folder" ? (scope.folderId ?? null) : null,
|
||||||
|
namespace: entry.namespace,
|
||||||
|
key: entry.key,
|
||||||
|
value: entry.value,
|
||||||
|
updatedBy,
|
||||||
|
updatedAt: now,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await this.onWrite?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteScope(scope: HostDefaultsScope): Promise<number> {
|
||||||
|
const result = await this.context.drizzle
|
||||||
|
.delete(hostDefaults)
|
||||||
|
.where(eq(hostDefaults.scopeKey, scopeKeyFor(scope)));
|
||||||
|
const affected = rowsAffected(result);
|
||||||
|
if (affected > 0) await this.onWrite?.();
|
||||||
|
return affected;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Folder ids and paths for a set of users. */
|
||||||
|
async listFolders(
|
||||||
|
userIds: string[],
|
||||||
|
): Promise<Array<{ id: number; userId: string; name: string }>> {
|
||||||
|
const rows: Array<{ id: number; userId: string; name: string }> = [];
|
||||||
|
for (const part of chunks([...new Set(userIds)])) {
|
||||||
|
rows.push(
|
||||||
|
...(await this.context.drizzle
|
||||||
|
.select({
|
||||||
|
id: sshFolders.id,
|
||||||
|
userId: sshFolders.userId,
|
||||||
|
name: sshFolders.name,
|
||||||
|
})
|
||||||
|
.from(sshFolders)
|
||||||
|
.where(inArray(sshFolders.userId, part))),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
async findFolder(
|
||||||
|
folderId: number,
|
||||||
|
): Promise<{ id: number; userId: string; name: string } | null> {
|
||||||
|
const [row] = await this.context.drizzle
|
||||||
|
.select({
|
||||||
|
id: sshFolders.id,
|
||||||
|
userId: sshFolders.userId,
|
||||||
|
name: sshFolders.name,
|
||||||
|
})
|
||||||
|
.from(sshFolders)
|
||||||
|
.where(eq(sshFolders.id, folderId))
|
||||||
|
.limit(1);
|
||||||
|
return row ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The folder row for a path, created when the folder only exists on hosts. */
|
||||||
|
async ensureFolder(userId: string, name: string): Promise<number> {
|
||||||
|
const [existing] = await this.context.drizzle
|
||||||
|
.select({ id: sshFolders.id })
|
||||||
|
.from(sshFolders)
|
||||||
|
.where(and(eq(sshFolders.userId, userId), eq(sshFolders.name, name)))
|
||||||
|
.limit(1);
|
||||||
|
if (existing) return existing.id;
|
||||||
|
await this.context.drizzle.insert(sshFolders).values({ userId, name });
|
||||||
|
const [created] = await this.context.drizzle
|
||||||
|
.select({ id: sshFolders.id })
|
||||||
|
.from(sshFolders)
|
||||||
|
.where(and(eq(sshFolders.userId, userId), eq(sshFolders.name, name)))
|
||||||
|
.limit(1);
|
||||||
|
await this.onWrite?.();
|
||||||
|
return created.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Host rows by id or by owner. Secrets are read as stored, never used. */
|
||||||
|
async listHosts(filter: {
|
||||||
|
hostIds?: number[];
|
||||||
|
userIds?: string[];
|
||||||
|
all?: boolean;
|
||||||
|
}): Promise<HostRow[]> {
|
||||||
|
const db = this.context.drizzle;
|
||||||
|
if (filter.all) return db.select().from(hosts);
|
||||||
|
const rows: HostRow[] = [];
|
||||||
|
const conditions: SQL[] = [];
|
||||||
|
for (const part of chunks(filter.hostIds ?? [])) {
|
||||||
|
conditions.push(inArray(hosts.id, part));
|
||||||
|
}
|
||||||
|
for (const part of chunks(filter.userIds ?? [])) {
|
||||||
|
conditions.push(inArray(hosts.userId, part));
|
||||||
|
}
|
||||||
|
for (const condition of conditions) {
|
||||||
|
rows.push(...(await db.select().from(hosts).where(condition)));
|
||||||
|
}
|
||||||
|
const seen = new Set<number>();
|
||||||
|
return rows.filter((row) => {
|
||||||
|
if (seen.has(row.id)) return false;
|
||||||
|
seen.add(row.id);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Every host of the given owners, for walking sub-host chains. */
|
||||||
|
async listHostPlacement(userIds: string[]): Promise<
|
||||||
|
Array<{
|
||||||
|
id: number;
|
||||||
|
userId: string;
|
||||||
|
folder: string | null;
|
||||||
|
parentHostId: number | null;
|
||||||
|
}>
|
||||||
|
> {
|
||||||
|
const rows: Array<{
|
||||||
|
id: number;
|
||||||
|
userId: string;
|
||||||
|
folder: string | null;
|
||||||
|
parentHostId: number | null;
|
||||||
|
}> = [];
|
||||||
|
for (const part of chunks([...new Set(userIds)])) {
|
||||||
|
rows.push(
|
||||||
|
...(await this.context.drizzle
|
||||||
|
.select({
|
||||||
|
id: hosts.id,
|
||||||
|
userId: hosts.userId,
|
||||||
|
folder: hosts.folder,
|
||||||
|
parentHostId: hosts.parentHostId,
|
||||||
|
})
|
||||||
|
.from(hosts)
|
||||||
|
.where(inArray(hosts.userId, part))),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Writes column patches, one statement per host, in one transaction.
|
||||||
|
* `updatedAt` is set on each, which is what sync looks for.
|
||||||
|
*/
|
||||||
|
async updateHosts(
|
||||||
|
patches: Array<{ id: number; values: Partial<HostRow> }>,
|
||||||
|
): Promise<void> {
|
||||||
|
if (patches.length === 0) return;
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
for (const part of chunks(patches)) {
|
||||||
|
if (this.context.dialect === "sqlite") {
|
||||||
|
this.context.drizzle.transaction((tx) => {
|
||||||
|
for (const patch of part) {
|
||||||
|
tx.update(hosts)
|
||||||
|
.set({ ...patch.values, updatedAt: now })
|
||||||
|
.where(eq(hosts.id, patch.id))
|
||||||
|
.run();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await this.context.drizzle.transaction(async (tx) => {
|
||||||
|
for (const patch of part) {
|
||||||
|
await tx
|
||||||
|
.update(hosts)
|
||||||
|
.set({ ...patch.values, updatedAt: now })
|
||||||
|
.where(eq(hosts.id, patch.id));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await this.onWrite?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Rows that name a folder, credential or host a user no longer has. */
|
||||||
|
async deleteWhere(condition: SQL): Promise<number> {
|
||||||
|
const result = await this.context.drizzle
|
||||||
|
.delete(hostDefaults)
|
||||||
|
.where(condition);
|
||||||
|
const affected = rowsAffected(result);
|
||||||
|
if (affected > 0) await this.onWrite?.();
|
||||||
|
return affected;
|
||||||
|
}
|
||||||
|
|
||||||
|
async listByKey(
|
||||||
|
namespace: string,
|
||||||
|
key: string,
|
||||||
|
): Promise<HostDefaultsRecord[]> {
|
||||||
|
return this.context.drizzle
|
||||||
|
.select()
|
||||||
|
.from(hostDefaults)
|
||||||
|
.where(
|
||||||
|
and(eq(hostDefaults.namespace, namespace), eq(hostDefaults.key, key)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,16 @@ import {
|
|||||||
remapImportedJumpHosts,
|
remapImportedJumpHosts,
|
||||||
} from "./host-import-order.js";
|
} from "./host-import-order.js";
|
||||||
import { sshOptionsForWrite } from "../../hosts/ssh-options.js";
|
import { sshOptionsForWrite } from "../../hosts/ssh-options.js";
|
||||||
|
import {
|
||||||
|
applyDefaultsAfterHostWrite,
|
||||||
|
applyHostDefaultsToWrite,
|
||||||
|
} from "../../hosts/defaults/index.js";
|
||||||
|
import {
|
||||||
|
changeHostOverrides,
|
||||||
|
type OverrideChange,
|
||||||
|
} from "../../hosts/defaults/overrides.js";
|
||||||
|
import { recompute } from "../../hosts/defaults/recompute.js";
|
||||||
|
import { splitDefaultKey } from "../../../types/host-defaults.js";
|
||||||
import {
|
import {
|
||||||
keepUsableProtocolCredentials,
|
keepUsableProtocolCredentials,
|
||||||
readProtocolAuthPayload,
|
readProtocolAuthPayload,
|
||||||
@@ -16,6 +26,7 @@ import {
|
|||||||
createCurrentCredentialRepository,
|
createCurrentCredentialRepository,
|
||||||
createCurrentHostRepository,
|
createCurrentHostRepository,
|
||||||
createCurrentHostResolutionRepository,
|
createCurrentHostResolutionRepository,
|
||||||
|
createCurrentHostDefaultsRepository,
|
||||||
} from "../repositories/factory.js";
|
} from "../repositories/factory.js";
|
||||||
import { validateParentHostId } from "./host-parent-validation.js";
|
import { validateParentHostId } from "./host-parent-validation.js";
|
||||||
import {
|
import {
|
||||||
@@ -203,6 +214,20 @@ export function registerHostBulkRoutes(
|
|||||||
* description: Plugin id to on or off, for each plugin that declares a host enable switch.
|
* description: Plugin id to on or off, for each plugin that declares a host enable switch.
|
||||||
* additionalProperties:
|
* additionalProperties:
|
||||||
* type: boolean
|
* type: boolean
|
||||||
|
* resetDefaults:
|
||||||
|
* type: object
|
||||||
|
* description: Hands keys back to the host defaults. `all`, or `namespaces` ("core" or a plugin id), or `keys` as "namespace.key".
|
||||||
|
* properties:
|
||||||
|
* all:
|
||||||
|
* type: boolean
|
||||||
|
* namespaces:
|
||||||
|
* type: array
|
||||||
|
* items:
|
||||||
|
* type: string
|
||||||
|
* keys:
|
||||||
|
* type: array
|
||||||
|
* items:
|
||||||
|
* type: string
|
||||||
* responses:
|
* responses:
|
||||||
* 200:
|
* 200:
|
||||||
* description: Bulk update completed.
|
* description: Bulk update completed.
|
||||||
@@ -309,6 +334,14 @@ export function registerHostBulkRoutes(
|
|||||||
simpleUpdates,
|
simpleUpdates,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (typeof updates.statusCheckEnabled === "boolean") {
|
||||||
|
await changeHostOverrides(ownedIds, {
|
||||||
|
own: [["core", "statusCheckEnabled"]],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if ("folder" in simpleUpdates || "parentHostId" in simpleUpdates) {
|
||||||
|
void recompute({ userIds: [userId] }).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
// Each plugin's own host switch, by plugin id: the field its manifest
|
// Each plugin's own host switch, by plugin id: the field its manifest
|
||||||
// names in contributes.settings.host.enableKey.
|
// names in contributes.settings.host.enableKey.
|
||||||
@@ -323,6 +356,9 @@ export function registerHostBulkRoutes(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const reset = readDefaultsReset(updates.resetDefaults);
|
||||||
|
if (reset) await changeHostOverrides(ownedIds, reset);
|
||||||
|
|
||||||
return res.json({
|
return res.json({
|
||||||
updated: ownedIds.length,
|
updated: ownedIds.length,
|
||||||
failed: unauthorizedIds.length,
|
failed: unauthorizedIds.length,
|
||||||
@@ -727,6 +763,12 @@ export function registerHostBulkRoutes(
|
|||||||
|
|
||||||
const lookupKey = `${hostData.ip}:${hostData.port}:${hostData.username}`;
|
const lookupKey = `${hostData.ip}:${hostData.port}:${hostData.username}`;
|
||||||
const existing = existingHostMap?.get(lookupKey);
|
const existing = existingHostMap?.get(lookupKey);
|
||||||
|
await withHostDefaults(
|
||||||
|
userId,
|
||||||
|
existing?.id ?? null,
|
||||||
|
sshDataObj,
|
||||||
|
hostData as Record<string, unknown>,
|
||||||
|
);
|
||||||
|
|
||||||
let savedHostId: number;
|
let savedHostId: number;
|
||||||
if (existing) {
|
if (existing) {
|
||||||
@@ -770,6 +812,7 @@ export function registerHostBulkRoutes(
|
|||||||
savedHostId,
|
savedHostId,
|
||||||
hostData as Record<string, unknown>,
|
hostData as Record<string, unknown>,
|
||||||
);
|
);
|
||||||
|
await applyDefaultsAfterHostWrite(savedHostId);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
results.failed++;
|
results.failed++;
|
||||||
results.errors.push(`Host ${i + 1}: ${getErrorMessage(error)}`);
|
results.errors.push(`Host ${i + 1}: ${getErrorMessage(error)}`);
|
||||||
@@ -947,6 +990,12 @@ export function registerHostBulkRoutes(
|
|||||||
|
|
||||||
const lookupKey = `${hostData.ip}:${hostData.port}:${hostData.username}`;
|
const lookupKey = `${hostData.ip}:${hostData.port}:${hostData.username}`;
|
||||||
const existing = existingHostMap?.get(lookupKey);
|
const existing = existingHostMap?.get(lookupKey);
|
||||||
|
await withHostDefaults(
|
||||||
|
userId,
|
||||||
|
existing?.id ?? null,
|
||||||
|
sshDataObj,
|
||||||
|
hostData as unknown as Record<string, unknown>,
|
||||||
|
);
|
||||||
|
|
||||||
if (existing) {
|
if (existing) {
|
||||||
await hostRepository.updateEncryptedForUser(
|
await hostRepository.updateEncryptedForUser(
|
||||||
@@ -954,10 +1003,15 @@ export function registerHostBulkRoutes(
|
|||||||
existing.id,
|
existing.id,
|
||||||
sshDataObj,
|
sshDataObj,
|
||||||
);
|
);
|
||||||
|
await applyDefaultsAfterHostWrite(existing.id);
|
||||||
results.updated++;
|
results.updated++;
|
||||||
} else {
|
} else {
|
||||||
sshDataObj.createdAt = new Date().toISOString();
|
sshDataObj.createdAt = new Date().toISOString();
|
||||||
await hostRepository.createEncryptedForUser(userId, sshDataObj);
|
const saved = await hostRepository.createEncryptedForUser(
|
||||||
|
userId,
|
||||||
|
sshDataObj,
|
||||||
|
);
|
||||||
|
await applyDefaultsAfterHostWrite(saved.id);
|
||||||
results.success++;
|
results.success++;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -1022,3 +1076,49 @@ function listKnownAuthTypes(): Set<string> {
|
|||||||
...listSshAuthTypeOwners().map((owner) => owner.type),
|
...listSshAuthTypeOwners().map((owner) => owner.type),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Fills an imported host's inherited keys and records what it sets itself. */
|
||||||
|
async function withHostDefaults(
|
||||||
|
userId: string,
|
||||||
|
hostId: number | null,
|
||||||
|
columns: Record<string, unknown>,
|
||||||
|
body: Record<string, unknown>,
|
||||||
|
): Promise<void> {
|
||||||
|
const stored =
|
||||||
|
hostId === null
|
||||||
|
? null
|
||||||
|
: ((
|
||||||
|
await createCurrentHostDefaultsRepository().listHosts({
|
||||||
|
hostIds: [hostId],
|
||||||
|
})
|
||||||
|
)[0] ?? null);
|
||||||
|
columns.defaultOverrides = JSON.stringify(
|
||||||
|
await applyHostDefaultsToWrite({
|
||||||
|
ownerId: userId,
|
||||||
|
hostId,
|
||||||
|
columns,
|
||||||
|
body,
|
||||||
|
stored,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A resetDefaults body as an override change, or null when there is none. */
|
||||||
|
export function readDefaultsReset(raw: unknown): OverrideChange | null {
|
||||||
|
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
|
||||||
|
const source = raw as Record<string, unknown>;
|
||||||
|
const strings = (value: unknown) =>
|
||||||
|
Array.isArray(value)
|
||||||
|
? value.filter((item): item is string => typeof item === "string")
|
||||||
|
: [];
|
||||||
|
const change: OverrideChange = {
|
||||||
|
inheritAll: source.all === true,
|
||||||
|
inheritNamespaces: strings(source.namespaces),
|
||||||
|
inherit: strings(source.keys).map(splitDefaultKey),
|
||||||
|
};
|
||||||
|
return change.inheritAll ||
|
||||||
|
change.inheritNamespaces!.length > 0 ||
|
||||||
|
change.inherit!.length > 0
|
||||||
|
? change
|
||||||
|
: null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,708 @@
|
|||||||
|
import type { Request, RequestHandler, Response, Router } from "express";
|
||||||
|
import type { AuthenticatedRequest } from "../../../types/index.js";
|
||||||
|
import { databaseLogger } from "../../utils/logger.js";
|
||||||
|
import {
|
||||||
|
getRequestMeta,
|
||||||
|
logAudit,
|
||||||
|
getAuditUsername,
|
||||||
|
} from "../../utils/audit-logger.js";
|
||||||
|
import { PermissionManager } from "../../utils/permission-manager.js";
|
||||||
|
import { createCurrentHostDefaultsRepository } from "../repositories/factory.js";
|
||||||
|
import type { HostDefaultsScope } from "../repositories/host-defaults-repository.js";
|
||||||
|
import { findUsableCredential } from "../../hosts/usable-credential.js";
|
||||||
|
import {
|
||||||
|
currentCatalog,
|
||||||
|
materializeHosts,
|
||||||
|
} from "../../hosts/defaults/materialize.js";
|
||||||
|
import {
|
||||||
|
getRecomputeJob,
|
||||||
|
recompute,
|
||||||
|
startRecomputeJob,
|
||||||
|
targetForScope,
|
||||||
|
} from "../../hosts/defaults/recompute.js";
|
||||||
|
import {
|
||||||
|
levelsAbove,
|
||||||
|
readLevel,
|
||||||
|
resolveAll,
|
||||||
|
resolveForEditor,
|
||||||
|
saveLevel,
|
||||||
|
validateLevelChange,
|
||||||
|
type CheckContext,
|
||||||
|
type LevelChangeInput,
|
||||||
|
} from "../../hosts/defaults/service.js";
|
||||||
|
import { changeHostOverrides } from "../../hosts/defaults/overrides.js";
|
||||||
|
import { readDefaultsReset } from "./host-bulk-routes.js";
|
||||||
|
|
||||||
|
type HostDefaultsRoutesDeps = {
|
||||||
|
authenticateJWT: RequestHandler;
|
||||||
|
requireEditPermission: RequestHandler;
|
||||||
|
requireDataAccess: RequestHandler;
|
||||||
|
requireAdminSettings: RequestHandler;
|
||||||
|
};
|
||||||
|
|
||||||
|
function checkContext(actorId: string, ownerId: string | null): CheckContext {
|
||||||
|
const permissions = PermissionManager.getInstance();
|
||||||
|
return {
|
||||||
|
actorId,
|
||||||
|
ownerId,
|
||||||
|
canUseCredential: async (credentialId, userId) =>
|
||||||
|
(await findUsableCredential(credentialId, userId)) !== null,
|
||||||
|
canUseHost: async (hostId, userId) =>
|
||||||
|
(await permissions.canAccessHost(userId, hostId, "connect")).hasAccess,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function readChange(body: unknown): LevelChangeInput {
|
||||||
|
const source =
|
||||||
|
body && typeof body === "object" ? (body as Record<string, unknown>) : {};
|
||||||
|
return {
|
||||||
|
set:
|
||||||
|
source.set && typeof source.set === "object" && !Array.isArray(source.set)
|
||||||
|
? (source.set as Record<string, unknown>)
|
||||||
|
: {},
|
||||||
|
unset: Array.isArray(source.unset)
|
||||||
|
? source.unset.filter((key): key is string => typeof key === "string")
|
||||||
|
: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function describeLevel(
|
||||||
|
scope: HostDefaultsScope,
|
||||||
|
folderOwner?: { userId: string; name: string },
|
||||||
|
) {
|
||||||
|
const { catalog } = currentCatalog();
|
||||||
|
return {
|
||||||
|
values: await readLevel(scope),
|
||||||
|
inherited: resolveAll(catalog, await levelsAbove(scope, folderOwner)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The folder a user may edit defaults for, by id. */
|
||||||
|
async function ownedFolder(userId: string, folderId: number) {
|
||||||
|
const folder =
|
||||||
|
await createCurrentHostDefaultsRepository().findFolder(folderId);
|
||||||
|
return folder && folder.userId === userId ? folder : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerHostDefaultsRoutes(
|
||||||
|
router: Router,
|
||||||
|
{
|
||||||
|
authenticateJWT,
|
||||||
|
requireEditPermission,
|
||||||
|
requireDataAccess,
|
||||||
|
requireAdminSettings,
|
||||||
|
}: HostDefaultsRoutesDeps,
|
||||||
|
): void {
|
||||||
|
const save = async (
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
scope: HostDefaultsScope,
|
||||||
|
ownerId: string | null,
|
||||||
|
folderOwnerId?: string,
|
||||||
|
) => {
|
||||||
|
const actorId = (req as AuthenticatedRequest).userId;
|
||||||
|
const change = await validateLevelChange(
|
||||||
|
scope,
|
||||||
|
readChange(req.body),
|
||||||
|
checkContext(actorId, ownerId),
|
||||||
|
);
|
||||||
|
if (Object.keys(change.errors).length > 0) {
|
||||||
|
return res
|
||||||
|
.status(400)
|
||||||
|
.json({ error: "Some defaults were rejected", errors: change.errors });
|
||||||
|
}
|
||||||
|
await saveLevel(scope, change, actorId);
|
||||||
|
const keys = new Set([...change.set.keys(), ...change.unset]);
|
||||||
|
|
||||||
|
const { ipAddress, userAgent } = getRequestMeta(req);
|
||||||
|
await logAudit({
|
||||||
|
userId: actorId,
|
||||||
|
username: await getAuditUsername(actorId),
|
||||||
|
action: "update_host_defaults",
|
||||||
|
resourceType: "host_defaults",
|
||||||
|
resourceId:
|
||||||
|
scope.level === "admin"
|
||||||
|
? "admin"
|
||||||
|
: scope.level === "user"
|
||||||
|
? String(scope.userId)
|
||||||
|
: String(scope.folderId),
|
||||||
|
resourceName: scope.level,
|
||||||
|
details: JSON.stringify({ keys: [...keys] }),
|
||||||
|
ipAddress,
|
||||||
|
userAgent,
|
||||||
|
success: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const target = targetForScope(scope, folderOwnerId);
|
||||||
|
if (scope.level === "admin") {
|
||||||
|
const job = startRecomputeJob(target, keys);
|
||||||
|
return res.json({ jobId: job.id });
|
||||||
|
}
|
||||||
|
const result = await recompute(target, keys);
|
||||||
|
return res.json({ changedHosts: result.changedHostIds.length });
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @openapi
|
||||||
|
* /host/defaults/admin:
|
||||||
|
* get:
|
||||||
|
* summary: Get the server's host defaults
|
||||||
|
* description: The host defaults every user's hosts follow unless the user, a folder or the host sets its own. Returns the values set at this level and what each key reads without them.
|
||||||
|
* tags:
|
||||||
|
* - Host Defaults
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: Values and inherited values, keyed by "namespace.key".
|
||||||
|
* 403:
|
||||||
|
* description: Missing admin.settings.manage.
|
||||||
|
*/
|
||||||
|
router.get(
|
||||||
|
"/defaults/admin",
|
||||||
|
authenticateJWT,
|
||||||
|
requireAdminSettings,
|
||||||
|
async (_req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
res.json(await describeLevel({ level: "admin" }));
|
||||||
|
} catch (error) {
|
||||||
|
databaseLogger.error("Failed to read admin host defaults", error);
|
||||||
|
res.status(500).json({ error: "Failed to read host defaults" });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @openapi
|
||||||
|
* /host/defaults/admin:
|
||||||
|
* put:
|
||||||
|
* summary: Update the server's host defaults
|
||||||
|
* description: Sets and clears server-wide host defaults. Every inheriting host is updated in the background; poll the returned job.
|
||||||
|
* tags:
|
||||||
|
* - Host Defaults
|
||||||
|
* requestBody:
|
||||||
|
* required: true
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* type: object
|
||||||
|
* properties:
|
||||||
|
* set:
|
||||||
|
* type: object
|
||||||
|
* description: Values by "namespace.key".
|
||||||
|
* unset:
|
||||||
|
* type: array
|
||||||
|
* items:
|
||||||
|
* type: string
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: Saved. Returns the recompute job id.
|
||||||
|
* 400:
|
||||||
|
* description: One or more values were rejected.
|
||||||
|
* 403:
|
||||||
|
* description: Missing admin.settings.manage.
|
||||||
|
*/
|
||||||
|
router.put(
|
||||||
|
"/defaults/admin",
|
||||||
|
authenticateJWT,
|
||||||
|
requireAdminSettings,
|
||||||
|
async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
await save(req, res, { level: "admin" }, null);
|
||||||
|
} catch (error) {
|
||||||
|
databaseLogger.error("Failed to update admin host defaults", error);
|
||||||
|
res.status(500).json({ error: "Failed to update host defaults" });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @openapi
|
||||||
|
* /host/defaults/user:
|
||||||
|
* get:
|
||||||
|
* summary: Get your host defaults
|
||||||
|
* description: The defaults your hosts follow, over the server's. Returns the values you set and what each key reads without them.
|
||||||
|
* tags:
|
||||||
|
* - Host Defaults
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: Values and inherited values, keyed by "namespace.key".
|
||||||
|
*/
|
||||||
|
router.get(
|
||||||
|
"/defaults/user",
|
||||||
|
authenticateJWT,
|
||||||
|
requireDataAccess,
|
||||||
|
async (req: Request, res: Response) => {
|
||||||
|
const userId = (req as AuthenticatedRequest).userId;
|
||||||
|
try {
|
||||||
|
res.json(await describeLevel({ level: "user", userId }));
|
||||||
|
} catch (error) {
|
||||||
|
databaseLogger.error("Failed to read user host defaults", error);
|
||||||
|
res.status(500).json({ error: "Failed to read host defaults" });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @openapi
|
||||||
|
* /host/defaults/user:
|
||||||
|
* put:
|
||||||
|
* summary: Update your host defaults
|
||||||
|
* description: Sets and clears your own host defaults and updates every host of yours that follows them.
|
||||||
|
* tags:
|
||||||
|
* - Host Defaults
|
||||||
|
* requestBody:
|
||||||
|
* required: true
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* type: object
|
||||||
|
* properties:
|
||||||
|
* set:
|
||||||
|
* type: object
|
||||||
|
* unset:
|
||||||
|
* type: array
|
||||||
|
* items:
|
||||||
|
* type: string
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: Saved. Returns how many hosts changed.
|
||||||
|
* 400:
|
||||||
|
* description: One or more values were rejected.
|
||||||
|
*/
|
||||||
|
router.put(
|
||||||
|
"/defaults/user",
|
||||||
|
authenticateJWT,
|
||||||
|
requireEditPermission,
|
||||||
|
requireDataAccess,
|
||||||
|
async (req: Request, res: Response) => {
|
||||||
|
const userId = (req as AuthenticatedRequest).userId;
|
||||||
|
try {
|
||||||
|
await save(req, res, { level: "user", userId }, userId);
|
||||||
|
} catch (error) {
|
||||||
|
databaseLogger.error("Failed to update user host defaults", error);
|
||||||
|
res.status(500).json({ error: "Failed to update host defaults" });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @openapi
|
||||||
|
* /host/defaults/folders/{folderId}:
|
||||||
|
* get:
|
||||||
|
* summary: Get a folder's host defaults
|
||||||
|
* description: The defaults hosts in this folder and its subfolders follow, over yours and the server's.
|
||||||
|
* tags:
|
||||||
|
* - Host Defaults
|
||||||
|
* parameters:
|
||||||
|
* - in: path
|
||||||
|
* name: folderId
|
||||||
|
* required: true
|
||||||
|
* schema:
|
||||||
|
* type: integer
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: Values and inherited values, keyed by "namespace.key".
|
||||||
|
* 404:
|
||||||
|
* description: No such folder of yours.
|
||||||
|
*/
|
||||||
|
router.get(
|
||||||
|
"/defaults/folders/:folderId",
|
||||||
|
authenticateJWT,
|
||||||
|
requireDataAccess,
|
||||||
|
async (req: Request, res: Response) => {
|
||||||
|
const userId = (req as AuthenticatedRequest).userId;
|
||||||
|
const folder = await ownedFolder(userId, Number(req.params.folderId));
|
||||||
|
if (!folder) return res.status(404).json({ error: "Folder not found" });
|
||||||
|
try {
|
||||||
|
res.json(
|
||||||
|
await describeLevel(
|
||||||
|
{ level: "folder", folderId: folder.id, userId },
|
||||||
|
folder,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
databaseLogger.error("Failed to read folder host defaults", error);
|
||||||
|
res.status(500).json({ error: "Failed to read host defaults" });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @openapi
|
||||||
|
* /host/defaults/folders/{folderId}:
|
||||||
|
* put:
|
||||||
|
* summary: Update a folder's host defaults
|
||||||
|
* description: Sets and clears a folder's host defaults and updates every host in it (and its subfolders) that follows them.
|
||||||
|
* tags:
|
||||||
|
* - Host Defaults
|
||||||
|
* parameters:
|
||||||
|
* - in: path
|
||||||
|
* name: folderId
|
||||||
|
* required: true
|
||||||
|
* schema:
|
||||||
|
* type: integer
|
||||||
|
* requestBody:
|
||||||
|
* required: true
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* type: object
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: Saved. Returns how many hosts changed.
|
||||||
|
* 400:
|
||||||
|
* description: One or more values were rejected.
|
||||||
|
* 404:
|
||||||
|
* description: No such folder of yours.
|
||||||
|
*/
|
||||||
|
router.put(
|
||||||
|
"/defaults/folders/:folderId",
|
||||||
|
authenticateJWT,
|
||||||
|
requireEditPermission,
|
||||||
|
requireDataAccess,
|
||||||
|
async (req: Request, res: Response) => {
|
||||||
|
const userId = (req as AuthenticatedRequest).userId;
|
||||||
|
const folder = await ownedFolder(userId, Number(req.params.folderId));
|
||||||
|
if (!folder) return res.status(404).json({ error: "Folder not found" });
|
||||||
|
try {
|
||||||
|
await save(
|
||||||
|
req,
|
||||||
|
res,
|
||||||
|
{ level: "folder", folderId: folder.id, userId },
|
||||||
|
userId,
|
||||||
|
userId,
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
databaseLogger.error("Failed to update folder host defaults", error);
|
||||||
|
res.status(500).json({ error: "Failed to update host defaults" });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @openapi
|
||||||
|
* /host/defaults/folders:
|
||||||
|
* post:
|
||||||
|
* summary: Get or create a folder's defaults id
|
||||||
|
* description: A folder can exist only as a path on its hosts. This returns its id, creating the folder row when needed, so its defaults can be edited.
|
||||||
|
* tags:
|
||||||
|
* - Host Defaults
|
||||||
|
* requestBody:
|
||||||
|
* required: true
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* type: object
|
||||||
|
* properties:
|
||||||
|
* name:
|
||||||
|
* type: string
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: The folder's id.
|
||||||
|
* 400:
|
||||||
|
* description: Missing folder name.
|
||||||
|
*/
|
||||||
|
router.post(
|
||||||
|
"/defaults/folders",
|
||||||
|
authenticateJWT,
|
||||||
|
requireEditPermission,
|
||||||
|
requireDataAccess,
|
||||||
|
async (req: Request, res: Response) => {
|
||||||
|
const userId = (req as AuthenticatedRequest).userId;
|
||||||
|
const name = typeof req.body?.name === "string" ? req.body.name : "";
|
||||||
|
if (!name.trim()) {
|
||||||
|
return res.status(400).json({ error: "Folder name is required" });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const id = await createCurrentHostDefaultsRepository().ensureFolder(
|
||||||
|
userId,
|
||||||
|
name,
|
||||||
|
);
|
||||||
|
res.json({ id });
|
||||||
|
} catch (error) {
|
||||||
|
databaseLogger.error("Failed to find folder for host defaults", error);
|
||||||
|
res.status(500).json({ error: "Failed to find the folder" });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @openapi
|
||||||
|
* /host/defaults/preview:
|
||||||
|
* post:
|
||||||
|
* summary: Preview a host defaults change
|
||||||
|
* description: How many hosts would change if a level were saved with these values. Nothing is written.
|
||||||
|
* tags:
|
||||||
|
* - Host Defaults
|
||||||
|
* requestBody:
|
||||||
|
* required: true
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* type: object
|
||||||
|
* properties:
|
||||||
|
* level:
|
||||||
|
* type: string
|
||||||
|
* enum: [admin, user, folder]
|
||||||
|
* folderId:
|
||||||
|
* type: integer
|
||||||
|
* set:
|
||||||
|
* type: object
|
||||||
|
* unset:
|
||||||
|
* type: array
|
||||||
|
* items:
|
||||||
|
* type: string
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: The number of hosts that would change.
|
||||||
|
* 403:
|
||||||
|
* description: Not allowed to change that level.
|
||||||
|
*/
|
||||||
|
router.post(
|
||||||
|
"/defaults/preview",
|
||||||
|
authenticateJWT,
|
||||||
|
requireDataAccess,
|
||||||
|
async (req: Request, res: Response) => {
|
||||||
|
const userId = (req as AuthenticatedRequest).userId;
|
||||||
|
const level = req.body?.level;
|
||||||
|
try {
|
||||||
|
let target;
|
||||||
|
let overlayUserId: string | undefined;
|
||||||
|
let folderId: number | undefined;
|
||||||
|
if (level === "admin") {
|
||||||
|
const permissions = PermissionManager.getInstance();
|
||||||
|
if (
|
||||||
|
!(await permissions.hasPermission(userId, "admin.settings.manage"))
|
||||||
|
) {
|
||||||
|
return res.status(403).json({ error: "Access denied" });
|
||||||
|
}
|
||||||
|
target = { all: true };
|
||||||
|
} else if (level === "user") {
|
||||||
|
target = { userIds: [userId] };
|
||||||
|
overlayUserId = userId;
|
||||||
|
} else if (level === "folder") {
|
||||||
|
const folder = await ownedFolder(userId, Number(req.body?.folderId));
|
||||||
|
if (!folder)
|
||||||
|
return res.status(404).json({ error: "Folder not found" });
|
||||||
|
target = { userIds: [userId] };
|
||||||
|
folderId = folder.id;
|
||||||
|
} else {
|
||||||
|
return res.status(400).json({ error: "Unknown level" });
|
||||||
|
}
|
||||||
|
const change = readChange(req.body);
|
||||||
|
const { catalog } = currentCatalog();
|
||||||
|
const set = new Map<string, unknown>();
|
||||||
|
for (const [key, value] of Object.entries(change.set ?? {})) {
|
||||||
|
const info = catalog.get(key);
|
||||||
|
if (info) set.set(key, info.normalize(value));
|
||||||
|
}
|
||||||
|
const result = await materializeHosts(target, {
|
||||||
|
dryRun: true,
|
||||||
|
overlay: {
|
||||||
|
level,
|
||||||
|
userId: overlayUserId,
|
||||||
|
folderId,
|
||||||
|
set,
|
||||||
|
unset: new Set(change.unset ?? []),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
res.json({ changedHosts: result.changedHostIds.length });
|
||||||
|
} catch (error) {
|
||||||
|
databaseLogger.error("Failed to preview host defaults", error);
|
||||||
|
res.status(500).json({ error: "Failed to preview host defaults" });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @openapi
|
||||||
|
* /host/defaults/resolve:
|
||||||
|
* get:
|
||||||
|
* summary: Resolve host defaults for a host
|
||||||
|
* description: What every key resolves to for a host, or for a new host in a folder or under a parent host, and where each value comes from.
|
||||||
|
* tags:
|
||||||
|
* - Host Defaults
|
||||||
|
* parameters:
|
||||||
|
* - in: query
|
||||||
|
* name: hostId
|
||||||
|
* schema:
|
||||||
|
* type: integer
|
||||||
|
* - in: query
|
||||||
|
* name: folder
|
||||||
|
* schema:
|
||||||
|
* type: string
|
||||||
|
* - in: query
|
||||||
|
* name: parentHostId
|
||||||
|
* schema:
|
||||||
|
* type: integer
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: Resolved values with their source, keyed by "namespace.key".
|
||||||
|
* 404:
|
||||||
|
* description: No such host you can edit.
|
||||||
|
*/
|
||||||
|
router.get(
|
||||||
|
"/defaults/resolve",
|
||||||
|
authenticateJWT,
|
||||||
|
requireDataAccess,
|
||||||
|
async (req: Request, res: Response) => {
|
||||||
|
const userId = (req as AuthenticatedRequest).userId;
|
||||||
|
try {
|
||||||
|
let ownerId = userId;
|
||||||
|
const hostId =
|
||||||
|
req.query.hostId !== undefined ? Number(req.query.hostId) : null;
|
||||||
|
if (hostId !== null) {
|
||||||
|
if (!Number.isInteger(hostId)) {
|
||||||
|
return res.status(400).json({ error: "Invalid host ID" });
|
||||||
|
}
|
||||||
|
const access = await PermissionManager.getInstance().canAccessHost(
|
||||||
|
userId,
|
||||||
|
hostId,
|
||||||
|
"edit",
|
||||||
|
);
|
||||||
|
if (!access.hasAccess) {
|
||||||
|
return res.status(404).json({ error: "Host not found" });
|
||||||
|
}
|
||||||
|
const [row] = await createCurrentHostDefaultsRepository().listHosts({
|
||||||
|
hostIds: [hostId],
|
||||||
|
});
|
||||||
|
if (!row) return res.status(404).json({ error: "Host not found" });
|
||||||
|
ownerId = row.userId;
|
||||||
|
}
|
||||||
|
const folder =
|
||||||
|
typeof req.query.folder === "string" ? req.query.folder : undefined;
|
||||||
|
const parentHostId =
|
||||||
|
req.query.parentHostId !== undefined && req.query.parentHostId !== ""
|
||||||
|
? Number(req.query.parentHostId)
|
||||||
|
: undefined;
|
||||||
|
res.json({
|
||||||
|
values: await resolveForEditor({
|
||||||
|
ownerId,
|
||||||
|
hostId,
|
||||||
|
folder: folder === undefined ? undefined : folder || null,
|
||||||
|
parentHostId:
|
||||||
|
parentHostId === undefined || !Number.isInteger(parentHostId)
|
||||||
|
? folder !== undefined
|
||||||
|
? null
|
||||||
|
: undefined
|
||||||
|
: parentHostId,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
databaseLogger.error("Failed to resolve host defaults", error);
|
||||||
|
res.status(500).json({ error: "Failed to resolve host defaults" });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @openapi
|
||||||
|
* /host/defaults/jobs/{id}:
|
||||||
|
* get:
|
||||||
|
* summary: Host defaults job status
|
||||||
|
* description: Progress of a server-wide host defaults update.
|
||||||
|
* tags:
|
||||||
|
* - Host Defaults
|
||||||
|
* parameters:
|
||||||
|
* - in: path
|
||||||
|
* name: id
|
||||||
|
* required: true
|
||||||
|
* schema:
|
||||||
|
* type: string
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: The job's status and how many hosts changed.
|
||||||
|
* 404:
|
||||||
|
* description: No such job.
|
||||||
|
*/
|
||||||
|
router.get(
|
||||||
|
"/defaults/jobs/:id",
|
||||||
|
authenticateJWT,
|
||||||
|
requireAdminSettings,
|
||||||
|
(req: Request, res: Response) => {
|
||||||
|
const job = getRecomputeJob(String(req.params.id));
|
||||||
|
if (!job) return res.status(404).json({ error: "Job not found" });
|
||||||
|
res.json(job);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @openapi
|
||||||
|
* /host/db/host/{id}/reset-defaults:
|
||||||
|
* post:
|
||||||
|
* summary: Reset a host to its defaults
|
||||||
|
* description: Hands keys the host set itself back to its host defaults. `all`, or `namespaces` ("core" or a plugin id), or `keys` as "namespace.key".
|
||||||
|
* tags:
|
||||||
|
* - Host Defaults
|
||||||
|
* parameters:
|
||||||
|
* - in: path
|
||||||
|
* name: id
|
||||||
|
* required: true
|
||||||
|
* schema:
|
||||||
|
* type: integer
|
||||||
|
* requestBody:
|
||||||
|
* required: true
|
||||||
|
* content:
|
||||||
|
* application/json:
|
||||||
|
* schema:
|
||||||
|
* type: object
|
||||||
|
* properties:
|
||||||
|
* all:
|
||||||
|
* type: boolean
|
||||||
|
* namespaces:
|
||||||
|
* type: array
|
||||||
|
* items:
|
||||||
|
* type: string
|
||||||
|
* keys:
|
||||||
|
* type: array
|
||||||
|
* items:
|
||||||
|
* type: string
|
||||||
|
* responses:
|
||||||
|
* 200:
|
||||||
|
* description: Reset.
|
||||||
|
* 400:
|
||||||
|
* description: Nothing to reset.
|
||||||
|
* 403:
|
||||||
|
* description: No edit access to the host.
|
||||||
|
*/
|
||||||
|
router.post(
|
||||||
|
"/db/host/:id/reset-defaults",
|
||||||
|
authenticateJWT,
|
||||||
|
requireEditPermission,
|
||||||
|
requireDataAccess,
|
||||||
|
async (req: Request, res: Response) => {
|
||||||
|
const userId = (req as AuthenticatedRequest).userId;
|
||||||
|
const hostId = Number(req.params.id);
|
||||||
|
if (!Number.isInteger(hostId)) {
|
||||||
|
return res.status(400).json({ error: "Invalid host ID" });
|
||||||
|
}
|
||||||
|
const reset = readDefaultsReset(req.body);
|
||||||
|
if (!reset) return res.status(400).json({ error: "Nothing to reset" });
|
||||||
|
try {
|
||||||
|
const access = await PermissionManager.getInstance().canAccessHost(
|
||||||
|
userId,
|
||||||
|
hostId,
|
||||||
|
"edit",
|
||||||
|
);
|
||||||
|
if (!access.hasAccess) {
|
||||||
|
return res.status(403).json({ error: "Access denied" });
|
||||||
|
}
|
||||||
|
if (!access.isOwner) {
|
||||||
|
// A shared editor never takes over the owner's SSH login.
|
||||||
|
reset.inherit = (reset.inherit ?? []).filter(
|
||||||
|
([namespace, key]) => !(namespace === "core" && key === "auth"),
|
||||||
|
);
|
||||||
|
if (reset.inheritAll || reset.inheritNamespaces?.includes("core")) {
|
||||||
|
return res.status(403).json({
|
||||||
|
error: "Only the host owner can reset the host's SSH login",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await changeHostOverrides([hostId], reset);
|
||||||
|
res.json({ message: "Host reset to its defaults" });
|
||||||
|
} catch (error) {
|
||||||
|
databaseLogger.error("Failed to reset host defaults", error);
|
||||||
|
res.status(500).json({ error: "Failed to reset host defaults" });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -11,6 +11,8 @@ import {
|
|||||||
createCurrentSshCredentialUsageRepository,
|
createCurrentSshCredentialUsageRepository,
|
||||||
} from "../repositories/factory.js";
|
} from "../repositories/factory.js";
|
||||||
import { isNonEmptyString } from "./host-normalizers.js";
|
import { isNonEmptyString } from "./host-normalizers.js";
|
||||||
|
import { recompute } from "../../hosts/defaults/recompute.js";
|
||||||
|
import { setFolderCredentialDefault } from "../../hosts/defaults/service.js";
|
||||||
|
|
||||||
type HostFolderRoutesDeps = {
|
type HostFolderRoutesDeps = {
|
||||||
authenticateJWT: RequestHandler;
|
authenticateJWT: RequestHandler;
|
||||||
@@ -92,6 +94,8 @@ export function registerHostFolderRoutes(
|
|||||||
oldName,
|
oldName,
|
||||||
newName,
|
newName,
|
||||||
);
|
);
|
||||||
|
// Subfolders change parents with a rename, so their chains do too.
|
||||||
|
void recompute({ userIds: [userId] }).catch(() => {});
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
message: "Folder renamed successfully",
|
message: "Folder renamed successfully",
|
||||||
@@ -243,6 +247,14 @@ export function registerHostFolderRoutes(
|
|||||||
localOnly,
|
localOnly,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (normalizedCredentialId !== undefined) {
|
||||||
|
await setFolderCredentialDefault(
|
||||||
|
userId,
|
||||||
|
folder.id,
|
||||||
|
normalizedCredentialId,
|
||||||
|
);
|
||||||
|
void recompute({ userIds: [userId] }).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
if (!created) {
|
if (!created) {
|
||||||
databaseLogger.info("Updating SSH folder", {
|
databaseLogger.info("Updating SSH folder", {
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { parseDefaultOverrides } from "../../../types/host-defaults.js";
|
||||||
import {
|
import {
|
||||||
parseSshOptions,
|
parseSshOptions,
|
||||||
type HostSshOptions,
|
type HostSshOptions,
|
||||||
@@ -481,5 +482,6 @@ export function transformHostResponse(
|
|||||||
portKnockSequence: host.portKnockSequence
|
portKnockSequence: host.portKnockSequence
|
||||||
? JSON.parse(host.portKnockSequence as string)
|
? JSON.parse(host.portKnockSequence as string)
|
||||||
: [],
|
: [],
|
||||||
|
defaultOverrides: parseDefaultOverrides(host.defaultOverrides),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -224,14 +224,23 @@ export function attachHostPluginSettings(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The single-host convenience wrapper. */
|
/**
|
||||||
|
* The single-host convenience wrapper. With a viewer, a shared host's
|
||||||
|
* personal fields come from that viewer's own defaults.
|
||||||
|
*/
|
||||||
export async function withHostPluginSettings(
|
export async function withHostPluginSettings(
|
||||||
host: Record<string, unknown>,
|
host: Record<string, unknown>,
|
||||||
|
viewerId?: string,
|
||||||
): Promise<Record<string, unknown>> {
|
): Promise<Record<string, unknown>> {
|
||||||
const hostId = Number(host.id);
|
const hostId = Number(host.id);
|
||||||
if (!Number.isInteger(hostId)) return host;
|
if (!Number.isInteger(hostId)) return host;
|
||||||
|
|
||||||
const settings = await loadHostPluginSettings([hostId]);
|
const settings = await loadHostPluginSettings([hostId]);
|
||||||
|
if (viewerId) {
|
||||||
|
const { applyPersonalHostValues } =
|
||||||
|
await import("../../hosts/defaults/personal.js");
|
||||||
|
await applyPersonalHostValues(settings, viewerId).catch(() => {});
|
||||||
|
}
|
||||||
const values = settings.get(hostId);
|
const values = settings.get(hostId);
|
||||||
if (!values) return host;
|
if (!values) return host;
|
||||||
const result: Record<string, unknown> = {
|
const result: Record<string, unknown> = {
|
||||||
@@ -391,5 +400,8 @@ export async function setHostPluginEnabled(
|
|||||||
await setSetting(manifest, "host", hostId, enableKey, enabled);
|
await setSetting(manifest, "host", hostId, enableKey, enabled);
|
||||||
await touchHost(hostId);
|
await touchHost(hostId);
|
||||||
}
|
}
|
||||||
|
const { changeHostOverrides } =
|
||||||
|
await import("../../hosts/defaults/overrides.js");
|
||||||
|
await changeHostOverrides(hostIds, { own: [[pluginId, enableKey]] });
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -26,6 +26,7 @@ import {
|
|||||||
createCurrentHostRepository,
|
createCurrentHostRepository,
|
||||||
createCurrentUserRepository,
|
createCurrentUserRepository,
|
||||||
createCurrentSharedHostAuthOverrideRepository,
|
createCurrentSharedHostAuthOverrideRepository,
|
||||||
|
createCurrentHostDefaultsRepository,
|
||||||
} from "../repositories/factory.js";
|
} from "../repositories/factory.js";
|
||||||
import {
|
import {
|
||||||
applyHostKeyTypeUpdate,
|
applyHostKeyTypeUpdate,
|
||||||
@@ -51,6 +52,7 @@ import { validateParentHostId } from "./host-parent-validation.js";
|
|||||||
import { registerHostFolderRoutes } from "./host-folder-routes.js";
|
import { registerHostFolderRoutes } from "./host-folder-routes.js";
|
||||||
import { registerHostNetworkRoutes } from "./host-network-routes.js";
|
import { registerHostNetworkRoutes } from "./host-network-routes.js";
|
||||||
import { registerHostBulkRoutes } from "./host-bulk-routes.js";
|
import { registerHostBulkRoutes } from "./host-bulk-routes.js";
|
||||||
|
import { registerHostDefaultsRoutes } from "./host-defaults-routes.js";
|
||||||
import { registerHostStatusRoutes } from "./host-status-routes.js";
|
import { registerHostStatusRoutes } from "./host-status-routes.js";
|
||||||
import {
|
import {
|
||||||
applyHostEnrollmentDefaults,
|
applyHostEnrollmentDefaults,
|
||||||
@@ -71,6 +73,11 @@ import {
|
|||||||
} from "../../utils/shared-host-auth-resolver.js";
|
} from "../../utils/shared-host-auth-resolver.js";
|
||||||
import { rejectSharedCopyWrites } from "../../sync/shared-copy-guard.js";
|
import { rejectSharedCopyWrites } from "../../sync/shared-copy-guard.js";
|
||||||
import { sshOptionsForWrite } from "../../hosts/ssh-options.js";
|
import { sshOptionsForWrite } from "../../hosts/ssh-options.js";
|
||||||
|
import {
|
||||||
|
applyDefaultsAfterHostWrite,
|
||||||
|
applyHostDefaultsToWrite,
|
||||||
|
} from "../../hosts/defaults/index.js";
|
||||||
|
import { applyPersonalHostValues } from "../../hosts/defaults/personal.js";
|
||||||
import {
|
import {
|
||||||
applyProtocolAuthPlan,
|
applyProtocolAuthPlan,
|
||||||
attachProtocolAuth,
|
attachProtocolAuth,
|
||||||
@@ -385,6 +392,14 @@ router.post(
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
sshDataObj.defaultOverrides = JSON.stringify(
|
||||||
|
await applyHostDefaultsToWrite({
|
||||||
|
ownerId: userId,
|
||||||
|
hostId: null,
|
||||||
|
columns: sshDataObj,
|
||||||
|
body: hostData,
|
||||||
|
}),
|
||||||
|
);
|
||||||
const result = await createCurrentHostRepository().createEncryptedForUser(
|
const result = await createCurrentHostRepository().createEncryptedForUser(
|
||||||
userId,
|
userId,
|
||||||
sshDataObj,
|
sshDataObj,
|
||||||
@@ -402,6 +417,7 @@ router.post(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const createdHost = result;
|
const createdHost = result;
|
||||||
|
await applyDefaultsAfterHostWrite(createdHost.id as number);
|
||||||
if (protocolAuthPatch) {
|
if (protocolAuthPatch) {
|
||||||
await writeProtocolAuth(
|
await writeProtocolAuth(
|
||||||
userId,
|
userId,
|
||||||
@@ -1072,11 +1088,37 @@ router.put(
|
|||||||
return res.status(400).json({ error: terminalFieldsError });
|
return res.status(400).json({ error: terminalFieldsError });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const storedRow = (
|
||||||
|
await createCurrentHostDefaultsRepository().listHosts({
|
||||||
|
hostIds: [Number(hostId)],
|
||||||
|
})
|
||||||
|
)[0];
|
||||||
|
sshDataObj.defaultOverrides = JSON.stringify(
|
||||||
|
await applyHostDefaultsToWrite({
|
||||||
|
ownerId,
|
||||||
|
hostId: Number(hostId),
|
||||||
|
columns: sshDataObj,
|
||||||
|
body: hostData,
|
||||||
|
stored: storedRow,
|
||||||
|
lockedKeys: accessInfo.isOwner ? [] : ["auth"],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
await createCurrentHostRepository().updateEncryptedForUser(
|
await createCurrentHostRepository().updateEncryptedForUser(
|
||||||
ownerId,
|
ownerId,
|
||||||
Number(hostId),
|
Number(hostId),
|
||||||
sshDataObj,
|
sshDataObj,
|
||||||
);
|
);
|
||||||
|
await applyDefaultsAfterHostWrite(Number(hostId), {
|
||||||
|
moved:
|
||||||
|
!!storedRow &&
|
||||||
|
((sshDataObj.folder !== undefined &&
|
||||||
|
(sshDataObj.folder ?? null) !== (storedRow.folder ?? null)) ||
|
||||||
|
(sshDataObj.parentHostId !== undefined &&
|
||||||
|
(sshDataObj.parentHostId ?? null) !==
|
||||||
|
(storedRow.parentHostId ?? null))),
|
||||||
|
ownerId,
|
||||||
|
});
|
||||||
if (protocolAuthPlan) {
|
if (protocolAuthPlan) {
|
||||||
await applyProtocolAuthPlan(ownerId, Number(hostId), protocolAuthPlan);
|
await applyProtocolAuthPlan(ownerId, Number(hostId), protocolAuthPlan);
|
||||||
}
|
}
|
||||||
@@ -1296,14 +1338,15 @@ router.get(
|
|||||||
|
|
||||||
// After sanitizing: the connect-level projection reduces a shared host
|
// After sanitizing: the connect-level projection reduces a shared host
|
||||||
// to an allowlist, which would drop this again. One query for the list.
|
// to an allowlist, which would drop this again. One query for the list.
|
||||||
attachHostPluginSettings(
|
const pluginSettingsByHost = await loadHostPluginSettings(
|
||||||
sanitized,
|
sanitized
|
||||||
await loadHostPluginSettings(
|
.map((host) => Number(host.id))
|
||||||
sanitized
|
.filter((id) => Number.isInteger(id)),
|
||||||
.map((host) => Number(host.id))
|
|
||||||
.filter((id) => Number.isInteger(id)),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
|
await applyPersonalHostValues(pluginSettingsByHost, userId).catch(
|
||||||
|
() => {},
|
||||||
|
);
|
||||||
|
attachHostPluginSettings(sanitized, pluginSettingsByHost);
|
||||||
|
|
||||||
res.json(sanitized);
|
res.json(sanitized);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -1432,6 +1475,7 @@ router.get(
|
|||||||
resolvedSharedResult,
|
resolvedSharedResult,
|
||||||
accessInfo.permissionLevel,
|
accessInfo.permissionLevel,
|
||||||
),
|
),
|
||||||
|
userId,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -2301,6 +2345,15 @@ registerHostNetworkRoutes(router, {
|
|||||||
requireDataAccess,
|
requireDataAccess,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
registerHostDefaultsRoutes(router, {
|
||||||
|
authenticateJWT,
|
||||||
|
requireEditPermission: permissionManager.requirePermission("hosts.edit"),
|
||||||
|
requireDataAccess,
|
||||||
|
requireAdminSettings: permissionManager.requirePermission(
|
||||||
|
"admin.settings.manage",
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|
||||||
/** Seconds between status checks, or null to follow the global setting. */
|
/** Seconds between status checks, or null to follow the global setting. */
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ import {
|
|||||||
} from "../repositories/factory.js";
|
} from "../repositories/factory.js";
|
||||||
import { getPluginRuntime } from "../../plugins/index.js";
|
import { getPluginRuntime } from "../../plugins/index.js";
|
||||||
import { describePluginFrontend } from "../../plugins/assets.js";
|
import { describePluginFrontend } from "../../plugins/assets.js";
|
||||||
import { getSetting } from "../../plugins/settings.js";
|
|
||||||
import { invalidatePluginPermissionCache } from "../../plugins/permissions.js";
|
import { invalidatePluginPermissionCache } from "../../plugins/permissions.js";
|
||||||
import { getPluginPublicHttpRoutes } from "../../plugins/http.js";
|
import { getPluginPublicHttpRoutes } from "../../plugins/http.js";
|
||||||
import { getPluginPublicWsRoutes } from "../../plugins/ws.js";
|
import { getPluginPublicWsRoutes } from "../../plugins/ws.js";
|
||||||
@@ -254,7 +253,7 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => {
|
|||||||
dependencies?: Record<string, string>;
|
dependencies?: Record<string, string>;
|
||||||
optionalDependencies?: Record<string, string>;
|
optionalDependencies?: Record<string, string>;
|
||||||
};
|
};
|
||||||
contributes = await withHostFieldDefaults(manifest as PluginManifest);
|
contributes = manifest?.contributes ?? null;
|
||||||
capabilities = Array.isArray(manifest?.capabilities)
|
capabilities = Array.isArray(manifest?.capabilities)
|
||||||
? (manifest.capabilities as string[])
|
? (manifest.capabilities as string[])
|
||||||
: [];
|
: [];
|
||||||
@@ -1189,7 +1188,7 @@ router.get(
|
|||||||
* /plugins/{id}/settings/host/{hostId}:
|
* /plugins/{id}/settings/host/{hostId}:
|
||||||
* put:
|
* put:
|
||||||
* summary: Update a plugin's settings for one host
|
* summary: Update a plugin's settings for one host
|
||||||
* description: Writes host-scope values. Requires edit access to the host, so a user who may only connect to a shared host cannot change how a plugin treats it.
|
* description: Writes host-scope values. Requires edit access to the host, so a user who may only connect to a shared host cannot change how a plugin treats it. A value written here becomes the host's own rather than following the host defaults; list keys in `_inherit` to hand them back to the defaults.
|
||||||
* tags:
|
* tags:
|
||||||
* - Plugins
|
* - Plugins
|
||||||
* parameters:
|
* parameters:
|
||||||
@@ -1253,12 +1252,19 @@ router.put(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { _inherit: inheritRaw, ...written } = req.body as Record<
|
||||||
|
string,
|
||||||
|
unknown
|
||||||
|
>;
|
||||||
|
const inherit = Array.isArray(inheritRaw)
|
||||||
|
? inheritRaw.filter((key): key is string => typeof key === "string")
|
||||||
|
: [];
|
||||||
const errors = await applySettings(
|
const errors = await applySettings(
|
||||||
userId,
|
userId,
|
||||||
manifest,
|
manifest,
|
||||||
"host",
|
"host",
|
||||||
String(hostId),
|
String(hostId),
|
||||||
req.body,
|
written,
|
||||||
{ isHostOwner: access.isOwner === true },
|
{ isHostOwner: access.isOwner === true },
|
||||||
);
|
);
|
||||||
if (Object.keys(errors).length > 0) {
|
if (Object.keys(errors).length > 0) {
|
||||||
@@ -1267,6 +1273,14 @@ router.put(
|
|||||||
}
|
}
|
||||||
const { touchHost } = await import("./host-plugin-settings.js");
|
const { touchHost } = await import("./host-plugin-settings.js");
|
||||||
await touchHost(hostId);
|
await touchHost(hostId);
|
||||||
|
// A value set here is the host's own; a key in _inherit follows the
|
||||||
|
// host defaults again.
|
||||||
|
const { changeHostOverrides } =
|
||||||
|
await import("../../hosts/defaults/overrides.js");
|
||||||
|
await changeHostOverrides([hostId], {
|
||||||
|
own: Object.keys(written).map((key) => [pluginId, key]),
|
||||||
|
inherit: inherit.map((key) => [pluginId, key]),
|
||||||
|
});
|
||||||
|
|
||||||
const values = await getAllSettings(manifest, "host", hostId, {
|
const values = await getAllSettings(manifest, "host", hostId, {
|
||||||
redactSecrets: true,
|
redactSecrets: true,
|
||||||
@@ -1284,37 +1298,3 @@ router.put(
|
|||||||
);
|
);
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|
||||||
/**
|
|
||||||
* A host field with defaultFrom starts new hosts at an admin setting's value,
|
|
||||||
* so the host editor gets that value as the field's default.
|
|
||||||
*/
|
|
||||||
async function withHostFieldDefaults(
|
|
||||||
manifest: PluginManifest,
|
|
||||||
): Promise<unknown> {
|
|
||||||
const contributes = manifest?.contributes;
|
|
||||||
const host = contributes?.settings?.host;
|
|
||||||
if (!host?.fields.some((field) => field.defaultFrom)) {
|
|
||||||
return contributes ?? null;
|
|
||||||
}
|
|
||||||
const fields: PluginSettingsField[] = await Promise.all(
|
|
||||||
host.fields.map(async (field) => {
|
|
||||||
if (!field.defaultFrom) return field;
|
|
||||||
try {
|
|
||||||
const value = await getSetting(
|
|
||||||
manifest,
|
|
||||||
"admin",
|
|
||||||
null,
|
|
||||||
field.defaultFrom,
|
|
||||||
);
|
|
||||||
return value === undefined ? field : { ...field, default: value };
|
|
||||||
} catch {
|
|
||||||
return field;
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
return {
|
|
||||||
...contributes,
|
|
||||||
settings: { ...contributes.settings, host: { ...host, fields } },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -22,43 +22,6 @@ import {
|
|||||||
} from "../repositories/factory.js";
|
} from "../repositories/factory.js";
|
||||||
import type { UserRecord } from "../repositories/user-repository.js";
|
import type { UserRecord } from "../repositories/user-repository.js";
|
||||||
|
|
||||||
export type HostDefaults = {
|
|
||||||
useSocks5?: boolean;
|
|
||||||
socks5Host?: string;
|
|
||||||
socks5Port?: number;
|
|
||||||
socks5Username?: string;
|
|
||||||
socks5Password?: string;
|
|
||||||
credentialId?: number | null;
|
|
||||||
statusCheckEnabled?: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Keys host_defaults held before the terminal's new-host defaults became the
|
|
||||||
* ssh-terminal plugin's admin settings. Kept in the stored row for the boot
|
|
||||||
* copy and a downgrade, never served or overwritten here.
|
|
||||||
*/
|
|
||||||
const LEGACY_TERMINAL_HOST_DEFAULTS = [
|
|
||||||
"fontSize",
|
|
||||||
"fontFamily",
|
|
||||||
"theme",
|
|
||||||
"cursorStyle",
|
|
||||||
"cursorBlink",
|
|
||||||
"autoTmux",
|
|
||||||
"enableCommandHistory",
|
|
||||||
];
|
|
||||||
|
|
||||||
function parseHostDefaults(value: string | null): Record<string, unknown> {
|
|
||||||
if (!value) return {};
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(value);
|
|
||||||
return parsed && typeof parsed === "object" && !Array.isArray(parsed)
|
|
||||||
? parsed
|
|
||||||
: {};
|
|
||||||
} catch {
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getAdminActor(
|
async function getAdminActor(
|
||||||
userId: string | undefined,
|
userId: string | undefined,
|
||||||
): Promise<UserRecord | null> {
|
): Promise<UserRecord | null> {
|
||||||
@@ -559,95 +522,4 @@ export function registerUserSettingsRoutes(
|
|||||||
"update_notification_private_endpoints",
|
"update_notification_private_endpoints",
|
||||||
"notification endpoint",
|
"notification endpoint",
|
||||||
);
|
);
|
||||||
|
|
||||||
/**
|
|
||||||
* @openapi
|
|
||||||
* /users/host-defaults:
|
|
||||||
* get:
|
|
||||||
* summary: Get host creation defaults
|
|
||||||
* description: Returns the global default settings applied when creating a new host.
|
|
||||||
* tags:
|
|
||||||
* - Users
|
|
||||||
* responses:
|
|
||||||
* 200:
|
|
||||||
* description: Host defaults object.
|
|
||||||
* 500:
|
|
||||||
* description: Failed to get host defaults.
|
|
||||||
*/
|
|
||||||
router.get("/host-defaults", authenticateJWT, async (_req, res) => {
|
|
||||||
try {
|
|
||||||
const stored = parseHostDefaults(
|
|
||||||
await createCurrentSettingsRepository().get("host_defaults"),
|
|
||||||
);
|
|
||||||
for (const key of LEGACY_TERMINAL_HOST_DEFAULTS) delete stored[key];
|
|
||||||
res.json(stored as HostDefaults);
|
|
||||||
} catch (err) {
|
|
||||||
authLogger.error("Failed to get host defaults", err);
|
|
||||||
res.status(500).json({ error: "Failed to get host defaults" });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @openapi
|
|
||||||
* /users/host-defaults:
|
|
||||||
* patch:
|
|
||||||
* summary: Update host creation defaults (admin only)
|
|
||||||
* description: Sets global default settings applied when a new host is created.
|
|
||||||
* tags:
|
|
||||||
* - Users
|
|
||||||
* requestBody:
|
|
||||||
* required: true
|
|
||||||
* content:
|
|
||||||
* application/json:
|
|
||||||
* schema:
|
|
||||||
* type: object
|
|
||||||
* responses:
|
|
||||||
* 200:
|
|
||||||
* description: Host defaults updated.
|
|
||||||
* 403:
|
|
||||||
* description: Not authorized.
|
|
||||||
* 500:
|
|
||||||
* description: Failed to update host defaults.
|
|
||||||
*/
|
|
||||||
router.patch("/host-defaults", authenticateJWT, async (req, res) => {
|
|
||||||
const userId = (req as AuthenticatedRequest).userId;
|
|
||||||
try {
|
|
||||||
const actor = await getAdminActor(userId);
|
|
||||||
if (!actor) {
|
|
||||||
return res.status(403).json({ error: "Not authorized" });
|
|
||||||
}
|
|
||||||
const defaults: HostDefaults = { ...(req.body ?? {}) };
|
|
||||||
for (const key of LEGACY_TERMINAL_HOST_DEFAULTS) {
|
|
||||||
delete (defaults as Record<string, unknown>)[key];
|
|
||||||
}
|
|
||||||
const settings = createCurrentSettingsRepository();
|
|
||||||
const stored = parseHostDefaults(await settings.get("host_defaults"));
|
|
||||||
const legacy = Object.fromEntries(
|
|
||||||
LEGACY_TERMINAL_HOST_DEFAULTS.filter((key) => key in stored).map(
|
|
||||||
(key) => [key, stored[key]],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
await settings.set(
|
|
||||||
"host_defaults",
|
|
||||||
JSON.stringify({ ...legacy, ...defaults }),
|
|
||||||
);
|
|
||||||
|
|
||||||
const { ipAddress, userAgent } = getRequestMeta(req);
|
|
||||||
await logAudit({
|
|
||||||
userId,
|
|
||||||
username: actor.username ?? userId,
|
|
||||||
action: "update_host_defaults",
|
|
||||||
resourceType: "setting",
|
|
||||||
details: JSON.stringify(defaults),
|
|
||||||
ipAddress,
|
|
||||||
userAgent,
|
|
||||||
success: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
res.json(defaults);
|
|
||||||
} catch (err) {
|
|
||||||
authLogger.error("Failed to update host defaults", err);
|
|
||||||
res.status(500).json({ error: "Failed to update host defaults" });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
/**
|
||||||
|
* Every key a host can inherit: core's own catalog plus each running
|
||||||
|
* plugin's host fields, in one shape the resolver and materializer share.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type {
|
||||||
|
PluginManifest,
|
||||||
|
PluginSettingsField,
|
||||||
|
} from "@termix/plugin-sdk/manifest";
|
||||||
|
import { coerceSettingValue } from "@termix/plugin-sdk/settings";
|
||||||
|
import {
|
||||||
|
CORE_HOST_DEFAULTS,
|
||||||
|
CORE_NAMESPACE,
|
||||||
|
HOST_DEFAULTS_LEVELS,
|
||||||
|
joinDefaultKey,
|
||||||
|
type HostDefaultsLevel,
|
||||||
|
} from "../../../types/host-defaults.js";
|
||||||
|
import { declaredFields } from "../../plugins/settings.js";
|
||||||
|
|
||||||
|
export interface DefaultKeyInfo {
|
||||||
|
namespace: string;
|
||||||
|
key: string;
|
||||||
|
fullKey: string;
|
||||||
|
levels: readonly HostDefaultsLevel[];
|
||||||
|
/** Undefined when nothing is inherited unless some level sets it. */
|
||||||
|
builtin: unknown;
|
||||||
|
normalize: (value: unknown) => unknown;
|
||||||
|
personal: boolean;
|
||||||
|
/** Plugin keys only. */
|
||||||
|
field?: PluginSettingsField;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DefaultsCatalog = Map<string, DefaultKeyInfo>;
|
||||||
|
|
||||||
|
/** Whether a plugin's host field can have a default. */
|
||||||
|
export function isDefaultableField(field: PluginSettingsField): boolean {
|
||||||
|
if (field.type === "secret") return false;
|
||||||
|
if (field.type === "json" && field.secretKeys?.length) return false;
|
||||||
|
return field.defaultable !== false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A plugin's host fields that can have a default, enable switch included. */
|
||||||
|
export function defaultableHostFields(
|
||||||
|
manifest: PluginManifest,
|
||||||
|
): PluginSettingsField[] {
|
||||||
|
return declaredFields(manifest, "host").filter(isDefaultableField);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isUnset(value: unknown): boolean {
|
||||||
|
return value === undefined || value === null || value === "";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizePluginValue(
|
||||||
|
field: PluginSettingsField,
|
||||||
|
value: unknown,
|
||||||
|
): unknown {
|
||||||
|
// A row with no value reads as the manifest default, so compare that way.
|
||||||
|
if (isUnset(value)) return field.default ?? null;
|
||||||
|
try {
|
||||||
|
const coerced = coerceSettingValue(field, value);
|
||||||
|
return isUnset(coerced) ? (field.default ?? null) : coerced;
|
||||||
|
} catch {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function coreCatalogEntries(): DefaultKeyInfo[] {
|
||||||
|
return CORE_HOST_DEFAULTS.map((entry) => ({
|
||||||
|
namespace: CORE_NAMESPACE,
|
||||||
|
key: entry.key,
|
||||||
|
fullKey: joinDefaultKey(CORE_NAMESPACE, entry.key),
|
||||||
|
levels: entry.levels,
|
||||||
|
builtin: entry.builtin,
|
||||||
|
normalize: entry.normalize,
|
||||||
|
personal: false,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pluginCatalogEntries(
|
||||||
|
manifest: PluginManifest,
|
||||||
|
): DefaultKeyInfo[] {
|
||||||
|
return defaultableHostFields(manifest).map((field) => ({
|
||||||
|
namespace: manifest.id,
|
||||||
|
key: field.key,
|
||||||
|
fullKey: joinDefaultKey(manifest.id, field.key),
|
||||||
|
levels: field.defaultLevels?.length
|
||||||
|
? field.defaultLevels
|
||||||
|
: HOST_DEFAULTS_LEVELS,
|
||||||
|
builtin: normalizePluginValue(field, field.default),
|
||||||
|
normalize: (value: unknown) => normalizePluginValue(field, value),
|
||||||
|
personal: field.personal === true,
|
||||||
|
field,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildCatalog(manifests: PluginManifest[]): DefaultsCatalog {
|
||||||
|
const catalog: DefaultsCatalog = new Map();
|
||||||
|
for (const entry of coreCatalogEntries()) catalog.set(entry.fullKey, entry);
|
||||||
|
for (const manifest of manifests) {
|
||||||
|
for (const entry of pluginCatalogEntries(manifest)) {
|
||||||
|
catalog.set(entry.fullKey, entry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return catalog;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Namespaces the catalog covers: core plus each plugin with a defaultable field. */
|
||||||
|
export function catalogNamespaces(catalog: DefaultsCatalog): string[] {
|
||||||
|
return [...new Set([...catalog.values()].map((entry) => entry.namespace))];
|
||||||
|
}
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
/**
|
||||||
|
* Core host defaults in column form.
|
||||||
|
*
|
||||||
|
* A host row (or the column object a host route builds before its write) is
|
||||||
|
* read into a default's value and written back from one. Both directions go
|
||||||
|
* through the catalog's normalize, so a value compares the same however the
|
||||||
|
* row spelled it.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
findCoreHostDefault,
|
||||||
|
normalizeAuthDefault,
|
||||||
|
normalizeSocks5Default,
|
||||||
|
} from "../../../types/host-defaults.js";
|
||||||
|
import { parseSshOptions } from "../ssh-options.js";
|
||||||
|
|
||||||
|
type Columns = Record<string, unknown>;
|
||||||
|
|
||||||
|
const SSH_OPTION_DEFAULTS = [
|
||||||
|
"keepaliveInterval",
|
||||||
|
"keepaliveCountMax",
|
||||||
|
"allowLegacyAlgorithms",
|
||||||
|
"agentForwarding",
|
||||||
|
"environmentVariables",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
function isSshOptionDefault(key: string): boolean {
|
||||||
|
return (SSH_OPTION_DEFAULTS as readonly string[]).includes(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
function asBoolean(value: unknown): boolean {
|
||||||
|
return value === true || value === 1 || value === "1" || value === "true";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The value a core key has on a row, normalized. */
|
||||||
|
export function readCoreDefault(
|
||||||
|
key: string,
|
||||||
|
row: Columns,
|
||||||
|
storedSshOptions?: unknown,
|
||||||
|
): unknown {
|
||||||
|
const entry = findCoreHostDefault(key);
|
||||||
|
if (!entry) return undefined;
|
||||||
|
const sshOptions = parseSshOptions(
|
||||||
|
row.sshOptions !== undefined ? row.sshOptions : storedSshOptions,
|
||||||
|
) as Record<string, unknown>;
|
||||||
|
|
||||||
|
switch (key) {
|
||||||
|
case "username":
|
||||||
|
return entry.normalize(row.username);
|
||||||
|
case "sshPort":
|
||||||
|
return entry.normalize(row.sshPort ?? row.port);
|
||||||
|
case "auth":
|
||||||
|
return normalizeAuthDefault({
|
||||||
|
authType: row.authType,
|
||||||
|
credentialId: row.credentialId,
|
||||||
|
overrideCredentialUsername: asBoolean(row.overrideCredentialUsername),
|
||||||
|
agentSocketPath: sshOptions.agentSocketPath,
|
||||||
|
agentIdentity: sshOptions.agentIdentity,
|
||||||
|
});
|
||||||
|
case "forceKeyboardInteractive":
|
||||||
|
return entry.normalize(row.forceKeyboardInteractive);
|
||||||
|
case "socks5":
|
||||||
|
return normalizeSocks5Default({
|
||||||
|
useSocks5: asBoolean(row.useSocks5),
|
||||||
|
socks5Host: row.socks5Host,
|
||||||
|
socks5Port: row.socks5Port,
|
||||||
|
socks5Username: row.socks5Username,
|
||||||
|
socks5ProxyChain: row.socks5ProxyChain,
|
||||||
|
});
|
||||||
|
case "jumpHosts":
|
||||||
|
case "portKnockSequence":
|
||||||
|
case "statusCheckEnabled":
|
||||||
|
case "statusCheckInterval":
|
||||||
|
return entry.normalize(row[key]);
|
||||||
|
default:
|
||||||
|
if (isSshOptionDefault(key)) return entry.normalize(sshOptions[key]);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The column patch that gives a row this value. `row` is read for what the
|
||||||
|
* patch depends on: the connection type (an SSH host's port follows its SSH
|
||||||
|
* port) and the SSH options the others share a column with.
|
||||||
|
*/
|
||||||
|
export function writeCoreDefault(
|
||||||
|
key: string,
|
||||||
|
value: unknown,
|
||||||
|
row: Columns,
|
||||||
|
storedSshOptions?: unknown,
|
||||||
|
): Columns {
|
||||||
|
const entry = findCoreHostDefault(key);
|
||||||
|
if (!entry) return {};
|
||||||
|
const normalized = entry.normalize(value);
|
||||||
|
|
||||||
|
if (isSshOptionDefault(key) || key === "auth") {
|
||||||
|
const current = parseSshOptions(
|
||||||
|
row.sshOptions !== undefined ? row.sshOptions : storedSshOptions,
|
||||||
|
) as Record<string, unknown>;
|
||||||
|
if (key === "auth") {
|
||||||
|
const auth = normalizeAuthDefault(value);
|
||||||
|
if (!auth) return {};
|
||||||
|
const next = { ...current };
|
||||||
|
if (auth.authType === "agent") {
|
||||||
|
next.agentSocketPath = auth.agentSocketPath;
|
||||||
|
next.agentIdentity = auth.agentIdentity;
|
||||||
|
} else {
|
||||||
|
delete next.agentSocketPath;
|
||||||
|
delete next.agentIdentity;
|
||||||
|
}
|
||||||
|
// A credential host may keep its own password next to the credential;
|
||||||
|
// every other secretless type has no use for one.
|
||||||
|
return {
|
||||||
|
authType: auth.authType,
|
||||||
|
credentialId: auth.credentialId,
|
||||||
|
overrideCredentialUsername: auth.overrideCredentialUsername,
|
||||||
|
sshOptions: JSON.stringify(next),
|
||||||
|
...(auth.authType === "credential" ? {} : { password: null }),
|
||||||
|
key: null,
|
||||||
|
keyPassword: null,
|
||||||
|
keyType: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { sshOptions: JSON.stringify({ ...current, [key]: normalized }) };
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (key) {
|
||||||
|
case "username":
|
||||||
|
return normalized === null ? {} : { username: normalized };
|
||||||
|
case "sshPort": {
|
||||||
|
const patch: Columns = { sshPort: normalized };
|
||||||
|
if ((row.connectionType ?? "ssh") === "ssh") patch.port = normalized;
|
||||||
|
return patch;
|
||||||
|
}
|
||||||
|
case "forceKeyboardInteractive":
|
||||||
|
return { forceKeyboardInteractive: normalized ? "true" : "false" };
|
||||||
|
case "socks5": {
|
||||||
|
const socks = normalizeSocks5Default(value);
|
||||||
|
return {
|
||||||
|
useSocks5: socks.useSocks5,
|
||||||
|
socks5Host: socks.socks5Host,
|
||||||
|
socks5Port: socks.socks5Port,
|
||||||
|
socks5Username: socks.socks5Username,
|
||||||
|
socks5ProxyChain: socks.socks5ProxyChain
|
||||||
|
? JSON.stringify(mergeChainPasswords(socks.socks5ProxyChain, row))
|
||||||
|
: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case "jumpHosts":
|
||||||
|
case "portKnockSequence":
|
||||||
|
return {
|
||||||
|
[key]:
|
||||||
|
Array.isArray(normalized) && normalized.length > 0
|
||||||
|
? JSON.stringify(normalized)
|
||||||
|
: null,
|
||||||
|
};
|
||||||
|
case "statusCheckEnabled":
|
||||||
|
return { statusCheckEnabled: !!normalized };
|
||||||
|
case "statusCheckInterval":
|
||||||
|
return { statusCheckInterval: normalized };
|
||||||
|
default:
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A chain that follows a default keeps the passwords the host saved for it. */
|
||||||
|
function mergeChainPasswords(chain: unknown[], row: Columns): unknown[] {
|
||||||
|
let current: unknown = row.socks5ProxyChain;
|
||||||
|
if (typeof current === "string") {
|
||||||
|
try {
|
||||||
|
current = JSON.parse(current);
|
||||||
|
} catch {
|
||||||
|
current = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const saved = Array.isArray(current) ? current : [];
|
||||||
|
return chain.map((node, index) => {
|
||||||
|
const own = saved[index] as Record<string, unknown> | undefined;
|
||||||
|
const password = own?.password;
|
||||||
|
return typeof password === "string" && password
|
||||||
|
? { ...(node as Record<string, unknown>), password }
|
||||||
|
: node;
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { databaseLogger } from "../../utils/logger.js";
|
||||||
|
import { recompute } from "./recompute.js";
|
||||||
|
|
||||||
|
export { applyHostDefaultsToWrite } from "./write-rules.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Brings one host's plugin settings in line with its defaults after a write,
|
||||||
|
* and, when it moved folder or parent, every host of its owner (a sub-host
|
||||||
|
* follows its parent's folder). A failure here never fails the write.
|
||||||
|
*/
|
||||||
|
export async function applyDefaultsAfterHostWrite(
|
||||||
|
hostId: number,
|
||||||
|
options: { moved?: boolean; ownerId?: string } = {},
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
await recompute({ hostIds: [hostId] });
|
||||||
|
if (options.moved && options.ownerId) {
|
||||||
|
void recompute({ userIds: [options.ownerId] }).catch(() => {});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
databaseLogger.warn("Could not apply host defaults after a write", {
|
||||||
|
operation: "host_defaults_after_write",
|
||||||
|
hostId,
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
/**
|
||||||
|
* Loads host default levels from the database into the maps the resolver
|
||||||
|
* reads. One pass covers any number of users.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
createCurrentHostDefaultsRepository,
|
||||||
|
createCurrentSettingsRepository,
|
||||||
|
} from "../../database/repositories/factory.js";
|
||||||
|
import type { HostDefaultsRecord } from "../../database/repositories/host-defaults-repository.js";
|
||||||
|
import { joinDefaultKey } from "../../../types/host-defaults.js";
|
||||||
|
import {
|
||||||
|
folderChainPaths,
|
||||||
|
type FolderLevel,
|
||||||
|
type HostLevels,
|
||||||
|
type LevelMap,
|
||||||
|
} from "./resolve.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* On a desktop linked to a server, the server's admin defaults arrive over
|
||||||
|
* sync and are kept here. They stand in for the desktop's own admin level so
|
||||||
|
* both sides resolve a host the same way.
|
||||||
|
*/
|
||||||
|
export const REMOTE_ADMIN_DEFAULTS_KEY = "host_defaults_remote_admin";
|
||||||
|
|
||||||
|
export interface DefaultsIndex {
|
||||||
|
admin: LevelMap;
|
||||||
|
users: Map<string, LevelMap>;
|
||||||
|
/** userId -> folder path -> the folder's level. */
|
||||||
|
folders: Map<string, Map<string, FolderLevel>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function decodeDefaultValue(raw: string | null): unknown {
|
||||||
|
if (raw === null) return null;
|
||||||
|
try {
|
||||||
|
return JSON.parse(raw);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function encodeDefaultValue(value: unknown): string {
|
||||||
|
return JSON.stringify(value ?? null);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function remoteAdminLevel(): Promise<LevelMap | null> {
|
||||||
|
try {
|
||||||
|
const raw = await createCurrentSettingsRepository().get(
|
||||||
|
REMOTE_ADMIN_DEFAULTS_KEY,
|
||||||
|
);
|
||||||
|
if (!raw) return null;
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return new Map(Object.entries(parsed as Record<string, unknown>));
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildIndex(
|
||||||
|
rows: HostDefaultsRecord[],
|
||||||
|
folders: Array<{ id: number; userId: string; name: string }>,
|
||||||
|
): DefaultsIndex {
|
||||||
|
const index: DefaultsIndex = {
|
||||||
|
admin: new Map(),
|
||||||
|
users: new Map(),
|
||||||
|
folders: new Map(),
|
||||||
|
};
|
||||||
|
const folderById = new Map(folders.map((folder) => [folder.id, folder]));
|
||||||
|
for (const folder of folders) {
|
||||||
|
let own = index.folders.get(folder.userId);
|
||||||
|
if (!own) {
|
||||||
|
own = new Map();
|
||||||
|
index.folders.set(folder.userId, own);
|
||||||
|
}
|
||||||
|
own.set(folder.name, {
|
||||||
|
folderId: folder.id,
|
||||||
|
name: folder.name,
|
||||||
|
values: new Map(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const row of rows) {
|
||||||
|
const fullKey = joinDefaultKey(row.namespace, row.key);
|
||||||
|
const value = decodeDefaultValue(row.value);
|
||||||
|
if (row.level === "admin") {
|
||||||
|
index.admin.set(fullKey, value);
|
||||||
|
} else if (row.level === "user" && row.userId) {
|
||||||
|
let own = index.users.get(row.userId);
|
||||||
|
if (!own) {
|
||||||
|
own = new Map();
|
||||||
|
index.users.set(row.userId, own);
|
||||||
|
}
|
||||||
|
own.set(fullKey, value);
|
||||||
|
} else if (row.level === "folder" && row.folderId !== null) {
|
||||||
|
const folder = folderById.get(row.folderId);
|
||||||
|
if (!folder) continue;
|
||||||
|
index.folders
|
||||||
|
.get(folder.userId)
|
||||||
|
?.get(folder.name)
|
||||||
|
?.values.set(fullKey, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return index;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadDefaultsIndex(
|
||||||
|
userIds: string[],
|
||||||
|
): Promise<DefaultsIndex> {
|
||||||
|
const repository = createCurrentHostDefaultsRepository();
|
||||||
|
const [rows, folders, remoteAdmin] = await Promise.all([
|
||||||
|
repository.listForUsers(userIds),
|
||||||
|
repository.listFolders(userIds),
|
||||||
|
remoteAdminLevel(),
|
||||||
|
]);
|
||||||
|
const index = buildIndex(rows, folders);
|
||||||
|
if (remoteAdmin) index.admin = remoteAdmin;
|
||||||
|
return index;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function levelsForHost(
|
||||||
|
index: DefaultsIndex,
|
||||||
|
userId: string,
|
||||||
|
folderPath: string | null,
|
||||||
|
): HostLevels {
|
||||||
|
const own = index.folders.get(userId);
|
||||||
|
const folders: FolderLevel[] = [];
|
||||||
|
for (const path of folderChainPaths(folderPath)) {
|
||||||
|
const folder = own?.get(path);
|
||||||
|
if (folder) folders.push(folder);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
admin: index.admin,
|
||||||
|
user: index.users.get(userId) ?? new Map(),
|
||||||
|
folders,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,424 @@
|
|||||||
|
/**
|
||||||
|
* Writes resolved defaults into the hosts that follow them.
|
||||||
|
*
|
||||||
|
* A host keeps its effective values in its own columns and plugin setting
|
||||||
|
* rows, so every reader (connect, export, sync, a 2.8 downgrade) sees plain
|
||||||
|
* values. What changes when a default does is this pass: every host that
|
||||||
|
* inherits the key gets the new value written, and nothing else is touched.
|
||||||
|
*
|
||||||
|
* A namespace a host has never been classified for (a host from before host
|
||||||
|
* defaults, from an older sync peer, or a plugin enabled later) is classified
|
||||||
|
* first: a value equal to what resolves inherits, anything else is the
|
||||||
|
* host's own.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
|
||||||
|
import {
|
||||||
|
createCurrentHostDefaultsRepository,
|
||||||
|
createCurrentPluginSettingsRepository,
|
||||||
|
} from "../../database/repositories/factory.js";
|
||||||
|
import type { HostRow } from "../../database/repositories/host-defaults-repository.js";
|
||||||
|
import type { PluginSettingsRecord } from "../../database/repositories/plugin-settings-repository.js";
|
||||||
|
import {
|
||||||
|
CORE_NAMESPACE,
|
||||||
|
defaultValuesEqual,
|
||||||
|
parseDefaultOverrides,
|
||||||
|
type DefaultOverrides,
|
||||||
|
type HostDefaultsLevel,
|
||||||
|
type ResolvedHostDefault,
|
||||||
|
} from "../../../types/host-defaults.js";
|
||||||
|
import { hostSettingsPlugins } from "../../database/routes/host-plugin-settings.js";
|
||||||
|
import { notifySettingChange } from "../../plugins/settings.js";
|
||||||
|
import { databaseLogger } from "../../utils/logger.js";
|
||||||
|
import {
|
||||||
|
buildCatalog,
|
||||||
|
catalogNamespaces,
|
||||||
|
type DefaultKeyInfo,
|
||||||
|
type DefaultsCatalog,
|
||||||
|
} from "./catalog.js";
|
||||||
|
import { readCoreDefault, writeCoreDefault } from "./core-columns.js";
|
||||||
|
import {
|
||||||
|
loadDefaultsIndex,
|
||||||
|
levelsForHost,
|
||||||
|
type DefaultsIndex,
|
||||||
|
} from "./levels.js";
|
||||||
|
import {
|
||||||
|
effectiveFolderPath,
|
||||||
|
isOwnValue,
|
||||||
|
resolveKey,
|
||||||
|
withoutSelfJump,
|
||||||
|
type HostLevels,
|
||||||
|
type HostPlacement,
|
||||||
|
} from "./resolve.js";
|
||||||
|
|
||||||
|
export interface HostDefaultsTarget {
|
||||||
|
hostIds?: number[];
|
||||||
|
userIds?: string[];
|
||||||
|
all?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A level change that has not been saved yet, for a preview. */
|
||||||
|
export interface LevelOverlay {
|
||||||
|
level: HostDefaultsLevel;
|
||||||
|
userId?: string;
|
||||||
|
folderId?: number;
|
||||||
|
set: Map<string, unknown>;
|
||||||
|
unset: Set<string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MaterializeOptions {
|
||||||
|
/** Only these keys are rewritten. Classification still covers every key. */
|
||||||
|
keys?: Set<string>;
|
||||||
|
dryRun?: boolean;
|
||||||
|
overlay?: LevelOverlay;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MaterializeResult {
|
||||||
|
changedHostIds: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PluginWrite {
|
||||||
|
hostId: number;
|
||||||
|
pluginId: string;
|
||||||
|
key: string;
|
||||||
|
/** undefined deletes the row, so the host reads the manifest default. */
|
||||||
|
value: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HostPlan {
|
||||||
|
columns: Record<string, unknown>;
|
||||||
|
pluginWrites: PluginWrite[];
|
||||||
|
overrides: DefaultOverrides;
|
||||||
|
overridesChanged: boolean;
|
||||||
|
coreKeysChanged: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
type PluginRows = Map<string, Map<string, PluginSettingsRecord>>;
|
||||||
|
|
||||||
|
function decode(raw: string | null | undefined): unknown {
|
||||||
|
if (raw === null || raw === undefined) return undefined;
|
||||||
|
try {
|
||||||
|
return JSON.parse(raw);
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function currentPluginValue(
|
||||||
|
info: DefaultKeyInfo,
|
||||||
|
rows: PluginRows | undefined,
|
||||||
|
): unknown {
|
||||||
|
const row = rows?.get(info.namespace)?.get(info.key);
|
||||||
|
return info.normalize(row ? decode(row.value) : undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyOverlay(
|
||||||
|
index: DefaultsIndex,
|
||||||
|
overlay: LevelOverlay | undefined,
|
||||||
|
): DefaultsIndex {
|
||||||
|
if (!overlay) return index;
|
||||||
|
const patch = (values: Map<string, unknown>) => {
|
||||||
|
const next = new Map(values);
|
||||||
|
for (const key of overlay.unset) next.delete(key);
|
||||||
|
for (const [key, value] of overlay.set) next.set(key, value);
|
||||||
|
return next;
|
||||||
|
};
|
||||||
|
if (overlay.level === "admin") {
|
||||||
|
return { ...index, admin: patch(index.admin) };
|
||||||
|
}
|
||||||
|
if (overlay.level === "user" && overlay.userId) {
|
||||||
|
const users = new Map(index.users);
|
||||||
|
users.set(overlay.userId, patch(users.get(overlay.userId) ?? new Map()));
|
||||||
|
return { ...index, users };
|
||||||
|
}
|
||||||
|
if (overlay.level === "folder" && overlay.folderId !== undefined) {
|
||||||
|
const folders = new Map(index.folders);
|
||||||
|
for (const [userId, byPath] of folders) {
|
||||||
|
for (const [path, folder] of byPath) {
|
||||||
|
if (folder.folderId !== overlay.folderId) continue;
|
||||||
|
const copy = new Map(byPath);
|
||||||
|
copy.set(path, { ...folder, values: patch(folder.values) });
|
||||||
|
folders.set(userId, copy);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { ...index, folders };
|
||||||
|
}
|
||||||
|
return index;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What one key resolves to for one host, with the jump-host guard applied. */
|
||||||
|
export function resolveForHost(
|
||||||
|
info: DefaultKeyInfo,
|
||||||
|
levels: HostLevels,
|
||||||
|
hostId: number | null,
|
||||||
|
): ResolvedHostDefault | undefined {
|
||||||
|
const resolved = resolveKey(info, levels);
|
||||||
|
if (!resolved || hostId === null || info.fullKey !== "core.jumpHosts") {
|
||||||
|
return resolved;
|
||||||
|
}
|
||||||
|
return { ...resolved, value: withoutSelfJump(resolved.value, hostId) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Plans one host: which namespaces get classified, and what each inheriting
|
||||||
|
* key has to be rewritten to. Pure, apart from the catalog it is handed.
|
||||||
|
*/
|
||||||
|
export function planHost(
|
||||||
|
row: HostRow,
|
||||||
|
levels: HostLevels,
|
||||||
|
catalog: DefaultsCatalog,
|
||||||
|
pluginRows: PluginRows | undefined,
|
||||||
|
options: { keys?: Set<string> } = {},
|
||||||
|
): HostPlan {
|
||||||
|
const stored = parseDefaultOverrides(row.defaultOverrides);
|
||||||
|
const overrides: DefaultOverrides = { ...(stored ?? {}) };
|
||||||
|
let overridesChanged = stored === null;
|
||||||
|
|
||||||
|
const byNamespace = new Map<string, DefaultKeyInfo[]>();
|
||||||
|
for (const info of catalog.values()) {
|
||||||
|
const list = byNamespace.get(info.namespace);
|
||||||
|
if (list) list.push(info);
|
||||||
|
else byNamespace.set(info.namespace, [info]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const current = (info: DefaultKeyInfo) =>
|
||||||
|
info.namespace === CORE_NAMESPACE
|
||||||
|
? readCoreDefault(info.key, row as unknown as Record<string, unknown>)
|
||||||
|
: currentPluginValue(info, pluginRows);
|
||||||
|
|
||||||
|
for (const namespace of catalogNamespaces(catalog)) {
|
||||||
|
if (overrides[namespace]) continue;
|
||||||
|
overrides[namespace] = (byNamespace.get(namespace) ?? [])
|
||||||
|
.filter((info) =>
|
||||||
|
isOwnValue(info, current(info), resolveForHost(info, levels, row.id)),
|
||||||
|
)
|
||||||
|
.map((info) => info.key)
|
||||||
|
.sort();
|
||||||
|
overridesChanged = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
let columns: Record<string, unknown> = {};
|
||||||
|
const pluginWrites: PluginWrite[] = [];
|
||||||
|
const coreKeysChanged: string[] = [];
|
||||||
|
|
||||||
|
for (const info of catalog.values()) {
|
||||||
|
if (options.keys && !options.keys.has(info.fullKey)) continue;
|
||||||
|
if (overrides[info.namespace]?.includes(info.key)) continue;
|
||||||
|
const resolved = resolveForHost(info, levels, row.id);
|
||||||
|
if (!resolved) continue;
|
||||||
|
const desired = info.normalize(resolved.value);
|
||||||
|
if (defaultValuesEqual(current(info), desired)) continue;
|
||||||
|
|
||||||
|
if (info.namespace === CORE_NAMESPACE) {
|
||||||
|
const base = { ...row, ...columns } as Record<string, unknown>;
|
||||||
|
columns = { ...columns, ...writeCoreDefault(info.key, desired, base) };
|
||||||
|
coreKeysChanged.push(info.key);
|
||||||
|
} else {
|
||||||
|
const manifestDefault = info.normalize(undefined);
|
||||||
|
pluginWrites.push({
|
||||||
|
hostId: row.id,
|
||||||
|
pluginId: info.namespace,
|
||||||
|
key: info.key,
|
||||||
|
value: defaultValuesEqual(desired, manifestDefault)
|
||||||
|
? undefined
|
||||||
|
: desired,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (overridesChanged) columns.defaultOverrides = JSON.stringify(overrides);
|
||||||
|
return {
|
||||||
|
columns,
|
||||||
|
pluginWrites,
|
||||||
|
overrides,
|
||||||
|
overridesChanged,
|
||||||
|
coreKeysChanged,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The running plugins' manifests, and the catalog they make with core's. */
|
||||||
|
export function currentCatalog(): {
|
||||||
|
manifests: PluginManifest[];
|
||||||
|
catalog: DefaultsCatalog;
|
||||||
|
} {
|
||||||
|
const manifests = hostSettingsPlugins();
|
||||||
|
return { manifests, catalog: buildCatalog(manifests) };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadPluginRows(
|
||||||
|
hostIds: number[],
|
||||||
|
): Promise<Map<number, PluginRows>> {
|
||||||
|
const result = new Map<number, PluginRows>();
|
||||||
|
const repository = createCurrentPluginSettingsRepository();
|
||||||
|
for (let i = 0; i < hostIds.length; i += 500) {
|
||||||
|
const part = hostIds.slice(i, i + 500).map(String);
|
||||||
|
for (const row of await repository.getAllForScopeIds("host", part)) {
|
||||||
|
const hostId = Number(row.scopeId);
|
||||||
|
let byPlugin = result.get(hostId);
|
||||||
|
if (!byPlugin) {
|
||||||
|
byPlugin = new Map();
|
||||||
|
result.set(hostId, byPlugin);
|
||||||
|
}
|
||||||
|
let byKey = byPlugin.get(row.pluginId);
|
||||||
|
if (!byKey) {
|
||||||
|
byKey = new Map();
|
||||||
|
byPlugin.set(row.pluginId, byKey);
|
||||||
|
}
|
||||||
|
byKey.set(row.key, row);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadPlacement(
|
||||||
|
userIds: string[],
|
||||||
|
): Promise<Map<number, HostPlacement>> {
|
||||||
|
const rows =
|
||||||
|
await createCurrentHostDefaultsRepository().listHostPlacement(userIds);
|
||||||
|
return new Map(
|
||||||
|
rows.map((row) => [
|
||||||
|
row.id,
|
||||||
|
{ folder: row.folder, parentHostId: row.parentHostId },
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Runs the side effects every changed host needs, after its write. */
|
||||||
|
async function afterHostsChanged(
|
||||||
|
changed: Array<{ row: HostRow; plan: HostPlan }>,
|
||||||
|
): Promise<void> {
|
||||||
|
if (changed.length === 0) return;
|
||||||
|
try {
|
||||||
|
const { markChanged } = await import("../../sync/server/feed.js");
|
||||||
|
markChanged();
|
||||||
|
} catch {
|
||||||
|
// Sync is not loaded in every context.
|
||||||
|
}
|
||||||
|
|
||||||
|
const { pluginEvents, TOPICS } = await import("../../plugins/events.js");
|
||||||
|
const notified = new Map<string, unknown>();
|
||||||
|
let secrets: {
|
||||||
|
resyncHost: (hostId: number) => Promise<unknown>;
|
||||||
|
} | null = null;
|
||||||
|
for (const { row, plan } of changed) {
|
||||||
|
try {
|
||||||
|
pluginEvents.emit(TOPICS.hostUpdated, {
|
||||||
|
hostId: row.id,
|
||||||
|
userId: row.userId,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// A subscriber must not fail the write that already happened.
|
||||||
|
}
|
||||||
|
for (const write of plan.pluginWrites) {
|
||||||
|
notified.set(`${write.pluginId}\u0000${write.key}`, write.value);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
plan.coreKeysChanged.includes("auth") ||
|
||||||
|
plan.coreKeysChanged.includes("username")
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
if (!secrets) {
|
||||||
|
const { SharedHostSecretsManager } =
|
||||||
|
await import("../../utils/shared-host-secrets-manager.js");
|
||||||
|
secrets = SharedHostSecretsManager.getInstance();
|
||||||
|
}
|
||||||
|
await secrets.resyncHost(row.id);
|
||||||
|
} catch (error) {
|
||||||
|
databaseLogger.warn("Failed to resync shared host after defaults", {
|
||||||
|
operation: "host_defaults_resync",
|
||||||
|
hostId: row.id,
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const [pair, value] of notified) {
|
||||||
|
const [pluginId, key] = pair.split("\u0000");
|
||||||
|
notifySettingChange(pluginId, key, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Classifies and rewrites a set of hosts. Returns the hosts whose values or
|
||||||
|
* overrides changed; with `dryRun` nothing is written.
|
||||||
|
*/
|
||||||
|
export async function materializeHosts(
|
||||||
|
target: HostDefaultsTarget,
|
||||||
|
options: MaterializeOptions = {},
|
||||||
|
): Promise<MaterializeResult> {
|
||||||
|
const repository = createCurrentHostDefaultsRepository();
|
||||||
|
const rows = (await repository.listHosts(target)).filter(
|
||||||
|
// A desktop's read-only copy of a shared host follows its owner's server.
|
||||||
|
(row) => !row.sharedSource,
|
||||||
|
);
|
||||||
|
if (rows.length === 0) return { changedHostIds: [] };
|
||||||
|
|
||||||
|
const { catalog } = currentCatalog();
|
||||||
|
const userIds = [...new Set(rows.map((row) => row.userId))];
|
||||||
|
const [index, placement, pluginRows] = await Promise.all([
|
||||||
|
loadDefaultsIndex(userIds),
|
||||||
|
loadPlacement(userIds),
|
||||||
|
loadPluginRows(rows.map((row) => row.id)),
|
||||||
|
]);
|
||||||
|
const withOverlay = applyOverlay(index, options.overlay);
|
||||||
|
|
||||||
|
const changed: Array<{ row: HostRow; plan: HostPlan }> = [];
|
||||||
|
for (const row of rows) {
|
||||||
|
const levels = levelsForHost(
|
||||||
|
withOverlay,
|
||||||
|
row.userId,
|
||||||
|
effectiveFolderPath(row.id, placement),
|
||||||
|
);
|
||||||
|
const plan = planHost(row, levels, catalog, pluginRows.get(row.id), {
|
||||||
|
keys: options.keys,
|
||||||
|
});
|
||||||
|
const valuesChanged =
|
||||||
|
Object.keys(plan.columns).some((key) => key !== "defaultOverrides") ||
|
||||||
|
plan.pluginWrites.length > 0;
|
||||||
|
if (options.dryRun) {
|
||||||
|
if (valuesChanged) changed.push({ row, plan });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (valuesChanged || plan.overridesChanged) changed.push({ row, plan });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.dryRun) {
|
||||||
|
return { changedHostIds: changed.map(({ row }) => row.id) };
|
||||||
|
}
|
||||||
|
|
||||||
|
await repository.updateHosts(
|
||||||
|
changed.map(({ row, plan }) => ({
|
||||||
|
id: row.id,
|
||||||
|
values: plan.columns as Partial<HostRow>,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
const settings = createCurrentPluginSettingsRepository();
|
||||||
|
for (const { plan } of changed) {
|
||||||
|
for (const write of plan.pluginWrites) {
|
||||||
|
if (write.value === undefined) {
|
||||||
|
await settings.delete(
|
||||||
|
write.pluginId,
|
||||||
|
"host",
|
||||||
|
String(write.hostId),
|
||||||
|
write.key,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
await settings.set(
|
||||||
|
write.pluginId,
|
||||||
|
"host",
|
||||||
|
String(write.hostId),
|
||||||
|
write.key,
|
||||||
|
JSON.stringify(write.value),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const valueChanges = changed.filter(
|
||||||
|
({ plan }) =>
|
||||||
|
plan.pluginWrites.length > 0 ||
|
||||||
|
Object.keys(plan.columns).some((key) => key !== "defaultOverrides"),
|
||||||
|
);
|
||||||
|
await afterHostsChanged(valueChanges);
|
||||||
|
return { changedHostIds: valueChanges.map(({ row }) => row.id) };
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
/**
|
||||||
|
* Changes which keys a host sets itself, without going through a full host
|
||||||
|
* write: a patch that sets a value makes it the host's own, and a reset hands
|
||||||
|
* it back to the defaults.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { createCurrentHostDefaultsRepository } from "../../database/repositories/factory.js";
|
||||||
|
import {
|
||||||
|
parseDefaultOverrides,
|
||||||
|
type DefaultOverrides,
|
||||||
|
} from "../../../types/host-defaults.js";
|
||||||
|
import { catalogNamespaces } from "./catalog.js";
|
||||||
|
import { currentCatalog } from "./materialize.js";
|
||||||
|
import { recompute } from "./recompute.js";
|
||||||
|
|
||||||
|
export interface OverrideChange {
|
||||||
|
/** Keys that become the host's own, as [namespace, key]. */
|
||||||
|
own?: Array<[string, string]>;
|
||||||
|
/** Keys that follow the defaults again. */
|
||||||
|
inherit?: Array<[string, string]>;
|
||||||
|
/** Namespaces whose every key follows the defaults again. */
|
||||||
|
inheritNamespaces?: string[];
|
||||||
|
/** Every key follows the defaults again. */
|
||||||
|
inheritAll?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyOverrideChange(
|
||||||
|
current: DefaultOverrides | null,
|
||||||
|
change: OverrideChange,
|
||||||
|
allNamespaces: string[] = [],
|
||||||
|
): DefaultOverrides {
|
||||||
|
const next: DefaultOverrides = {};
|
||||||
|
for (const [namespace, keys] of Object.entries(current ?? {})) {
|
||||||
|
next[namespace] = [...keys];
|
||||||
|
}
|
||||||
|
if (change.inheritAll) {
|
||||||
|
for (const namespace of [...Object.keys(next), ...allNamespaces]) {
|
||||||
|
next[namespace] = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const namespace of change.inheritNamespaces ?? []) {
|
||||||
|
next[namespace] = [];
|
||||||
|
}
|
||||||
|
for (const [namespace, key] of change.inherit ?? []) {
|
||||||
|
if (next[namespace]) {
|
||||||
|
next[namespace] = next[namespace].filter((own) => own !== key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const [namespace, key] of change.own ?? []) {
|
||||||
|
// A namespace nobody classified yet stays that way: its first pass
|
||||||
|
// compares every value, this one included, and would keep it.
|
||||||
|
if (!next[namespace]) continue;
|
||||||
|
const keys = new Set(next[namespace] ?? []);
|
||||||
|
keys.add(key);
|
||||||
|
next[namespace] = [...keys].sort();
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Applies a change to a set of hosts. A change that hands keys back to the
|
||||||
|
* defaults is followed by a pass that writes what they now resolve to.
|
||||||
|
*/
|
||||||
|
export async function changeHostOverrides(
|
||||||
|
hostIds: number[],
|
||||||
|
change: OverrideChange,
|
||||||
|
): Promise<void> {
|
||||||
|
if (hostIds.length === 0) return;
|
||||||
|
const repository = createCurrentHostDefaultsRepository();
|
||||||
|
const rows = await repository.listHosts({ hostIds });
|
||||||
|
const namespaces = catalogNamespaces(currentCatalog().catalog);
|
||||||
|
const patches: Array<{ id: number; values: { defaultOverrides: string } }> =
|
||||||
|
[];
|
||||||
|
for (const row of rows) {
|
||||||
|
const current = parseDefaultOverrides(row.defaultOverrides);
|
||||||
|
const next = applyOverrideChange(current, change, namespaces);
|
||||||
|
const encoded = JSON.stringify(next);
|
||||||
|
if (encoded !== row.defaultOverrides) {
|
||||||
|
patches.push({ id: row.id, values: { defaultOverrides: encoded } });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await repository.updateHosts(patches);
|
||||||
|
const resets =
|
||||||
|
change.inheritAll ||
|
||||||
|
(change.inheritNamespaces?.length ?? 0) > 0 ||
|
||||||
|
(change.inherit?.length ?? 0) > 0;
|
||||||
|
if (resets) await recompute({ hostIds });
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
/**
|
||||||
|
* Personal fields: look-and-feel values (a terminal's font, a remote
|
||||||
|
* desktop's color depth) that a user a host is shared with sees through
|
||||||
|
* their own defaults while the host follows its defaults. The stored value is
|
||||||
|
* the owner's; this swaps it for the viewer's at read time.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
|
||||||
|
import { createCurrentHostDefaultsRepository } from "../../database/repositories/factory.js";
|
||||||
|
import type { HostRow } from "../../database/repositories/host-defaults-repository.js";
|
||||||
|
import {
|
||||||
|
hostSettingsPlugins,
|
||||||
|
type HostPluginSettings,
|
||||||
|
} from "../../database/routes/host-plugin-settings.js";
|
||||||
|
import { parseDefaultOverrides } from "../../../types/host-defaults.js";
|
||||||
|
import { pluginCatalogEntries, type DefaultKeyInfo } from "./catalog.js";
|
||||||
|
import { loadDefaultsIndex } from "./levels.js";
|
||||||
|
import { resolveKey, type HostLevels } from "./resolve.js";
|
||||||
|
|
||||||
|
function personalEntries(manifests: PluginManifest[]): DefaultKeyInfo[] {
|
||||||
|
return manifests.flatMap((manifest) =>
|
||||||
|
pluginCatalogEntries(manifest).filter((entry) => entry.personal),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function viewerLevels(viewerId: string): Promise<HostLevels> {
|
||||||
|
const index = await loadDefaultsIndex([viewerId]);
|
||||||
|
return {
|
||||||
|
admin: index.admin,
|
||||||
|
user: index.users.get(viewerId) ?? new Map(),
|
||||||
|
folders: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function follows(row: HostRow, entry: DefaultKeyInfo): boolean {
|
||||||
|
const overrides = parseDefaultOverrides(row.defaultOverrides);
|
||||||
|
const own = overrides?.[entry.namespace];
|
||||||
|
return !!own && !own.includes(entry.key);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Replaces personal values in a host list's plugin settings, in place. */
|
||||||
|
export async function applyPersonalHostValues(
|
||||||
|
settings: Map<number, HostPluginSettings>,
|
||||||
|
viewerId: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const entries = personalEntries(hostSettingsPlugins());
|
||||||
|
if (entries.length === 0 || settings.size === 0) return;
|
||||||
|
const rows = (
|
||||||
|
await createCurrentHostDefaultsRepository().listHosts({
|
||||||
|
hostIds: [...settings.keys()],
|
||||||
|
})
|
||||||
|
).filter((row) => row.userId !== viewerId);
|
||||||
|
if (rows.length === 0) return;
|
||||||
|
|
||||||
|
const levels = await viewerLevels(viewerId);
|
||||||
|
for (const row of rows) {
|
||||||
|
const values = settings.get(row.id);
|
||||||
|
if (!values) continue;
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (!follows(row, entry) || !values[entry.namespace]) continue;
|
||||||
|
const resolved = resolveKey(entry, levels);
|
||||||
|
if (resolved) values[entry.namespace][entry.key] = resolved.value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One personal value as a user sees it, when that differs from the stored one. */
|
||||||
|
export async function personalHostValue(
|
||||||
|
manifest: PluginManifest,
|
||||||
|
hostId: number,
|
||||||
|
viewerId: string,
|
||||||
|
key: string,
|
||||||
|
): Promise<{ applies: boolean; value?: unknown }> {
|
||||||
|
const entry = pluginCatalogEntries(manifest).find(
|
||||||
|
(candidate) => candidate.key === key && candidate.personal,
|
||||||
|
);
|
||||||
|
if (!entry || !Number.isInteger(hostId)) return { applies: false };
|
||||||
|
const [row] = await createCurrentHostDefaultsRepository().listHosts({
|
||||||
|
hostIds: [hostId],
|
||||||
|
});
|
||||||
|
if (!row || row.userId === viewerId || !follows(row, entry)) {
|
||||||
|
return { applies: false };
|
||||||
|
}
|
||||||
|
const resolved = resolveKey(entry, await viewerLevels(viewerId));
|
||||||
|
return resolved
|
||||||
|
? { applies: true, value: resolved.value }
|
||||||
|
: { applies: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A host field as a user's hosts get it with nothing more specific set. */
|
||||||
|
export async function userHostDefault(
|
||||||
|
manifest: PluginManifest,
|
||||||
|
userId: string,
|
||||||
|
key: string,
|
||||||
|
): Promise<unknown> {
|
||||||
|
const entry = pluginCatalogEntries(manifest).find(
|
||||||
|
(candidate) => candidate.key === key,
|
||||||
|
);
|
||||||
|
if (!entry) return undefined;
|
||||||
|
return resolveKey(entry, await viewerLevels(userId))?.value;
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
/**
|
||||||
|
* Runs materialize passes one at a time, so two default changes can never
|
||||||
|
* write the same host at once. A server-wide change can touch every host, so
|
||||||
|
* it runs as a job the admin panel polls instead of holding the request open.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { randomUUID } from "crypto";
|
||||||
|
import { databaseLogger } from "../../utils/logger.js";
|
||||||
|
import type { HostDefaultsScope } from "../../database/repositories/host-defaults-repository.js";
|
||||||
|
import {
|
||||||
|
materializeHosts,
|
||||||
|
type HostDefaultsTarget,
|
||||||
|
type MaterializeResult,
|
||||||
|
} from "./materialize.js";
|
||||||
|
|
||||||
|
let queue: Promise<unknown> = Promise.resolve();
|
||||||
|
|
||||||
|
function enqueue<T>(task: () => Promise<T>): Promise<T> {
|
||||||
|
const run = queue.catch(() => {}).then(task);
|
||||||
|
queue = run;
|
||||||
|
return run;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RecomputeJob {
|
||||||
|
id: string;
|
||||||
|
status: "running" | "done" | "failed";
|
||||||
|
changedHosts: number;
|
||||||
|
error?: string;
|
||||||
|
finishedAt?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const jobs = new Map<string, RecomputeJob>();
|
||||||
|
const JOB_TTL_MS = 60 * 60 * 1000;
|
||||||
|
|
||||||
|
function pruneJobs(): void {
|
||||||
|
const cutoff = Date.now() - JOB_TTL_MS;
|
||||||
|
for (const [id, job] of jobs) {
|
||||||
|
if (job.finishedAt && job.finishedAt < cutoff) jobs.delete(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Which hosts a level's defaults reach. A folder's owner is resolved by the caller. */
|
||||||
|
export function targetForScope(
|
||||||
|
scope: HostDefaultsScope,
|
||||||
|
folderOwnerId?: string,
|
||||||
|
): HostDefaultsTarget {
|
||||||
|
if (scope.level === "admin") return { all: true };
|
||||||
|
if (scope.level === "user") return { userIds: [String(scope.userId)] };
|
||||||
|
return { userIds: folderOwnerId ? [folderOwnerId] : [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function recompute(
|
||||||
|
target: HostDefaultsTarget,
|
||||||
|
keys?: Set<string>,
|
||||||
|
): Promise<MaterializeResult> {
|
||||||
|
return enqueue(() => materializeHosts(target, { keys }));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Starts a pass in the background and returns its job. */
|
||||||
|
export function startRecomputeJob(
|
||||||
|
target: HostDefaultsTarget,
|
||||||
|
keys?: Set<string>,
|
||||||
|
): RecomputeJob {
|
||||||
|
pruneJobs();
|
||||||
|
const job: RecomputeJob = {
|
||||||
|
id: randomUUID(),
|
||||||
|
status: "running",
|
||||||
|
changedHosts: 0,
|
||||||
|
};
|
||||||
|
jobs.set(job.id, job);
|
||||||
|
recompute(target, keys)
|
||||||
|
.then((result) => {
|
||||||
|
job.status = "done";
|
||||||
|
job.changedHosts = result.changedHostIds.length;
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
job.status = "failed";
|
||||||
|
job.error = error instanceof Error ? error.message : String(error);
|
||||||
|
databaseLogger.error("Host defaults recompute failed", error, {
|
||||||
|
operation: "host_defaults_recompute",
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
job.finishedAt = Date.now();
|
||||||
|
});
|
||||||
|
return job;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRecomputeJob(id: string): RecomputeJob | undefined {
|
||||||
|
return jobs.get(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Classifies and brings every host up to date. Run after boot and when a
|
||||||
|
* plugin starts, so a host or namespace nobody has classified yet catches up.
|
||||||
|
*/
|
||||||
|
export function recomputeEverything(reason: string): void {
|
||||||
|
recompute({ all: true })
|
||||||
|
.then((result) => {
|
||||||
|
if (result.changedHostIds.length > 0) {
|
||||||
|
databaseLogger.info("Applied host defaults", {
|
||||||
|
operation: "host_defaults_recompute",
|
||||||
|
reason,
|
||||||
|
changedHosts: result.changedHostIds.length,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
databaseLogger.warn("Could not apply host defaults", {
|
||||||
|
operation: "host_defaults_recompute",
|
||||||
|
reason,
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
/**
|
||||||
|
* Host default resolution. No database access: the levels come in as maps,
|
||||||
|
* so every rule here is testable on its own.
|
||||||
|
*
|
||||||
|
* A key resolves from the deepest folder a host sits in up to its root
|
||||||
|
* folder, then the owner's own defaults, then the server's, then the key's
|
||||||
|
* built-in value. A level the key does not allow is skipped.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
defaultValuesEqual,
|
||||||
|
type HostDefaultSource,
|
||||||
|
type ResolvedHostDefault,
|
||||||
|
} from "../../../types/host-defaults.js";
|
||||||
|
import type { DefaultKeyInfo } from "./catalog.js";
|
||||||
|
|
||||||
|
export type LevelMap = Map<string, unknown>;
|
||||||
|
|
||||||
|
export interface FolderLevel {
|
||||||
|
folderId: number;
|
||||||
|
name: string;
|
||||||
|
values: LevelMap;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HostLevels {
|
||||||
|
admin: LevelMap;
|
||||||
|
user: LevelMap;
|
||||||
|
/** Root first, deepest last. */
|
||||||
|
folders: FolderLevel[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FOLDER_SEPARATOR = " / ";
|
||||||
|
|
||||||
|
/** "A / B / C" -> ["A", "A / B", "A / B / C"]. */
|
||||||
|
export function folderChainPaths(path: string | null | undefined): string[] {
|
||||||
|
if (!path) return [];
|
||||||
|
const segments = path.split(FOLDER_SEPARATOR).filter(Boolean);
|
||||||
|
return segments.map((_, index) =>
|
||||||
|
segments.slice(0, index + 1).join(FOLDER_SEPARATOR),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HostPlacement {
|
||||||
|
folder: string | null;
|
||||||
|
parentHostId: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The folder whose defaults a host follows. A sub-host has no folder of its
|
||||||
|
* own, so it takes the nearest ancestor's.
|
||||||
|
*/
|
||||||
|
export function effectiveFolderPath(
|
||||||
|
hostId: number,
|
||||||
|
placement: Map<number, HostPlacement>,
|
||||||
|
): string | null {
|
||||||
|
const seen = new Set<number>();
|
||||||
|
let current: number | null = hostId;
|
||||||
|
while (current !== null && !seen.has(current)) {
|
||||||
|
seen.add(current);
|
||||||
|
const own = placement.get(current);
|
||||||
|
if (!own) return null;
|
||||||
|
if (own.folder) return own.folder;
|
||||||
|
current = own.parentHostId;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveKey(
|
||||||
|
info: DefaultKeyInfo,
|
||||||
|
levels: HostLevels,
|
||||||
|
): ResolvedHostDefault | undefined {
|
||||||
|
const allowed = new Set(info.levels);
|
||||||
|
if (allowed.has("folder")) {
|
||||||
|
for (let i = levels.folders.length - 1; i >= 0; i--) {
|
||||||
|
const folder = levels.folders[i];
|
||||||
|
if (folder.values.has(info.fullKey)) {
|
||||||
|
return {
|
||||||
|
value: info.normalize(folder.values.get(info.fullKey)),
|
||||||
|
source: {
|
||||||
|
level: "folder",
|
||||||
|
folderId: folder.folderId,
|
||||||
|
folderName: folder.name,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const order: Array<["user" | "admin", LevelMap]> = [
|
||||||
|
["user", levels.user],
|
||||||
|
["admin", levels.admin],
|
||||||
|
];
|
||||||
|
for (const [level, values] of order) {
|
||||||
|
if (allowed.has(level) && values.has(info.fullKey)) {
|
||||||
|
return {
|
||||||
|
value: info.normalize(values.get(info.fullKey)),
|
||||||
|
source: { level },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (info.builtin === undefined) return undefined;
|
||||||
|
const source: HostDefaultSource = { level: "builtin" };
|
||||||
|
return { value: info.normalize(info.builtin), source };
|
||||||
|
}
|
||||||
|
|
||||||
|
function isEmpty(value: unknown): boolean {
|
||||||
|
if (value === undefined || value === null || value === "") return true;
|
||||||
|
if (Array.isArray(value)) return value.length === 0;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a host's value is its own rather than the default: it differs from
|
||||||
|
* what resolves, or nothing resolves and the host has a value at all.
|
||||||
|
*/
|
||||||
|
export function isOwnValue(
|
||||||
|
info: DefaultKeyInfo,
|
||||||
|
hostValue: unknown,
|
||||||
|
resolved: ResolvedHostDefault | undefined,
|
||||||
|
): boolean {
|
||||||
|
const own = info.normalize(hostValue);
|
||||||
|
if (!resolved) return !isEmpty(own);
|
||||||
|
return !defaultValuesEqual(own, resolved.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A host never jumps through itself, even when a folder default says to. */
|
||||||
|
export function withoutSelfJump(value: unknown, hostId: number): unknown {
|
||||||
|
if (!Array.isArray(value)) return value;
|
||||||
|
return value.filter(
|
||||||
|
(entry) => Number((entry as { hostId?: unknown })?.hostId) !== hostId,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,286 @@
|
|||||||
|
/**
|
||||||
|
* Reading, checking and saving one level of host defaults, and resolving a
|
||||||
|
* host's defaults for the editor. The routes are a thin layer over this.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
coerceSettingValue,
|
||||||
|
validateSettingValue,
|
||||||
|
} from "@termix/plugin-sdk/settings";
|
||||||
|
import { createCurrentHostDefaultsRepository } from "../../database/repositories/factory.js";
|
||||||
|
import type { HostDefaultsScope } from "../../database/repositories/host-defaults-repository.js";
|
||||||
|
import { validateSettingsSave } from "../../plugins/settings.js";
|
||||||
|
import {
|
||||||
|
CORE_NAMESPACE,
|
||||||
|
SECRET_AUTH_TYPES,
|
||||||
|
normalizeAuthDefault,
|
||||||
|
splitDefaultKey,
|
||||||
|
type ResolvedHostDefault,
|
||||||
|
} from "../../../types/host-defaults.js";
|
||||||
|
import type { DefaultKeyInfo, DefaultsCatalog } from "./catalog.js";
|
||||||
|
import {
|
||||||
|
decodeDefaultValue,
|
||||||
|
encodeDefaultValue,
|
||||||
|
loadDefaultsIndex,
|
||||||
|
levelsForHost,
|
||||||
|
} from "./levels.js";
|
||||||
|
import {
|
||||||
|
currentCatalog,
|
||||||
|
loadPlacement,
|
||||||
|
resolveForHost,
|
||||||
|
} from "./materialize.js";
|
||||||
|
import {
|
||||||
|
effectiveFolderPath,
|
||||||
|
folderChainPaths,
|
||||||
|
type HostLevels,
|
||||||
|
} from "./resolve.js";
|
||||||
|
|
||||||
|
export interface LevelChangeInput {
|
||||||
|
set?: Record<string, unknown>;
|
||||||
|
unset?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ValidatedLevelChange {
|
||||||
|
set: Map<string, unknown>;
|
||||||
|
unset: Set<string>;
|
||||||
|
errors: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What a level holds, by "namespace.key". */
|
||||||
|
export async function readLevel(
|
||||||
|
scope: HostDefaultsScope,
|
||||||
|
): Promise<Record<string, unknown>> {
|
||||||
|
const rows = await createCurrentHostDefaultsRepository().listScope(scope);
|
||||||
|
const values: Record<string, unknown> = {};
|
||||||
|
for (const row of rows) {
|
||||||
|
values[`${row.namespace}.${row.key}`] = decodeDefaultValue(row.value);
|
||||||
|
}
|
||||||
|
return values;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The levels above one, which is what a key reads when this level leaves it unset. */
|
||||||
|
export async function levelsAbove(
|
||||||
|
scope: HostDefaultsScope,
|
||||||
|
folderOwner?: { userId: string; name: string },
|
||||||
|
): Promise<HostLevels> {
|
||||||
|
const empty: HostLevels = { admin: new Map(), user: new Map(), folders: [] };
|
||||||
|
if (scope.level === "admin") return empty;
|
||||||
|
const ownerId =
|
||||||
|
scope.level === "user" ? String(scope.userId) : folderOwner?.userId;
|
||||||
|
if (!ownerId) return empty;
|
||||||
|
const index = await loadDefaultsIndex([ownerId]);
|
||||||
|
if (scope.level === "user") {
|
||||||
|
return { ...empty, admin: index.admin };
|
||||||
|
}
|
||||||
|
const parents = folderChainPaths(folderOwner?.name).slice(0, -1);
|
||||||
|
const own = index.folders.get(ownerId);
|
||||||
|
return {
|
||||||
|
admin: index.admin,
|
||||||
|
user: index.users.get(ownerId) ?? new Map(),
|
||||||
|
folders: parents
|
||||||
|
.map((path) => own?.get(path))
|
||||||
|
.filter((folder): folder is NonNullable<typeof folder> => !!folder),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveAll(
|
||||||
|
catalog: DefaultsCatalog,
|
||||||
|
levels: HostLevels,
|
||||||
|
hostId: number | null = null,
|
||||||
|
): Record<string, ResolvedHostDefault> {
|
||||||
|
const result: Record<string, ResolvedHostDefault> = {};
|
||||||
|
for (const info of catalog.values()) {
|
||||||
|
const resolved = resolveForHost(info, levels, hostId);
|
||||||
|
if (resolved) result[info.fullKey] = resolved;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CheckContext {
|
||||||
|
actorId: string;
|
||||||
|
/** Whose rows a user or folder level may point at. */
|
||||||
|
ownerId: string | null;
|
||||||
|
canUseCredential: (credentialId: number, userId: string) => Promise<boolean>;
|
||||||
|
canUseHost: (hostId: number, userId: string) => Promise<boolean>;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function checkCoreValue(
|
||||||
|
info: DefaultKeyInfo,
|
||||||
|
value: unknown,
|
||||||
|
scope: HostDefaultsScope,
|
||||||
|
context: CheckContext,
|
||||||
|
): Promise<string | null> {
|
||||||
|
if (info.key === "auth") {
|
||||||
|
const auth = normalizeAuthDefault(value);
|
||||||
|
if (!auth) return "An auth default needs an auth type";
|
||||||
|
if (SECRET_AUTH_TYPES.includes(auth.authType)) {
|
||||||
|
return "Password and key logins belong to one host and cannot be a default";
|
||||||
|
}
|
||||||
|
if (auth.authType === "credential") {
|
||||||
|
if (scope.level === "admin") {
|
||||||
|
return "A credential belongs to one user, so it cannot be a server default";
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
auth.credentialId === null ||
|
||||||
|
!context.ownerId ||
|
||||||
|
!(await context.canUseCredential(auth.credentialId, context.ownerId))
|
||||||
|
) {
|
||||||
|
return "That credential is not available";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (info.key === "jumpHosts" && Array.isArray(value) && context.ownerId) {
|
||||||
|
for (const entry of value as Array<{ hostId?: unknown }>) {
|
||||||
|
const hostId = Number(entry?.hostId);
|
||||||
|
if (
|
||||||
|
!Number.isInteger(hostId) ||
|
||||||
|
!(await context.canUseHost(hostId, context.ownerId))
|
||||||
|
) {
|
||||||
|
return "A jump host in the chain is not available";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Checks a level change against the catalog, each key's levels and its field. */
|
||||||
|
export async function validateLevelChange(
|
||||||
|
scope: HostDefaultsScope,
|
||||||
|
input: LevelChangeInput,
|
||||||
|
context: CheckContext,
|
||||||
|
catalog: DefaultsCatalog = currentCatalog().catalog,
|
||||||
|
): Promise<ValidatedLevelChange> {
|
||||||
|
const set = new Map<string, unknown>();
|
||||||
|
const unset = new Set<string>();
|
||||||
|
const errors: Record<string, string> = {};
|
||||||
|
|
||||||
|
for (const fullKey of input.unset ?? []) {
|
||||||
|
if (typeof fullKey === "string") unset.add(fullKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
const pluginValues = new Map<string, Record<string, unknown>>();
|
||||||
|
for (const [fullKey, raw] of Object.entries(input.set ?? {})) {
|
||||||
|
const info = catalog.get(fullKey);
|
||||||
|
if (!info) {
|
||||||
|
errors[fullKey] = "Not a host setting that can have a default";
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!info.levels.includes(scope.level)) {
|
||||||
|
errors[fullKey] = `Cannot be set as a ${scope.level} default`;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (info.namespace === CORE_NAMESPACE) {
|
||||||
|
const error = await checkCoreValue(info, raw, scope, context);
|
||||||
|
if (error) {
|
||||||
|
errors[fullKey] = error;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
set.set(fullKey, info.normalize(raw));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const field = info.field!;
|
||||||
|
const coerced = coerceSettingValue(field, raw);
|
||||||
|
const error = validateSettingValue(field, coerced);
|
||||||
|
if (error) {
|
||||||
|
errors[fullKey] = error;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
set.set(fullKey, info.normalize(coerced));
|
||||||
|
const own = pluginValues.get(info.namespace) ?? {};
|
||||||
|
own[info.key] = coerced;
|
||||||
|
pluginValues.set(info.namespace, own);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [pluginId, values] of pluginValues) {
|
||||||
|
const rejected = await validateSettingsSave(
|
||||||
|
pluginId,
|
||||||
|
"host",
|
||||||
|
null,
|
||||||
|
values,
|
||||||
|
{ level: scope.level },
|
||||||
|
);
|
||||||
|
for (const [key, message] of Object.entries(rejected)) {
|
||||||
|
errors[key === "_" ? pluginId : `${pluginId}.${key}`] = message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const key of set.keys()) unset.delete(key);
|
||||||
|
return { set, unset, errors };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveLevel(
|
||||||
|
scope: HostDefaultsScope,
|
||||||
|
change: ValidatedLevelChange,
|
||||||
|
actorId: string,
|
||||||
|
): Promise<void> {
|
||||||
|
await createCurrentHostDefaultsRepository().apply(
|
||||||
|
scope,
|
||||||
|
[...change.set].map(([fullKey, value]) => {
|
||||||
|
const [namespace, key] = splitDefaultKey(fullKey);
|
||||||
|
return { namespace, key, value: encodeDefaultValue(value) };
|
||||||
|
}),
|
||||||
|
[...change.unset].map((fullKey) => {
|
||||||
|
const [namespace, key] = splitDefaultKey(fullKey);
|
||||||
|
return { namespace, key };
|
||||||
|
}),
|
||||||
|
actorId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What a host (or a new one in a folder or under a parent) resolves every
|
||||||
|
* key to, with where each value comes from.
|
||||||
|
*/
|
||||||
|
export async function resolveForEditor(input: {
|
||||||
|
ownerId: string;
|
||||||
|
hostId?: number | null;
|
||||||
|
folder?: string | null;
|
||||||
|
parentHostId?: number | null;
|
||||||
|
}): Promise<Record<string, ResolvedHostDefault>> {
|
||||||
|
const { catalog } = currentCatalog();
|
||||||
|
const placement = await loadPlacement([input.ownerId]);
|
||||||
|
const self = input.hostId ?? -1;
|
||||||
|
const stored = placement.get(self);
|
||||||
|
placement.set(self, {
|
||||||
|
folder:
|
||||||
|
input.folder !== undefined ? input.folder : (stored?.folder ?? null),
|
||||||
|
parentHostId:
|
||||||
|
input.parentHostId !== undefined
|
||||||
|
? input.parentHostId
|
||||||
|
: (stored?.parentHostId ?? null),
|
||||||
|
});
|
||||||
|
const index = await loadDefaultsIndex([input.ownerId]);
|
||||||
|
const levels = levelsForHost(
|
||||||
|
index,
|
||||||
|
input.ownerId,
|
||||||
|
effectiveFolderPath(self, placement),
|
||||||
|
);
|
||||||
|
return resolveAll(catalog, levels, input.hostId ?? null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A folder's credential is its auth default: hosts in the folder that follow
|
||||||
|
* their defaults sign in with it. Null clears it.
|
||||||
|
*/
|
||||||
|
export async function setFolderCredentialDefault(
|
||||||
|
userId: string,
|
||||||
|
folderId: number,
|
||||||
|
credentialId: number | null,
|
||||||
|
): Promise<void> {
|
||||||
|
await createCurrentHostDefaultsRepository().apply(
|
||||||
|
{ level: "folder", folderId, userId },
|
||||||
|
credentialId === null
|
||||||
|
? []
|
||||||
|
: [
|
||||||
|
{
|
||||||
|
namespace: CORE_NAMESPACE,
|
||||||
|
key: "auth",
|
||||||
|
value: encodeDefaultValue(
|
||||||
|
normalizeAuthDefault({ authType: "credential", credentialId }),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
credentialId === null ? [{ namespace: CORE_NAMESPACE, key: "auth" }] : [],
|
||||||
|
userId,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
/**
|
||||||
|
* A user's own and folder host defaults in the per-user export database.
|
||||||
|
* Folders travel by path, since their ids mean nothing on another server.
|
||||||
|
* An import is checked like any other save, so a credential or jump host
|
||||||
|
* the importing user cannot use is skipped, and a key the level already
|
||||||
|
* holds is left as it is.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type Database from "better-sqlite3";
|
||||||
|
import { createCurrentHostDefaultsRepository } from "../../database/repositories/factory.js";
|
||||||
|
import type { HostDefaultsScope } from "../../database/repositories/host-defaults-repository.js";
|
||||||
|
import { PermissionManager } from "../../utils/permission-manager.js";
|
||||||
|
import { findUsableCredential } from "../usable-credential.js";
|
||||||
|
import { decodeDefaultValue } from "./levels.js";
|
||||||
|
import { readLevel, saveLevel, validateLevelChange } from "./service.js";
|
||||||
|
|
||||||
|
export async function writeHostDefaultsToExport(
|
||||||
|
exportDb: Database.Database,
|
||||||
|
userId: string,
|
||||||
|
): Promise<void> {
|
||||||
|
exportDb.exec(`
|
||||||
|
CREATE TABLE host_defaults (
|
||||||
|
level TEXT NOT NULL,
|
||||||
|
folder_name TEXT,
|
||||||
|
namespace TEXT NOT NULL,
|
||||||
|
key TEXT NOT NULL,
|
||||||
|
value TEXT
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
const repository = createCurrentHostDefaultsRepository();
|
||||||
|
const folders = new Map(
|
||||||
|
(await repository.listFolders([userId])).map((folder) => [
|
||||||
|
folder.id,
|
||||||
|
folder.name,
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
const insert = exportDb.prepare(
|
||||||
|
"INSERT INTO host_defaults (level, folder_name, namespace, key, value) VALUES (?, ?, ?, ?, ?)",
|
||||||
|
);
|
||||||
|
for (const row of await repository.listOwnedBy(userId)) {
|
||||||
|
const folderName =
|
||||||
|
row.level === "folder" && row.folderId !== null
|
||||||
|
? folders.get(row.folderId)
|
||||||
|
: null;
|
||||||
|
if (row.level === "folder" && !folderName) continue;
|
||||||
|
insert.run(row.level, folderName, row.namespace, row.key, row.value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function importHostDefaults(
|
||||||
|
importDb: Database.Database,
|
||||||
|
userId: string,
|
||||||
|
): Promise<{ imported: number; skipped: number }> {
|
||||||
|
let rows: Array<{
|
||||||
|
level: string;
|
||||||
|
folder_name: string | null;
|
||||||
|
namespace: string;
|
||||||
|
key: string;
|
||||||
|
value: string | null;
|
||||||
|
}>;
|
||||||
|
try {
|
||||||
|
rows = importDb.prepare("SELECT * FROM host_defaults").all() as never;
|
||||||
|
} catch {
|
||||||
|
return { imported: 0, skipped: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const groups = new Map<string, Record<string, unknown>>();
|
||||||
|
for (const row of rows) {
|
||||||
|
const group =
|
||||||
|
row.level === "folder" && row.folder_name
|
||||||
|
? `f:${row.folder_name}`
|
||||||
|
: row.level === "user"
|
||||||
|
? "u"
|
||||||
|
: null;
|
||||||
|
if (!group) continue;
|
||||||
|
const values = groups.get(group) ?? {};
|
||||||
|
values[`${row.namespace}.${row.key}`] = decodeDefaultValue(row.value);
|
||||||
|
groups.set(group, values);
|
||||||
|
}
|
||||||
|
|
||||||
|
const permissions = PermissionManager.getInstance();
|
||||||
|
const context = {
|
||||||
|
actorId: userId,
|
||||||
|
ownerId: userId,
|
||||||
|
canUseCredential: async (credentialId: number, owner: string) =>
|
||||||
|
(await findUsableCredential(credentialId, owner)) !== null,
|
||||||
|
canUseHost: async (hostId: number, owner: string) =>
|
||||||
|
(await permissions.canAccessHost(owner, hostId, "connect")).hasAccess,
|
||||||
|
};
|
||||||
|
|
||||||
|
const repository = createCurrentHostDefaultsRepository();
|
||||||
|
let imported = 0;
|
||||||
|
let skipped = 0;
|
||||||
|
for (const [group, values] of groups) {
|
||||||
|
const scope: HostDefaultsScope =
|
||||||
|
group === "u"
|
||||||
|
? { level: "user", userId }
|
||||||
|
: {
|
||||||
|
level: "folder",
|
||||||
|
userId,
|
||||||
|
folderId: await repository.ensureFolder(userId, group.slice(2)),
|
||||||
|
};
|
||||||
|
const existing = await readLevel(scope);
|
||||||
|
const fresh = Object.fromEntries(
|
||||||
|
Object.entries(values).filter(([key]) => !(key in existing)),
|
||||||
|
);
|
||||||
|
skipped += Object.keys(values).length - Object.keys(fresh).length;
|
||||||
|
const change = await validateLevelChange(scope, { set: fresh }, context);
|
||||||
|
skipped += Object.keys(change.errors).length;
|
||||||
|
if (change.set.size === 0) continue;
|
||||||
|
await saveLevel(scope, { ...change, errors: {} }, userId);
|
||||||
|
imported += change.set.size;
|
||||||
|
}
|
||||||
|
return { imported, skipped };
|
||||||
|
}
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
/**
|
||||||
|
* How a host write decides which core keys the host sets itself.
|
||||||
|
*
|
||||||
|
* - A write that carries `defaultOverrides` says so outright (the host
|
||||||
|
* editor). Values it sends for inherited keys are replaced by what
|
||||||
|
* resolves.
|
||||||
|
* - An update without one (an API client, Termix-Mobile) keeps what was
|
||||||
|
* overridden, and a key whose value changed becomes overridden.
|
||||||
|
* - A create without one (import, quick connect, enroll) inherits every key
|
||||||
|
* whose value matches what resolves, or that it left out.
|
||||||
|
*
|
||||||
|
* Plugin namespaces are not in the host body. They keep their state here and
|
||||||
|
* are classified or rewritten by the materialize pass that follows the write.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { HostRow } from "../../database/repositories/host-defaults-repository.js";
|
||||||
|
import {
|
||||||
|
CORE_NAMESPACE,
|
||||||
|
defaultValuesEqual,
|
||||||
|
parseDefaultOverrides,
|
||||||
|
type DefaultOverrides,
|
||||||
|
} from "../../../types/host-defaults.js";
|
||||||
|
import { coreCatalogEntries } from "./catalog.js";
|
||||||
|
import { readCoreDefault, writeCoreDefault } from "./core-columns.js";
|
||||||
|
import { loadDefaultsIndex, levelsForHost } from "./levels.js";
|
||||||
|
import { loadPlacement, resolveForHost } from "./materialize.js";
|
||||||
|
import { effectiveFolderPath, isOwnValue } from "./resolve.js";
|
||||||
|
|
||||||
|
export interface HostDefaultsWrite {
|
||||||
|
/** The host's owner, whose defaults apply. */
|
||||||
|
ownerId: string;
|
||||||
|
/** Null on create. */
|
||||||
|
hostId: number | null;
|
||||||
|
/** The column object the route is about to write. Changed in place. */
|
||||||
|
columns: Record<string, unknown>;
|
||||||
|
/** The request body as sent. */
|
||||||
|
body: Record<string, unknown>;
|
||||||
|
/** The row before an update. */
|
||||||
|
stored?: HostRow | null;
|
||||||
|
/** Core keys this caller may not change, which keep their state. */
|
||||||
|
lockedKeys?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Body fields each core key is read from, to tell a left-out key on create. */
|
||||||
|
const BODY_FIELDS: Record<string, string[]> = {
|
||||||
|
username: ["username"],
|
||||||
|
sshPort: ["sshPort", "port"],
|
||||||
|
auth: ["authType", "authMethod", "credentialId"],
|
||||||
|
forceKeyboardInteractive: ["forceKeyboardInteractive"],
|
||||||
|
keepaliveInterval: ["sshOptions", "terminalConfig"],
|
||||||
|
keepaliveCountMax: ["sshOptions", "terminalConfig"],
|
||||||
|
allowLegacyAlgorithms: ["sshOptions", "terminalConfig"],
|
||||||
|
agentForwarding: ["sshOptions", "terminalConfig"],
|
||||||
|
environmentVariables: ["sshOptions", "terminalConfig"],
|
||||||
|
socks5: ["useSocks5"],
|
||||||
|
jumpHosts: ["jumpHosts"],
|
||||||
|
portKnockSequence: ["portKnockSequence"],
|
||||||
|
statusCheckEnabled: ["statusCheckEnabled"],
|
||||||
|
statusCheckInterval: ["statusCheckInterval"],
|
||||||
|
};
|
||||||
|
|
||||||
|
function leftOut(body: Record<string, unknown>, key: string): boolean {
|
||||||
|
const fields = BODY_FIELDS[key] ?? [key];
|
||||||
|
return fields.every((field) => body[field] === undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fills inherited core keys into `columns` and returns the host's overrides.
|
||||||
|
* The caller stores them as `defaultOverrides`.
|
||||||
|
*/
|
||||||
|
export async function applyHostDefaultsToWrite(
|
||||||
|
write: HostDefaultsWrite,
|
||||||
|
): Promise<DefaultOverrides> {
|
||||||
|
const { ownerId, hostId, columns, body, stored } = write;
|
||||||
|
const sent = parseDefaultOverrides(body.defaultOverrides);
|
||||||
|
const before = parseDefaultOverrides(stored?.defaultOverrides);
|
||||||
|
const locked = new Set(write.lockedKeys ?? []);
|
||||||
|
|
||||||
|
const placement = await loadPlacement([ownerId]);
|
||||||
|
const self = hostId ?? -1;
|
||||||
|
placement.set(self, {
|
||||||
|
folder: (columns.folder !== undefined
|
||||||
|
? columns.folder
|
||||||
|
: (stored?.folder ?? null)) as string | null,
|
||||||
|
parentHostId: (columns.parentHostId !== undefined
|
||||||
|
? columns.parentHostId
|
||||||
|
: (stored?.parentHostId ?? null)) as number | null,
|
||||||
|
});
|
||||||
|
const index = await loadDefaultsIndex([ownerId]);
|
||||||
|
const levels = levelsForHost(
|
||||||
|
index,
|
||||||
|
ownerId,
|
||||||
|
effectiveFolderPath(self, placement),
|
||||||
|
);
|
||||||
|
|
||||||
|
const storedColumns = (stored ?? {}) as Record<string, unknown>;
|
||||||
|
// A partial write (a plugin's ctx.hosts.update) leaves the rest as stored.
|
||||||
|
const incoming = (key: string) =>
|
||||||
|
readCoreDefault(key, { ...storedColumns, ...columns });
|
||||||
|
const previous = (key: string) => readCoreDefault(key, storedColumns);
|
||||||
|
|
||||||
|
const overridden: string[] = [];
|
||||||
|
for (const info of coreCatalogEntries()) {
|
||||||
|
const resolved = resolveForHost(info, levels, hostId);
|
||||||
|
let own: boolean;
|
||||||
|
if (locked.has(info.key)) {
|
||||||
|
own = before?.[CORE_NAMESPACE]?.includes(info.key) ?? true;
|
||||||
|
if (own) overridden.push(info.key);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (sent?.[CORE_NAMESPACE]) {
|
||||||
|
own = sent[CORE_NAMESPACE].includes(info.key);
|
||||||
|
} else if (stored && before?.[CORE_NAMESPACE]) {
|
||||||
|
own =
|
||||||
|
before[CORE_NAMESPACE].includes(info.key) ||
|
||||||
|
!defaultValuesEqual(
|
||||||
|
info.normalize(incoming(info.key)),
|
||||||
|
info.normalize(previous(info.key)),
|
||||||
|
);
|
||||||
|
} else if (!stored && leftOut(body, info.key)) {
|
||||||
|
own = false;
|
||||||
|
} else {
|
||||||
|
own = isOwnValue(info, incoming(info.key), resolved);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (own || !resolved) {
|
||||||
|
if (own) overridden.push(info.key);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
Object.assign(
|
||||||
|
columns,
|
||||||
|
writeCoreDefault(
|
||||||
|
info.key,
|
||||||
|
resolved.value,
|
||||||
|
{ ...storedColumns, ...columns },
|
||||||
|
storedColumns.sshOptions,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const overrides: DefaultOverrides = { ...(before ?? {}) };
|
||||||
|
if (sent) {
|
||||||
|
for (const [namespace, keys] of Object.entries(sent)) {
|
||||||
|
if (namespace !== CORE_NAMESPACE) overrides[namespace] = keys;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
overrides[CORE_NAMESPACE] = overridden.sort();
|
||||||
|
return overrides;
|
||||||
|
}
|
||||||
@@ -372,6 +372,21 @@ export function createPluginHosts({ manifest, bag, audit }: Deps): PluginHosts {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Host settings a plugin writes are the host's own, not its defaults. */
|
||||||
|
const markSettingsOwn = async (
|
||||||
|
hostId: number,
|
||||||
|
writes: Awaited<ReturnType<typeof checkSettings>>,
|
||||||
|
) => {
|
||||||
|
if (writes.length === 0) return;
|
||||||
|
const { changeHostOverrides } =
|
||||||
|
await import("../hosts/defaults/overrides.js");
|
||||||
|
await changeHostOverrides([hostId], {
|
||||||
|
own: writes.flatMap(({ manifest: owner, values }) =>
|
||||||
|
Object.keys(values).map((key): [string, string] => [owner.id, key]),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
list: async () => {
|
list: async () => {
|
||||||
await requireRead();
|
await requireRead();
|
||||||
@@ -467,12 +482,25 @@ export function createPluginHosts({ manifest, bag, audit }: Deps): PluginHosts {
|
|||||||
const writes = await checkSettings(settings);
|
const writes = await checkSettings(settings);
|
||||||
const { createCurrentHostRepository } =
|
const { createCurrentHostRepository } =
|
||||||
await import("../database/repositories/factory.js");
|
await import("../database/repositories/factory.js");
|
||||||
|
const { applyHostDefaultsToWrite, applyDefaultsAfterHostWrite } =
|
||||||
|
await import("../hosts/defaults/index.js");
|
||||||
|
const columns: Record<string, unknown> = { ...row, userId };
|
||||||
|
columns.defaultOverrides = JSON.stringify(
|
||||||
|
await applyHostDefaultsToWrite({
|
||||||
|
ownerId: userId,
|
||||||
|
hostId: null,
|
||||||
|
columns,
|
||||||
|
body: host as unknown as Record<string, unknown>,
|
||||||
|
}),
|
||||||
|
);
|
||||||
const created =
|
const created =
|
||||||
await createCurrentHostRepository().createEncryptedForUser(userId, {
|
await createCurrentHostRepository().createEncryptedForUser(
|
||||||
...row,
|
|
||||||
userId,
|
userId,
|
||||||
});
|
columns,
|
||||||
|
);
|
||||||
|
await applyDefaultsAfterHostWrite(created.id);
|
||||||
await writeSettings(created.id, writes);
|
await writeSettings(created.id, writes);
|
||||||
|
await markSettingsOwn(created.id, writes);
|
||||||
await audit("hosts_create", `host ${created.id}`, { success: true });
|
await audit("hosts_create", `host ${created.id}`, { success: true });
|
||||||
return (
|
return (
|
||||||
await toRecords([created as unknown as Record<string, unknown>])
|
await toRecords([created as unknown as Record<string, unknown>])
|
||||||
@@ -497,13 +525,47 @@ export function createPluginHosts({ manifest, bag, audit }: Deps): PluginHosts {
|
|||||||
const writes = await checkSettings(settings);
|
const writes = await checkSettings(settings);
|
||||||
const { createCurrentHostRepository } =
|
const { createCurrentHostRepository } =
|
||||||
await import("../database/repositories/factory.js");
|
await import("../database/repositories/factory.js");
|
||||||
|
const { applyHostDefaultsToWrite, applyDefaultsAfterHostWrite } =
|
||||||
|
await import("../hosts/defaults/index.js");
|
||||||
|
const { createCurrentHostDefaultsRepository } =
|
||||||
|
await import("../database/repositories/factory.js");
|
||||||
|
const stored = (
|
||||||
|
await createCurrentHostDefaultsRepository().listHosts({
|
||||||
|
hostIds: [hostId],
|
||||||
|
})
|
||||||
|
)[0];
|
||||||
|
const columns: Record<string, unknown> = { ...row };
|
||||||
|
if (stored && stored.userId === userId) {
|
||||||
|
columns.defaultOverrides = JSON.stringify(
|
||||||
|
await applyHostDefaultsToWrite({
|
||||||
|
ownerId: userId,
|
||||||
|
hostId,
|
||||||
|
columns,
|
||||||
|
body: patch as unknown as Record<string, unknown>,
|
||||||
|
stored,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
const updated =
|
const updated =
|
||||||
await createCurrentHostRepository().updateEncryptedForUser(
|
await createCurrentHostRepository().updateEncryptedForUser(
|
||||||
userId,
|
userId,
|
||||||
hostId,
|
hostId,
|
||||||
row,
|
columns,
|
||||||
);
|
);
|
||||||
if (updated) await writeSettings(hostId, writes);
|
if (updated) {
|
||||||
|
await writeSettings(hostId, writes);
|
||||||
|
await markSettingsOwn(hostId, writes);
|
||||||
|
await applyDefaultsAfterHostWrite(hostId, {
|
||||||
|
moved:
|
||||||
|
!!stored &&
|
||||||
|
((columns.folder !== undefined &&
|
||||||
|
(columns.folder ?? null) !== (stored.folder ?? null)) ||
|
||||||
|
(columns.parentHostId !== undefined &&
|
||||||
|
(columns.parentHostId ?? null) !==
|
||||||
|
(stored.parentHostId ?? null))),
|
||||||
|
ownerId: userId,
|
||||||
|
});
|
||||||
|
}
|
||||||
await audit("hosts_update", `host ${hostId}`, {
|
await audit("hosts_update", `host ${hostId}`, {
|
||||||
success: updated !== null,
|
success: updated !== null,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -816,7 +816,40 @@ export function createPluginContext(
|
|||||||
|
|
||||||
getHost: (hostId, key) =>
|
getHost: (hostId, key) =>
|
||||||
pluginSettings.getSetting(manifest, "host", hostId, key) as never,
|
pluginSettings.getSetting(manifest, "host", hostId, key) as never,
|
||||||
setHost: async (hostId, key, value) => {
|
getHostFor: async (hostId, userId, key) => {
|
||||||
|
const { personalHostValue } =
|
||||||
|
await import("../hosts/defaults/personal.js");
|
||||||
|
const personal = await personalHostValue(
|
||||||
|
manifest,
|
||||||
|
Number(hostId),
|
||||||
|
userId,
|
||||||
|
key,
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
personal.applies
|
||||||
|
? personal.value
|
||||||
|
: await pluginSettings.getSetting(manifest, "host", hostId, key)
|
||||||
|
) as never;
|
||||||
|
},
|
||||||
|
getHostDefault: async (userId, key) => {
|
||||||
|
const { userHostDefault } =
|
||||||
|
await import("../hosts/defaults/personal.js");
|
||||||
|
return (await userHostDefault(manifest, userId, key)) as never;
|
||||||
|
},
|
||||||
|
setHost: async (hostId, key, value, options) => {
|
||||||
|
const { changeHostOverrides } =
|
||||||
|
await import("../hosts/defaults/overrides.js");
|
||||||
|
if (options?.inherit) {
|
||||||
|
if (!pluginSettings.findField(manifest, "host", key)) {
|
||||||
|
throw new Error(
|
||||||
|
`"${key}" is not a host setting this plugin declares`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await changeHostOverrides([Number(hostId)], {
|
||||||
|
inherit: [[pluginId, key]],
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
const error = await pluginSettings.setSetting(
|
const error = await pluginSettings.setSetting(
|
||||||
manifest,
|
manifest,
|
||||||
"host",
|
"host",
|
||||||
@@ -825,6 +858,7 @@ export function createPluginContext(
|
|||||||
value,
|
value,
|
||||||
);
|
);
|
||||||
if (error) throw new Error(error);
|
if (error) throw new Error(error);
|
||||||
|
await changeHostOverrides([Number(hostId)], { own: [[pluginId, key]] });
|
||||||
},
|
},
|
||||||
|
|
||||||
listHostValues: async (key) => {
|
listHostValues: async (key) => {
|
||||||
|
|||||||
@@ -75,7 +75,11 @@ export function onSettingsChange(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function notify(pluginId: string, key: string, value: unknown): void {
|
export function notifySettingChange(
|
||||||
|
pluginId: string,
|
||||||
|
key: string,
|
||||||
|
value: unknown,
|
||||||
|
): void {
|
||||||
for (const listener of listeners.get(pluginId)?.get(key) ?? []) {
|
for (const listener of listeners.get(pluginId)?.get(key) ?? []) {
|
||||||
try {
|
try {
|
||||||
listener(value);
|
listener(value);
|
||||||
@@ -92,7 +96,10 @@ function notify(pluginId: string, key: string, value: unknown): void {
|
|||||||
|
|
||||||
export type SettingsValidator = (
|
export type SettingsValidator = (
|
||||||
values: Record<string, unknown>,
|
values: Record<string, unknown>,
|
||||||
context: { hostId?: number },
|
context: {
|
||||||
|
hostId?: number;
|
||||||
|
defaults?: { level: "admin" | "user" | "folder" };
|
||||||
|
},
|
||||||
) =>
|
) =>
|
||||||
| Record<string, string>
|
| Record<string, string>
|
||||||
| void
|
| void
|
||||||
@@ -131,6 +138,7 @@ export async function validateSettingsSave(
|
|||||||
scope: PluginSettingsScope,
|
scope: PluginSettingsScope,
|
||||||
scopeId: string | null,
|
scopeId: string | null,
|
||||||
values: Record<string, unknown>,
|
values: Record<string, unknown>,
|
||||||
|
defaults?: { level: "admin" | "user" | "folder" },
|
||||||
): Promise<Record<string, string>> {
|
): Promise<Record<string, string>> {
|
||||||
const errors: Record<string, string> = {};
|
const errors: Record<string, string> = {};
|
||||||
for (const entry of validators.get(pluginId) ?? []) {
|
for (const entry of validators.get(pluginId) ?? []) {
|
||||||
@@ -138,6 +146,7 @@ export async function validateSettingsSave(
|
|||||||
try {
|
try {
|
||||||
const result = await entry.run(values, {
|
const result = await entry.run(values, {
|
||||||
hostId: scope === "host" && scopeId ? Number(scopeId) : undefined,
|
hostId: scope === "host" && scopeId ? Number(scopeId) : undefined,
|
||||||
|
...(defaults ? { defaults } : {}),
|
||||||
});
|
});
|
||||||
Object.assign(errors, result ?? {});
|
Object.assign(errors, result ?? {});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -299,7 +308,7 @@ export async function setSetting(
|
|||||||
shouldEncrypt,
|
shouldEncrypt,
|
||||||
);
|
);
|
||||||
|
|
||||||
notify(manifest.id, key, coerced);
|
notifySettingChange(manifest.id, key, coerced);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import { FieldCrypto } from "../utils/field-crypto.js";
|
|||||||
import { syncLogger } from "../utils/logger.js";
|
import { syncLogger } from "../utils/logger.js";
|
||||||
import { getPluginRuntime } from "../plugins/index.js";
|
import { getPluginRuntime } from "../plugins/index.js";
|
||||||
import { singletonSyncId } from "./wire.js";
|
import { singletonSyncId } from "./wire.js";
|
||||||
|
import { parseDefaultOverrides } from "../../types/host-defaults.js";
|
||||||
import {
|
import {
|
||||||
exportHostPluginSettings,
|
exportHostPluginSettings,
|
||||||
importHostPluginSettings,
|
importHostPluginSettings,
|
||||||
@@ -31,6 +32,14 @@ import {
|
|||||||
exportProtocolLogins,
|
exportProtocolLogins,
|
||||||
importProtocolLogins,
|
importProtocolLogins,
|
||||||
} from "./host-protocol-auth.js";
|
} from "./host-protocol-auth.js";
|
||||||
|
import {
|
||||||
|
coversHostDefault,
|
||||||
|
eraseHostDefault,
|
||||||
|
loadAdminDefaults,
|
||||||
|
loadHostDefaults,
|
||||||
|
writeAdminDefaults,
|
||||||
|
writeHostDefault,
|
||||||
|
} from "./host-defaults.js";
|
||||||
|
|
||||||
const CREDENTIAL_REFERENCE = {
|
const CREDENTIAL_REFERENCE = {
|
||||||
field: "credentialId",
|
field: "credentialId",
|
||||||
@@ -167,9 +176,22 @@ export function registerCoreSyncEntities(): void {
|
|||||||
}),
|
}),
|
||||||
afterWrite: async ({ id, userId, wire, resolveId }) => {
|
afterWrite: async ({ id, userId, wire, resolveId }) => {
|
||||||
if (id === null) return;
|
if (id === null) return;
|
||||||
await importHostPluginSettings(id, wire.pluginSettings);
|
// The server owns host defaults: it keeps what the host set itself
|
||||||
|
// and resolves the rest. A desktop takes the server's values as sent.
|
||||||
|
const { getLink } = await import("./client/link-store.js");
|
||||||
|
const onServer = !(await getLink().catch(() => null));
|
||||||
|
await importHostPluginSettings(
|
||||||
|
id,
|
||||||
|
wire.pluginSettings,
|
||||||
|
onServer ? parseDefaultOverrides(wire.defaultOverrides) : null,
|
||||||
|
);
|
||||||
await importProtocolLogins(id, userId, wire.protocolAuth, resolveId);
|
await importProtocolLogins(id, userId, wire.protocolAuth, resolveId);
|
||||||
await dropInvalidParent(userId, id);
|
await dropInvalidParent(userId, id);
|
||||||
|
if (onServer) {
|
||||||
|
const { applyDefaultsAfterHostWrite } =
|
||||||
|
await import("../hosts/defaults/index.js");
|
||||||
|
await applyDefaultsAfterHostWrite(id);
|
||||||
|
}
|
||||||
const { SharedHostSecretsManager } =
|
const { SharedHostSecretsManager } =
|
||||||
await import("../utils/shared-host-secrets-manager.js");
|
await import("../utils/shared-host-secrets-manager.js");
|
||||||
await SharedHostSecretsManager.getInstance().resyncHost(id);
|
await SharedHostSecretsManager.getInstance().resyncHost(id);
|
||||||
@@ -193,6 +215,29 @@ export function registerCoreSyncEntities(): void {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// After hosts: a folder's jump chain names hosts by syncId.
|
||||||
|
registerEntity(
|
||||||
|
CORE_OWNER,
|
||||||
|
{ type: "hostDefaults", table: null, order: 60 },
|
||||||
|
{
|
||||||
|
load: loadHostDefaults,
|
||||||
|
write: writeHostDefault,
|
||||||
|
erase: eraseHostDefault,
|
||||||
|
covers: (syncId) => coversHostDefault(syncId, activeManifest),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
registerEntity(
|
||||||
|
CORE_OWNER,
|
||||||
|
{ type: "hostDefaultsAdmin", table: null, order: 7, singleton: true },
|
||||||
|
{
|
||||||
|
readOnly: true,
|
||||||
|
load: loadAdminDefaults,
|
||||||
|
write: writeAdminDefaults,
|
||||||
|
erase: async () => {},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
// dashboardServiceLinks (70) and homepageItems (80) belong to homepage.
|
// dashboardServiceLinks (70) and homepageItems (80) belong to homepage.
|
||||||
|
|
||||||
registerEntity(CORE_OWNER, {
|
registerEntity(CORE_OWNER, {
|
||||||
|
|||||||
@@ -0,0 +1,335 @@
|
|||||||
|
/**
|
||||||
|
* Host defaults over sync.
|
||||||
|
*
|
||||||
|
* A user's own and folder defaults travel as one row per key. A value that
|
||||||
|
* names a row by local id (the credential in an auth default, the hosts in a
|
||||||
|
* jump chain, a plugin's own ids) travels by syncId and is translated back on
|
||||||
|
* the other side. The server's admin defaults reach a linked desktop as one
|
||||||
|
* read-only snapshot, so the desktop resolves a host the way its server does.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { and, eq } from "drizzle-orm";
|
||||||
|
import type { SyncRow } from "@termix/plugin-sdk/backend";
|
||||||
|
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
|
||||||
|
import { hosts, sshCredentials, sshFolders } from "../database/db/schema.js";
|
||||||
|
import {
|
||||||
|
createCurrentHostDefaultsRepository,
|
||||||
|
createCurrentRepositoryContext,
|
||||||
|
createCurrentSettingsRepository,
|
||||||
|
} from "../database/repositories/factory.js";
|
||||||
|
import { consume } from "../plugins/registry.js";
|
||||||
|
import {
|
||||||
|
CORE_NAMESPACE,
|
||||||
|
joinDefaultKey,
|
||||||
|
splitDefaultKey,
|
||||||
|
} from "../../types/host-defaults.js";
|
||||||
|
import {
|
||||||
|
decodeDefaultValue,
|
||||||
|
encodeDefaultValue,
|
||||||
|
REMOTE_ADMIN_DEFAULTS_KEY,
|
||||||
|
} from "../hosts/defaults/levels.js";
|
||||||
|
import type { PluginHostSettingsSync } from "./host-plugin-settings.js";
|
||||||
|
import { singletonSyncId } from "./wire.js";
|
||||||
|
|
||||||
|
type IdMapper = (id: number) => Promise<string | null>;
|
||||||
|
type SyncIdMapper = (syncId: string) => Promise<number | null>;
|
||||||
|
|
||||||
|
function db() {
|
||||||
|
return createCurrentRepositoryContext().drizzle;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function credentialSyncId(id: number): Promise<string | null> {
|
||||||
|
const [row] = await db()
|
||||||
|
.select({ syncId: sshCredentials.syncId })
|
||||||
|
.from(sshCredentials)
|
||||||
|
.where(eq(sshCredentials.id, id))
|
||||||
|
.limit(1);
|
||||||
|
return row?.syncId ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function hostSyncId(id: number): Promise<string | null> {
|
||||||
|
const [row] = await db()
|
||||||
|
.select({ syncId: hosts.syncId })
|
||||||
|
.from(hosts)
|
||||||
|
.where(eq(hosts.id, id))
|
||||||
|
.limit(1);
|
||||||
|
return row?.syncId ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function localCredential(userId: string): SyncIdMapper {
|
||||||
|
return async (syncId) => {
|
||||||
|
const [row] = await db()
|
||||||
|
.select({ id: sshCredentials.id })
|
||||||
|
.from(sshCredentials)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(sshCredentials.syncId, syncId),
|
||||||
|
eq(sshCredentials.userId, userId),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
return row?.id ?? null;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function localHost(userId: string): SyncIdMapper {
|
||||||
|
return async (syncId) => {
|
||||||
|
const [row] = await db()
|
||||||
|
.select({ id: hosts.id })
|
||||||
|
.from(hosts)
|
||||||
|
.where(and(eq(hosts.syncId, syncId), eq(hosts.userId, userId)))
|
||||||
|
.limit(1);
|
||||||
|
return row?.id ?? null;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function hostSettingsHook(
|
||||||
|
pluginId: string,
|
||||||
|
): PluginHostSettingsSync | undefined {
|
||||||
|
return consume<PluginHostSettingsSync>(`${pluginId}.hostSettingsSync`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A value on its way out: local ids become syncIds. */
|
||||||
|
export async function exportDefaultValue(
|
||||||
|
namespace: string,
|
||||||
|
key: string,
|
||||||
|
value: unknown,
|
||||||
|
maps: { credential: IdMapper; host: IdMapper } = {
|
||||||
|
credential: credentialSyncId,
|
||||||
|
host: hostSyncId,
|
||||||
|
},
|
||||||
|
): Promise<unknown> {
|
||||||
|
if (namespace !== CORE_NAMESPACE) {
|
||||||
|
const hook = hostSettingsHook(namespace);
|
||||||
|
return hook?.exportValue ? hook.exportValue(key, value) : value;
|
||||||
|
}
|
||||||
|
if (key === "auth" && value && typeof value === "object") {
|
||||||
|
const auth = value as Record<string, unknown>;
|
||||||
|
if (typeof auth.credentialId !== "number") return value;
|
||||||
|
const { credentialId, ...rest } = auth;
|
||||||
|
return { ...rest, credentialSyncId: await maps.credential(credentialId) };
|
||||||
|
}
|
||||||
|
if (key === "jumpHosts" && Array.isArray(value)) {
|
||||||
|
const mapped = [];
|
||||||
|
for (const entry of value) {
|
||||||
|
const hostId = Number((entry as { hostId?: unknown })?.hostId);
|
||||||
|
const syncId = Number.isInteger(hostId) ? await maps.host(hostId) : null;
|
||||||
|
if (syncId) mapped.push({ hostSyncId: syncId });
|
||||||
|
}
|
||||||
|
return mapped;
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A value on its way in: syncIds become this side's ids. */
|
||||||
|
export async function importDefaultValue(
|
||||||
|
namespace: string,
|
||||||
|
key: string,
|
||||||
|
value: unknown,
|
||||||
|
maps: { credential: SyncIdMapper; host: SyncIdMapper },
|
||||||
|
): Promise<unknown> {
|
||||||
|
if (namespace !== CORE_NAMESPACE) {
|
||||||
|
const hook = hostSettingsHook(namespace);
|
||||||
|
return hook?.importValue ? hook.importValue(key, value) : value;
|
||||||
|
}
|
||||||
|
if (key === "auth" && value && typeof value === "object") {
|
||||||
|
const auth = value as Record<string, unknown>;
|
||||||
|
if (typeof auth.credentialSyncId !== "string") return value;
|
||||||
|
const { credentialSyncId, ...rest } = auth;
|
||||||
|
return { ...rest, credentialId: await maps.credential(credentialSyncId) };
|
||||||
|
}
|
||||||
|
if (key === "jumpHosts" && Array.isArray(value)) {
|
||||||
|
const mapped = [];
|
||||||
|
for (const entry of value) {
|
||||||
|
const syncId = (entry as { hostSyncId?: unknown })?.hostSyncId;
|
||||||
|
const hostId =
|
||||||
|
typeof syncId === "string" ? await maps.host(syncId) : null;
|
||||||
|
if (hostId !== null) mapped.push({ hostId });
|
||||||
|
}
|
||||||
|
return mapped;
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadHostDefaults(userId: string): Promise<SyncRow[]> {
|
||||||
|
const repository = createCurrentHostDefaultsRepository();
|
||||||
|
const rows = await repository.listOwnedBy(userId);
|
||||||
|
const folderSyncIds = new Map<number, string>();
|
||||||
|
const folders = await db()
|
||||||
|
.select({
|
||||||
|
id: sshFolders.id,
|
||||||
|
syncId: sshFolders.syncId,
|
||||||
|
localOnly: sshFolders.localOnly,
|
||||||
|
})
|
||||||
|
.from(sshFolders)
|
||||||
|
.where(eq(sshFolders.userId, userId));
|
||||||
|
for (const folder of folders) {
|
||||||
|
if (folder.syncId && !folder.localOnly) {
|
||||||
|
folderSyncIds.set(folder.id, folder.syncId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const result: SyncRow[] = [];
|
||||||
|
for (const row of rows) {
|
||||||
|
const fullKey = joinDefaultKey(row.namespace, row.key);
|
||||||
|
let syncId: string;
|
||||||
|
let folderSyncId: string | null = null;
|
||||||
|
if (row.level === "user") {
|
||||||
|
syncId = `u:${fullKey}`;
|
||||||
|
} else if (row.level === "folder" && row.folderId !== null) {
|
||||||
|
const folder = folderSyncIds.get(row.folderId);
|
||||||
|
if (!folder) continue;
|
||||||
|
folderSyncId = folder;
|
||||||
|
syncId = `f:${folder}:${fullKey}`;
|
||||||
|
} else {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
result.push({
|
||||||
|
syncId,
|
||||||
|
level: row.level,
|
||||||
|
folderSyncId,
|
||||||
|
namespace: row.namespace,
|
||||||
|
key: row.key,
|
||||||
|
value: await exportDefaultValue(
|
||||||
|
row.namespace,
|
||||||
|
row.key,
|
||||||
|
decodeDefaultValue(row.value),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Splits "u:<ns.key>" or "f:<folderSyncId>:<ns.key>". */
|
||||||
|
export function parseHostDefaultsSyncId(syncId: string): {
|
||||||
|
level: "user" | "folder";
|
||||||
|
folderSyncId: string | null;
|
||||||
|
fullKey: string;
|
||||||
|
} | null {
|
||||||
|
if (syncId.startsWith("u:")) {
|
||||||
|
return { level: "user", folderSyncId: null, fullKey: syncId.slice(2) };
|
||||||
|
}
|
||||||
|
if (syncId.startsWith("f:")) {
|
||||||
|
const rest = syncId.slice(2);
|
||||||
|
const colon = rest.indexOf(":");
|
||||||
|
if (colon <= 0) return null;
|
||||||
|
return {
|
||||||
|
level: "folder",
|
||||||
|
folderSyncId: rest.slice(0, colon),
|
||||||
|
fullKey: rest.slice(colon + 1),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function folderIdFor(
|
||||||
|
userId: string,
|
||||||
|
folderSyncId: string,
|
||||||
|
): Promise<number | null> {
|
||||||
|
const [row] = await db()
|
||||||
|
.select({ id: sshFolders.id })
|
||||||
|
.from(sshFolders)
|
||||||
|
.where(
|
||||||
|
and(eq(sshFolders.syncId, folderSyncId), eq(sshFolders.userId, userId)),
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
return row?.id ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function scopeFor(
|
||||||
|
userId: string,
|
||||||
|
parsed: NonNullable<ReturnType<typeof parseHostDefaultsSyncId>>,
|
||||||
|
) {
|
||||||
|
if (parsed.level === "user") return { level: "user" as const, userId };
|
||||||
|
const folderId = parsed.folderSyncId
|
||||||
|
? await folderIdFor(userId, parsed.folderSyncId)
|
||||||
|
: null;
|
||||||
|
if (folderId === null) return null;
|
||||||
|
return { level: "folder" as const, userId, folderId };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function writeHostDefault(
|
||||||
|
userId: string,
|
||||||
|
wire: SyncRow,
|
||||||
|
): Promise<void> {
|
||||||
|
const parsed = parseHostDefaultsSyncId(String(wire.syncId));
|
||||||
|
if (!parsed) return;
|
||||||
|
const scope = await scopeFor(userId, parsed);
|
||||||
|
if (!scope) return;
|
||||||
|
const [namespace, key] = splitDefaultKey(parsed.fullKey);
|
||||||
|
const value = await importDefaultValue(namespace, key, wire.value, {
|
||||||
|
credential: localCredential(userId),
|
||||||
|
host: localHost(userId),
|
||||||
|
});
|
||||||
|
await createCurrentHostDefaultsRepository().apply(
|
||||||
|
scope,
|
||||||
|
[{ namespace, key, value: encodeDefaultValue(value) }],
|
||||||
|
[],
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
await afterSyncedLevelChange(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function eraseHostDefault(
|
||||||
|
userId: string,
|
||||||
|
syncId: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const parsed = parseHostDefaultsSyncId(syncId);
|
||||||
|
if (!parsed) return;
|
||||||
|
const scope = await scopeFor(userId, parsed);
|
||||||
|
if (!scope) return;
|
||||||
|
const [namespace, key] = splitDefaultKey(parsed.fullKey);
|
||||||
|
await createCurrentHostDefaultsRepository().apply(
|
||||||
|
scope,
|
||||||
|
[],
|
||||||
|
[{ namespace, key }],
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
await afterSyncedLevelChange(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A level a desktop pushed changes what the server's hosts resolve to. On a
|
||||||
|
* desktop the hosts arrive already resolved, so nothing runs there.
|
||||||
|
*/
|
||||||
|
async function afterSyncedLevelChange(userId: string): Promise<void> {
|
||||||
|
const { getLink } = await import("./client/link-store.js");
|
||||||
|
if (await getLink().catch(() => null)) return;
|
||||||
|
const { recompute } = await import("../hosts/defaults/recompute.js");
|
||||||
|
void recompute({ userIds: [userId] }).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function coversHostDefault(
|
||||||
|
syncId: string,
|
||||||
|
activeManifest: (pluginId: string) => PluginManifest | undefined,
|
||||||
|
): boolean {
|
||||||
|
const parsed = parseHostDefaultsSyncId(syncId);
|
||||||
|
if (!parsed) return false;
|
||||||
|
const [namespace] = splitDefaultKey(parsed.fullKey);
|
||||||
|
return namespace === CORE_NAMESPACE || !!activeManifest(namespace);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadAdminDefaults(): Promise<SyncRow[]> {
|
||||||
|
const rows = await createCurrentHostDefaultsRepository().listScope({
|
||||||
|
level: "admin",
|
||||||
|
});
|
||||||
|
const values: Record<string, unknown> = {};
|
||||||
|
for (const row of rows) {
|
||||||
|
values[joinDefaultKey(row.namespace, row.key)] = decodeDefaultValue(
|
||||||
|
row.value,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return [{ syncId: singletonSyncId("hostDefaultsAdmin"), values }];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function writeAdminDefaults(
|
||||||
|
_userId: string,
|
||||||
|
wire: SyncRow,
|
||||||
|
): Promise<void> {
|
||||||
|
const values =
|
||||||
|
wire.values && typeof wire.values === "object" ? wire.values : {};
|
||||||
|
await createCurrentSettingsRepository().set(
|
||||||
|
REMOTE_ADMIN_DEFAULTS_KEY,
|
||||||
|
JSON.stringify(values),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
setSetting,
|
setSetting,
|
||||||
} from "../plugins/settings.js";
|
} from "../plugins/settings.js";
|
||||||
import { databaseLogger } from "../utils/logger.js";
|
import { databaseLogger } from "../utils/logger.js";
|
||||||
|
import type { DefaultOverrides } from "../../types/host-defaults.js";
|
||||||
import {
|
import {
|
||||||
hostSettingsPlugins,
|
hostSettingsPlugins,
|
||||||
type HostPluginSettings,
|
type HostPluginSettings,
|
||||||
@@ -50,10 +51,16 @@ export async function exportHostPluginSettings(
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Writes what a synced host carried, translated back to local ids. */
|
/**
|
||||||
|
* Writes what a synced host carried, translated back to local ids. With
|
||||||
|
* `only`, a classified namespace takes just the keys the host sets itself;
|
||||||
|
* the rest follow this side's defaults and are written by the materialize
|
||||||
|
* pass that follows.
|
||||||
|
*/
|
||||||
export async function importHostPluginSettings(
|
export async function importHostPluginSettings(
|
||||||
hostId: number,
|
hostId: number,
|
||||||
carried: unknown,
|
carried: unknown,
|
||||||
|
only?: DefaultOverrides | null,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (!carried || typeof carried !== "object") return;
|
if (!carried || typeof carried !== "object") return;
|
||||||
const byPlugin = carried as HostPluginSettings;
|
const byPlugin = carried as HostPluginSettings;
|
||||||
@@ -63,8 +70,10 @@ export async function importHostPluginSettings(
|
|||||||
const hook = consume<PluginHostSettingsSync>(
|
const hook = consume<PluginHostSettingsSync>(
|
||||||
`${manifest.id}.hostSettingsSync`,
|
`${manifest.id}.hostSettingsSync`,
|
||||||
);
|
);
|
||||||
|
const ownKeys = only?.[manifest.id];
|
||||||
for (const field of declaredFields(manifest, "host")) {
|
for (const field of declaredFields(manifest, "host")) {
|
||||||
if (field.type === "secret" || !(field.key in own)) continue;
|
if (field.type === "secret" || !(field.key in own)) continue;
|
||||||
|
if (ownKeys && !ownKeys.includes(field.key)) continue;
|
||||||
try {
|
try {
|
||||||
const value = hook?.importValue
|
const value = hook?.importValue
|
||||||
? await hook.importValue(field.key, own[field.key])
|
? await hook.importValue(field.key, own[field.key])
|
||||||
|
|||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
|
import { TestSqliteDatabase } from "./test-support.js";
|
||||||
|
import { HostDefaultsRepository } from "../../../database/repositories/host-defaults-repository.js";
|
||||||
|
|
||||||
|
describe("HostDefaultsRepository", () => {
|
||||||
|
let adapter: TestSqliteDatabase | null = null;
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
if (adapter) {
|
||||||
|
await adapter.close();
|
||||||
|
adapter = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function createRepository(): Promise<HostDefaultsRepository> {
|
||||||
|
adapter = new TestSqliteDatabase();
|
||||||
|
const context = await adapter.connect();
|
||||||
|
await adapter.exec(`
|
||||||
|
INSERT INTO users (id, username, password_hash)
|
||||||
|
VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash');
|
||||||
|
INSERT INTO ssh_folders (id, user_id, name)
|
||||||
|
VALUES (1, 'user-1', 'prod'), (2, 'user-2', 'prod');
|
||||||
|
INSERT INTO ssh_data (id, user_id, name, ip, port, username, folder, auth_type)
|
||||||
|
VALUES
|
||||||
|
(1, 'user-1', 'one', '10.0.0.1', 22, 'root', 'prod', 'password'),
|
||||||
|
(2, 'user-2', 'two', '10.0.0.2', 22, 'root', null, 'password');
|
||||||
|
`);
|
||||||
|
return new HostDefaultsRepository(context);
|
||||||
|
}
|
||||||
|
|
||||||
|
it("keeps each level apart, the server level included", async () => {
|
||||||
|
const repository = await createRepository();
|
||||||
|
await repository.apply(
|
||||||
|
{ level: "admin" },
|
||||||
|
[{ namespace: "core", key: "sshPort", value: "2022" }],
|
||||||
|
[],
|
||||||
|
"user-1",
|
||||||
|
);
|
||||||
|
await repository.apply(
|
||||||
|
{ level: "user", userId: "user-1" },
|
||||||
|
[{ namespace: "core", key: "sshPort", value: "2023" }],
|
||||||
|
[],
|
||||||
|
"user-1",
|
||||||
|
);
|
||||||
|
await repository.apply(
|
||||||
|
{ level: "folder", userId: "user-1", folderId: 1 },
|
||||||
|
[{ namespace: "term", key: "fontSize", value: "16" }],
|
||||||
|
[],
|
||||||
|
"user-1",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(await repository.listScope({ level: "admin" })).toHaveLength(1);
|
||||||
|
const forUser = await repository.listForUsers(["user-1"]);
|
||||||
|
expect(forUser.map((row) => `${row.level}:${row.value}`).sort()).toEqual([
|
||||||
|
"admin:2022",
|
||||||
|
"folder:16",
|
||||||
|
"user:2023",
|
||||||
|
]);
|
||||||
|
expect(await repository.listForUsers(["user-2"])).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("updates a key in place and clears one", async () => {
|
||||||
|
const repository = await createRepository();
|
||||||
|
const scope = { level: "user" as const, userId: "user-1" };
|
||||||
|
await repository.apply(
|
||||||
|
scope,
|
||||||
|
[{ namespace: "core", key: "sshPort", value: "1" }],
|
||||||
|
[],
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
await repository.apply(
|
||||||
|
scope,
|
||||||
|
[{ namespace: "core", key: "sshPort", value: "2" }],
|
||||||
|
[],
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
expect((await repository.listScope(scope)).map((row) => row.value)).toEqual(
|
||||||
|
["2"],
|
||||||
|
);
|
||||||
|
await repository.apply(
|
||||||
|
scope,
|
||||||
|
[],
|
||||||
|
[{ namespace: "core", key: "sshPort" }],
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
expect(await repository.listScope(scope)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("finds or creates a folder row for a path", async () => {
|
||||||
|
const repository = await createRepository();
|
||||||
|
expect(await repository.ensureFolder("user-1", "prod")).toBe(1);
|
||||||
|
const created = await repository.ensureFolder("user-1", "prod / web");
|
||||||
|
expect(await repository.findFolder(created)).toMatchObject({
|
||||||
|
userId: "user-1",
|
||||||
|
name: "prod / web",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads and writes hosts in bulk", async () => {
|
||||||
|
const repository = await createRepository();
|
||||||
|
expect(
|
||||||
|
(await repository.listHosts({ userIds: ["user-1"] })).map(
|
||||||
|
(row) => row.id,
|
||||||
|
),
|
||||||
|
).toEqual([1]);
|
||||||
|
await repository.updateHosts([
|
||||||
|
{ id: 1, values: { sshPort: 2022, defaultOverrides: '{"core":[]}' } },
|
||||||
|
]);
|
||||||
|
const [row] = await repository.listHosts({ hostIds: [1] });
|
||||||
|
expect(row.sshPort).toBe(2022);
|
||||||
|
expect(row.defaultOverrides).toBe('{"core":[]}');
|
||||||
|
expect(await repository.listHostPlacement(["user-1"])).toEqual([
|
||||||
|
{ id: 1, userId: "user-1", folder: "prod", parentHostId: null },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import express, { type RequestHandler } from "express";
|
||||||
|
import request from "supertest";
|
||||||
|
|
||||||
|
const state = vi.hoisted(() => ({
|
||||||
|
isAdmin: true,
|
||||||
|
access: { hasAccess: true, isOwner: true },
|
||||||
|
saved: [] as unknown[],
|
||||||
|
resets: [] as unknown[],
|
||||||
|
audits: [] as unknown[],
|
||||||
|
folders: new Map<number, { id: number; userId: string; name: string }>(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../../utils/logger.js", () => ({
|
||||||
|
databaseLogger: { error: vi.fn(), warn: vi.fn(), info: vi.fn() },
|
||||||
|
}));
|
||||||
|
vi.mock("../../../utils/audit-logger.js", () => ({
|
||||||
|
logAudit: async (entry: unknown) => {
|
||||||
|
state.audits.push(entry);
|
||||||
|
},
|
||||||
|
getAuditUsername: async () => "admin",
|
||||||
|
getRequestMeta: () => ({ ipAddress: "127.0.0.1", userAgent: "test" }),
|
||||||
|
}));
|
||||||
|
vi.mock("../../../utils/permission-manager.js", () => ({
|
||||||
|
PermissionManager: {
|
||||||
|
getInstance: () => ({
|
||||||
|
canAccessHost: async () => state.access,
|
||||||
|
hasPermission: async () => state.isAdmin,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
vi.mock("../../../hosts/usable-credential.js", () => ({
|
||||||
|
findUsableCredential: async () => ({}),
|
||||||
|
}));
|
||||||
|
vi.mock("../../../database/repositories/factory.js", () => ({
|
||||||
|
createCurrentHostDefaultsRepository: () => ({
|
||||||
|
findFolder: async (id: number) => state.folders.get(id) ?? null,
|
||||||
|
ensureFolder: async () => 42,
|
||||||
|
listHosts: async () => [{ id: 1, userId: "u1" }],
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
vi.mock("../../../hosts/defaults/materialize.js", () => ({
|
||||||
|
currentCatalog: () => ({ catalog: new Map() }),
|
||||||
|
materializeHosts: async () => ({ changedHostIds: [1, 2] }),
|
||||||
|
}));
|
||||||
|
vi.mock("../../../hosts/defaults/recompute.js", () => ({
|
||||||
|
getRecomputeJob: (id: string) =>
|
||||||
|
id === "job-1" ? { id, status: "done", changedHosts: 3 } : undefined,
|
||||||
|
recompute: async () => ({ changedHostIds: [5] }),
|
||||||
|
startRecomputeJob: () => ({ id: "job-1" }),
|
||||||
|
targetForScope: () => ({ all: true }),
|
||||||
|
}));
|
||||||
|
vi.mock("../../../hosts/defaults/service.js", () => ({
|
||||||
|
levelsAbove: async () => ({ admin: new Map(), user: new Map(), folders: [] }),
|
||||||
|
readLevel: async () => ({ "core.sshPort": 2022 }),
|
||||||
|
resolveAll: () => ({}),
|
||||||
|
resolveForEditor: async () => ({
|
||||||
|
"core.sshPort": { value: 2022, source: { level: "user" } },
|
||||||
|
}),
|
||||||
|
saveLevel: async (scope: unknown, change: unknown) => {
|
||||||
|
state.saved.push({ scope, change });
|
||||||
|
},
|
||||||
|
validateLevelChange: async (
|
||||||
|
_scope: unknown,
|
||||||
|
input: { set?: Record<string, unknown> },
|
||||||
|
) =>
|
||||||
|
input.set && "bad.key" in input.set
|
||||||
|
? { set: new Map(), unset: new Set(), errors: { "bad.key": "no" } }
|
||||||
|
: {
|
||||||
|
set: new Map(Object.entries(input.set ?? {})),
|
||||||
|
unset: new Set(),
|
||||||
|
errors: {},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
vi.mock("../../../hosts/defaults/overrides.js", () => ({
|
||||||
|
changeHostOverrides: async (hostIds: number[], change: unknown) => {
|
||||||
|
state.resets.push({ hostIds, change });
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
vi.mock("../../../database/routes/host-bulk-routes.js", async () => {
|
||||||
|
const actual = await vi.importActual<
|
||||||
|
typeof import("../../../database/routes/host-bulk-routes.js")
|
||||||
|
>("../../../database/routes/host-bulk-routes.js");
|
||||||
|
return { readDefaultsReset: actual.readDefaultsReset };
|
||||||
|
});
|
||||||
|
|
||||||
|
const { registerHostDefaultsRoutes } =
|
||||||
|
await import("../../../database/routes/host-defaults-routes.js");
|
||||||
|
|
||||||
|
const pass: RequestHandler = (_req, _res, next) => next();
|
||||||
|
const adminOnly: RequestHandler = (_req, res, next) =>
|
||||||
|
state.isAdmin ? next() : void res.status(403).json({ error: "no" });
|
||||||
|
|
||||||
|
function app() {
|
||||||
|
const router = express.Router();
|
||||||
|
router.use((req, _res, next) => {
|
||||||
|
(req as unknown as { userId: string }).userId = "u1";
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
registerHostDefaultsRoutes(router, {
|
||||||
|
authenticateJWT: pass,
|
||||||
|
requireEditPermission: pass,
|
||||||
|
requireDataAccess: pass,
|
||||||
|
requireAdminSettings: adminOnly,
|
||||||
|
});
|
||||||
|
const server = express();
|
||||||
|
server.use(express.json());
|
||||||
|
server.use("/host", router);
|
||||||
|
return server;
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
state.isAdmin = true;
|
||||||
|
state.access = { hasAccess: true, isOwner: true };
|
||||||
|
state.saved = [];
|
||||||
|
state.resets = [];
|
||||||
|
state.audits = [];
|
||||||
|
state.folders = new Map([
|
||||||
|
[1, { id: 1, userId: "u1", name: "Prod" }],
|
||||||
|
[2, { id: 2, userId: "u2", name: "Theirs" }],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("host defaults routes", () => {
|
||||||
|
it("keeps the server level to admins", async () => {
|
||||||
|
state.isAdmin = false;
|
||||||
|
expect((await request(app()).get("/host/defaults/admin")).status).toBe(403);
|
||||||
|
expect(
|
||||||
|
(await request(app()).put("/host/defaults/admin").send({ set: {} }))
|
||||||
|
.status,
|
||||||
|
).toBe(403);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("saves the server level as a background job, audited", async () => {
|
||||||
|
const response = await request(app())
|
||||||
|
.put("/host/defaults/admin")
|
||||||
|
.send({ set: { "core.sshPort": 2022 } });
|
||||||
|
expect(response.body).toEqual({ jobId: "job-1" });
|
||||||
|
expect(state.audits).toHaveLength(1);
|
||||||
|
const job = await request(app()).get("/host/defaults/jobs/job-1");
|
||||||
|
expect(job.body.changedHosts).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("saves a user's own level and says how many hosts changed", async () => {
|
||||||
|
const response = await request(app())
|
||||||
|
.put("/host/defaults/user")
|
||||||
|
.send({ set: { "core.sshPort": 2022 } });
|
||||||
|
expect(response.body).toEqual({ changedHosts: 1 });
|
||||||
|
expect(state.saved[0]).toMatchObject({
|
||||||
|
scope: { level: "user", userId: "u1" },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports rejected values", async () => {
|
||||||
|
const response = await request(app())
|
||||||
|
.put("/host/defaults/user")
|
||||||
|
.send({ set: { "bad.key": 1 } });
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
expect(response.body.errors).toEqual({ "bad.key": "no" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("only opens a folder the user owns", async () => {
|
||||||
|
expect((await request(app()).get("/host/defaults/folders/1")).status).toBe(
|
||||||
|
200,
|
||||||
|
);
|
||||||
|
expect((await request(app()).get("/host/defaults/folders/2")).status).toBe(
|
||||||
|
404,
|
||||||
|
);
|
||||||
|
const created = await request(app())
|
||||||
|
.post("/host/defaults/folders")
|
||||||
|
.send({ name: "New" });
|
||||||
|
expect(created.body).toEqual({ id: 42 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("previews a change without saving it", async () => {
|
||||||
|
const response = await request(app())
|
||||||
|
.post("/host/defaults/preview")
|
||||||
|
.send({ level: "user", set: { "core.sshPort": 1 } });
|
||||||
|
expect(response.body).toEqual({ changedHosts: 2 });
|
||||||
|
expect(state.saved).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolves a host's defaults for the editor", async () => {
|
||||||
|
const response = await request(app()).get(
|
||||||
|
"/host/defaults/resolve?hostId=1",
|
||||||
|
);
|
||||||
|
expect(response.body.values["core.sshPort"].value).toBe(2022);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resets a host, but never a shared editor over the owner's login", async () => {
|
||||||
|
await request(app())
|
||||||
|
.post("/host/db/host/7/reset-defaults")
|
||||||
|
.send({ keys: ["core.sshPort"] });
|
||||||
|
expect(state.resets).toHaveLength(1);
|
||||||
|
|
||||||
|
state.access = { hasAccess: true, isOwner: false };
|
||||||
|
const all = await request(app())
|
||||||
|
.post("/host/db/host/7/reset-defaults")
|
||||||
|
.send({ all: true });
|
||||||
|
expect(all.status).toBe(403);
|
||||||
|
expect(
|
||||||
|
(await request(app()).post("/host/db/host/7/reset-defaults").send({}))
|
||||||
|
.status,
|
||||||
|
).toBe(400);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -11,7 +11,17 @@ vi.mock("../../../database/routes/host-plugin-settings.js", () => ({
|
|||||||
applyPluginHostImportSettings: mocks.applyPluginSettings,
|
applyPluginHostImportSettings: mocks.applyPluginSettings,
|
||||||
setHostPluginEnabled: vi.fn(),
|
setHostPluginEnabled: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
vi.mock("../../../hosts/defaults/index.js", () => ({
|
||||||
|
applyHostDefaultsToWrite: async () => ({ core: [] }),
|
||||||
|
applyDefaultsAfterHostWrite: async () => {},
|
||||||
|
}));
|
||||||
|
vi.mock("../../../hosts/defaults/overrides.js", () => ({
|
||||||
|
changeHostOverrides: async () => {},
|
||||||
|
}));
|
||||||
vi.mock("../../../database/repositories/factory.js", () => ({
|
vi.mock("../../../database/repositories/factory.js", () => ({
|
||||||
|
createCurrentHostDefaultsRepository: () => ({
|
||||||
|
listHosts: async () => [],
|
||||||
|
}),
|
||||||
createCurrentCredentialRepository: () => ({
|
createCurrentCredentialRepository: () => ({
|
||||||
listDecryptedByUserId: async () => [],
|
listDecryptedByUserId: async () => [],
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -52,6 +52,9 @@ vi.mock("../../../database/repositories/factory.js", () => ({
|
|||||||
createCurrentPluginSettingsRepository: () => ({ getAllForScopeIds, set }),
|
createCurrentPluginSettingsRepository: () => ({ getAllForScopeIds, set }),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../../hosts/defaults/overrides.js", () => ({
|
||||||
|
changeHostOverrides: async () => {},
|
||||||
|
}));
|
||||||
vi.mock("../../../plugins/index.js", () => ({
|
vi.mock("../../../plugins/index.js", () => ({
|
||||||
getPluginRuntime: () => ({ loader: { list: () => loaded } }),
|
getPluginRuntime: () => ({ loader: { list: () => loaded } }),
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* The admin host defaults. The terminal half moved to the ssh-terminal
|
* The old admin host defaults routes are gone: host defaults live under
|
||||||
* plugin's new-host settings; its keys stay in the stored row for the boot
|
* /host/defaults now. The GET used to hand the admin's proxy password to any
|
||||||
* copy and a downgrade, but are never served or overwritten here.
|
* signed-in user.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
@@ -65,40 +65,18 @@ beforeEach(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("/users/host-defaults", () => {
|
describe("/users/host-defaults", () => {
|
||||||
it("serves core's defaults without the terminal keys the plugin took over", async () => {
|
it("is no longer served, so the stored row never leaves the server", async () => {
|
||||||
state.settings.host_defaults = JSON.stringify({
|
state.settings.host_defaults = JSON.stringify({
|
||||||
useSocks5: true,
|
useSocks5: true,
|
||||||
fontSize: 18,
|
socks5Password: "secret",
|
||||||
autoTmux: true,
|
|
||||||
});
|
});
|
||||||
const response = await request(app()).get("/users/host-defaults");
|
const read = await request(app()).get("/users/host-defaults");
|
||||||
expect(response.status).toBe(200);
|
expect(read.status).toBe(404);
|
||||||
expect(response.body).toEqual({ useSocks5: true });
|
expect(JSON.stringify(read.body)).not.toContain("secret");
|
||||||
});
|
const write = await request(app())
|
||||||
|
|
||||||
it("keeps the stored terminal keys when an admin saves core's defaults", async () => {
|
|
||||||
state.settings.host_defaults = JSON.stringify({
|
|
||||||
useSocks5: true,
|
|
||||||
fontSize: 18,
|
|
||||||
enableCommandHistory: false,
|
|
||||||
});
|
|
||||||
const response = await request(app())
|
|
||||||
.patch("/users/host-defaults")
|
.patch("/users/host-defaults")
|
||||||
.send({ useSocks5: false, statusCheckEnabled: false, autoTmux: true });
|
.send({ useSocks5: false });
|
||||||
expect(response.status).toBe(200);
|
expect(write.status).toBe(404);
|
||||||
expect(JSON.parse(state.settings.host_defaults)).toEqual({
|
expect(JSON.parse(state.settings.host_defaults).useSocks5).toBe(true);
|
||||||
fontSize: 18,
|
|
||||||
enableCommandHistory: false,
|
|
||||||
useSocks5: false,
|
|
||||||
statusCheckEnabled: false,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("refuses a user who is not an admin", async () => {
|
|
||||||
state.admin = false;
|
|
||||||
const response = await request(app())
|
|
||||||
.patch("/users/host-defaults")
|
|
||||||
.send({ useSocks5: true });
|
|
||||||
expect(response.status).toBe(403);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -450,6 +450,28 @@ CREATE INDEX `idx_host_protocol_auth_credential` ON `host_protocol_auth` (`crede
|
|||||||
-- install loses what it stored.
|
-- install loses what it stored.
|
||||||
SELECT 1;
|
SELECT 1;
|
||||||
|
|
||||||
|
-- ==== core 0065_ambiguous_agent_zero ====
|
||||||
|
CREATE TABLE `host_defaults` (
|
||||||
|
`id` int AUTO_INCREMENT NOT NULL,
|
||||||
|
`level` enum('admin','user','folder') NOT NULL,
|
||||||
|
`scope_key` varchar(255) NOT NULL,
|
||||||
|
`user_id` varchar(255),
|
||||||
|
`folder_id` int,
|
||||||
|
`namespace` varchar(255) NOT NULL,
|
||||||
|
`key` varchar(255) NOT NULL,
|
||||||
|
`value` text,
|
||||||
|
`updated_by` text,
|
||||||
|
`updated_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP),
|
||||||
|
CONSTRAINT `host_defaults_id` PRIMARY KEY(`id`),
|
||||||
|
CONSTRAINT `idx_host_defaults_scope_key` UNIQUE(`scope_key`,`namespace`,`key`)
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE `ssh_data` ADD `default_overrides` text;--> statement-breakpoint
|
||||||
|
ALTER TABLE `host_defaults` ADD CONSTRAINT `host_defaults_user_id_users_id_fk` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE `host_defaults` ADD CONSTRAINT `host_defaults_folder_id_ssh_folders_id_fk` FOREIGN KEY (`folder_id`) REFERENCES `ssh_folders`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
CREATE INDEX `idx_host_defaults_user` ON `host_defaults` (`user_id`);--> statement-breakpoint
|
||||||
|
CREATE INDEX `idx_host_defaults_folder` ON `host_defaults` (`folder_id`);
|
||||||
|
|
||||||
-- ==== plugin workspaces 0001_adopt_user_workspaces.sql ====
|
-- ==== plugin workspaces 0001_adopt_user_workspaces.sql ====
|
||||||
-- workspaces 0001: adopt_user_workspaces
|
-- workspaces 0001: adopt_user_workspaces
|
||||||
-- Generated by termix-plugin migrations. Review before committing.
|
-- Generated by termix-plugin migrations. Review before committing.
|
||||||
|
|||||||
@@ -469,6 +469,27 @@ CREATE INDEX "idx_host_protocol_auth_credential" ON "host_protocol_auth" USING b
|
|||||||
-- install loses what it stored.
|
-- install loses what it stored.
|
||||||
SELECT 1;
|
SELECT 1;
|
||||||
|
|
||||||
|
-- ==== core 0065_regular_joystick ====
|
||||||
|
CREATE TABLE "host_defaults" (
|
||||||
|
"id" serial PRIMARY KEY NOT NULL,
|
||||||
|
"level" text NOT NULL,
|
||||||
|
"scope_key" varchar(255) NOT NULL,
|
||||||
|
"user_id" varchar(255),
|
||||||
|
"folder_id" integer,
|
||||||
|
"namespace" varchar(255) NOT NULL,
|
||||||
|
"key" varchar(255) NOT NULL,
|
||||||
|
"value" text,
|
||||||
|
"updated_by" text,
|
||||||
|
"updated_at" text DEFAULT CURRENT_TIMESTAMP NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "ssh_data" ADD COLUMN "default_overrides" text;--> statement-breakpoint
|
||||||
|
ALTER TABLE "host_defaults" ADD CONSTRAINT "host_defaults_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "host_defaults" ADD CONSTRAINT "host_defaults_folder_id_ssh_folders_id_fk" FOREIGN KEY ("folder_id") REFERENCES "public"."ssh_folders"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "idx_host_defaults_scope_key" ON "host_defaults" USING btree ("scope_key","namespace","key");--> statement-breakpoint
|
||||||
|
CREATE INDEX "idx_host_defaults_user" ON "host_defaults" USING btree ("user_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "idx_host_defaults_folder" ON "host_defaults" USING btree ("folder_id");
|
||||||
|
|
||||||
-- ==== plugin workspaces 0001_adopt_user_workspaces.sql ====
|
-- ==== plugin workspaces 0001_adopt_user_workspaces.sql ====
|
||||||
-- workspaces 0001: adopt_user_workspaces
|
-- workspaces 0001: adopt_user_workspaces
|
||||||
-- Generated by termix-plugin migrations. Review before committing.
|
-- Generated by termix-plugin migrations. Review before committing.
|
||||||
|
|||||||
@@ -0,0 +1,121 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
vi.mock("../../../database/repositories/factory.js", () => ({}));
|
||||||
|
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
|
||||||
|
import {
|
||||||
|
buildCatalog,
|
||||||
|
catalogNamespaces,
|
||||||
|
isDefaultableField,
|
||||||
|
} from "../../../hosts/defaults/catalog.js";
|
||||||
|
import {
|
||||||
|
defaultValuesEqual,
|
||||||
|
normalizeAuthDefault,
|
||||||
|
normalizeSocks5Default,
|
||||||
|
parseDefaultOverrides,
|
||||||
|
splitDefaultKey,
|
||||||
|
stableStringify,
|
||||||
|
} from "../../../../types/host-defaults.js";
|
||||||
|
|
||||||
|
const manifest = {
|
||||||
|
id: "fixture",
|
||||||
|
contributes: {
|
||||||
|
settings: {
|
||||||
|
host: {
|
||||||
|
enableKey: "enabled",
|
||||||
|
enableDefault: true,
|
||||||
|
fields: [
|
||||||
|
{ key: "mode", type: "select", default: "a", options: [] },
|
||||||
|
{ key: "token", type: "secret" },
|
||||||
|
{ key: "config", type: "json", secretKeys: ["password"] },
|
||||||
|
{ key: "mac", type: "string", defaultable: false },
|
||||||
|
{
|
||||||
|
key: "snippet",
|
||||||
|
type: "number",
|
||||||
|
defaultLevels: ["user", "folder"],
|
||||||
|
},
|
||||||
|
{ key: "font", type: "number", default: 14, personal: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as unknown as PluginManifest;
|
||||||
|
|
||||||
|
describe("the catalog", () => {
|
||||||
|
it("holds core's keys and each defaultable plugin field", () => {
|
||||||
|
const catalog = buildCatalog([manifest]);
|
||||||
|
expect(catalog.has("core.sshPort")).toBe(true);
|
||||||
|
expect(catalog.has("core.auth")).toBe(true);
|
||||||
|
expect(catalog.has("fixture.enabled")).toBe(true);
|
||||||
|
expect(catalog.has("fixture.mode")).toBe(true);
|
||||||
|
expect(catalog.has("fixture.token")).toBe(false);
|
||||||
|
expect(catalog.has("fixture.config")).toBe(false);
|
||||||
|
expect(catalog.has("fixture.mac")).toBe(false);
|
||||||
|
expect(catalog.get("fixture.snippet")?.levels).toEqual(["user", "folder"]);
|
||||||
|
expect(catalog.get("fixture.font")?.personal).toBe(true);
|
||||||
|
expect(catalog.get("fixture.enabled")?.builtin).toBe(true);
|
||||||
|
expect(catalogNamespaces(catalog)).toEqual(["core", "fixture"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads an unset plugin value as its manifest default", () => {
|
||||||
|
const mode = buildCatalog([manifest]).get("fixture.mode")!;
|
||||||
|
expect(mode.normalize(null)).toBe("a");
|
||||||
|
expect(mode.normalize("")).toBe("a");
|
||||||
|
expect(mode.normalize("b")).toBe("b");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never lets a secret be a default", () => {
|
||||||
|
expect(isDefaultableField({ key: "s", type: "secret" })).toBe(false);
|
||||||
|
expect(
|
||||||
|
isDefaultableField({ key: "s", type: "secret", defaultable: true }),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("value shapes", () => {
|
||||||
|
it("keeps only the auth fields its type uses", () => {
|
||||||
|
expect(
|
||||||
|
normalizeAuthDefault({
|
||||||
|
authType: "agent",
|
||||||
|
credentialId: 4,
|
||||||
|
agentSocketPath: "/tmp/agent",
|
||||||
|
}),
|
||||||
|
).toEqual({
|
||||||
|
authType: "agent",
|
||||||
|
credentialId: null,
|
||||||
|
overrideCredentialUsername: false,
|
||||||
|
agentSocketPath: "/tmp/agent",
|
||||||
|
agentIdentity: null,
|
||||||
|
});
|
||||||
|
expect(normalizeAuthDefault(null)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops a proxy's settings while it is off, and chain passwords always", () => {
|
||||||
|
expect(
|
||||||
|
normalizeSocks5Default({ useSocks5: false, socks5Host: "x" }).socks5Host,
|
||||||
|
).toBeNull();
|
||||||
|
expect(
|
||||||
|
normalizeSocks5Default({
|
||||||
|
useSocks5: true,
|
||||||
|
socks5ProxyChain: [{ host: "a", password: "p" }],
|
||||||
|
}).socks5ProxyChain,
|
||||||
|
).toEqual([{ host: "a" }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("compares objects regardless of key order", () => {
|
||||||
|
expect(stableStringify({ b: 1, a: 2 })).toBe(
|
||||||
|
stableStringify({ a: 2, b: 1 }),
|
||||||
|
);
|
||||||
|
expect(defaultValuesEqual([{ a: 1 }], [{ a: 1 }])).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses overrides and splits keys", () => {
|
||||||
|
expect(parseDefaultOverrides('{"core":["b","a","a"],"x":"no"}')).toEqual({
|
||||||
|
core: ["a", "b"],
|
||||||
|
});
|
||||||
|
expect(parseDefaultOverrides(null)).toBeNull();
|
||||||
|
expect(splitDefaultKey("ssh-terminal.fontSize")).toEqual([
|
||||||
|
"ssh-terminal",
|
||||||
|
"fontSize",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
readCoreDefault,
|
||||||
|
writeCoreDefault,
|
||||||
|
} from "../../../hosts/defaults/core-columns.js";
|
||||||
|
|
||||||
|
describe("core defaults in column form", () => {
|
||||||
|
it("reads the SSH options a row keeps as JSON", () => {
|
||||||
|
const row = {
|
||||||
|
sshOptions: JSON.stringify({ keepaliveInterval: 30 }),
|
||||||
|
};
|
||||||
|
expect(readCoreDefault("keepaliveInterval", row)).toBe(30);
|
||||||
|
expect(readCoreDefault("keepaliveCountMax", row)).toBe(5);
|
||||||
|
expect(readCoreDefault("allowLegacyAlgorithms", row)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("writes one SSH option and keeps the others", () => {
|
||||||
|
const row = {
|
||||||
|
sshOptions: JSON.stringify({
|
||||||
|
keepaliveInterval: 30,
|
||||||
|
agentForwarding: true,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
const patch = writeCoreDefault("keepaliveInterval", 90, row);
|
||||||
|
expect(JSON.parse(patch.sshOptions as string)).toEqual({
|
||||||
|
keepaliveInterval: 90,
|
||||||
|
agentForwarding: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("moves an SSH host's port with its SSH port, but not a plugin protocol's", () => {
|
||||||
|
expect(
|
||||||
|
writeCoreDefault("sshPort", 2222, { connectionType: "ssh" }),
|
||||||
|
).toEqual({ sshPort: 2222, port: 2222 });
|
||||||
|
expect(
|
||||||
|
writeCoreDefault("sshPort", 2222, { connectionType: "rdp" }),
|
||||||
|
).toEqual({ sshPort: 2222 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("writes a credential login and clears any key the host held", () => {
|
||||||
|
const patch = writeCoreDefault(
|
||||||
|
"auth",
|
||||||
|
{ authType: "credential", credentialId: 7 },
|
||||||
|
{ sshOptions: null, password: "kept" },
|
||||||
|
);
|
||||||
|
expect(patch).toMatchObject({
|
||||||
|
authType: "credential",
|
||||||
|
credentialId: 7,
|
||||||
|
key: null,
|
||||||
|
keyPassword: null,
|
||||||
|
});
|
||||||
|
expect(patch).not.toHaveProperty("password");
|
||||||
|
expect(
|
||||||
|
writeCoreDefault("auth", { authType: "agent" }, {}).password,
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("round-trips a proxy and keeps the host's own chain passwords", () => {
|
||||||
|
const row = {
|
||||||
|
socks5ProxyChain: JSON.stringify([{ host: "a", password: "mine" }]),
|
||||||
|
};
|
||||||
|
const patch = writeCoreDefault(
|
||||||
|
"socks5",
|
||||||
|
{ useSocks5: true, socks5ProxyChain: [{ host: "a" }] },
|
||||||
|
row,
|
||||||
|
);
|
||||||
|
expect(JSON.parse(patch.socks5ProxyChain as string)).toEqual([
|
||||||
|
{ host: "a", password: "mine" },
|
||||||
|
]);
|
||||||
|
expect(readCoreDefault("socks5", { ...row, ...patch })).toMatchObject({
|
||||||
|
useSocks5: true,
|
||||||
|
socks5ProxyChain: [{ host: "a" }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stores empty lists as null and booleans as the column expects", () => {
|
||||||
|
expect(writeCoreDefault("jumpHosts", [], {})).toEqual({ jumpHosts: null });
|
||||||
|
expect(writeCoreDefault("forceKeyboardInteractive", true, {})).toEqual({
|
||||||
|
forceKeyboardInteractive: "true",
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
readCoreDefault("forceKeyboardInteractive", {
|
||||||
|
forceKeyboardInteractive: "false",
|
||||||
|
}),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,324 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The materialize pass against in-memory tables: hosts, plugin host rows and
|
||||||
|
* the defaults levels.
|
||||||
|
*/
|
||||||
|
|
||||||
|
type Row = Record<string, unknown> & { id: number; userId: string };
|
||||||
|
|
||||||
|
const db = vi.hoisted(() => ({
|
||||||
|
hosts: [] as Array<Record<string, unknown> & { id: number; userId: string }>,
|
||||||
|
pluginRows: [] as Array<{
|
||||||
|
pluginId: string;
|
||||||
|
scope: string;
|
||||||
|
scopeId: string;
|
||||||
|
key: string;
|
||||||
|
value: string | null;
|
||||||
|
encrypted: boolean;
|
||||||
|
}>,
|
||||||
|
defaults: [] as Array<Record<string, unknown>>,
|
||||||
|
folders: [] as Array<{ id: number; userId: string; name: string }>,
|
||||||
|
events: [] as unknown[],
|
||||||
|
notified: [] as unknown[],
|
||||||
|
}));
|
||||||
|
|
||||||
|
const manifest = vi.hoisted(() => ({
|
||||||
|
id: "term",
|
||||||
|
contributes: {
|
||||||
|
settings: {
|
||||||
|
host: {
|
||||||
|
enableKey: "enabled",
|
||||||
|
enableDefault: true,
|
||||||
|
fields: [
|
||||||
|
{ key: "fontSize", type: "number", default: 14 },
|
||||||
|
{ key: "mac", type: "string", defaultable: false },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../../database/routes/host-plugin-settings.js", () => ({
|
||||||
|
hostSettingsPlugins: () => [manifest],
|
||||||
|
}));
|
||||||
|
vi.mock("../../../plugins/events.js", () => ({
|
||||||
|
TOPICS: { hostUpdated: "host.updated" },
|
||||||
|
pluginEvents: { emit: (...args: unknown[]) => db.events.push(args) },
|
||||||
|
}));
|
||||||
|
vi.mock("../../../sync/server/feed.js", () => ({ markChanged: () => {} }));
|
||||||
|
vi.mock("../../../utils/shared-host-secrets-manager.js", () => ({
|
||||||
|
SharedHostSecretsManager: {
|
||||||
|
getInstance: () => ({ resyncHost: async () => {} }),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
vi.mock("../../../plugins/settings.js", async (importOriginal) => ({
|
||||||
|
...(await importOriginal<typeof import("../../../plugins/settings.js")>()),
|
||||||
|
notifySettingChange: (...args: unknown[]) => db.notified.push(args),
|
||||||
|
}));
|
||||||
|
vi.mock("../../../database/repositories/factory.js", () => ({
|
||||||
|
createCurrentSettingsRepository: () => ({ get: async () => null }),
|
||||||
|
createCurrentHostDefaultsRepository: () => ({
|
||||||
|
listHosts: async (filter: {
|
||||||
|
hostIds?: number[];
|
||||||
|
userIds?: string[];
|
||||||
|
all?: boolean;
|
||||||
|
}) =>
|
||||||
|
db.hosts.filter(
|
||||||
|
(host) =>
|
||||||
|
filter.all ||
|
||||||
|
filter.hostIds?.includes(host.id) ||
|
||||||
|
filter.userIds?.includes(host.userId),
|
||||||
|
),
|
||||||
|
listForUsers: async (userIds: string[]) =>
|
||||||
|
db.defaults.filter(
|
||||||
|
(row) =>
|
||||||
|
row.level === "admin" || userIds.includes(row.userId as string),
|
||||||
|
),
|
||||||
|
listFolders: async (userIds: string[]) =>
|
||||||
|
db.folders.filter((folder) => userIds.includes(folder.userId)),
|
||||||
|
listHostPlacement: async (userIds: string[]) =>
|
||||||
|
db.hosts
|
||||||
|
.filter((host) => userIds.includes(host.userId))
|
||||||
|
.map((host) => ({
|
||||||
|
id: host.id,
|
||||||
|
userId: host.userId,
|
||||||
|
folder: (host.folder as string | null) ?? null,
|
||||||
|
parentHostId: (host.parentHostId as number | null) ?? null,
|
||||||
|
})),
|
||||||
|
updateHosts: async (patches: Array<{ id: number; values: object }>) => {
|
||||||
|
for (const patch of patches) {
|
||||||
|
const host = db.hosts.find((row) => row.id === patch.id)!;
|
||||||
|
Object.assign(host, patch.values);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
createCurrentPluginSettingsRepository: () => ({
|
||||||
|
getAllForScopeIds: async (_scope: string, ids: string[]) =>
|
||||||
|
db.pluginRows.filter((row) => ids.includes(row.scopeId)),
|
||||||
|
set: async (
|
||||||
|
pluginId: string,
|
||||||
|
scope: string,
|
||||||
|
scopeId: string,
|
||||||
|
key: string,
|
||||||
|
value: string,
|
||||||
|
) => {
|
||||||
|
const existing = db.pluginRows.find(
|
||||||
|
(row) =>
|
||||||
|
row.pluginId === pluginId &&
|
||||||
|
row.scopeId === scopeId &&
|
||||||
|
row.key === key,
|
||||||
|
);
|
||||||
|
if (existing) existing.value = value;
|
||||||
|
else
|
||||||
|
db.pluginRows.push({
|
||||||
|
pluginId,
|
||||||
|
scope,
|
||||||
|
scopeId,
|
||||||
|
key,
|
||||||
|
value,
|
||||||
|
encrypted: false,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
delete: async (
|
||||||
|
pluginId: string,
|
||||||
|
_scope: string,
|
||||||
|
scopeId: string,
|
||||||
|
key: string,
|
||||||
|
) => {
|
||||||
|
db.pluginRows = db.pluginRows.filter(
|
||||||
|
(row) =>
|
||||||
|
!(
|
||||||
|
row.pluginId === pluginId &&
|
||||||
|
row.scopeId === scopeId &&
|
||||||
|
row.key === key
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { materializeHosts } =
|
||||||
|
await import("../../../hosts/defaults/materialize.js");
|
||||||
|
|
||||||
|
function host(partial: Partial<Row> & { id: number }): Row {
|
||||||
|
return {
|
||||||
|
userId: "u1",
|
||||||
|
connectionType: "ssh",
|
||||||
|
port: 22,
|
||||||
|
sshPort: 22,
|
||||||
|
username: "root",
|
||||||
|
authType: "password",
|
||||||
|
folder: null,
|
||||||
|
parentHostId: null,
|
||||||
|
statusCheckEnabled: true,
|
||||||
|
defaultOverrides: null,
|
||||||
|
sharedSource: null,
|
||||||
|
...partial,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function setDefault(
|
||||||
|
level: "admin" | "user" | "folder",
|
||||||
|
key: string,
|
||||||
|
value: unknown,
|
||||||
|
extra: Record<string, unknown> = {},
|
||||||
|
) {
|
||||||
|
const [namespace, name] = key.split(".");
|
||||||
|
db.defaults.push({
|
||||||
|
level,
|
||||||
|
namespace,
|
||||||
|
key: name,
|
||||||
|
value: JSON.stringify(value),
|
||||||
|
userId: level === "admin" ? null : "u1",
|
||||||
|
folderId: null,
|
||||||
|
...extra,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function pluginValue(hostId: number, key: string): unknown {
|
||||||
|
const row = db.pluginRows.find(
|
||||||
|
(entry) => entry.scopeId === String(hostId) && entry.key === key,
|
||||||
|
);
|
||||||
|
return row ? JSON.parse(row.value as string) : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
db.hosts = [];
|
||||||
|
db.pluginRows = [];
|
||||||
|
db.defaults = [];
|
||||||
|
db.folders = [];
|
||||||
|
db.events = [];
|
||||||
|
db.notified = [];
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("materializeHosts", () => {
|
||||||
|
it("classifies a host from before host defaults: matching values follow them", async () => {
|
||||||
|
db.hosts = [host({ id: 1, sshPort: 22 }), host({ id: 2, sshPort: 2222 })];
|
||||||
|
db.pluginRows.push({
|
||||||
|
pluginId: "term",
|
||||||
|
scope: "host",
|
||||||
|
scopeId: "2",
|
||||||
|
key: "fontSize",
|
||||||
|
value: "18",
|
||||||
|
encrypted: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await materializeHosts({ all: true });
|
||||||
|
|
||||||
|
expect(result.changedHostIds).toEqual([]);
|
||||||
|
const first = JSON.parse(db.hosts[0].defaultOverrides as string);
|
||||||
|
const second = JSON.parse(db.hosts[1].defaultOverrides as string);
|
||||||
|
expect(first.core).not.toContain("sshPort");
|
||||||
|
expect(second.core).toContain("sshPort");
|
||||||
|
expect(first.term).toEqual([]);
|
||||||
|
expect(second.term).toEqual(["fontSize"]);
|
||||||
|
// Nothing a user sees changed.
|
||||||
|
expect(db.hosts[1].sshPort).toBe(2222);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("writes a new default into every host that follows it, and no other", async () => {
|
||||||
|
db.hosts = [
|
||||||
|
host({ id: 1, defaultOverrides: JSON.stringify({ core: [], term: [] }) }),
|
||||||
|
host({
|
||||||
|
id: 2,
|
||||||
|
sshPort: 2200,
|
||||||
|
defaultOverrides: JSON.stringify({ core: ["sshPort"], term: [] }),
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
setDefault("user", "core.sshPort", 2022);
|
||||||
|
setDefault("admin", "term.fontSize", 16);
|
||||||
|
|
||||||
|
const result = await materializeHosts({ userIds: ["u1"] });
|
||||||
|
|
||||||
|
expect(result.changedHostIds).toEqual([1, 2]);
|
||||||
|
expect(db.hosts[0].sshPort).toBe(2022);
|
||||||
|
expect(db.hosts[0].port).toBe(2022);
|
||||||
|
expect(db.hosts[1].sshPort).toBe(2200);
|
||||||
|
expect(pluginValue(1, "fontSize")).toBe(16);
|
||||||
|
expect(pluginValue(2, "fontSize")).toBe(16);
|
||||||
|
expect(db.events).toHaveLength(2);
|
||||||
|
expect(db.notified).toEqual([["term", "fontSize", 16]]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removes the row when a key goes back to the manifest default", async () => {
|
||||||
|
db.hosts = [
|
||||||
|
host({ id: 1, defaultOverrides: JSON.stringify({ core: [], term: [] }) }),
|
||||||
|
];
|
||||||
|
db.pluginRows.push({
|
||||||
|
pluginId: "term",
|
||||||
|
scope: "host",
|
||||||
|
scopeId: "1",
|
||||||
|
key: "fontSize",
|
||||||
|
value: "16",
|
||||||
|
encrypted: false,
|
||||||
|
});
|
||||||
|
await materializeHosts({ all: true });
|
||||||
|
expect(pluginValue(1, "fontSize")).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("follows the deepest folder, and a sub-host follows its parent's", async () => {
|
||||||
|
db.folders = [
|
||||||
|
{ id: 10, userId: "u1", name: "Prod" },
|
||||||
|
{ id: 11, userId: "u1", name: "Prod / Web" },
|
||||||
|
];
|
||||||
|
const classified = JSON.stringify({ core: [], term: [] });
|
||||||
|
db.hosts = [
|
||||||
|
host({ id: 1, folder: "Prod / Web", defaultOverrides: classified }),
|
||||||
|
host({ id: 2, parentHostId: 1, defaultOverrides: classified }),
|
||||||
|
host({ id: 3, folder: "Prod", defaultOverrides: classified }),
|
||||||
|
];
|
||||||
|
setDefault("folder", "core.sshPort", 2100, { folderId: 10 });
|
||||||
|
setDefault("folder", "core.sshPort", 2200, { folderId: 11 });
|
||||||
|
|
||||||
|
await materializeHosts({ all: true });
|
||||||
|
|
||||||
|
expect(db.hosts.map((row) => row.sshPort)).toEqual([2200, 2200, 2100]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never writes a jump chain through the host itself", async () => {
|
||||||
|
db.folders = [{ id: 10, userId: "u1", name: "Lab" }];
|
||||||
|
db.hosts = [
|
||||||
|
host({
|
||||||
|
id: 1,
|
||||||
|
folder: "Lab",
|
||||||
|
defaultOverrides: JSON.stringify({ core: [], term: [] }),
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
setDefault("folder", "core.jumpHosts", [{ hostId: 1 }, { hostId: 9 }], {
|
||||||
|
folderId: 10,
|
||||||
|
});
|
||||||
|
await materializeHosts({ all: true });
|
||||||
|
expect(JSON.parse(db.hosts[0].jumpHosts as string)).toEqual([
|
||||||
|
{ hostId: 9 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("previews without writing", async () => {
|
||||||
|
db.hosts = [
|
||||||
|
host({ id: 1, defaultOverrides: JSON.stringify({ core: [], term: [] }) }),
|
||||||
|
];
|
||||||
|
const result = await materializeHosts(
|
||||||
|
{ all: true },
|
||||||
|
{
|
||||||
|
dryRun: true,
|
||||||
|
overlay: {
|
||||||
|
level: "admin",
|
||||||
|
set: new Map([["core.sshPort", 2022]]),
|
||||||
|
unset: new Set(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
expect(result.changedHostIds).toEqual([1]);
|
||||||
|
expect(db.hosts[0].sshPort).toBe(22);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves a desktop's copy of a shared host alone", async () => {
|
||||||
|
db.hosts = [host({ id: 1, sharedSource: "{}" })];
|
||||||
|
setDefault("admin", "core.sshPort", 2022);
|
||||||
|
await materializeHosts({ all: true });
|
||||||
|
expect(db.hosts[0].sshPort).toBe(22);
|
||||||
|
expect(db.hosts[0].defaultOverrides).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
vi.mock("../../../database/repositories/factory.js", () => ({}));
|
||||||
|
import {
|
||||||
|
effectiveFolderPath,
|
||||||
|
folderChainPaths,
|
||||||
|
isOwnValue,
|
||||||
|
resolveKey,
|
||||||
|
withoutSelfJump,
|
||||||
|
type HostLevels,
|
||||||
|
} from "../../../hosts/defaults/resolve.js";
|
||||||
|
import { coreCatalogEntries } from "../../../hosts/defaults/catalog.js";
|
||||||
|
|
||||||
|
const entry = (key: string) =>
|
||||||
|
coreCatalogEntries().find((info) => info.key === key)!;
|
||||||
|
|
||||||
|
function levels(partial: Partial<HostLevels> = {}): HostLevels {
|
||||||
|
return {
|
||||||
|
admin: new Map(),
|
||||||
|
user: new Map(),
|
||||||
|
folders: [],
|
||||||
|
...partial,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("folderChainPaths", () => {
|
||||||
|
it("lists every ancestor, root first", () => {
|
||||||
|
expect(folderChainPaths("A / B / C")).toEqual(["A", "A / B", "A / B / C"]);
|
||||||
|
expect(folderChainPaths(null)).toEqual([]);
|
||||||
|
expect(folderChainPaths("")).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("effectiveFolderPath", () => {
|
||||||
|
it("takes a sub-host's folder from its nearest placed ancestor", () => {
|
||||||
|
const placement = new Map([
|
||||||
|
[1, { folder: "Prod", parentHostId: null }],
|
||||||
|
[2, { folder: null, parentHostId: 1 }],
|
||||||
|
[3, { folder: null, parentHostId: 2 }],
|
||||||
|
]);
|
||||||
|
expect(effectiveFolderPath(3, placement)).toBe("Prod");
|
||||||
|
expect(effectiveFolderPath(1, placement)).toBe("Prod");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stops on a parent cycle", () => {
|
||||||
|
const placement = new Map([
|
||||||
|
[1, { folder: null, parentHostId: 2 }],
|
||||||
|
[2, { folder: null, parentHostId: 1 }],
|
||||||
|
]);
|
||||||
|
expect(effectiveFolderPath(1, placement)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("resolveKey", () => {
|
||||||
|
it("prefers the deepest folder, then the user, then the server, then built-in", () => {
|
||||||
|
const port = entry("sshPort");
|
||||||
|
expect(resolveKey(port, levels())).toEqual({
|
||||||
|
value: 22,
|
||||||
|
source: { level: "builtin" },
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
resolveKey(port, levels({ admin: new Map([["core.sshPort", 2200]]) }))
|
||||||
|
?.source.level,
|
||||||
|
).toBe("admin");
|
||||||
|
const all = levels({
|
||||||
|
admin: new Map([["core.sshPort", 2200]]),
|
||||||
|
user: new Map([["core.sshPort", 2201]]),
|
||||||
|
folders: [
|
||||||
|
{ folderId: 1, name: "A", values: new Map([["core.sshPort", 2202]]) },
|
||||||
|
{
|
||||||
|
folderId: 2,
|
||||||
|
name: "A / B",
|
||||||
|
values: new Map([["core.sshPort", 2203]]),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect(resolveKey(port, all)).toEqual({
|
||||||
|
value: 2203,
|
||||||
|
source: { level: "folder", folderId: 2, folderName: "A / B" },
|
||||||
|
});
|
||||||
|
all.folders[1].values.clear();
|
||||||
|
expect(resolveKey(port, all)?.value).toBe(2202);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips a level the key cannot be set at", () => {
|
||||||
|
const jumps = entry("jumpHosts");
|
||||||
|
const result = resolveKey(
|
||||||
|
jumps,
|
||||||
|
levels({ admin: new Map([["core.jumpHosts", [{ hostId: 5 }]]]) }),
|
||||||
|
);
|
||||||
|
expect(result?.source.level).toBe("builtin");
|
||||||
|
expect(result?.value).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolves nothing for a key with no built-in value and no level", () => {
|
||||||
|
expect(resolveKey(entry("username"), levels())).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("isOwnValue", () => {
|
||||||
|
it("compares normalized values", () => {
|
||||||
|
const port = entry("sshPort");
|
||||||
|
const resolved = { value: 22, source: { level: "builtin" as const } };
|
||||||
|
expect(isOwnValue(port, "22", resolved)).toBe(false);
|
||||||
|
expect(isOwnValue(port, null, resolved)).toBe(false);
|
||||||
|
expect(isOwnValue(port, 2222, resolved)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats any value as the host's own when nothing resolves", () => {
|
||||||
|
const username = entry("username");
|
||||||
|
expect(isOwnValue(username, "root", undefined)).toBe(true);
|
||||||
|
expect(isOwnValue(username, "", undefined)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats keepalive left unset as the built-in value", () => {
|
||||||
|
const keepalive = entry("keepaliveInterval");
|
||||||
|
expect(
|
||||||
|
isOwnValue(keepalive, undefined, {
|
||||||
|
value: 60,
|
||||||
|
source: { level: "builtin" },
|
||||||
|
}),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("withoutSelfJump", () => {
|
||||||
|
it("drops the host itself from an inherited jump chain", () => {
|
||||||
|
expect(withoutSelfJump([{ hostId: 1 }, { hostId: 2 }], 2)).toEqual([
|
||||||
|
{ hostId: 1 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
|
||||||
|
|
||||||
|
vi.mock("../../../database/repositories/factory.js", () => ({}));
|
||||||
|
vi.mock("../../../database/routes/host-plugin-settings.js", () => ({
|
||||||
|
hostSettingsPlugins: () => [],
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { validateLevelChange } =
|
||||||
|
await import("../../../hosts/defaults/service.js");
|
||||||
|
const { buildCatalog } = await import("../../../hosts/defaults/catalog.js");
|
||||||
|
|
||||||
|
const catalog = buildCatalog([
|
||||||
|
{
|
||||||
|
id: "fx",
|
||||||
|
contributes: {
|
||||||
|
settings: {
|
||||||
|
host: {
|
||||||
|
fields: [
|
||||||
|
{ key: "size", type: "number", min: 8, max: 36, default: 14 },
|
||||||
|
{ key: "snippet", type: "number", defaultLevels: ["user"] },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as unknown as PluginManifest,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const context = {
|
||||||
|
actorId: "u1",
|
||||||
|
ownerId: "u1",
|
||||||
|
canUseCredential: async (id: number) => id === 7,
|
||||||
|
canUseHost: async (id: number) => id === 3,
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("validateLevelChange", () => {
|
||||||
|
it("accepts known keys and normalizes their values", async () => {
|
||||||
|
const change = await validateLevelChange(
|
||||||
|
{ level: "user", userId: "u1" },
|
||||||
|
{ set: { "core.sshPort": "2022", "fx.size": "16" }, unset: ["fx.x"] },
|
||||||
|
context,
|
||||||
|
catalog,
|
||||||
|
);
|
||||||
|
expect(change.errors).toEqual({});
|
||||||
|
expect(change.set.get("core.sshPort")).toBe(2022);
|
||||||
|
expect(change.set.get("fx.size")).toBe(16);
|
||||||
|
expect([...change.unset]).toEqual(["fx.x"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses unknown keys, out of range values and levels a key cannot use", async () => {
|
||||||
|
const change = await validateLevelChange(
|
||||||
|
{ level: "admin" },
|
||||||
|
{
|
||||||
|
set: {
|
||||||
|
"core.name": "x",
|
||||||
|
"fx.size": 99,
|
||||||
|
"fx.snippet": 1,
|
||||||
|
"core.jumpHosts": [{ hostId: 3 }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
context,
|
||||||
|
catalog,
|
||||||
|
);
|
||||||
|
expect(Object.keys(change.errors).sort()).toEqual([
|
||||||
|
"core.jumpHosts",
|
||||||
|
"core.name",
|
||||||
|
"fx.size",
|
||||||
|
"fx.snippet",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never takes a password or key login, or a credential at the server level", async () => {
|
||||||
|
const password = await validateLevelChange(
|
||||||
|
{ level: "user", userId: "u1" },
|
||||||
|
{ set: { "core.auth": { authType: "password" } } },
|
||||||
|
context,
|
||||||
|
catalog,
|
||||||
|
);
|
||||||
|
expect(password.errors["core.auth"]).toMatch(/cannot be a default/);
|
||||||
|
|
||||||
|
const serverCredential = await validateLevelChange(
|
||||||
|
{ level: "admin" },
|
||||||
|
{ set: { "core.auth": { authType: "credential", credentialId: 7 } } },
|
||||||
|
context,
|
||||||
|
catalog,
|
||||||
|
);
|
||||||
|
expect(serverCredential.errors["core.auth"]).toMatch(/server default/);
|
||||||
|
|
||||||
|
const ownCredential = await validateLevelChange(
|
||||||
|
{ level: "folder", folderId: 1, userId: "u1" },
|
||||||
|
{ set: { "core.auth": { authType: "credential", credentialId: 7 } } },
|
||||||
|
context,
|
||||||
|
catalog,
|
||||||
|
);
|
||||||
|
expect(ownCredential.errors).toEqual({});
|
||||||
|
|
||||||
|
const otherCredential = await validateLevelChange(
|
||||||
|
{ level: "user", userId: "u1" },
|
||||||
|
{ set: { "core.auth": { authType: "credential", credentialId: 8 } } },
|
||||||
|
context,
|
||||||
|
catalog,
|
||||||
|
);
|
||||||
|
expect(otherCredential.errors["core.auth"]).toMatch(/not available/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("checks every jump host in a chain", async () => {
|
||||||
|
const change = await validateLevelChange(
|
||||||
|
{ level: "user", userId: "u1" },
|
||||||
|
{ set: { "core.jumpHosts": [{ hostId: 3 }, { hostId: 4 }] } },
|
||||||
|
context,
|
||||||
|
catalog,
|
||||||
|
);
|
||||||
|
expect(change.errors["core.jumpHosts"]).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const db = vi.hoisted(() => ({
|
||||||
|
defaults: [] as Array<Record<string, unknown>>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../../database/routes/host-plugin-settings.js", () => ({
|
||||||
|
hostSettingsPlugins: () => [],
|
||||||
|
}));
|
||||||
|
vi.mock("../../../database/repositories/factory.js", () => ({
|
||||||
|
createCurrentSettingsRepository: () => ({ get: async () => null }),
|
||||||
|
createCurrentHostDefaultsRepository: () => ({
|
||||||
|
listForUsers: async () => db.defaults,
|
||||||
|
listFolders: async () => [],
|
||||||
|
listHostPlacement: async () => [],
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { applyHostDefaultsToWrite } =
|
||||||
|
await import("../../../hosts/defaults/write-rules.js");
|
||||||
|
const { applyOverrideChange } =
|
||||||
|
await import("../../../hosts/defaults/overrides.js");
|
||||||
|
|
||||||
|
function userDefault(key: string, value: unknown) {
|
||||||
|
db.defaults.push({
|
||||||
|
level: "user",
|
||||||
|
userId: "u1",
|
||||||
|
folderId: null,
|
||||||
|
namespace: "core",
|
||||||
|
key,
|
||||||
|
value: JSON.stringify(value),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
db.defaults = [];
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("applyHostDefaultsToWrite", () => {
|
||||||
|
it("takes the editor's list as the answer and fills what it inherits", async () => {
|
||||||
|
userDefault("sshPort", 2022);
|
||||||
|
const columns: Record<string, unknown> = {
|
||||||
|
sshPort: 9999,
|
||||||
|
port: 9999,
|
||||||
|
connectionType: "ssh",
|
||||||
|
};
|
||||||
|
const overrides = await applyHostDefaultsToWrite({
|
||||||
|
ownerId: "u1",
|
||||||
|
hostId: null,
|
||||||
|
columns,
|
||||||
|
body: { defaultOverrides: { core: [], term: ["fontSize"] } },
|
||||||
|
});
|
||||||
|
expect(columns.sshPort).toBe(2022);
|
||||||
|
expect(columns.port).toBe(2022);
|
||||||
|
expect(overrides.core).toEqual([]);
|
||||||
|
expect(overrides.term).toEqual(["fontSize"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("inherits what a create leaves out, and keeps what it sets", async () => {
|
||||||
|
userDefault("sshPort", 2022);
|
||||||
|
userDefault("statusCheckEnabled", false);
|
||||||
|
const columns: Record<string, unknown> = {
|
||||||
|
sshPort: 22,
|
||||||
|
port: 22,
|
||||||
|
statusCheckEnabled: 1,
|
||||||
|
username: "deploy",
|
||||||
|
connectionType: "ssh",
|
||||||
|
};
|
||||||
|
const overrides = await applyHostDefaultsToWrite({
|
||||||
|
ownerId: "u1",
|
||||||
|
hostId: null,
|
||||||
|
columns,
|
||||||
|
body: { ip: "10.0.0.1", port: 22, username: "deploy" },
|
||||||
|
});
|
||||||
|
expect(columns.statusCheckEnabled).toBe(false);
|
||||||
|
// The port was sent and differs from the default, so it is the host's.
|
||||||
|
expect(overrides.core).toContain("sshPort");
|
||||||
|
expect(columns.sshPort).toBe(22);
|
||||||
|
expect(overrides.core).toContain("username");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("marks a changed key on an update without a list, and keeps the rest", async () => {
|
||||||
|
userDefault("sshPort", 2022);
|
||||||
|
const stored = {
|
||||||
|
id: 5,
|
||||||
|
userId: "u1",
|
||||||
|
sshPort: 2022,
|
||||||
|
port: 2022,
|
||||||
|
statusCheckEnabled: true,
|
||||||
|
username: "root",
|
||||||
|
defaultOverrides: JSON.stringify({ core: ["username"] }),
|
||||||
|
};
|
||||||
|
const columns: Record<string, unknown> = {
|
||||||
|
sshPort: 2022,
|
||||||
|
port: 2022,
|
||||||
|
statusCheckEnabled: 0,
|
||||||
|
username: "root",
|
||||||
|
};
|
||||||
|
const overrides = await applyHostDefaultsToWrite({
|
||||||
|
ownerId: "u1",
|
||||||
|
hostId: 5,
|
||||||
|
columns,
|
||||||
|
body: {},
|
||||||
|
stored: stored as never,
|
||||||
|
});
|
||||||
|
expect(overrides.core).toEqual(["statusCheckEnabled", "username"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps a locked key's state for a shared editor", async () => {
|
||||||
|
const overrides = await applyHostDefaultsToWrite({
|
||||||
|
ownerId: "u1",
|
||||||
|
hostId: 5,
|
||||||
|
columns: {},
|
||||||
|
body: { defaultOverrides: { core: [] } },
|
||||||
|
stored: {
|
||||||
|
id: 5,
|
||||||
|
userId: "u1",
|
||||||
|
defaultOverrides: JSON.stringify({ core: ["auth"] }),
|
||||||
|
} as never,
|
||||||
|
lockedKeys: ["auth"],
|
||||||
|
});
|
||||||
|
expect(overrides.core).toContain("auth");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("applyOverrideChange", () => {
|
||||||
|
it("adds and removes keys, per namespace", () => {
|
||||||
|
const next = applyOverrideChange(
|
||||||
|
{ core: ["sshPort"], term: ["fontSize", "theme"] },
|
||||||
|
{ own: [["core", "username"]], inherit: [["term", "theme"]] },
|
||||||
|
);
|
||||||
|
expect(next).toEqual({ core: ["sshPort", "username"], term: ["fontSize"] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves a namespace nobody classified yet to its first pass", () => {
|
||||||
|
expect(applyOverrideChange({ core: [] }, { own: [["term", "x"]] })).toEqual(
|
||||||
|
{ core: [] },
|
||||||
|
);
|
||||||
|
expect(applyOverrideChange(null, { own: [["core", "x"]] })).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resets everything, known namespaces included", () => {
|
||||||
|
expect(
|
||||||
|
applyOverrideChange({ core: ["a"] }, { inheritAll: true }, [
|
||||||
|
"core",
|
||||||
|
"term",
|
||||||
|
]),
|
||||||
|
).toEqual({ core: [], term: [] });
|
||||||
|
});
|
||||||
|
});
|
||||||
Loaded 100 of 144 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user