mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 06:05:24 +00:00
feat: redesign user host defaults system
This commit is contained in:
1 parent
3028ab1176
commit
a4a4d4f27b
144 files changed
+24609
-2555
No files matched your search
@@ -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,
|
||||
"tag": "0064_flippant_killer_shrike",
|
||||
"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,
|
||||
"tag": "0064_sticky_pestilence",
|
||||
"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,
|
||||
"tag": "0059_adorable_wrecking_crew",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 60,
|
||||
"version": "6",
|
||||
"when": 1790742085632,
|
||||
"tag": "0060_fine_cerise",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -903,7 +903,21 @@
|
||||
"defaultFrom": {
|
||||
"type": "string",
|
||||
"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": {
|
||||
"type": "string",
|
||||
|
||||
@@ -500,15 +500,44 @@ export interface PluginSettings {
|
||||
getUser: <T = unknown>(userId: string, key: string) => Promise<T | undefined>;
|
||||
setUser: (userId: string, key: string, value: unknown) => Promise<void>;
|
||||
|
||||
/** Per-host settings. */
|
||||
/**
|
||||
* Per-host settings. A host follows its host defaults (server, user,
|
||||
* folder) for every key it has not set itself, and the value read here is
|
||||
* already the resolved one.
|
||||
*/
|
||||
getHost: <T = unknown>(
|
||||
hostId: number | string,
|
||||
key: string,
|
||||
) => Promise<T | undefined>;
|
||||
/**
|
||||
* The value as one user sees it. Only a `personal` field differs from
|
||||
* getHost: on a host shared with that user and following its defaults, it
|
||||
* resolves against the user's own defaults rather than the owner's.
|
||||
*/
|
||||
getHostFor: <T = unknown>(
|
||||
hostId: number | string,
|
||||
userId: string,
|
||||
key: string,
|
||||
) => Promise<T | undefined>;
|
||||
/**
|
||||
* What a user's hosts get for a host field when nothing more specific
|
||||
* sets it: their own defaults, then the server's, then the manifest
|
||||
* default. For something with no host, such as a quick connect.
|
||||
*/
|
||||
getHostDefault: <T = unknown>(
|
||||
userId: string,
|
||||
key: string,
|
||||
) => Promise<T | undefined>;
|
||||
/**
|
||||
* Writes a host value, which makes it the host's own rather than
|
||||
* following its defaults. `{ inherit: true }` hands the key back to the
|
||||
* defaults instead and ignores `value`.
|
||||
*/
|
||||
setHost: (
|
||||
hostId: number | string,
|
||||
key: string,
|
||||
value: unknown,
|
||||
options?: { inherit?: boolean },
|
||||
) => Promise<void>;
|
||||
|
||||
/**
|
||||
@@ -542,7 +571,14 @@ export interface PluginSettings {
|
||||
scope: "admin" | "user" | "host",
|
||||
validator: (
|
||||
values: Record<string, unknown>,
|
||||
context: { hostId?: number },
|
||||
/**
|
||||
* `defaults` is set when the values are a host defaults level being
|
||||
* saved rather than one host, so there is no hostId.
|
||||
*/
|
||||
context: {
|
||||
hostId?: number;
|
||||
defaults?: { level: "admin" | "user" | "folder" };
|
||||
},
|
||||
) => Record<string, string> | void | Promise<Record<string, string> | void>,
|
||||
) => () => void;
|
||||
|
||||
|
||||
@@ -403,6 +403,13 @@ export interface HostEditorSectionProps {
|
||||
adminTargetUserId?: string;
|
||||
/** Which connection protocols are switched on for this host. */
|
||||
protocols: Record<string, boolean>;
|
||||
/**
|
||||
* "defaults" while the editor sets a level of host defaults (server, user
|
||||
* or folder) rather than one host. There is no `host` then, and the form
|
||||
* holds the defaults. Only a section registered with `defaults: true` is
|
||||
* shown in that mode.
|
||||
*/
|
||||
mode?: "host" | "defaults";
|
||||
}
|
||||
|
||||
export interface HostEditorSectionContribution {
|
||||
@@ -418,6 +425,13 @@ export interface HostEditorSectionContribution {
|
||||
order?: number;
|
||||
/** Whether to offer the tab, from the host's enabled protocols. */
|
||||
visible?: (protocols: Record<string, boolean>) => boolean;
|
||||
/**
|
||||
* Also shown in the host defaults editor. Only for a section whose fields
|
||||
* are this plugin's host settings kept on the form, and that makes no
|
||||
* calls about one particular host. Off by default. "only" shows it in the
|
||||
* defaults editor and never for a host.
|
||||
*/
|
||||
defaults?: boolean | "only";
|
||||
component: ComponentType<HostEditorSectionProps>;
|
||||
}
|
||||
|
||||
|
||||
@@ -276,11 +276,30 @@ export interface PluginSettingsField {
|
||||
/** Registered component id. Required when type is "custom". */
|
||||
component?: string;
|
||||
/**
|
||||
* Host scope only: the admin field whose value a new host starts with,
|
||||
* instead of `default`. Hosts that never saved the field still read
|
||||
* `default`.
|
||||
* Deprecated and ignored: host defaults cover every host field now. Still
|
||||
* accepted so an older manifest loads. Removed in 3.0.0.
|
||||
*/
|
||||
defaultFrom?: string;
|
||||
/**
|
||||
* Host scope only: whether the field can have a host default (server,
|
||||
* user or folder) that hosts follow until they set their own. On by
|
||||
* default for every field except secrets and json fields with
|
||||
* `secretKeys`. Turn it off for a value that only makes sense per host,
|
||||
* such as a MAC address or an API endpoint.
|
||||
*/
|
||||
defaultable?: boolean;
|
||||
/**
|
||||
* Host scope only: the levels that may set a default for it. Defaults to
|
||||
* all three. Leave "admin" out for a value that points at one user's own
|
||||
* rows (a snippet id, a profile id).
|
||||
*/
|
||||
defaultLevels?: Array<"admin" | "user" | "folder">;
|
||||
/**
|
||||
* Host scope only: a look-and-feel value. When the host follows its
|
||||
* defaults, a user it is shared with gets their own defaults for it
|
||||
* rather than the owner's.
|
||||
*/
|
||||
personal?: boolean;
|
||||
/**
|
||||
* Host scope only: the lowest share level whose recipients see this value
|
||||
* in the host payload. Defaults to "connect", everyone who sees the host.
|
||||
@@ -626,6 +645,9 @@ const ALLOWED_SETTINGS_FIELD = [
|
||||
"component",
|
||||
"hidden",
|
||||
"defaultFrom",
|
||||
"defaultable",
|
||||
"defaultLevels",
|
||||
"personal",
|
||||
"shareRead",
|
||||
"ownerOnly",
|
||||
"secretKeys",
|
||||
@@ -1589,20 +1611,33 @@ function validateSettings(settings: unknown, errors: string[]): void {
|
||||
typeof host.enableKey === "string" ? host.enableKey : undefined,
|
||||
);
|
||||
|
||||
const adminKeys = new Set(
|
||||
(Array.isArray(settings.admin) ? settings.admin : [])
|
||||
.filter(isPlainObject)
|
||||
// A secret never becomes a default the host editor can read.
|
||||
.filter((field) => field.type !== "secret")
|
||||
.map((field) => field.key),
|
||||
);
|
||||
host.fields.forEach((raw, index) => {
|
||||
if (!isPlainObject(raw)) return;
|
||||
const fieldAt = `${at}.fields[${index}]`;
|
||||
if ("defaultFrom" in raw && !adminKeys.has(raw.defaultFrom as string)) {
|
||||
errors.push(
|
||||
`${fieldAt}.defaultFrom must name one of this plugin's non-secret admin fields`,
|
||||
);
|
||||
if ("defaultFrom" in raw && typeof raw.defaultFrom !== "string") {
|
||||
errors.push(`${fieldAt}.defaultFrom must be a string`);
|
||||
}
|
||||
for (const flag of ["defaultable", "personal"]) {
|
||||
if (flag in raw && typeof raw[flag] !== "boolean") {
|
||||
errors.push(`${fieldAt}.${flag} must be a boolean`);
|
||||
}
|
||||
}
|
||||
if ("defaultLevels" in raw) {
|
||||
const levels = raw.defaultLevels;
|
||||
if (
|
||||
!Array.isArray(levels) ||
|
||||
levels.length === 0 ||
|
||||
levels.some(
|
||||
(level) => !["admin", "user", "folder"].includes(level as string),
|
||||
)
|
||||
) {
|
||||
errors.push(
|
||||
`${fieldAt}.defaultLevels must be a non-empty array of "admin", "user" or "folder"`,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (raw.type === "secret" && raw.defaultable === true) {
|
||||
errors.push(`${fieldAt} is a secret and cannot be defaultable`);
|
||||
}
|
||||
if (
|
||||
"shareRead" in raw &&
|
||||
@@ -1634,7 +1669,14 @@ function validateSettings(settings: unknown, errors: string[]): void {
|
||||
if (!Array.isArray(fields)) continue;
|
||||
fields.forEach((raw, index) => {
|
||||
if (!isPlainObject(raw)) return;
|
||||
for (const key of ["defaultFrom", "shareRead", "ownerOnly"]) {
|
||||
for (const key of [
|
||||
"defaultFrom",
|
||||
"defaultable",
|
||||
"defaultLevels",
|
||||
"personal",
|
||||
"shareRead",
|
||||
"ownerOnly",
|
||||
]) {
|
||||
if (key in raw) {
|
||||
errors.push(
|
||||
`${where}.${scope}[${index}].${key} is only valid on host fields`,
|
||||
|
||||
@@ -826,8 +826,16 @@ export function createFakeContext(
|
||||
|
||||
getHost: async (hostId, key) =>
|
||||
settings.get(settingsKey("host", String(hostId), key)) as never,
|
||||
setHost: async (hostId, key, value) =>
|
||||
writeSetting("host", String(hostId), key, value),
|
||||
getHostFor: async (hostId, _userId, key) =>
|
||||
settings.get(settingsKey("host", String(hostId), key)) as never,
|
||||
// Host defaults live in core; the mock stands in the user's own
|
||||
// setting of the same key, so a test can set one with setUser.
|
||||
getHostDefault: async (userId, key) =>
|
||||
settings.get(settingsKey("user", userId, key)) as never,
|
||||
setHost: async (hostId, key, value, options) =>
|
||||
options?.inherit
|
||||
? void settings.delete(settingsKey("host", String(hostId), key))
|
||||
: writeSetting("host", String(hostId), key, value),
|
||||
|
||||
listHostValues: async (key) => {
|
||||
const found: Array<{ hostId: number; userId: string; value: never }> =
|
||||
|
||||
@@ -72,6 +72,8 @@ GridCardCatalogEntry (type): GridCardCatalogEntry
|
||||
GridColSpan (type): GridColSpan
|
||||
GridLayout (type): GridLayout
|
||||
GridSlot (type): GridSlot
|
||||
HostDefaultBadge (value): ({ settingKey }: { settingKey: string; }) => Element
|
||||
HostDefaultField (value): ({ settingKey, children, }: { settingKey: string; children: ReactNode; }) => Element
|
||||
HostFeatureFields (value): ({ form, updateForm, }: HostFeatureFieldsProps) => Element
|
||||
HostFeatureFieldsProps (type): HostFeatureFieldsProps
|
||||
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
|
||||
SelectValue (value): ({ ...props }: SelectValueProps & RefAttributes<HTMLSpanElement>) => 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
|
||||
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
|
||||
@@ -179,6 +181,7 @@ useAdaptivePolling (value): (poll: () => AdaptivePollResult | Promise<AdaptivePo
|
||||
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>; }
|
||||
useConnectionLog (value): () => ConnectionLogContextType
|
||||
useIsDefaultsEditor (value): () => boolean
|
||||
useIsMobile (value): () => boolean
|
||||
useOptionalConnectionLog (value): () => ConnectionLogContextType
|
||||
useTabs (value): () => TabContextType
|
||||
|
||||
@@ -102,25 +102,6 @@ export function registerRoutes(router: Router, deps: RouteDeps): void {
|
||||
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
|
||||
* /plugin-api/host-metrics/metrics/{id}:
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import {
|
||||
Button,
|
||||
HostDefaultBadge,
|
||||
Input,
|
||||
SectionCard,
|
||||
SettingRow,
|
||||
FakeSwitch,
|
||||
useIsDefaultsEditor,
|
||||
} from "@termix/plugin-sdk/ui";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useState } from "react";
|
||||
import {
|
||||
usePluginApi,
|
||||
useTranslation,
|
||||
type HostEditorSectionProps,
|
||||
} 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
|
||||
* the host itself.
|
||||
*/
|
||||
export function HostStatsTab({
|
||||
form,
|
||||
updateForm,
|
||||
host,
|
||||
}: HostEditorSectionProps) {
|
||||
export function HostStatsTab({ form, updateForm }: HostEditorSectionProps) {
|
||||
const { t } = useTranslation();
|
||||
const [newMount, setNewMount] = useState("");
|
||||
const [newMonitoredPath, setNewMonitoredPath] = useState("");
|
||||
@@ -38,7 +35,7 @@ export function HostStatsTab({
|
||||
];
|
||||
const settings = readHostMetricsSettings(bag);
|
||||
const { excludedMounts, monitoredMounts } = settings;
|
||||
const api = usePluginApi();
|
||||
const defaultsEditor = useIsDefaultsEditor();
|
||||
|
||||
const patch = (values: Record<string, unknown>) =>
|
||||
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 value = newMount.trim();
|
||||
if (!value || excludedMounts.includes(value)) {
|
||||
@@ -102,6 +82,7 @@ export function HostStatsTab({
|
||||
<SettingRow
|
||||
label={t("hosts.enableMetricsLabel")}
|
||||
description={t("hosts.enableMetricsDesc")}
|
||||
defaultKey="metricsEnabled"
|
||||
>
|
||||
<FakeSwitch
|
||||
checked={settings.metricsEnabled}
|
||||
@@ -112,6 +93,7 @@ export function HostStatsTab({
|
||||
<SettingRow
|
||||
label={t("hosts.useGlobalMetrics")}
|
||||
description={t("hosts.useGlobalMetricsDesc")}
|
||||
defaultKey="metricsInterval"
|
||||
>
|
||||
<FakeSwitch
|
||||
checked={settings.metricsInterval === null}
|
||||
@@ -141,8 +123,9 @@ export function HostStatsTab({
|
||||
icon={<HardDrive className="size-3.5" />}
|
||||
>
|
||||
<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")}
|
||||
<HostDefaultBadge settingKey="monitoredMounts" />
|
||||
</p>
|
||||
<div className="grid grid-cols-[1fr_0.7fr_auto] gap-2">
|
||||
<Input
|
||||
@@ -212,8 +195,9 @@ export function HostStatsTab({
|
||||
icon={<HardDrive className="size-3.5" />}
|
||||
>
|
||||
<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")}
|
||||
<HostDefaultBadge settingKey="excludedMounts" />
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
@@ -265,16 +249,18 @@ export function HostStatsTab({
|
||||
))}
|
||||
</div>
|
||||
</SectionCard>
|
||||
<SectionCard
|
||||
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">
|
||||
{t("hosts.widgetsMovedToHostMetrics")}
|
||||
</p>
|
||||
</div>
|
||||
</SectionCard>
|
||||
{!defaultsEditor && (
|
||||
<SectionCard
|
||||
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">
|
||||
{t("hosts.widgetsMovedToHostMetrics")}
|
||||
</p>
|
||||
</div>
|
||||
</SectionCard>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -96,6 +96,7 @@ export async function activate(app: TermixApp): Promise<void> {
|
||||
titleKey: "hosts.tabHostMetrics",
|
||||
icon: Activity,
|
||||
order: 70,
|
||||
defaults: true,
|
||||
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", () => {
|
||||
it("answers 404 until a sample exists", async () => {
|
||||
server = await startServer();
|
||||
|
||||
@@ -39,22 +39,26 @@
|
||||
{
|
||||
"key": "enableProxmox",
|
||||
"type": "custom",
|
||||
"component": "proxmoxHostSettings"
|
||||
"component": "proxmoxHostSettings",
|
||||
"defaultable": false
|
||||
},
|
||||
{
|
||||
"key": "proxmoxConfig",
|
||||
"type": "custom",
|
||||
"component": "proxmoxHostSettings"
|
||||
"component": "proxmoxHostSettings",
|
||||
"defaultable": false
|
||||
},
|
||||
{
|
||||
"key": "enableProxmoxStats",
|
||||
"type": "custom",
|
||||
"component": "proxmoxHostSettings"
|
||||
"component": "proxmoxHostSettings",
|
||||
"defaultable": false
|
||||
},
|
||||
{
|
||||
"key": "proxmoxStatsConfig",
|
||||
"type": "custom",
|
||||
"component": "proxmoxHostSettings"
|
||||
"component": "proxmoxHostSettings",
|
||||
"defaultable": false
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -323,7 +323,9 @@
|
||||
"enableToolbar": {
|
||||
"label": "Remote desktop toolbar",
|
||||
"description": "Show the toolbar in RDP, VNC and Telnet sessions."
|
||||
}
|
||||
},
|
||||
"displayGroup": "Display",
|
||||
"displayDefaultsTab": "Remote Desktop"
|
||||
}
|
||||
},
|
||||
"palette": {
|
||||
|
||||
@@ -150,7 +150,8 @@
|
||||
"labelKey": "settings.user.colorDepth32"
|
||||
}
|
||||
],
|
||||
"default": "inherit"
|
||||
"default": "inherit",
|
||||
"hidden": true
|
||||
},
|
||||
{
|
||||
"key": "resizeMethod",
|
||||
@@ -172,7 +173,8 @@
|
||||
"labelKey": "settings.user.resizeReconnect"
|
||||
}
|
||||
],
|
||||
"default": "inherit"
|
||||
"default": "inherit",
|
||||
"hidden": true
|
||||
},
|
||||
{
|
||||
"key": "forceLossless",
|
||||
@@ -194,7 +196,8 @@
|
||||
"labelKey": "settings.user.off"
|
||||
}
|
||||
],
|
||||
"default": "inherit"
|
||||
"default": "inherit",
|
||||
"hidden": true
|
||||
},
|
||||
{
|
||||
"key": "enableWallpaper",
|
||||
@@ -216,7 +219,8 @@
|
||||
"labelKey": "settings.user.off"
|
||||
}
|
||||
],
|
||||
"default": "inherit"
|
||||
"default": "inherit",
|
||||
"hidden": true
|
||||
},
|
||||
{
|
||||
"key": "enableFontSmoothing",
|
||||
@@ -238,7 +242,8 @@
|
||||
"labelKey": "settings.user.off"
|
||||
}
|
||||
],
|
||||
"default": "inherit"
|
||||
"default": "inherit",
|
||||
"hidden": true
|
||||
},
|
||||
{
|
||||
"key": "enableDesktopComposition",
|
||||
@@ -260,7 +265,8 @@
|
||||
"labelKey": "settings.user.off"
|
||||
}
|
||||
],
|
||||
"default": "inherit"
|
||||
"default": "inherit",
|
||||
"hidden": true
|
||||
},
|
||||
{
|
||||
"key": "disableAudio",
|
||||
@@ -282,7 +288,8 @@
|
||||
"labelKey": "settings.user.off"
|
||||
}
|
||||
],
|
||||
"default": "inherit"
|
||||
"default": "inherit",
|
||||
"hidden": true
|
||||
},
|
||||
{
|
||||
"key": "enablePrinting",
|
||||
@@ -304,7 +311,8 @@
|
||||
"labelKey": "settings.user.off"
|
||||
}
|
||||
],
|
||||
"default": "inherit"
|
||||
"default": "inherit",
|
||||
"hidden": true
|
||||
},
|
||||
{
|
||||
"key": "enableDrive",
|
||||
@@ -326,7 +334,8 @@
|
||||
"labelKey": "settings.user.off"
|
||||
}
|
||||
],
|
||||
"default": "inherit"
|
||||
"default": "inherit",
|
||||
"hidden": true
|
||||
},
|
||||
{
|
||||
"key": "disableCopy",
|
||||
@@ -348,7 +357,8 @@
|
||||
"labelKey": "settings.user.off"
|
||||
}
|
||||
],
|
||||
"default": "inherit"
|
||||
"default": "inherit",
|
||||
"hidden": true
|
||||
},
|
||||
{
|
||||
"key": "disablePaste",
|
||||
@@ -370,7 +380,8 @@
|
||||
"labelKey": "settings.user.off"
|
||||
}
|
||||
],
|
||||
"default": "inherit"
|
||||
"default": "inherit",
|
||||
"hidden": true
|
||||
}
|
||||
],
|
||||
"host": {
|
||||
@@ -380,21 +391,24 @@
|
||||
"type": "boolean",
|
||||
"labelKey": "settings.host.enableRdp.label",
|
||||
"default": false,
|
||||
"hidden": true
|
||||
"hidden": true,
|
||||
"defaultable": false
|
||||
},
|
||||
{
|
||||
"key": "enableVnc",
|
||||
"type": "boolean",
|
||||
"labelKey": "settings.host.enableVnc.label",
|
||||
"default": false,
|
||||
"hidden": true
|
||||
"hidden": true,
|
||||
"defaultable": false
|
||||
},
|
||||
{
|
||||
"key": "enableTelnet",
|
||||
"type": "boolean",
|
||||
"labelKey": "settings.host.enableTelnet.label",
|
||||
"default": false,
|
||||
"hidden": true
|
||||
"hidden": true,
|
||||
"defaultable": false
|
||||
},
|
||||
{
|
||||
"key": "rdpPort",
|
||||
@@ -403,7 +417,8 @@
|
||||
"min": 1,
|
||||
"max": 65535,
|
||||
"default": 3389,
|
||||
"hidden": true
|
||||
"hidden": true,
|
||||
"defaultable": false
|
||||
},
|
||||
{
|
||||
"key": "vncPort",
|
||||
@@ -412,7 +427,8 @@
|
||||
"min": 1,
|
||||
"max": 65535,
|
||||
"default": 5900,
|
||||
"hidden": true
|
||||
"hidden": true,
|
||||
"defaultable": false
|
||||
},
|
||||
{
|
||||
"key": "telnetPort",
|
||||
@@ -421,7 +437,8 @@
|
||||
"min": 1,
|
||||
"max": 65535,
|
||||
"default": 23,
|
||||
"hidden": true
|
||||
"hidden": true,
|
||||
"defaultable": false
|
||||
},
|
||||
{
|
||||
"key": "rdpSecurity",
|
||||
@@ -444,7 +461,8 @@
|
||||
"default": {},
|
||||
"hidden": true,
|
||||
"shareRead": "edit",
|
||||
"secretKeys": ["gateway-password"]
|
||||
"secretKeys": ["gateway-password"],
|
||||
"defaultable": false
|
||||
},
|
||||
{
|
||||
"key": "enableToolbar",
|
||||
@@ -453,6 +471,263 @@
|
||||
"descriptionKey": "settings.host.enableToolbar.description",
|
||||
"default": 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;
|
||||
}
|
||||
|
||||
/** 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> = {
|
||||
colorDepth: "color-depth",
|
||||
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
|
||||
* to the host and guacd; on and off become booleans.
|
||||
* Display settings as guacd parameters. "inherit" leaves a parameter to the
|
||||
* host and guacd; on and off become booleans.
|
||||
*/
|
||||
export function userDefaultParams(
|
||||
values: Record<string, unknown>,
|
||||
@@ -215,9 +215,22 @@ export function userDefaultParams(
|
||||
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,
|
||||
userId: string,
|
||||
hostId?: number,
|
||||
): 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,
|
||||
isRemoteProtocol,
|
||||
readHostSettings,
|
||||
readUserDefaults,
|
||||
readDisplayDefaults,
|
||||
type RemoteProtocol,
|
||||
} from "./host-settings.js";
|
||||
import { errorMessage, type RemoteDesktopLogger } from "./log.js";
|
||||
@@ -203,7 +203,7 @@ export function registerRoutes(router: Router, deps: RouteDeps): void {
|
||||
const userId = ctx.currentActor();
|
||||
if (type === "rdp" && userId) {
|
||||
for (const [key, value] of Object.entries(
|
||||
await readUserDefaults(ctx, userId),
|
||||
await readDisplayDefaults(ctx, userId),
|
||||
)) {
|
||||
if (isServerOwnedSetting(key)) continue;
|
||||
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({
|
||||
...(protocol === "rdp" ? await readUserDefaults(ctx, userId) : {}),
|
||||
...(protocol === "rdp"
|
||||
? await readDisplayDefaults(ctx, userId, hostId)
|
||||
: {}),
|
||||
...settings.guacamoleConfig,
|
||||
});
|
||||
let guacConfig = config;
|
||||
@@ -545,7 +547,9 @@ export function registerRoutes(router: Router, deps: RouteDeps): void {
|
||||
.json({ error: "Protocol is not enabled for this host" });
|
||||
}
|
||||
const { guacdOverrides } = cleanGuacConfig({
|
||||
...(protocol === "rdp" ? await readUserDefaults(ctx, userId) : {}),
|
||||
...(protocol === "rdp"
|
||||
? await readDisplayDefaults(ctx, userId, hostId)
|
||||
: {}),
|
||||
...settings.guacamoleConfig,
|
||||
});
|
||||
guacd = {
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
} from "@termix/plugin-sdk/frontend";
|
||||
import {
|
||||
FakeSwitch,
|
||||
HostFeatureFields,
|
||||
SectionCard,
|
||||
SettingRow,
|
||||
isElectron,
|
||||
@@ -166,6 +167,7 @@ function ToolbarCard({
|
||||
<SettingRow
|
||||
label={t("settings.host.enableToolbar.label")}
|
||||
description={t("settings.host.enableToolbar.description")}
|
||||
defaultKey="enableToolbar"
|
||||
>
|
||||
<FakeSwitch
|
||||
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>> = {
|
||||
rdp: RdpSection,
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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() } });
|
||||
await server.mock.ctx.settings.setHost(7, "enableRdp", true);
|
||||
await server.mock.ctx.settings.setHost(7, "guacamoleConfig", {
|
||||
"color-depth": 32,
|
||||
});
|
||||
await server.mock.ctx.settings.setUser("user-1", "colorDepth", "16");
|
||||
await server.mock.ctx.settings.setUser("user-1", "disableCopy", "on");
|
||||
// What the host follows from its defaults.
|
||||
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", {
|
||||
protocol: "rdp",
|
||||
|
||||
@@ -108,7 +108,8 @@
|
||||
"descriptionKey": "host.startupDescription",
|
||||
"hidden": true,
|
||||
"min": 1,
|
||||
"shareRead": "view"
|
||||
"shareRead": "view",
|
||||
"defaultLevels": ["user", "folder"]
|
||||
},
|
||||
{
|
||||
"key": "quickActions",
|
||||
@@ -117,7 +118,8 @@
|
||||
"descriptionKey": "host.quickActionsDescription",
|
||||
"hidden": true,
|
||||
"default": [],
|
||||
"shareRead": "view"
|
||||
"shareRead": "view",
|
||||
"defaultLevels": ["user", "folder"]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -3,7 +3,14 @@ import {
|
||||
useTranslation,
|
||||
type HostEditorSectionProps,
|
||||
} 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 { useSnippetOptions } from "./use-snippet-options";
|
||||
|
||||
@@ -62,110 +69,119 @@ export function HostSnippetsSection({
|
||||
|
||||
return (
|
||||
<>
|
||||
<SectionCard
|
||||
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">
|
||||
{t("host.startupDescription")}
|
||||
</p>
|
||||
<Select2
|
||||
aria-label={t("host.startupTitle")}
|
||||
value={startupSnippetId ?? ""}
|
||||
onChange={(e) =>
|
||||
write({
|
||||
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>
|
||||
{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"
|
||||
<HostDefaultField settingKey="startupSnippetId">
|
||||
<SectionCard
|
||||
title={t("host.startupTitle")}
|
||||
icon={<Play className="size-3.5" />}
|
||||
>
|
||||
<div className="flex flex-col gap-1.5 py-3">
|
||||
<p className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
{t("host.startupDescription")}
|
||||
<HostDefaultBadge settingKey="startupSnippetId" />
|
||||
</p>
|
||||
<Select2
|
||||
aria-label={t("host.startupTitle")}
|
||||
value={startupSnippetId ?? ""}
|
||||
onChange={(e) =>
|
||||
write({
|
||||
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"
|
||||
>
|
||||
<Input
|
||||
className="h-7 text-xs flex-1"
|
||||
placeholder={t("host.buttonLabel")}
|
||||
value={row.name}
|
||||
onChange={(e) => {
|
||||
const next = [...quickActions];
|
||||
next[index] = { ...row, name: e.target.value };
|
||||
setQuickActions(next);
|
||||
}}
|
||||
/>
|
||||
<Select2
|
||||
aria-label={t("host.selectSnippet")}
|
||||
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) => {
|
||||
const next = [...quickActions];
|
||||
next[index] = {
|
||||
...row,
|
||||
snippetId: e.target.value ? Number(e.target.value) : null,
|
||||
};
|
||||
setQuickActions(next);
|
||||
}}
|
||||
<option value="">{t("host.none")}</option>
|
||||
{options.map((snippet) => (
|
||||
<option key={snippet.id} value={snippet.id}>
|
||||
{snippet.name}
|
||||
</option>
|
||||
))}
|
||||
</Select2>
|
||||
</div>
|
||||
</SectionCard>
|
||||
</HostDefaultField>
|
||||
<HostDefaultField settingKey="quickActions">
|
||||
<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="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>
|
||||
{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>
|
||||
<Input
|
||||
className="h-7 text-xs flex-1"
|
||||
placeholder={t("host.buttonLabel")}
|
||||
value={row.name}
|
||||
onChange={(e) => {
|
||||
const next = [...quickActions];
|
||||
next[index] = { ...row, name: e.target.value };
|
||||
setQuickActions(next);
|
||||
}}
|
||||
/>
|
||||
<Select2
|
||||
aria-label={t("host.selectSnippet")}
|
||||
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) => {
|
||||
const next = [...quickActions];
|
||||
next[index] = {
|
||||
...row,
|
||||
snippetId: e.target.value ? Number(e.target.value) : null,
|
||||
};
|
||||
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",
|
||||
icon: Play,
|
||||
order: 15,
|
||||
defaults: true,
|
||||
component: HostSnippetsSection,
|
||||
});
|
||||
|
||||
|
||||
@@ -461,13 +461,11 @@
|
||||
"lineHeight": "Line Height",
|
||||
"lineHeightLabel": "Line Height",
|
||||
"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.",
|
||||
"linkClickBehaviorDirect": "Open directly",
|
||||
"linkClickBehaviorLabel": "Link Click Behavior",
|
||||
"loadingHost": "Loading host...",
|
||||
"localEchoAuto": "Automatic",
|
||||
"localEchoDefault": "Use global setting",
|
||||
"localEchoDesc": "Predict safe printable keystrokes locally on slow connections. Password prompts and control input are never echoed.",
|
||||
"localEchoLabel": "Local Echo",
|
||||
"localEchoOff": "Off",
|
||||
@@ -512,8 +510,6 @@
|
||||
"themePreview": "Theme Preview",
|
||||
"useSSHTitleDesc": "Update the tab title from the shell's window title instead of the host name",
|
||||
"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",
|
||||
"enableFileManager": "Enable File Manager",
|
||||
"enableDocker": "Enable Docker",
|
||||
@@ -571,19 +567,6 @@
|
||||
"startRecording": "Start 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": {
|
||||
"copy": "Copy selection",
|
||||
"paste": "Paste",
|
||||
|
||||
@@ -151,21 +151,24 @@
|
||||
"type": "boolean",
|
||||
"labelKey": "settings.admin.commandHistoryForNewHosts.label",
|
||||
"descriptionKey": "settings.admin.commandHistoryForNewHosts.description",
|
||||
"default": true
|
||||
"default": true,
|
||||
"hidden": true
|
||||
},
|
||||
{
|
||||
"key": "newHostTheme",
|
||||
"type": "custom",
|
||||
"component": "newHostTheme",
|
||||
"group": "settings.admin.group.newHosts",
|
||||
"default": "termix"
|
||||
"default": "termix",
|
||||
"hidden": true
|
||||
},
|
||||
{
|
||||
"key": "newHostFontFamily",
|
||||
"type": "custom",
|
||||
"component": "newHostFontFamily",
|
||||
"group": "settings.admin.group.newHosts",
|
||||
"default": "Caskaydia Cove Nerd Font Mono"
|
||||
"default": "Caskaydia Cove Nerd Font Mono",
|
||||
"hidden": true
|
||||
},
|
||||
{
|
||||
"key": "newHostFontSize",
|
||||
@@ -175,7 +178,8 @@
|
||||
"group": "settings.admin.group.newHosts",
|
||||
"min": 8,
|
||||
"max": 36,
|
||||
"default": 14
|
||||
"default": 14,
|
||||
"hidden": true
|
||||
},
|
||||
{
|
||||
"key": "newHostCursorStyle",
|
||||
@@ -196,14 +200,16 @@
|
||||
"value": "bar",
|
||||
"labelKey": "hosts.cursorStyleBar"
|
||||
}
|
||||
]
|
||||
],
|
||||
"hidden": true
|
||||
},
|
||||
{
|
||||
"key": "newHostCursorBlink",
|
||||
"type": "boolean",
|
||||
"labelKey": "settings.admin.newHostCursorBlink.label",
|
||||
"group": "settings.admin.group.newHosts",
|
||||
"default": true
|
||||
"default": true,
|
||||
"hidden": true
|
||||
},
|
||||
{
|
||||
"key": "newHostAutoTmux",
|
||||
@@ -211,7 +217,8 @@
|
||||
"labelKey": "settings.admin.newHostAutoTmux.label",
|
||||
"descriptionKey": "settings.admin.newHostAutoTmux.description",
|
||||
"group": "settings.admin.group.newHosts",
|
||||
"default": false
|
||||
"default": false,
|
||||
"hidden": true
|
||||
},
|
||||
{
|
||||
"key": "touchInput",
|
||||
@@ -399,15 +406,15 @@
|
||||
"type": "boolean",
|
||||
"labelKey": "settings.host.enableCommandHistory.label",
|
||||
"descriptionKey": "settings.host.enableCommandHistory.description",
|
||||
"default": true,
|
||||
"defaultFrom": "commandHistoryForNewHosts"
|
||||
"default": true
|
||||
},
|
||||
{
|
||||
"key": "inheritAppearance",
|
||||
"type": "boolean",
|
||||
"labelKey": "settings.host.inheritAppearance.label",
|
||||
"hidden": true,
|
||||
"default": true
|
||||
"default": true,
|
||||
"defaultable": false
|
||||
},
|
||||
{
|
||||
"key": "theme",
|
||||
@@ -415,7 +422,7 @@
|
||||
"labelKey": "settings.host.theme.label",
|
||||
"hidden": true,
|
||||
"default": "termix",
|
||||
"defaultFrom": "newHostTheme"
|
||||
"personal": true
|
||||
},
|
||||
{
|
||||
"key": "cursorBlink",
|
||||
@@ -423,7 +430,7 @@
|
||||
"labelKey": "settings.host.cursorBlink.label",
|
||||
"hidden": true,
|
||||
"default": true,
|
||||
"defaultFrom": "newHostCursorBlink"
|
||||
"personal": true
|
||||
},
|
||||
{
|
||||
"key": "cursorStyle",
|
||||
@@ -445,7 +452,7 @@
|
||||
"labelKey": "hosts.cursorStyleBar"
|
||||
}
|
||||
],
|
||||
"defaultFrom": "newHostCursorStyle"
|
||||
"personal": true
|
||||
},
|
||||
{
|
||||
"key": "fontSize",
|
||||
@@ -453,7 +460,7 @@
|
||||
"labelKey": "settings.host.fontSize.label",
|
||||
"hidden": true,
|
||||
"default": 14,
|
||||
"defaultFrom": "newHostFontSize"
|
||||
"personal": true
|
||||
},
|
||||
{
|
||||
"key": "fontFamily",
|
||||
@@ -461,28 +468,31 @@
|
||||
"labelKey": "settings.host.fontFamily.label",
|
||||
"hidden": true,
|
||||
"default": "Caskaydia Cove Nerd Font Mono",
|
||||
"defaultFrom": "newHostFontFamily"
|
||||
"personal": true
|
||||
},
|
||||
{
|
||||
"key": "scrollback",
|
||||
"type": "number",
|
||||
"labelKey": "settings.host.scrollback.label",
|
||||
"hidden": true,
|
||||
"default": 10000
|
||||
"default": 10000,
|
||||
"personal": true
|
||||
},
|
||||
{
|
||||
"key": "letterSpacing",
|
||||
"type": "number",
|
||||
"labelKey": "settings.host.letterSpacing.label",
|
||||
"hidden": true,
|
||||
"default": 0
|
||||
"default": 0,
|
||||
"personal": true
|
||||
},
|
||||
{
|
||||
"key": "lineHeight",
|
||||
"type": "number",
|
||||
"labelKey": "settings.host.lineHeight.label",
|
||||
"hidden": true,
|
||||
"default": 1
|
||||
"default": 1,
|
||||
"personal": true
|
||||
},
|
||||
{
|
||||
"key": "bellStyle",
|
||||
@@ -507,34 +517,39 @@
|
||||
"value": "both",
|
||||
"labelKey": "hosts.bellStyleBoth"
|
||||
}
|
||||
]
|
||||
],
|
||||
"personal": true
|
||||
},
|
||||
{
|
||||
"key": "minimumContrastRatio",
|
||||
"type": "number",
|
||||
"labelKey": "settings.host.minimumContrastRatio.label",
|
||||
"hidden": true,
|
||||
"default": 1
|
||||
"default": 1,
|
||||
"personal": true
|
||||
},
|
||||
{
|
||||
"key": "backgroundImage",
|
||||
"type": "string",
|
||||
"labelKey": "settings.host.backgroundImage.label",
|
||||
"hidden": true,
|
||||
"default": ""
|
||||
"default": "",
|
||||
"personal": true
|
||||
},
|
||||
{
|
||||
"key": "backgroundImageOpacity",
|
||||
"type": "number",
|
||||
"labelKey": "settings.host.backgroundImageOpacity.label",
|
||||
"hidden": true,
|
||||
"default": 0.15
|
||||
"default": 0.15,
|
||||
"personal": true
|
||||
},
|
||||
{
|
||||
"key": "customThemeColors",
|
||||
"type": "json",
|
||||
"labelKey": "settings.host.customThemeColors.label",
|
||||
"hidden": true
|
||||
"hidden": true,
|
||||
"personal": true
|
||||
},
|
||||
{
|
||||
"key": "rightClickSelectsWord",
|
||||
@@ -614,8 +629,7 @@
|
||||
"type": "boolean",
|
||||
"labelKey": "settings.host.autoTmux.label",
|
||||
"hidden": true,
|
||||
"default": false,
|
||||
"defaultFrom": "newHostAutoTmux"
|
||||
"default": false
|
||||
},
|
||||
{
|
||||
"key": "useSSHTitle",
|
||||
@@ -642,12 +656,8 @@
|
||||
"type": "select",
|
||||
"labelKey": "settings.host.linkClickBehavior.label",
|
||||
"hidden": true,
|
||||
"default": "default",
|
||||
"default": "confirm",
|
||||
"options": [
|
||||
{
|
||||
"value": "default",
|
||||
"labelKey": "hosts.linkClickBehaviorDefault"
|
||||
},
|
||||
{
|
||||
"value": "confirm",
|
||||
"labelKey": "hosts.linkClickBehaviorConfirm"
|
||||
@@ -663,12 +673,8 @@
|
||||
"type": "select",
|
||||
"labelKey": "settings.host.localEcho.label",
|
||||
"hidden": true,
|
||||
"default": "default",
|
||||
"default": "auto",
|
||||
"options": [
|
||||
{
|
||||
"value": "default",
|
||||
"labelKey": "hosts.localEchoDefault"
|
||||
},
|
||||
{
|
||||
"value": "off",
|
||||
"labelKey": "hosts.localEchoOff"
|
||||
@@ -704,7 +710,8 @@
|
||||
"key": "terminalDefaults",
|
||||
"type": "custom",
|
||||
"component": "terminalDefaults",
|
||||
"default": {}
|
||||
"default": {},
|
||||
"hidden": true
|
||||
},
|
||||
{
|
||||
"key": "customThemes",
|
||||
@@ -739,7 +746,8 @@
|
||||
"value": "on",
|
||||
"labelKey": "hosts.localEchoOn"
|
||||
}
|
||||
]
|
||||
],
|
||||
"hidden": true
|
||||
},
|
||||
{
|
||||
"key": "linkClickBehavior",
|
||||
@@ -756,7 +764,8 @@
|
||||
"value": "direct",
|
||||
"labelKey": "hosts.linkClickBehaviorDirect"
|
||||
}
|
||||
]
|
||||
],
|
||||
"hidden": true
|
||||
},
|
||||
{
|
||||
"key": "macros",
|
||||
|
||||
@@ -10,11 +10,7 @@ import {
|
||||
} from "./images/image-storage-settings.js";
|
||||
import { DEFAULT_TIMEOUT_MINUTES } from "./session-manager.js";
|
||||
import {
|
||||
DEFAULT_APPEARANCE,
|
||||
NEW_HOST_ADMIN_KEYS,
|
||||
pickTerminalValues,
|
||||
readUserSettings,
|
||||
type NewHostTerminalDefaults,
|
||||
type TerminalUserSettings,
|
||||
} from "../shared/terminal-settings.js";
|
||||
|
||||
@@ -84,8 +80,6 @@ export interface ClientSettings {
|
||||
sessionPersistence: boolean;
|
||||
commandHistoryEnabled: boolean;
|
||||
touchInput: TouchInputSettings;
|
||||
/** What a new host's terminal starts with. */
|
||||
newHostDefaults: NewHostTerminalDefaults;
|
||||
/** The caller's own terminal settings. */
|
||||
user: TerminalUserSettings;
|
||||
}
|
||||
@@ -114,26 +108,6 @@ export async function readClientSettings(
|
||||
sessionPersistence: all[ADMIN_KEYS.sessionPersistence] !== false,
|
||||
commandHistoryEnabled: all[ADMIN_KEYS.commandHistoryEnabled] !== false,
|
||||
touchInput: normalizeTouchInputSettings(touch),
|
||||
newHostDefaults: readNewHostDefaults(all),
|
||||
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,
|
||||
SendTextEditor,
|
||||
} from "./lib/keybinding-editors";
|
||||
import { TerminalDefaultsSettings } from "./settings/TerminalDefaultsSettings";
|
||||
import {
|
||||
NewHostFontSetting,
|
||||
NewHostThemeSetting,
|
||||
} from "./settings/NewHostLookSettings";
|
||||
import { TerminalPreview } from "./look/TerminalPreview";
|
||||
import { installTerminalGlobalStyles } from "./look/terminal-global-styles";
|
||||
import {
|
||||
@@ -60,7 +55,6 @@ import {
|
||||
} from "./look/look-actions";
|
||||
import {
|
||||
invalidateTerminalClientSettings,
|
||||
loadTerminalClientSettings,
|
||||
resetTerminalClientSettings,
|
||||
} from "./terminal-settings";
|
||||
import { moveLocalTerminalPreferences } from "./settings/local-preferences-migration";
|
||||
@@ -142,6 +136,7 @@ export function activate(app: TermixApp): void {
|
||||
titleKey: "hosts.tabTerminal",
|
||||
icon: SquareTerminal,
|
||||
order: 10,
|
||||
defaults: true,
|
||||
component: HostTerminalSection,
|
||||
});
|
||||
|
||||
@@ -305,9 +300,6 @@ export function activate(app: TermixApp): void {
|
||||
app.registerAction("terminal.resolveTheme", (async (
|
||||
request: ResolveLookRequest & { hostId?: number | string } = {},
|
||||
) => {
|
||||
const settings = await loadTerminalClientSettings(app.api).catch(
|
||||
() => null,
|
||||
);
|
||||
let host: PluginHostRecord | null | undefined = request.host;
|
||||
if (!host && request.hostId !== undefined) {
|
||||
host =
|
||||
@@ -316,15 +308,12 @@ export function activate(app: TermixApp): void {
|
||||
(entry) => String(entry.id) === String(request.hostId),
|
||||
);
|
||||
}
|
||||
return resolveTerminalLook({ ...request, host }, settings?.user);
|
||||
return resolveTerminalLook({ ...request, host });
|
||||
}) as never);
|
||||
app.registerAction("terminal.themes", () => listTerminalThemes());
|
||||
|
||||
app.registerSettingsComponent("touchInput", TouchInputSettings);
|
||||
app.registerSettingsComponent("imageStorageTest", ImageStorageTest);
|
||||
app.registerSettingsComponent("terminalDefaults", TerminalDefaultsSettings);
|
||||
app.registerSettingsComponent("newHostTheme", NewHostThemeSetting);
|
||||
app.registerSettingsComponent("newHostFontFamily", NewHostFontSetting);
|
||||
|
||||
app.onSettingsChanged(() => invalidateTerminalClientSettings());
|
||||
if (!app.guest) void moveLocalTerminalPreferences(app);
|
||||
|
||||
@@ -10,7 +10,6 @@ import { resolveForHost } from "../terminal-settings";
|
||||
import type {
|
||||
CursorStyle,
|
||||
TerminalThemeColors,
|
||||
TerminalUserSettings,
|
||||
} from "../../shared/terminal-settings";
|
||||
|
||||
/**
|
||||
@@ -45,9 +44,8 @@ export interface ResolveLookRequest {
|
||||
|
||||
export function resolveTerminalLook(
|
||||
request: ResolveLookRequest,
|
||||
user: TerminalUserSettings | undefined,
|
||||
): ResolvedTerminalLook {
|
||||
const config = resolveForHost(request.host ?? null, user);
|
||||
const config = resolveForHost(request.host ?? null);
|
||||
const themeId = request.theme || config.theme;
|
||||
ensureTerminalFontsLoaded(config.fontFamily);
|
||||
return {
|
||||
|
||||
@@ -5,6 +5,7 @@ import { toast } from "sonner";
|
||||
import {
|
||||
Button,
|
||||
FakeSwitch,
|
||||
HostDefaultBadge,
|
||||
Input,
|
||||
SectionCard,
|
||||
Select2,
|
||||
@@ -33,9 +34,6 @@ import {
|
||||
TERMINAL_FONT_ZOOM_MAX,
|
||||
} from "../look/terminal-font-zoom";
|
||||
import {
|
||||
APPEARANCE_KEYS,
|
||||
INHERIT_APPEARANCE_KEY,
|
||||
pickTerminalValues,
|
||||
readHostTerminalSettings,
|
||||
readUserSettings,
|
||||
type BackspaceMode,
|
||||
@@ -45,14 +43,10 @@ import {
|
||||
type HostTerminalSettings as HostTerminalValues,
|
||||
type SavedCustomTheme,
|
||||
} from "../../shared/terminal-settings";
|
||||
import {
|
||||
invalidateTerminalClientSettings,
|
||||
useTerminalClientSettings,
|
||||
} from "../terminal-settings";
|
||||
import { invalidateTerminalClientSettings } from "../terminal-settings";
|
||||
|
||||
const PLUGIN_ID = "ssh-terminal";
|
||||
const CUSTOM_FONT_OPTION = "__custom__";
|
||||
const APPEARANCE = new Set<string>(APPEARANCE_KEYS);
|
||||
|
||||
type HostPluginSettings = Record<string, Record<string, unknown>>;
|
||||
|
||||
@@ -64,10 +58,8 @@ type HostPluginSettings = Record<string, Record<string, unknown>>;
|
||||
export function HostTerminalSettings({
|
||||
form: editorForm,
|
||||
updateForm,
|
||||
host,
|
||||
}: Pick<HostEditorSectionProps, "form" | "setField" | "updateForm" | "host">) {
|
||||
const { t } = useTranslation();
|
||||
const client = useTerminalClientSettings();
|
||||
const userSettings = useSettings("user");
|
||||
// The preview ends when the editor section goes away.
|
||||
useEffect(() => () => setThemePreview(null), []);
|
||||
@@ -75,18 +67,8 @@ export function HostTerminalSettings({
|
||||
const stored = ((editorForm?.pluginSettings as HostPluginSettings)?.[
|
||||
PLUGIN_ID
|
||||
] ?? {}) as Record<string, unknown>;
|
||||
// A new host starts from the admin's new-host defaults.
|
||||
const seed = host ? {} : (client?.newHostDefaults ?? {});
|
||||
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;
|
||||
// The form holds what the host follows, so this is what it will look like.
|
||||
const form = readHostTerminalSettings(stored) as HostTerminalValues;
|
||||
|
||||
const writeValues = (values: Record<string, unknown>) =>
|
||||
updateForm((current) => {
|
||||
@@ -103,23 +85,7 @@ export function HostTerminalSettings({
|
||||
const setField = <K extends keyof HostTerminalValues>(
|
||||
key: K,
|
||||
value: HostTerminalValues[K],
|
||||
) =>
|
||||
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 });
|
||||
};
|
||||
) => writeValues({ [key]: value });
|
||||
|
||||
const [isCustomFont, setIsCustomFont] = useState(
|
||||
() => !TERMINAL_FONTS.some((f) => f.value === form.fontFamily),
|
||||
@@ -183,21 +149,7 @@ export function HostTerminalSettings({
|
||||
icon={<Palette className="size-3.5" />}
|
||||
>
|
||||
<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">
|
||||
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("hosts.themePreview")}
|
||||
@@ -215,8 +167,9 @@ export function HostTerminalSettings({
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<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")}
|
||||
<HostDefaultBadge settingKey="theme" />
|
||||
</label>
|
||||
<Select2
|
||||
value={form.theme}
|
||||
@@ -245,8 +198,9 @@ export function HostTerminalSettings({
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<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")}
|
||||
<HostDefaultBadge settingKey="fontFamily" />
|
||||
</label>
|
||||
<TooltipProvider delayDuration={200}>
|
||||
<Tooltip>
|
||||
@@ -292,8 +246,9 @@ export function HostTerminalSettings({
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<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")}
|
||||
<HostDefaultBadge settingKey="fontSize" />
|
||||
</label>
|
||||
<span className="text-[10px] text-muted-foreground tabular-nums">
|
||||
{form.fontSize}px
|
||||
@@ -308,8 +263,9 @@ export function HostTerminalSettings({
|
||||
/>
|
||||
</div>
|
||||
<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")}
|
||||
<HostDefaultBadge settingKey="cursorStyle" />
|
||||
</label>
|
||||
<Select2
|
||||
value={form.cursorStyle}
|
||||
@@ -327,8 +283,9 @@ export function HostTerminalSettings({
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<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")}
|
||||
<HostDefaultBadge settingKey="letterSpacing" />
|
||||
</label>
|
||||
<span className="text-[10px] text-muted-foreground tabular-nums">
|
||||
{form.letterSpacing}px
|
||||
@@ -344,8 +301,9 @@ export function HostTerminalSettings({
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<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")}
|
||||
<HostDefaultBadge settingKey="lineHeight" />
|
||||
</label>
|
||||
<span className="text-[10px] text-muted-foreground tabular-nums">
|
||||
{form.lineHeight.toFixed(1)}
|
||||
@@ -360,8 +318,9 @@ export function HostTerminalSettings({
|
||||
/>
|
||||
</div>
|
||||
<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")}
|
||||
<HostDefaultBadge settingKey="bellStyle" />
|
||||
</label>
|
||||
<Select2
|
||||
value={form.bellStyle}
|
||||
@@ -378,8 +337,9 @@ export function HostTerminalSettings({
|
||||
</Select2>
|
||||
</div>
|
||||
<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")}
|
||||
<HostDefaultBadge settingKey="backspaceMode" />
|
||||
</label>
|
||||
<Select2
|
||||
value={form.backspaceMode}
|
||||
@@ -397,7 +357,7 @@ export function HostTerminalSettings({
|
||||
</Select2>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
</>
|
||||
{form.theme === "custom" && (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
@@ -455,8 +415,9 @@ export function HostTerminalSettings({
|
||||
)}
|
||||
</div>
|
||||
<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")}
|
||||
<HostDefaultBadge settingKey="customThemeColors" />
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
@@ -569,6 +530,7 @@ export function HostTerminalSettings({
|
||||
)}
|
||||
<SettingRow
|
||||
label={t("hosts.cursorBlinking")}
|
||||
defaultKey="cursorBlink"
|
||||
description={t("hosts.cursorBlinkingDesc")}
|
||||
>
|
||||
<FakeSwitch
|
||||
@@ -578,6 +540,7 @@ export function HostTerminalSettings({
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
label={t("hosts.rightClickSelectsWordLabel")}
|
||||
defaultKey="rightClickSelectsWord"
|
||||
description={t("hosts.rightClickSelectsWordShortDesc")}
|
||||
>
|
||||
<FakeSwitch
|
||||
@@ -587,6 +550,7 @@ export function HostTerminalSettings({
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
label={t("hosts.macOptionIsMetaLabel")}
|
||||
defaultKey="macOptionIsMeta"
|
||||
description={t("hosts.macOptionIsMetaShortDesc")}
|
||||
>
|
||||
<FakeSwitch
|
||||
@@ -596,6 +560,7 @@ export function HostTerminalSettings({
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
label={t("hosts.syntaxHighlightingLabel")}
|
||||
defaultKey="syntaxHighlighting"
|
||||
description={t("hosts.syntaxHighlightingDesc")}
|
||||
>
|
||||
<FakeSwitch
|
||||
@@ -633,6 +598,7 @@ export function HostTerminalSettings({
|
||||
).map(([key, labelKey, descKey]) => (
|
||||
<SettingRow
|
||||
key={key}
|
||||
defaultKey="syntaxHighlightingOptions"
|
||||
label={t(`hosts.${labelKey}`)}
|
||||
description={t(`hosts.${descKey}`)}
|
||||
>
|
||||
@@ -650,8 +616,9 @@ export function HostTerminalSettings({
|
||||
</div>
|
||||
)}
|
||||
<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")}
|
||||
<HostDefaultBadge settingKey="backgroundImage" />
|
||||
</label>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
{t("hosts.backgroundImageDesc")}
|
||||
@@ -667,8 +634,9 @@ export function HostTerminalSettings({
|
||||
{form.backgroundImage && (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<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")}
|
||||
<HostDefaultBadge settingKey="backgroundImageOpacity" />
|
||||
</label>
|
||||
<span className="text-[10px] text-muted-foreground tabular-nums">
|
||||
{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-1.5">
|
||||
<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")}
|
||||
<HostDefaultBadge settingKey="scrollback" />
|
||||
</label>
|
||||
<span className="text-[10px] text-muted-foreground tabular-nums">
|
||||
{form.scrollback.toLocaleString()}{" "}
|
||||
@@ -711,6 +680,7 @@ export function HostTerminalSettings({
|
||||
</div>
|
||||
<SettingRow
|
||||
label={t("hosts.enableAutoTmux")}
|
||||
defaultKey="autoTmux"
|
||||
description={
|
||||
<>
|
||||
{t("hosts.enableAutoTmuxDesc")}{" "}
|
||||
@@ -732,6 +702,7 @@ export function HostTerminalSettings({
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
label={t("hosts.useSSHTitleLabel")}
|
||||
defaultKey="useSSHTitle"
|
||||
description={t("hosts.useSSHTitleDesc")}
|
||||
>
|
||||
<FakeSwitch
|
||||
@@ -741,6 +712,7 @@ export function HostTerminalSettings({
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
label={t("hosts.enableAutoMosh")}
|
||||
defaultKey="autoMosh"
|
||||
description={t("hosts.enableAutoMoshDesc")}
|
||||
>
|
||||
<FakeSwitch
|
||||
@@ -750,6 +722,7 @@ export function HostTerminalSettings({
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
label={t("hosts.passwordPromptAutoFillLabel")}
|
||||
defaultKey="passwordPromptAutoFill"
|
||||
description={t("hosts.passwordPromptAutoFillDesc")}
|
||||
>
|
||||
<FakeSwitch
|
||||
@@ -759,6 +732,7 @@ export function HostTerminalSettings({
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
label={t("hosts.sudoPasswordAutoFillLabel")}
|
||||
defaultKey="sudoPasswordAutoFill"
|
||||
description={t("hosts.sudoPasswordAutoFillDesc")}
|
||||
>
|
||||
<FakeSwitch
|
||||
@@ -767,20 +741,17 @@ export function HostTerminalSettings({
|
||||
/>
|
||||
</SettingRow>
|
||||
<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")}
|
||||
<HostDefaultBadge settingKey="localEcho" />
|
||||
</label>
|
||||
<Select2
|
||||
value={form.localEcho}
|
||||
onChange={(e) =>
|
||||
setField(
|
||||
"localEcho",
|
||||
e.target.value as "default" | "off" | "auto" | "on",
|
||||
)
|
||||
setField("localEcho", e.target.value as "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"
|
||||
>
|
||||
<option value="default">{t("hosts.localEchoDefault")}</option>
|
||||
<option value="off">{t("hosts.localEchoOff")}</option>
|
||||
<option value="auto">{t("hosts.localEchoAuto")}</option>
|
||||
<option value="on">{t("hosts.localEchoOn")}</option>
|
||||
@@ -798,22 +769,20 @@ export function HostTerminalSettings({
|
||||
</p>
|
||||
</div>
|
||||
<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")}
|
||||
<HostDefaultBadge settingKey="linkClickBehavior" />
|
||||
</label>
|
||||
<Select2
|
||||
value={form.linkClickBehavior}
|
||||
onChange={(e) =>
|
||||
setField(
|
||||
"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"
|
||||
>
|
||||
<option value="default">
|
||||
{t("hosts.linkClickBehaviorDefault")}
|
||||
</option>
|
||||
<option value="confirm">
|
||||
{t("hosts.linkClickBehaviorConfirm")}
|
||||
</option>
|
||||
@@ -827,8 +796,9 @@ export function HostTerminalSettings({
|
||||
</div>
|
||||
<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">
|
||||
<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")}
|
||||
<HostDefaultBadge settingKey="fastScrollModifier" />
|
||||
</label>
|
||||
<Select2
|
||||
value={form.fastScrollModifier}
|
||||
@@ -849,8 +819,9 @@ export function HostTerminalSettings({
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<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")}
|
||||
<HostDefaultBadge settingKey="fastScrollSensitivity" />
|
||||
</label>
|
||||
<span className="text-[10px] text-muted-foreground tabular-nums">
|
||||
{form.fastScrollSensitivity}
|
||||
@@ -867,8 +838,9 @@ export function HostTerminalSettings({
|
||||
</div>
|
||||
{form.autoMosh && (
|
||||
<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")}
|
||||
<HostDefaultBadge settingKey="moshCommand" />
|
||||
</label>
|
||||
<Input
|
||||
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";
|
||||
import {
|
||||
readUserSettings,
|
||||
type NewHostTerminalDefaults,
|
||||
type TerminalUserSettings,
|
||||
} from "../shared/terminal-settings";
|
||||
|
||||
@@ -16,7 +15,6 @@ export interface TerminalClientSettings {
|
||||
sessionPersistence: boolean;
|
||||
commandHistoryEnabled: boolean;
|
||||
touchInput: TouchInputSettings;
|
||||
newHostDefaults?: NewHostTerminalDefaults;
|
||||
user: TerminalUserSettings;
|
||||
}
|
||||
|
||||
|
||||
@@ -105,11 +105,9 @@ export function hostTerminalValues(
|
||||
|
||||
export function resolveForHost(
|
||||
host: object | null | undefined,
|
||||
user: TerminalUserSettings = DEFAULT_USER_SETTINGS,
|
||||
): ResolvedTerminalConfig {
|
||||
const resolved = resolveTerminalSettings(
|
||||
readHostTerminalSettings(hostTerminalValues(host)),
|
||||
user.terminalDefaults,
|
||||
);
|
||||
return {
|
||||
...resolved,
|
||||
@@ -126,7 +124,7 @@ export function useTerminalSettings(
|
||||
const ownKey = own ? JSON.stringify(own) : "";
|
||||
return useMemo(() => {
|
||||
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
|
||||
}, [ownKey, client]);
|
||||
}
|
||||
@@ -47,7 +47,7 @@ export type BackspaceMode = "normal" | "control-h";
|
||||
export type HostLocalEcho = "default" | "off" | "auto" | "on";
|
||||
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 {
|
||||
theme: string;
|
||||
cursorBlink: boolean;
|
||||
@@ -64,7 +64,7 @@ export interface TerminalAppearance {
|
||||
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 {
|
||||
rightClickSelectsWord: boolean;
|
||||
macOptionIsMeta: boolean;
|
||||
@@ -165,8 +165,8 @@ export const DEFAULT_BEHAVIOR: TerminalBehavior = {
|
||||
useSSHTitle: false,
|
||||
syntaxHighlighting: true,
|
||||
syntaxHighlightingOptions: DEFAULT_SYNTAX_HIGHLIGHTING,
|
||||
linkClickBehavior: "default",
|
||||
localEcho: "default",
|
||||
linkClickBehavior: "confirm",
|
||||
localEcho: "auto",
|
||||
passwordPromptAutoFill: true,
|
||||
sudoPasswordAutoFill: false,
|
||||
};
|
||||
@@ -177,9 +177,6 @@ export const DEFAULT_HOST_TERMINAL_SETTINGS: HostTerminalSettings = {
|
||||
inheritAppearance: true,
|
||||
};
|
||||
|
||||
/** The user's terminal defaults: any appearance key, unset ones inherit. */
|
||||
export type TerminalDefaults = Partial<TerminalAppearance>;
|
||||
|
||||
export interface SavedCustomTheme {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -188,7 +185,6 @@ export interface SavedCustomTheme {
|
||||
|
||||
/** The ssh-terminal user settings the terminal reads. */
|
||||
export interface TerminalUserSettings {
|
||||
terminalDefaults: TerminalDefaults;
|
||||
customThemes: SavedCustomTheme[];
|
||||
commandAutocomplete: boolean;
|
||||
localEcho: "off" | "auto" | "on";
|
||||
@@ -196,33 +192,12 @@ export interface TerminalUserSettings {
|
||||
}
|
||||
|
||||
export const DEFAULT_USER_SETTINGS: TerminalUserSettings = {
|
||||
terminalDefaults: {},
|
||||
customThemes: [],
|
||||
commandAutocomplete: false,
|
||||
localEcho: "auto",
|
||||
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[]> = {
|
||||
cursorStyle: ["block", "underline", "bar"],
|
||||
bellStyle: ["none", "sound", "visual", "both"],
|
||||
@@ -357,7 +332,6 @@ export function readUserSettings(
|
||||
values: Record<string, unknown> | null | undefined,
|
||||
): TerminalUserSettings {
|
||||
const source = values ?? {};
|
||||
const defaults = asObject(source.terminalDefaults);
|
||||
const themes = Array.isArray(source.customThemes)
|
||||
? source.customThemes
|
||||
: typeof source.customThemes === "string"
|
||||
@@ -371,10 +345,6 @@ export function readUserSettings(
|
||||
})()
|
||||
: [];
|
||||
return {
|
||||
terminalDefaults: pickTerminalValues(
|
||||
defaults,
|
||||
APPEARANCE_KEYS,
|
||||
) as TerminalDefaults,
|
||||
customThemes: themes.filter(
|
||||
(theme): theme is SavedCustomTheme =>
|
||||
isObject(theme) &&
|
||||
@@ -393,35 +363,25 @@ export function readUserSettings(
|
||||
}
|
||||
|
||||
/**
|
||||
* What a terminal on a host runs with: the defaults, then the user's
|
||||
* terminal defaults, then the host's own look when it does not follow the
|
||||
* user, then the host's behavior.
|
||||
* What a terminal on a host runs with: the host's values over the built-in
|
||||
* ones. The host's values already follow its host defaults, and for a host
|
||||
* shared with this user its look follows theirs.
|
||||
*/
|
||||
export function resolveTerminalSettings(
|
||||
host: HostTerminalSettings | null | undefined,
|
||||
userDefaults: TerminalDefaults = {},
|
||||
): TerminalAppearance & TerminalBehavior {
|
||||
const own = host ?? DEFAULT_HOST_TERMINAL_SETTINGS;
|
||||
const appearance: TerminalAppearance = {
|
||||
...DEFAULT_APPEARANCE,
|
||||
...pickTerminalValues(userDefaults, APPEARANCE_KEYS),
|
||||
};
|
||||
if (!own.inheritAppearance) {
|
||||
for (const key of APPEARANCE_KEYS) {
|
||||
(appearance as unknown as Record<string, unknown>)[key] = own[key];
|
||||
}
|
||||
const resolved = {} as Record<string, unknown>;
|
||||
for (const key of APPEARANCE_KEYS) {
|
||||
resolved[key] = own[key] ?? DEFAULT_APPEARANCE[key];
|
||||
}
|
||||
const behavior = {} as TerminalBehavior;
|
||||
for (const key of BEHAVIOR_KEYS) {
|
||||
(behavior as unknown as Record<string, unknown>)[key] = own[key];
|
||||
}
|
||||
return { ...appearance, ...behavior };
|
||||
for (const key of BEHAVIOR_KEYS) resolved[key] = own[key];
|
||||
return resolved as unknown as TerminalAppearance & TerminalBehavior;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* the user, the way 2.8 stored it.
|
||||
* still read it (Termix-Mobile).
|
||||
*/
|
||||
export function terminalConfigFromHostSettings(
|
||||
values: Record<string, unknown>,
|
||||
@@ -429,9 +389,7 @@ export function terminalConfigFromHostSettings(
|
||||
const settings = readHostTerminalSettings(values);
|
||||
const config: Record<string, unknown> = {};
|
||||
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.localEcho === "default") delete config.localEcho;
|
||||
return config;
|
||||
|
||||
@@ -54,8 +54,9 @@ describe("hostPayloadLegacy", () => {
|
||||
fontSize: 20,
|
||||
}) as { terminalConfig: Record<string, unknown> };
|
||||
expect(terminalConfig.autoTmux).toBe(true);
|
||||
expect(terminalConfig).not.toHaveProperty("fontSize");
|
||||
expect(terminalConfig).not.toHaveProperty("localEcho");
|
||||
// The values already follow the host defaults, so the look is included.
|
||||
expect(terminalConfig.fontSize).toBe(20);
|
||||
expect(terminalConfig.localEcho).toBe("auto");
|
||||
});
|
||||
|
||||
it("includes the look of a host that has its own", () => {
|
||||
|
||||
@@ -146,26 +146,20 @@ describe("terminal settings routes", () => {
|
||||
authType: "password",
|
||||
});
|
||||
|
||||
it("hands every user their own terminal settings and the new-host defaults", async () => {
|
||||
server = await startServer({
|
||||
settings: { newHostFontSize: 20, newHostTheme: "nord" },
|
||||
});
|
||||
it("hands every user their own terminal settings", async () => {
|
||||
server = await startServer();
|
||||
await server.mock.ctx.settings.setUser(
|
||||
"user-1",
|
||||
"commandAutocomplete",
|
||||
true,
|
||||
);
|
||||
await server.mock.ctx.settings.setUser("user-1", "terminalDefaults", {
|
||||
fontSize: 18,
|
||||
});
|
||||
const { body } = await server.request("GET", "/client-settings");
|
||||
expect(body.user).toMatchObject({
|
||||
commandAutocomplete: true,
|
||||
terminalDefaults: { fontSize: 18 },
|
||||
localEcho: "auto",
|
||||
linkClickBehavior: "confirm",
|
||||
});
|
||||
expect(body.newHostDefaults).toMatchObject({ fontSize: 20, theme: "nord" });
|
||||
expect(body).not.toHaveProperty("newHostDefaults");
|
||||
const other = await server.request("GET", "/client-settings", {
|
||||
user: "user-2",
|
||||
});
|
||||
|
||||
@@ -30,19 +30,25 @@ describe("hostSettingsFromTerminalConfig", () => {
|
||||
});
|
||||
|
||||
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 resolved = resolveTerminalSettings(host, { fontSize: 18 });
|
||||
expect(resolved.fontSize).toBe(18);
|
||||
const resolved = resolveTerminalSettings(host);
|
||||
expect(resolved.fontSize).toBe(30);
|
||||
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({
|
||||
inheritAppearance: false,
|
||||
fontSize: 30,
|
||||
inheritAppearance: true,
|
||||
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", () => {
|
||||
@@ -62,7 +68,6 @@ describe("readUserSettings", () => {
|
||||
linkClickBehavior: "direct",
|
||||
}),
|
||||
).toEqual({
|
||||
terminalDefaults: { fontSize: 16 },
|
||||
customThemes: [{ id: "a", name: "A", colors: {} }],
|
||||
commandAutocomplete: false,
|
||||
localEcho: "auto",
|
||||
|
||||
@@ -116,11 +116,11 @@ describe(`${manifest.id} activate`, () => {
|
||||
it("registers its settings components", async () => {
|
||||
rendered = await renderWithApp(plugin, { manifest, locales });
|
||||
expect(rendered.registered.settingsComponents()).toEqual(
|
||||
expect.arrayContaining([
|
||||
"terminalDefaults",
|
||||
"newHostTheme",
|
||||
"newHostFontFamily",
|
||||
]),
|
||||
expect.arrayContaining(["touchInput", "imageStorageTest"]),
|
||||
);
|
||||
// Terminal defaults are host defaults now, set in the host editor.
|
||||
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">
|
||||
<SettingRow
|
||||
label={t("hosts.enableTmuxMonitor")}
|
||||
defaultKey="enableTmuxMonitor"
|
||||
description={
|
||||
<>
|
||||
{t("hosts.enableTmuxMonitorDesc")}{" "}
|
||||
|
||||
@@ -55,6 +55,7 @@ export function activate(app: TermixApp): void {
|
||||
titleKey: "hosts.tabTmuxMonitor",
|
||||
icon: Layers,
|
||||
order: 45,
|
||||
defaults: true,
|
||||
component: HostTmuxMonitorSection,
|
||||
});
|
||||
}
|
||||
@@ -79,7 +79,8 @@
|
||||
"key": "tunnelConnections",
|
||||
"type": "custom",
|
||||
"component": "tunnelConnections",
|
||||
"requires": "enableTunnel"
|
||||
"requires": "enableTunnel",
|
||||
"defaultable": false
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -62,7 +62,8 @@
|
||||
"type": "number",
|
||||
"labelKey": "settings.host.profileId.label",
|
||||
"hidden": true,
|
||||
"ownerOnly": true
|
||||
"ownerOnly": true,
|
||||
"defaultLevels": ["user", "folder"]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -46,7 +46,8 @@
|
||||
"type": "string",
|
||||
"labelKey": "settings.host.macAddress.label",
|
||||
"descriptionKey": "settings.host.macAddress.description",
|
||||
"placeholderKey": "settings.host.macAddress.placeholder"
|
||||
"placeholderKey": "settings.host.macAddress.placeholder",
|
||||
"defaultable": false
|
||||
},
|
||||
{
|
||||
"key": "broadcastAddress",
|
||||
|
||||
@@ -47,7 +47,8 @@
|
||||
"key": "webUiConfig",
|
||||
"type": "json",
|
||||
"labelKey": "hosts.webUiEndpointsSection",
|
||||
"requires": "enableWebUi"
|
||||
"requires": "enableWebUi",
|
||||
"defaultable": false
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -93,6 +93,8 @@ const ALLOWED = [
|
||||
"GridColSpan",
|
||||
"GridLayout",
|
||||
"GridSlot",
|
||||
"HostDefaultBadge",
|
||||
"HostDefaultField",
|
||||
"HostFeatureFields",
|
||||
"HostFeatureFieldsProps",
|
||||
"HostKeyVerificationDialog",
|
||||
@@ -200,6 +202,7 @@ const ALLOWED = [
|
||||
"useAppTheme",
|
||||
"useConfirmation",
|
||||
"useConnectionLog",
|
||||
"useIsDefaultsEditor",
|
||||
"useIsMobile",
|
||||
"useOptionalConnectionLog",
|
||||
"useTabs",
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
import { getErrorMessage } from "../utils/error-message.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 http from "http";
|
||||
import https from "https";
|
||||
@@ -951,6 +960,7 @@ app.post("/database/export", authenticateJWT, async (req, res) => {
|
||||
}
|
||||
|
||||
writeSettingsToExportDatabase(exportDb, await getExportableSettings());
|
||||
await writeHostDefaultsToExport(exportDb, userId);
|
||||
} finally {
|
||||
exportDb.close();
|
||||
}
|
||||
@@ -1197,6 +1207,15 @@ app.post(
|
||||
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(
|
||||
userId,
|
||||
hostData,
|
||||
@@ -1205,6 +1224,7 @@ app.post(
|
||||
Number(created.id),
|
||||
importedHostPluginSettings(importDb, host),
|
||||
);
|
||||
await applyDefaultsAfterHostWrite(Number(created.id));
|
||||
await importHostProtocolLogins(
|
||||
importDb,
|
||||
host,
|
||||
@@ -1285,6 +1305,12 @@ app.post(
|
||||
result.summary.skippedItems += pluginRows.skipped;
|
||||
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);
|
||||
if (targetUser?.isAdmin) {
|
||||
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 {
|
||||
if (getRawSettingValue("rbac_permission_levels_v2") === null) {
|
||||
sqlite.exec(
|
||||
|
||||
@@ -237,6 +237,11 @@ export const hosts = mysqlTable(
|
||||
// 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.
|
||||
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")
|
||||
.notNull()
|
||||
@@ -390,6 +395,41 @@ export const sshFolders = mysqlTable(
|
||||
(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(
|
||||
"recent_activity",
|
||||
{
|
||||
|
||||
@@ -237,6 +237,11 @@ export const hosts = pgTable(
|
||||
// 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.
|
||||
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")
|
||||
.notNull()
|
||||
@@ -390,6 +395,41 @@ export const sshFolders = pgTable(
|
||||
(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(
|
||||
"recent_activity",
|
||||
{
|
||||
|
||||
@@ -229,6 +229,11 @@ export const hosts = sqliteTable(
|
||||
// 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.
|
||||
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")
|
||||
.notNull()
|
||||
@@ -382,6 +387,41 @@ export const sshFolders = sqliteTable(
|
||||
(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(
|
||||
"recent_activity",
|
||||
{
|
||||
|
||||
@@ -16,6 +16,7 @@ import { OpenTabRepository } from "./open-tab-repository.js";
|
||||
import { PluginRepository } from "./plugin-repository.js";
|
||||
import { PluginStorageRepository } from "./plugin-storage-repository.js";
|
||||
import { PluginSettingsRepository } from "./plugin-settings-repository.js";
|
||||
import { HostDefaultsRepository } from "./host-defaults-repository.js";
|
||||
import { PluginMigrationRepository } from "./plugin-migration-repository.js";
|
||||
import { PluginPermissionGrantRepository } from "./plugin-permission-grant-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 {
|
||||
return new PluginSettingsRepository(
|
||||
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,
|
||||
} from "./host-import-order.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 {
|
||||
keepUsableProtocolCredentials,
|
||||
readProtocolAuthPayload,
|
||||
@@ -16,6 +26,7 @@ import {
|
||||
createCurrentCredentialRepository,
|
||||
createCurrentHostRepository,
|
||||
createCurrentHostResolutionRepository,
|
||||
createCurrentHostDefaultsRepository,
|
||||
} from "../repositories/factory.js";
|
||||
import { validateParentHostId } from "./host-parent-validation.js";
|
||||
import {
|
||||
@@ -203,6 +214,20 @@ export function registerHostBulkRoutes(
|
||||
* description: Plugin id to on or off, for each plugin that declares a host enable switch.
|
||||
* additionalProperties:
|
||||
* 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:
|
||||
* 200:
|
||||
* description: Bulk update completed.
|
||||
@@ -309,6 +334,14 @@ export function registerHostBulkRoutes(
|
||||
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
|
||||
// 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({
|
||||
updated: ownedIds.length,
|
||||
failed: unauthorizedIds.length,
|
||||
@@ -727,6 +763,12 @@ export function registerHostBulkRoutes(
|
||||
|
||||
const lookupKey = `${hostData.ip}:${hostData.port}:${hostData.username}`;
|
||||
const existing = existingHostMap?.get(lookupKey);
|
||||
await withHostDefaults(
|
||||
userId,
|
||||
existing?.id ?? null,
|
||||
sshDataObj,
|
||||
hostData as Record<string, unknown>,
|
||||
);
|
||||
|
||||
let savedHostId: number;
|
||||
if (existing) {
|
||||
@@ -770,6 +812,7 @@ export function registerHostBulkRoutes(
|
||||
savedHostId,
|
||||
hostData as Record<string, unknown>,
|
||||
);
|
||||
await applyDefaultsAfterHostWrite(savedHostId);
|
||||
} catch (error) {
|
||||
results.failed++;
|
||||
results.errors.push(`Host ${i + 1}: ${getErrorMessage(error)}`);
|
||||
@@ -947,6 +990,12 @@ export function registerHostBulkRoutes(
|
||||
|
||||
const lookupKey = `${hostData.ip}:${hostData.port}:${hostData.username}`;
|
||||
const existing = existingHostMap?.get(lookupKey);
|
||||
await withHostDefaults(
|
||||
userId,
|
||||
existing?.id ?? null,
|
||||
sshDataObj,
|
||||
hostData as unknown as Record<string, unknown>,
|
||||
);
|
||||
|
||||
if (existing) {
|
||||
await hostRepository.updateEncryptedForUser(
|
||||
@@ -954,10 +1003,15 @@ export function registerHostBulkRoutes(
|
||||
existing.id,
|
||||
sshDataObj,
|
||||
);
|
||||
await applyDefaultsAfterHostWrite(existing.id);
|
||||
results.updated++;
|
||||
} else {
|
||||
sshDataObj.createdAt = new Date().toISOString();
|
||||
await hostRepository.createEncryptedForUser(userId, sshDataObj);
|
||||
const saved = await hostRepository.createEncryptedForUser(
|
||||
userId,
|
||||
sshDataObj,
|
||||
);
|
||||
await applyDefaultsAfterHostWrite(saved.id);
|
||||
results.success++;
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -1022,3 +1076,49 @@ function listKnownAuthTypes(): Set<string> {
|
||||
...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,
|
||||
} from "../repositories/factory.js";
|
||||
import { isNonEmptyString } from "./host-normalizers.js";
|
||||
import { recompute } from "../../hosts/defaults/recompute.js";
|
||||
import { setFolderCredentialDefault } from "../../hosts/defaults/service.js";
|
||||
|
||||
type HostFolderRoutesDeps = {
|
||||
authenticateJWT: RequestHandler;
|
||||
@@ -92,6 +94,8 @@ export function registerHostFolderRoutes(
|
||||
oldName,
|
||||
newName,
|
||||
);
|
||||
// Subfolders change parents with a rename, so their chains do too.
|
||||
void recompute({ userIds: [userId] }).catch(() => {});
|
||||
|
||||
res.json({
|
||||
message: "Folder renamed successfully",
|
||||
@@ -243,6 +247,14 @@ export function registerHostFolderRoutes(
|
||||
localOnly,
|
||||
);
|
||||
}
|
||||
if (normalizedCredentialId !== undefined) {
|
||||
await setFolderCredentialDefault(
|
||||
userId,
|
||||
folder.id,
|
||||
normalizedCredentialId,
|
||||
);
|
||||
void recompute({ userIds: [userId] }).catch(() => {});
|
||||
}
|
||||
|
||||
if (!created) {
|
||||
databaseLogger.info("Updating SSH folder", {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { parseDefaultOverrides } from "../../../types/host-defaults.js";
|
||||
import {
|
||||
parseSshOptions,
|
||||
type HostSshOptions,
|
||||
@@ -481,5 +482,6 @@ export function transformHostResponse(
|
||||
portKnockSequence: host.portKnockSequence
|
||||
? 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(
|
||||
host: Record<string, unknown>,
|
||||
viewerId?: string,
|
||||
): Promise<Record<string, unknown>> {
|
||||
const hostId = Number(host.id);
|
||||
if (!Number.isInteger(hostId)) return host;
|
||||
|
||||
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);
|
||||
if (!values) return host;
|
||||
const result: Record<string, unknown> = {
|
||||
@@ -391,5 +400,8 @@ export async function setHostPluginEnabled(
|
||||
await setSetting(manifest, "host", hostId, enableKey, enabled);
|
||||
await touchHost(hostId);
|
||||
}
|
||||
const { changeHostOverrides } =
|
||||
await import("../../hosts/defaults/overrides.js");
|
||||
await changeHostOverrides(hostIds, { own: [[pluginId, enableKey]] });
|
||||
return true;
|
||||
}
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
createCurrentHostRepository,
|
||||
createCurrentUserRepository,
|
||||
createCurrentSharedHostAuthOverrideRepository,
|
||||
createCurrentHostDefaultsRepository,
|
||||
} from "../repositories/factory.js";
|
||||
import {
|
||||
applyHostKeyTypeUpdate,
|
||||
@@ -51,6 +52,7 @@ import { validateParentHostId } from "./host-parent-validation.js";
|
||||
import { registerHostFolderRoutes } from "./host-folder-routes.js";
|
||||
import { registerHostNetworkRoutes } from "./host-network-routes.js";
|
||||
import { registerHostBulkRoutes } from "./host-bulk-routes.js";
|
||||
import { registerHostDefaultsRoutes } from "./host-defaults-routes.js";
|
||||
import { registerHostStatusRoutes } from "./host-status-routes.js";
|
||||
import {
|
||||
applyHostEnrollmentDefaults,
|
||||
@@ -71,6 +73,11 @@ import {
|
||||
} from "../../utils/shared-host-auth-resolver.js";
|
||||
import { rejectSharedCopyWrites } from "../../sync/shared-copy-guard.js";
|
||||
import { sshOptionsForWrite } from "../../hosts/ssh-options.js";
|
||||
import {
|
||||
applyDefaultsAfterHostWrite,
|
||||
applyHostDefaultsToWrite,
|
||||
} from "../../hosts/defaults/index.js";
|
||||
import { applyPersonalHostValues } from "../../hosts/defaults/personal.js";
|
||||
import {
|
||||
applyProtocolAuthPlan,
|
||||
attachProtocolAuth,
|
||||
@@ -385,6 +392,14 @@ router.post(
|
||||
}
|
||||
|
||||
try {
|
||||
sshDataObj.defaultOverrides = JSON.stringify(
|
||||
await applyHostDefaultsToWrite({
|
||||
ownerId: userId,
|
||||
hostId: null,
|
||||
columns: sshDataObj,
|
||||
body: hostData,
|
||||
}),
|
||||
);
|
||||
const result = await createCurrentHostRepository().createEncryptedForUser(
|
||||
userId,
|
||||
sshDataObj,
|
||||
@@ -402,6 +417,7 @@ router.post(
|
||||
}
|
||||
|
||||
const createdHost = result;
|
||||
await applyDefaultsAfterHostWrite(createdHost.id as number);
|
||||
if (protocolAuthPatch) {
|
||||
await writeProtocolAuth(
|
||||
userId,
|
||||
@@ -1072,11 +1088,37 @@ router.put(
|
||||
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(
|
||||
ownerId,
|
||||
Number(hostId),
|
||||
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) {
|
||||
await applyProtocolAuthPlan(ownerId, Number(hostId), protocolAuthPlan);
|
||||
}
|
||||
@@ -1296,14 +1338,15 @@ router.get(
|
||||
|
||||
// After sanitizing: the connect-level projection reduces a shared host
|
||||
// to an allowlist, which would drop this again. One query for the list.
|
||||
attachHostPluginSettings(
|
||||
sanitized,
|
||||
await loadHostPluginSettings(
|
||||
sanitized
|
||||
.map((host) => Number(host.id))
|
||||
.filter((id) => Number.isInteger(id)),
|
||||
),
|
||||
const pluginSettingsByHost = await loadHostPluginSettings(
|
||||
sanitized
|
||||
.map((host) => Number(host.id))
|
||||
.filter((id) => Number.isInteger(id)),
|
||||
);
|
||||
await applyPersonalHostValues(pluginSettingsByHost, userId).catch(
|
||||
() => {},
|
||||
);
|
||||
attachHostPluginSettings(sanitized, pluginSettingsByHost);
|
||||
|
||||
res.json(sanitized);
|
||||
} catch (err) {
|
||||
@@ -1432,6 +1475,7 @@ router.get(
|
||||
resolvedSharedResult,
|
||||
accessInfo.permissionLevel,
|
||||
),
|
||||
userId,
|
||||
),
|
||||
);
|
||||
} catch (err) {
|
||||
@@ -2301,6 +2345,15 @@ registerHostNetworkRoutes(router, {
|
||||
requireDataAccess,
|
||||
});
|
||||
|
||||
registerHostDefaultsRoutes(router, {
|
||||
authenticateJWT,
|
||||
requireEditPermission: permissionManager.requirePermission("hosts.edit"),
|
||||
requireDataAccess,
|
||||
requireAdminSettings: permissionManager.requirePermission(
|
||||
"admin.settings.manage",
|
||||
),
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
||||
/** Seconds between status checks, or null to follow the global setting. */
|
||||
|
||||
@@ -14,7 +14,6 @@ import {
|
||||
} from "../repositories/factory.js";
|
||||
import { getPluginRuntime } from "../../plugins/index.js";
|
||||
import { describePluginFrontend } from "../../plugins/assets.js";
|
||||
import { getSetting } from "../../plugins/settings.js";
|
||||
import { invalidatePluginPermissionCache } from "../../plugins/permissions.js";
|
||||
import { getPluginPublicHttpRoutes } from "../../plugins/http.js";
|
||||
import { getPluginPublicWsRoutes } from "../../plugins/ws.js";
|
||||
@@ -254,7 +253,7 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => {
|
||||
dependencies?: Record<string, string>;
|
||||
optionalDependencies?: Record<string, string>;
|
||||
};
|
||||
contributes = await withHostFieldDefaults(manifest as PluginManifest);
|
||||
contributes = manifest?.contributes ?? null;
|
||||
capabilities = Array.isArray(manifest?.capabilities)
|
||||
? (manifest.capabilities as string[])
|
||||
: [];
|
||||
@@ -1189,7 +1188,7 @@ router.get(
|
||||
* /plugins/{id}/settings/host/{hostId}:
|
||||
* put:
|
||||
* 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:
|
||||
* - Plugins
|
||||
* parameters:
|
||||
@@ -1253,12 +1252,19 @@ router.put(
|
||||
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(
|
||||
userId,
|
||||
manifest,
|
||||
"host",
|
||||
String(hostId),
|
||||
req.body,
|
||||
written,
|
||||
{ isHostOwner: access.isOwner === true },
|
||||
);
|
||||
if (Object.keys(errors).length > 0) {
|
||||
@@ -1267,6 +1273,14 @@ router.put(
|
||||
}
|
||||
const { touchHost } = await import("./host-plugin-settings.js");
|
||||
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, {
|
||||
redactSecrets: true,
|
||||
@@ -1284,37 +1298,3 @@ router.put(
|
||||
);
|
||||
|
||||
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";
|
||||
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(
|
||||
userId: string | undefined,
|
||||
): Promise<UserRecord | null> {
|
||||
@@ -559,95 +522,4 @@ export function registerUserSettingsRoutes(
|
||||
"update_notification_private_endpoints",
|
||||
"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 {
|
||||
list: async () => {
|
||||
await requireRead();
|
||||
@@ -467,12 +482,25 @@ export function createPluginHosts({ manifest, bag, audit }: Deps): PluginHosts {
|
||||
const writes = await checkSettings(settings);
|
||||
const { createCurrentHostRepository } =
|
||||
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 =
|
||||
await createCurrentHostRepository().createEncryptedForUser(userId, {
|
||||
...row,
|
||||
await createCurrentHostRepository().createEncryptedForUser(
|
||||
userId,
|
||||
});
|
||||
columns,
|
||||
);
|
||||
await applyDefaultsAfterHostWrite(created.id);
|
||||
await writeSettings(created.id, writes);
|
||||
await markSettingsOwn(created.id, writes);
|
||||
await audit("hosts_create", `host ${created.id}`, { success: true });
|
||||
return (
|
||||
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 { createCurrentHostRepository } =
|
||||
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 =
|
||||
await createCurrentHostRepository().updateEncryptedForUser(
|
||||
userId,
|
||||
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}`, {
|
||||
success: updated !== null,
|
||||
});
|
||||
|
||||
@@ -816,7 +816,40 @@ export function createPluginContext(
|
||||
|
||||
getHost: (hostId, key) =>
|
||||
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(
|
||||
manifest,
|
||||
"host",
|
||||
@@ -825,6 +858,7 @@ export function createPluginContext(
|
||||
value,
|
||||
);
|
||||
if (error) throw new Error(error);
|
||||
await changeHostOverrides([Number(hostId)], { own: [[pluginId, 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) ?? []) {
|
||||
try {
|
||||
listener(value);
|
||||
@@ -92,7 +96,10 @@ function notify(pluginId: string, key: string, value: unknown): void {
|
||||
|
||||
export type SettingsValidator = (
|
||||
values: Record<string, unknown>,
|
||||
context: { hostId?: number },
|
||||
context: {
|
||||
hostId?: number;
|
||||
defaults?: { level: "admin" | "user" | "folder" };
|
||||
},
|
||||
) =>
|
||||
| Record<string, string>
|
||||
| void
|
||||
@@ -131,6 +138,7 @@ export async function validateSettingsSave(
|
||||
scope: PluginSettingsScope,
|
||||
scopeId: string | null,
|
||||
values: Record<string, unknown>,
|
||||
defaults?: { level: "admin" | "user" | "folder" },
|
||||
): Promise<Record<string, string>> {
|
||||
const errors: Record<string, string> = {};
|
||||
for (const entry of validators.get(pluginId) ?? []) {
|
||||
@@ -138,6 +146,7 @@ export async function validateSettingsSave(
|
||||
try {
|
||||
const result = await entry.run(values, {
|
||||
hostId: scope === "host" && scopeId ? Number(scopeId) : undefined,
|
||||
...(defaults ? { defaults } : {}),
|
||||
});
|
||||
Object.assign(errors, result ?? {});
|
||||
} catch (error) {
|
||||
@@ -299,7 +308,7 @@ export async function setSetting(
|
||||
shouldEncrypt,
|
||||
);
|
||||
|
||||
notify(manifest.id, key, coerced);
|
||||
notifySettingChange(manifest.id, key, coerced);
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ import { FieldCrypto } from "../utils/field-crypto.js";
|
||||
import { syncLogger } from "../utils/logger.js";
|
||||
import { getPluginRuntime } from "../plugins/index.js";
|
||||
import { singletonSyncId } from "./wire.js";
|
||||
import { parseDefaultOverrides } from "../../types/host-defaults.js";
|
||||
import {
|
||||
exportHostPluginSettings,
|
||||
importHostPluginSettings,
|
||||
@@ -31,6 +32,14 @@ import {
|
||||
exportProtocolLogins,
|
||||
importProtocolLogins,
|
||||
} from "./host-protocol-auth.js";
|
||||
import {
|
||||
coversHostDefault,
|
||||
eraseHostDefault,
|
||||
loadAdminDefaults,
|
||||
loadHostDefaults,
|
||||
writeAdminDefaults,
|
||||
writeHostDefault,
|
||||
} from "./host-defaults.js";
|
||||
|
||||
const CREDENTIAL_REFERENCE = {
|
||||
field: "credentialId",
|
||||
@@ -167,9 +176,22 @@ export function registerCoreSyncEntities(): void {
|
||||
}),
|
||||
afterWrite: async ({ id, userId, wire, resolveId }) => {
|
||||
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 dropInvalidParent(userId, id);
|
||||
if (onServer) {
|
||||
const { applyDefaultsAfterHostWrite } =
|
||||
await import("../hosts/defaults/index.js");
|
||||
await applyDefaultsAfterHostWrite(id);
|
||||
}
|
||||
const { SharedHostSecretsManager } =
|
||||
await import("../utils/shared-host-secrets-manager.js");
|
||||
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.
|
||||
|
||||
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,
|
||||
} from "../plugins/settings.js";
|
||||
import { databaseLogger } from "../utils/logger.js";
|
||||
import type { DefaultOverrides } from "../../types/host-defaults.js";
|
||||
import {
|
||||
hostSettingsPlugins,
|
||||
type HostPluginSettings,
|
||||
@@ -50,10 +51,16 @@ export async function exportHostPluginSettings(
|
||||
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(
|
||||
hostId: number,
|
||||
carried: unknown,
|
||||
only?: DefaultOverrides | null,
|
||||
): Promise<void> {
|
||||
if (!carried || typeof carried !== "object") return;
|
||||
const byPlugin = carried as HostPluginSettings;
|
||||
@@ -63,8 +70,10 @@ export async function importHostPluginSettings(
|
||||
const hook = consume<PluginHostSettingsSync>(
|
||||
`${manifest.id}.hostSettingsSync`,
|
||||
);
|
||||
const ownKeys = only?.[manifest.id];
|
||||
for (const field of declaredFields(manifest, "host")) {
|
||||
if (field.type === "secret" || !(field.key in own)) continue;
|
||||
if (ownKeys && !ownKeys.includes(field.key)) continue;
|
||||
try {
|
||||
const value = hook?.importValue
|
||||
? 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,
|
||||
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", () => ({
|
||||
createCurrentHostDefaultsRepository: () => ({
|
||||
listHosts: async () => [],
|
||||
}),
|
||||
createCurrentCredentialRepository: () => ({
|
||||
listDecryptedByUserId: async () => [],
|
||||
}),
|
||||
|
||||
@@ -52,6 +52,9 @@ vi.mock("../../../database/repositories/factory.js", () => ({
|
||||
createCurrentPluginSettingsRepository: () => ({ getAllForScopeIds, set }),
|
||||
}));
|
||||
|
||||
vi.mock("../../../hosts/defaults/overrides.js", () => ({
|
||||
changeHostOverrides: async () => {},
|
||||
}));
|
||||
vi.mock("../../../plugins/index.js", () => ({
|
||||
getPluginRuntime: () => ({ loader: { list: () => loaded } }),
|
||||
}));
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* The admin host defaults. The terminal half moved to the ssh-terminal
|
||||
* plugin's new-host settings; its keys stay in the stored row for the boot
|
||||
* copy and a downgrade, but are never served or overwritten here.
|
||||
* The old admin host defaults routes are gone: host defaults live under
|
||||
* /host/defaults now. The GET used to hand the admin's proxy password to any
|
||||
* signed-in user.
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
@@ -65,40 +65,18 @@ beforeEach(() => {
|
||||
});
|
||||
|
||||
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({
|
||||
useSocks5: true,
|
||||
fontSize: 18,
|
||||
autoTmux: true,
|
||||
socks5Password: "secret",
|
||||
});
|
||||
const response = await request(app()).get("/users/host-defaults");
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual({ useSocks5: true });
|
||||
});
|
||||
|
||||
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())
|
||||
const read = await request(app()).get("/users/host-defaults");
|
||||
expect(read.status).toBe(404);
|
||||
expect(JSON.stringify(read.body)).not.toContain("secret");
|
||||
const write = await request(app())
|
||||
.patch("/users/host-defaults")
|
||||
.send({ useSocks5: false, statusCheckEnabled: false, autoTmux: true });
|
||||
expect(response.status).toBe(200);
|
||||
expect(JSON.parse(state.settings.host_defaults)).toEqual({
|
||||
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);
|
||||
.send({ useSocks5: false });
|
||||
expect(write.status).toBe(404);
|
||||
expect(JSON.parse(state.settings.host_defaults).useSocks5).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -450,6 +450,28 @@ CREATE INDEX `idx_host_protocol_auth_credential` ON `host_protocol_auth` (`crede
|
||||
-- install loses what it stored.
|
||||
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 ====
|
||||
-- workspaces 0001: adopt_user_workspaces
|
||||
-- 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.
|
||||
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 ====
|
||||
-- workspaces 0001: adopt_user_workspaces
|
||||
-- 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