mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 06:35:24 +00:00
feat: redesign user host defaults system
This commit is contained in:
1 parent
3028ab1176
commit
a4a4d4f27b
144 files changed
+24609
-2555
No files matched your search
@@ -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: [] });
|
||||
});
|
||||
});
|
||||
@@ -65,6 +65,9 @@ vi.mock("../../utils/permission-manager.js", () => ({
|
||||
},
|
||||
}));
|
||||
vi.mock("../../database/repositories/factory.js", () => ({
|
||||
createCurrentHostDefaultsRepository: () => ({
|
||||
listHosts: async () => [],
|
||||
}),
|
||||
createCurrentHostResolutionRepository: () => ({
|
||||
findHostsByUserId: async () => h.ownedHosts,
|
||||
listHostRowsForAccessList: async () => h.sharedRows,
|
||||
@@ -185,6 +188,14 @@ vi.mock("../../utils/shared-host-secrets-manager.js", () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../hosts/defaults/index.js", () => ({
|
||||
applyHostDefaultsToWrite: async () => ({ core: [] }),
|
||||
applyDefaultsAfterHostWrite: async () => {},
|
||||
}));
|
||||
vi.mock("../../hosts/defaults/overrides.js", () => ({
|
||||
changeHostOverrides: async () => {},
|
||||
}));
|
||||
|
||||
vi.mock("../../hosts/delete-host.js", () => ({
|
||||
deleteOwnedHost: async (userId: string, hostId: number) => {
|
||||
h.deleted.push({ userId, hostId });
|
||||
|
||||
@@ -77,7 +77,14 @@ vi.mock("../../../utils/permission-manager.js", () => ({
|
||||
}),
|
||||
},
|
||||
}));
|
||||
vi.mock("../../../hosts/defaults/index.js", () => ({
|
||||
applyHostDefaultsToWrite: async () => ({ core: [] }),
|
||||
applyDefaultsAfterHostWrite: async () => {},
|
||||
}));
|
||||
vi.mock("../../../database/repositories/factory.js", () => ({
|
||||
createCurrentHostDefaultsRepository: () => ({
|
||||
listHosts: async () => [],
|
||||
}),
|
||||
createCurrentHostRepository: () => ({
|
||||
createEncryptedForUser: async () => secretHost(1),
|
||||
updateEncryptedForUser: async () => secretHost(1),
|
||||
|
||||
@@ -58,6 +58,10 @@ const UNGATED: Record<string, string> = {
|
||||
"settings.getUser": "the plugin's own user settings",
|
||||
"settings.setUser": "the plugin's own user settings",
|
||||
"settings.getHost": "the plugin's own host settings",
|
||||
"settings.getHostFor":
|
||||
"the plugin's own host settings, as one user sees them",
|
||||
"settings.getHostDefault":
|
||||
"the plugin's own host settings, as a user's defaults",
|
||||
"settings.setHost": "the plugin's own host settings",
|
||||
"settings.getAll": "the plugin's own settings",
|
||||
"settings.onChange": "the plugin's own settings",
|
||||
|
||||
@@ -418,14 +418,62 @@ describe("host field options", () => {
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("rejects a defaultFrom that names a secret or unknown admin field", () => {
|
||||
for (const defaultFrom of ["token", "missing"]) {
|
||||
expect(
|
||||
validateManifest(
|
||||
host([{ key: "mode", type: "boolean", labelKey: "k", defaultFrom }]),
|
||||
).join(),
|
||||
).toMatch(/defaultFrom/);
|
||||
}
|
||||
it("still accepts an old defaultFrom, but only as a string", () => {
|
||||
expect(
|
||||
validateManifest(
|
||||
host([
|
||||
{
|
||||
key: "mode",
|
||||
type: "boolean",
|
||||
labelKey: "k",
|
||||
defaultFrom: "missing",
|
||||
},
|
||||
]),
|
||||
),
|
||||
).toEqual([]);
|
||||
expect(
|
||||
validateManifest(
|
||||
host([{ key: "mode", type: "boolean", labelKey: "k", defaultFrom: 1 }]),
|
||||
).join(),
|
||||
).toMatch(/defaultFrom/);
|
||||
});
|
||||
|
||||
it("accepts defaultable, defaultLevels and personal on host fields", () => {
|
||||
expect(
|
||||
validateManifest(
|
||||
host([
|
||||
{
|
||||
key: "mode",
|
||||
type: "boolean",
|
||||
labelKey: "k",
|
||||
defaultable: false,
|
||||
defaultLevels: ["user", "folder"],
|
||||
personal: true,
|
||||
},
|
||||
]),
|
||||
),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("rejects bad default options and a defaultable secret", () => {
|
||||
const errors = (field: Record<string, unknown>) =>
|
||||
validateManifest(
|
||||
host([{ key: "mode", type: "boolean", labelKey: "k", ...field }]),
|
||||
).join();
|
||||
expect(errors({ defaultable: "yes" })).toMatch(/defaultable/);
|
||||
expect(errors({ personal: 1 })).toMatch(/personal/);
|
||||
expect(errors({ defaultLevels: [] })).toMatch(/defaultLevels/);
|
||||
expect(errors({ defaultLevels: ["host"] })).toMatch(/defaultLevels/);
|
||||
expect(errors({ type: "secret", defaultable: true })).toMatch(
|
||||
/cannot be defaultable/,
|
||||
);
|
||||
expect(
|
||||
validateManifest(
|
||||
withSettings({
|
||||
user: [{ key: "a", type: "boolean", labelKey: "k", personal: true }],
|
||||
}),
|
||||
).join(),
|
||||
).toMatch(/only valid on host fields/);
|
||||
});
|
||||
|
||||
it("rejects an unknown shareRead level and host options on admin fields", () => {
|
||||
|
||||
@@ -24,6 +24,9 @@ vi.mock("../../utils/crypto-migration/raw-rows.js", () => ({
|
||||
runStatement: vi.fn(async () => {}),
|
||||
}));
|
||||
|
||||
vi.mock("../../hosts/defaults/overrides.js", () => ({
|
||||
changeHostOverrides: async () => {},
|
||||
}));
|
||||
vi.mock("../../utils/logger.js", () => {
|
||||
const logger = {
|
||||
debug: vi.fn(),
|
||||
|
||||
@@ -127,11 +127,13 @@ describe("core sync entities", () => {
|
||||
it("registers what core syncs, under the wire names 2.8 used", () => {
|
||||
expect(listEntityTypes()).toEqual([
|
||||
"accountProfile",
|
||||
"hostDefaultsAdmin",
|
||||
"sshCredentials",
|
||||
"sharedCredentials",
|
||||
"sshFolders",
|
||||
"hosts",
|
||||
"sharedHosts",
|
||||
"hostDefaults",
|
||||
"userPreferences",
|
||||
"pluginUserSettings",
|
||||
]);
|
||||
@@ -168,7 +170,11 @@ describe("core sync entities", () => {
|
||||
.filter((entity) => entity.singleton)
|
||||
.map((entity) => entity.type);
|
||||
|
||||
expect(singletons.sort()).toEqual(["accountProfile", "userPreferences"]);
|
||||
expect(singletons.sort()).toEqual([
|
||||
"accountProfile",
|
||||
"hostDefaultsAdmin",
|
||||
"userPreferences",
|
||||
]);
|
||||
});
|
||||
|
||||
it("only sends shared copies and the account one way", () => {
|
||||
@@ -178,6 +184,7 @@ describe("core sync entities", () => {
|
||||
|
||||
expect(readOnly.sort()).toEqual([
|
||||
"accountProfile",
|
||||
"hostDefaultsAdmin",
|
||||
"sharedCredentials",
|
||||
"sharedHosts",
|
||||
]);
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("../../database/repositories/factory.js", () => ({}));
|
||||
vi.mock("../../plugins/registry.js", () => ({ consume: () => undefined }));
|
||||
|
||||
const { exportDefaultValue, importDefaultValue, parseHostDefaultsSyncId } =
|
||||
await import("../../sync/host-defaults.js");
|
||||
|
||||
describe("host defaults over sync", () => {
|
||||
it("names a row by level, folder and key", () => {
|
||||
expect(parseHostDefaultsSyncId("u:core.sshPort")).toEqual({
|
||||
level: "user",
|
||||
folderSyncId: null,
|
||||
fullKey: "core.sshPort",
|
||||
});
|
||||
expect(parseHostDefaultsSyncId("f:abc-1:term.fontSize")).toEqual({
|
||||
level: "folder",
|
||||
folderSyncId: "abc-1",
|
||||
fullKey: "term.fontSize",
|
||||
});
|
||||
expect(parseHostDefaultsSyncId("x:core.sshPort")).toBeNull();
|
||||
expect(parseHostDefaultsSyncId("f:core.sshPort")).toBeNull();
|
||||
});
|
||||
|
||||
it("sends a credential and jump hosts by syncId and maps them back", async () => {
|
||||
const out = {
|
||||
credential: async (id: number) => `cred-${id}`,
|
||||
host: async (id: number) => (id === 9 ? null : `host-${id}`),
|
||||
};
|
||||
const auth = await exportDefaultValue(
|
||||
"core",
|
||||
"auth",
|
||||
{ authType: "credential", credentialId: 4 },
|
||||
out,
|
||||
);
|
||||
expect(auth).toEqual({
|
||||
authType: "credential",
|
||||
credentialSyncId: "cred-4",
|
||||
});
|
||||
const jumps = await exportDefaultValue(
|
||||
"core",
|
||||
"jumpHosts",
|
||||
[{ hostId: 1 }, { hostId: 9 }],
|
||||
out,
|
||||
);
|
||||
expect(jumps).toEqual([{ hostSyncId: "host-1" }]);
|
||||
|
||||
const back = {
|
||||
credential: async (syncId: string) => Number(syncId.split("-")[1]) + 100,
|
||||
host: async (syncId: string) => Number(syncId.split("-")[1]) + 100,
|
||||
};
|
||||
expect(await importDefaultValue("core", "auth", auth, back)).toEqual({
|
||||
authType: "credential",
|
||||
credentialId: 104,
|
||||
});
|
||||
expect(await importDefaultValue("core", "jumpHosts", jumps, back)).toEqual([
|
||||
{ hostId: 101 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("passes other values through", async () => {
|
||||
expect(await exportDefaultValue("core", "sshPort", 22)).toBe(22);
|
||||
expect(
|
||||
await importDefaultValue("term", "fontSize", 16, {
|
||||
credential: async () => null,
|
||||
host: async () => null,
|
||||
}),
|
||||
).toBe(16);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,311 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
type PluginRow = {
|
||||
pluginId: string;
|
||||
scope: string;
|
||||
scopeId: string | null;
|
||||
key: string;
|
||||
value: string | null;
|
||||
};
|
||||
|
||||
const db = vi.hoisted(() => ({
|
||||
settings: {} as Record<string, string>,
|
||||
pluginRows: [] as PluginRow[],
|
||||
defaults: [] as Array<{
|
||||
scope: Record<string, unknown>;
|
||||
namespace: string;
|
||||
key: string;
|
||||
value: string;
|
||||
}>,
|
||||
hosts: [] as Array<Record<string, unknown>>,
|
||||
folders: [] as Array<{ id: number; userId: string; credentialId: number }>,
|
||||
folderCredential: new Map<string, number>(),
|
||||
}));
|
||||
|
||||
const manifests = vi.hoisted(() => [
|
||||
{
|
||||
id: "ssh-terminal",
|
||||
contributes: {
|
||||
settings: {
|
||||
host: {
|
||||
fields: [
|
||||
{ key: "fontSize", type: "number", default: 14 },
|
||||
{ key: "theme", type: "string", default: "termix" },
|
||||
{ key: "localEcho", type: "select", default: "auto" },
|
||||
{ key: "linkClickBehavior", type: "select", default: "confirm" },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "remote-desktop",
|
||||
contributes: {
|
||||
settings: {
|
||||
host: {
|
||||
fields: [{ key: "colorDepth", type: "select", default: "inherit" }],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{ id: "host-metrics", contributes: { settings: { host: { fields: [] } } } },
|
||||
]);
|
||||
|
||||
vi.mock("../../utils/logger.js", () => ({
|
||||
databaseLogger: { info: vi.fn(), warn: vi.fn() },
|
||||
}));
|
||||
vi.mock("../../database/routes/host-plugin-settings.js", () => ({
|
||||
hostSettingsPlugins: () => manifests,
|
||||
}));
|
||||
vi.mock("../../database/db/schema.js", () => ({
|
||||
sshFolders: { id: "id", userId: "userId", credentialId: "credentialId" },
|
||||
}));
|
||||
vi.mock("../../database/repositories/factory.js", () => {
|
||||
const scopeKey = (scope: Record<string, unknown>) =>
|
||||
scope.level === "admin"
|
||||
? "admin"
|
||||
: scope.level === "user"
|
||||
? `u:${scope.userId}`
|
||||
: `f:${scope.folderId}`;
|
||||
return {
|
||||
createCurrentSettingsRepository: () => ({
|
||||
get: async (key: string) => db.settings[key] ?? null,
|
||||
set: async (key: string, value: string) => {
|
||||
db.settings[key] = value;
|
||||
},
|
||||
}),
|
||||
createCurrentPluginSettingsRepository: () => ({
|
||||
listByKey: async (pluginId: string, scope: string, key: string) =>
|
||||
db.pluginRows.filter(
|
||||
(row) =>
|
||||
row.pluginId === pluginId && row.scope === scope && row.key === key,
|
||||
),
|
||||
getAll: async (pluginId: string, scope: string, scopeId: string) =>
|
||||
db.pluginRows.filter(
|
||||
(row) =>
|
||||
row.pluginId === pluginId &&
|
||||
row.scope === scope &&
|
||||
row.scopeId === scopeId,
|
||||
),
|
||||
set: async (
|
||||
pluginId: string,
|
||||
scope: string,
|
||||
scopeId: string,
|
||||
key: string,
|
||||
value: string,
|
||||
) => {
|
||||
db.pluginRows = db.pluginRows.filter(
|
||||
(row) =>
|
||||
!(
|
||||
row.pluginId === pluginId &&
|
||||
row.scope === scope &&
|
||||
row.scopeId === scopeId &&
|
||||
row.key === key
|
||||
),
|
||||
);
|
||||
db.pluginRows.push({ pluginId, scope, scopeId, key, value });
|
||||
},
|
||||
delete: async (
|
||||
pluginId: string,
|
||||
scope: string,
|
||||
scopeId: string,
|
||||
key: string,
|
||||
) => {
|
||||
db.pluginRows = db.pluginRows.filter(
|
||||
(row) =>
|
||||
!(
|
||||
row.pluginId === pluginId &&
|
||||
row.scope === scope &&
|
||||
row.scopeId === scopeId &&
|
||||
row.key === key
|
||||
),
|
||||
);
|
||||
},
|
||||
}),
|
||||
createCurrentHostDefaultsRepository: () => ({
|
||||
listScope: async (scope: Record<string, unknown>) =>
|
||||
db.defaults.filter((row) => scopeKey(row.scope) === scopeKey(scope)),
|
||||
apply: async (
|
||||
scope: Record<string, unknown>,
|
||||
set: Array<{ namespace: string; key: string; value: string }>,
|
||||
) => {
|
||||
for (const entry of set) db.defaults.push({ scope, ...entry });
|
||||
},
|
||||
listHosts: async () => db.hosts,
|
||||
updateHosts: async (
|
||||
patches: Array<{ id: number; values: Record<string, unknown> }>,
|
||||
) => {
|
||||
for (const patch of patches) {
|
||||
Object.assign(
|
||||
db.hosts.find((host) => host.id === patch.id)!,
|
||||
patch.values,
|
||||
);
|
||||
}
|
||||
},
|
||||
}),
|
||||
createCurrentHostResolutionRepository: () => ({
|
||||
findFolderCredentialId: async (_userId: string, folder: string) =>
|
||||
db.folderCredential.get(folder) ?? null,
|
||||
}),
|
||||
createCurrentRepositoryContext: () => ({
|
||||
drizzle: {
|
||||
select: () => ({
|
||||
from: () => ({ where: async () => db.folders }),
|
||||
}),
|
||||
},
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
const { runHostDefaultsMigration } =
|
||||
await import("../../upgrade/host-defaults-migration.js");
|
||||
|
||||
function level(namespace: string, key: string, scopeLevel: string) {
|
||||
const row = db.defaults.find(
|
||||
(entry) =>
|
||||
entry.namespace === namespace &&
|
||||
entry.key === key &&
|
||||
entry.scope.level === scopeLevel,
|
||||
);
|
||||
return row ? JSON.parse(row.value) : undefined;
|
||||
}
|
||||
|
||||
function hostValue(hostId: number, pluginId: string, key: string) {
|
||||
const row = db.pluginRows.find(
|
||||
(entry) =>
|
||||
entry.pluginId === pluginId &&
|
||||
entry.scope === "host" &&
|
||||
entry.scopeId === String(hostId) &&
|
||||
entry.key === key,
|
||||
);
|
||||
return row?.value ? JSON.parse(row.value) : undefined;
|
||||
}
|
||||
|
||||
const plugin = (
|
||||
pluginId: string,
|
||||
scope: string,
|
||||
scopeId: string | null,
|
||||
key: string,
|
||||
value: unknown,
|
||||
): PluginRow => ({
|
||||
pluginId,
|
||||
scope,
|
||||
scopeId,
|
||||
key,
|
||||
value: JSON.stringify(value),
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
db.settings = {};
|
||||
db.pluginRows = [];
|
||||
db.defaults = [];
|
||||
db.hosts = [];
|
||||
db.folders = [];
|
||||
db.folderCredential = new Map();
|
||||
});
|
||||
|
||||
describe("runHostDefaultsMigration", () => {
|
||||
it("moves the old server defaults, dropping the credential and password", async () => {
|
||||
db.settings.host_defaults = JSON.stringify({
|
||||
useSocks5: true,
|
||||
socks5Host: "proxy",
|
||||
socks5Port: 1081,
|
||||
socks5Password: "secret",
|
||||
credentialId: 3,
|
||||
statusCheckEnabled: false,
|
||||
});
|
||||
db.pluginRows.push(
|
||||
plugin("ssh-terminal", "admin", null, "newHostFontSize", 18),
|
||||
plugin("host-metrics", "admin", null, "enabledForNewHosts", false),
|
||||
);
|
||||
db.folders = [{ id: 4, userId: "u1", credentialId: 9 }];
|
||||
|
||||
await runHostDefaultsMigration();
|
||||
|
||||
expect(level("core", "socks5", "admin")).toMatchObject({
|
||||
useSocks5: true,
|
||||
socks5Host: "proxy",
|
||||
socks5Port: 1081,
|
||||
});
|
||||
expect(JSON.stringify(db.defaults)).not.toContain("secret");
|
||||
expect(level("core", "statusCheckEnabled", "admin")).toBe(false);
|
||||
expect(level("ssh-terminal", "fontSize", "admin")).toBe(18);
|
||||
expect(level("host-metrics", "metricsEnabled", "admin")).toBe(false);
|
||||
expect(level("core", "auth", "folder")).toMatchObject({
|
||||
authType: "credential",
|
||||
credentialId: 9,
|
||||
});
|
||||
});
|
||||
|
||||
it("moves each user's terminal and remote desktop defaults to their own level", async () => {
|
||||
db.pluginRows.push(
|
||||
plugin("ssh-terminal", "user", "u1", "terminalDefaults", {
|
||||
fontSize: 20,
|
||||
}),
|
||||
plugin("ssh-terminal", "user", "u1", "localEcho", "on"),
|
||||
plugin("remote-desktop", "user", "u1", "colorDepth", "16"),
|
||||
plugin("remote-desktop", "user", "u1", "disableAudio", "inherit"),
|
||||
);
|
||||
await runHostDefaultsMigration();
|
||||
expect(level("ssh-terminal", "fontSize", "user")).toBe(20);
|
||||
expect(level("ssh-terminal", "localEcho", "user")).toBe("on");
|
||||
expect(level("remote-desktop", "colorDepth", "user")).toBe("16");
|
||||
expect(level("remote-desktop", "disableAudio", "user")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("writes in the look a host really had while it followed its user", async () => {
|
||||
db.hosts = [
|
||||
{ id: 1, userId: "u1", defaultOverrides: null },
|
||||
{ id: 2, userId: "u1", defaultOverrides: null },
|
||||
{ id: 3, userId: "u1", defaultOverrides: '{"core":[]}' },
|
||||
];
|
||||
db.pluginRows.push(
|
||||
plugin("ssh-terminal", "user", "u1", "terminalDefaults", {
|
||||
fontSize: 20,
|
||||
}),
|
||||
plugin("ssh-terminal", "user", "u1", "localEcho", "on"),
|
||||
plugin("ssh-terminal", "host", "1", "fontSize", 30),
|
||||
plugin("ssh-terminal", "host", "1", "localEcho", "default"),
|
||||
plugin("ssh-terminal", "host", "2", "inheritAppearance", false),
|
||||
plugin("ssh-terminal", "host", "2", "fontSize", 30),
|
||||
plugin("ssh-terminal", "host", "2", "localEcho", "off"),
|
||||
plugin("remote-desktop", "user", "u1", "colorDepth", "24"),
|
||||
);
|
||||
|
||||
await runHostDefaultsMigration();
|
||||
|
||||
// Followed the user: the user's look, and their echo mode.
|
||||
expect(hostValue(1, "ssh-terminal", "fontSize")).toBe(20);
|
||||
expect(hostValue(1, "ssh-terminal", "localEcho")).toBe("on");
|
||||
expect(hostValue(1, "remote-desktop", "colorDepth")).toBe("24");
|
||||
// Had its own look: kept.
|
||||
expect(hostValue(2, "ssh-terminal", "fontSize")).toBe(30);
|
||||
expect(hostValue(2, "ssh-terminal", "localEcho")).toBe("off");
|
||||
// Saved since host defaults: left alone.
|
||||
expect(hostValue(3, "ssh-terminal", "fontSize")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("gives a credential host its folder's credential", async () => {
|
||||
db.hosts = [
|
||||
{
|
||||
id: 1,
|
||||
userId: "u1",
|
||||
authType: "credential",
|
||||
credentialId: null,
|
||||
folder: "Prod",
|
||||
defaultOverrides: null,
|
||||
},
|
||||
];
|
||||
db.folderCredential.set("Prod", 12);
|
||||
await runHostDefaultsMigration();
|
||||
expect(db.hosts[0].credentialId).toBe(12);
|
||||
});
|
||||
|
||||
it("moves each level once", async () => {
|
||||
db.settings.host_defaults = JSON.stringify({ statusCheckEnabled: false });
|
||||
await runHostDefaultsMigration();
|
||||
db.defaults = [];
|
||||
await runHostDefaultsMigration();
|
||||
expect(level("core", "statusCheckEnabled", "admin")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -35,6 +35,7 @@ import { runTotpMigration } from "./totp-migration.js";
|
||||
import { runNotificationChannelMigration } from "./notification-channel-migration.js";
|
||||
import { runTermixIdentityCaMigration } from "./termix-identity-ca-migration.js";
|
||||
import { runSsoSettingsMigration } from "./sso-settings-migration.js";
|
||||
import { runHostDefaultsMigration } from "./host-defaults-migration.js";
|
||||
|
||||
const MIGRATIONS: Array<[string, () => Promise<unknown>]> = [
|
||||
["runTailscaleSettingsMigration", runTailscaleSettingsMigration],
|
||||
@@ -66,6 +67,8 @@ const MIGRATIONS: Array<[string, () => Promise<unknown>]> = [
|
||||
["runNotificationChannelMigration", runNotificationChannelMigration],
|
||||
["runTermixIdentityCaMigration", runTermixIdentityCaMigration],
|
||||
["runSsoSettingsMigration", runSsoSettingsMigration],
|
||||
// Last: it reads what the moves above wrote.
|
||||
["runHostDefaultsMigration", runHostDefaultsMigration],
|
||||
];
|
||||
|
||||
export async function runPluginDataMigrations(): Promise<void> {
|
||||
@@ -79,4 +82,25 @@ export async function runPluginDataMigrations(): Promise<void> {
|
||||
});
|
||||
}
|
||||
}
|
||||
await applyHostDefaults();
|
||||
}
|
||||
|
||||
/**
|
||||
* Classifies hosts nobody classified yet (from an older release, or for a
|
||||
* plugin just started) and brings every host up to date with its defaults.
|
||||
* A linked desktop gets its hosts already resolved from its server.
|
||||
*/
|
||||
async function applyHostDefaults(): Promise<void> {
|
||||
try {
|
||||
const { getLink } = await import("../sync/client/link-store.js");
|
||||
if (await getLink().catch(() => null)) return;
|
||||
const { recomputeEverything } =
|
||||
await import("../hosts/defaults/recompute.js");
|
||||
recomputeEverything("plugins started");
|
||||
} catch (error) {
|
||||
databaseLogger.warn("Could not apply host defaults", {
|
||||
operation: "plugin_data_migration",
|
||||
error: getErrorMessage(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,447 @@
|
||||
/**
|
||||
* Moves 2.8 and early 2.9 default settings into host defaults, and gets hosts
|
||||
* ready to be classified against them.
|
||||
*
|
||||
* Levels, each moved once:
|
||||
* - core `settings.host_defaults` (SOCKS5 and the status check) becomes the
|
||||
* server level. Its credential and SOCKS5 password are dropped: a
|
||||
* credential belongs to one user and a password is never a default.
|
||||
* - a folder's credential becomes that folder's auth default.
|
||||
* - ssh-terminal's admin "new host" look, tmux and command history become the
|
||||
* server level; each user's terminal defaults, local echo and link click
|
||||
* setting become their user level.
|
||||
* - host-metrics' "enabled for new hosts" becomes the server level.
|
||||
* - remote-desktop's per-user display settings become the user level.
|
||||
*
|
||||
* Hosts: a host saved before host defaults gets the value it really used
|
||||
* written in, where that was not its stored value. A terminal that
|
||||
* followed the user's look used the user's look; an echo mode of "default"
|
||||
* used the user's setting; a credential host with no credential used its
|
||||
* folder's; a remote desktop used the connecting user's display settings.
|
||||
* The classify pass that follows compares those against the new levels, so
|
||||
* nothing a user sees changes.
|
||||
*
|
||||
* The old rows stay in place for a downgrade until 3.0.0.
|
||||
*/
|
||||
|
||||
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
|
||||
import {
|
||||
createCurrentHostDefaultsRepository,
|
||||
createCurrentPluginSettingsRepository,
|
||||
createCurrentSettingsRepository,
|
||||
} from "../database/repositories/factory.js";
|
||||
import type { HostRow } from "../database/repositories/host-defaults-repository.js";
|
||||
import { hostSettingsPlugins } from "../database/routes/host-plugin-settings.js";
|
||||
import {
|
||||
CORE_NAMESPACE,
|
||||
defaultValuesEqual,
|
||||
normalizeSocks5Default,
|
||||
parseDefaultOverrides,
|
||||
} from "../../types/host-defaults.js";
|
||||
import { encodeDefaultValue } from "../hosts/defaults/levels.js";
|
||||
import { folderChainPaths } from "../hosts/defaults/resolve.js";
|
||||
import { databaseLogger } from "../utils/logger.js";
|
||||
|
||||
const TERMINAL = "ssh-terminal";
|
||||
const METRICS = "host-metrics";
|
||||
const DESKTOP = "remote-desktop";
|
||||
|
||||
const TERMINAL_APPEARANCE_KEYS = [
|
||||
"theme",
|
||||
"cursorBlink",
|
||||
"cursorStyle",
|
||||
"fontSize",
|
||||
"fontFamily",
|
||||
"scrollback",
|
||||
"letterSpacing",
|
||||
"lineHeight",
|
||||
"bellStyle",
|
||||
"minimumContrastRatio",
|
||||
"backgroundImage",
|
||||
"backgroundImageOpacity",
|
||||
"customThemeColors",
|
||||
];
|
||||
|
||||
/** ssh-terminal admin key -> the host key it seeded. */
|
||||
const TERMINAL_NEW_HOST_KEYS: Record<string, string> = {
|
||||
commandHistoryForNewHosts: "enableCommandHistory",
|
||||
newHostTheme: "theme",
|
||||
newHostFontFamily: "fontFamily",
|
||||
newHostFontSize: "fontSize",
|
||||
newHostCursorStyle: "cursorStyle",
|
||||
newHostCursorBlink: "cursorBlink",
|
||||
newHostAutoTmux: "autoTmux",
|
||||
};
|
||||
|
||||
/** ssh-terminal keys whose "default" meant the user's own setting. */
|
||||
const TERMINAL_USER_MODES: Record<string, string> = {
|
||||
localEcho: "auto",
|
||||
linkClickBehavior: "confirm",
|
||||
};
|
||||
|
||||
const DESKTOP_DISPLAY_KEYS = [
|
||||
"colorDepth",
|
||||
"resizeMethod",
|
||||
"forceLossless",
|
||||
"enableWallpaper",
|
||||
"enableFontSmoothing",
|
||||
"enableDesktopComposition",
|
||||
"disableAudio",
|
||||
"enablePrinting",
|
||||
"enableDrive",
|
||||
"disableCopy",
|
||||
"disablePaste",
|
||||
];
|
||||
|
||||
const MARKER_PREFIX = "host_defaults_moved_";
|
||||
|
||||
function decode(raw: string | null | undefined): unknown {
|
||||
if (raw === null || raw === undefined) return undefined;
|
||||
try {
|
||||
let parsed = JSON.parse(raw);
|
||||
if (typeof parsed === "string" && /^[[{]/.test(parsed)) {
|
||||
parsed = JSON.parse(parsed);
|
||||
}
|
||||
return parsed;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function asObject(value: unknown): Record<string, unknown> | null {
|
||||
return value && typeof value === "object" && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: null;
|
||||
}
|
||||
|
||||
async function once(name: string, run: () => Promise<void>): Promise<void> {
|
||||
const settings = createCurrentSettingsRepository();
|
||||
const marker = `${MARKER_PREFIX}${name}`;
|
||||
if (await settings.get(marker)) return;
|
||||
await run();
|
||||
await settings.set(marker, new Date().toISOString());
|
||||
}
|
||||
|
||||
type Level =
|
||||
| { level: "admin" }
|
||||
| { level: "user"; userId: string }
|
||||
| { level: "folder"; userId: string; folderId: number };
|
||||
|
||||
async function setDefault(
|
||||
scope: Level,
|
||||
namespace: string,
|
||||
key: string,
|
||||
value: unknown,
|
||||
): Promise<void> {
|
||||
const repository = createCurrentHostDefaultsRepository();
|
||||
const existing = await repository.listScope(scope);
|
||||
if (existing.some((row) => row.namespace === namespace && row.key === key)) {
|
||||
return;
|
||||
}
|
||||
await repository.apply(
|
||||
scope,
|
||||
[{ namespace, key, value: encodeDefaultValue(value) }],
|
||||
[],
|
||||
null,
|
||||
);
|
||||
}
|
||||
|
||||
async function moveCoreLevels(): Promise<void> {
|
||||
const raw = await createCurrentSettingsRepository().get("host_defaults");
|
||||
const stored = asObject(decode(raw)) ?? {};
|
||||
if (stored.useSocks5 === true) {
|
||||
await setDefault(
|
||||
{ level: "admin" },
|
||||
CORE_NAMESPACE,
|
||||
"socks5",
|
||||
normalizeSocks5Default({ ...stored, socks5ProxyChain: null }),
|
||||
);
|
||||
}
|
||||
if (stored.statusCheckEnabled === false) {
|
||||
await setDefault(
|
||||
{ level: "admin" },
|
||||
CORE_NAMESPACE,
|
||||
"statusCheckEnabled",
|
||||
false,
|
||||
);
|
||||
}
|
||||
if (stored.credentialId !== undefined || stored.socks5Password) {
|
||||
databaseLogger.info(
|
||||
"Host defaults no longer hold a credential or proxy password; set a credential per user or folder instead",
|
||||
{ operation: "host_defaults_migration" },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function moveFolderCredentials(): Promise<void> {
|
||||
const { createCurrentRepositoryContext } =
|
||||
await import("../database/repositories/factory.js");
|
||||
const { sshFolders } = await import("../database/db/schema.js");
|
||||
const { isNotNull } = await import("drizzle-orm");
|
||||
const rows = await createCurrentRepositoryContext()
|
||||
.drizzle.select({
|
||||
id: sshFolders.id,
|
||||
userId: sshFolders.userId,
|
||||
credentialId: sshFolders.credentialId,
|
||||
})
|
||||
.from(sshFolders)
|
||||
.where(isNotNull(sshFolders.credentialId));
|
||||
for (const row of rows) {
|
||||
await setDefault(
|
||||
{ level: "folder", userId: row.userId, folderId: row.id },
|
||||
CORE_NAMESPACE,
|
||||
"auth",
|
||||
{
|
||||
authType: "credential",
|
||||
credentialId: row.credentialId,
|
||||
overrideCredentialUsername: false,
|
||||
agentSocketPath: null,
|
||||
agentIdentity: null,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function storedRows(
|
||||
pluginId: string,
|
||||
scope: "admin" | "user",
|
||||
key: string,
|
||||
) {
|
||||
return (
|
||||
await createCurrentPluginSettingsRepository().listByKey(
|
||||
pluginId,
|
||||
scope,
|
||||
key,
|
||||
)
|
||||
).filter((row) => row.value !== null);
|
||||
}
|
||||
|
||||
async function moveTerminalLevels(): Promise<void> {
|
||||
for (const [adminKey, hostKey] of Object.entries(TERMINAL_NEW_HOST_KEYS)) {
|
||||
for (const row of await storedRows(TERMINAL, "admin", adminKey)) {
|
||||
const value = decode(row.value);
|
||||
if (value === undefined || value === null) continue;
|
||||
await setDefault({ level: "admin" }, TERMINAL, hostKey, value);
|
||||
}
|
||||
}
|
||||
for (const row of await storedRows(TERMINAL, "user", "terminalDefaults")) {
|
||||
const values = asObject(decode(row.value));
|
||||
if (!values || !row.scopeId) continue;
|
||||
for (const key of TERMINAL_APPEARANCE_KEYS) {
|
||||
if (values[key] === undefined) continue;
|
||||
await setDefault(
|
||||
{ level: "user", userId: row.scopeId },
|
||||
TERMINAL,
|
||||
key,
|
||||
values[key],
|
||||
);
|
||||
}
|
||||
}
|
||||
for (const key of Object.keys(TERMINAL_USER_MODES)) {
|
||||
for (const row of await storedRows(TERMINAL, "user", key)) {
|
||||
const value = decode(row.value);
|
||||
if (typeof value !== "string" || !row.scopeId) continue;
|
||||
await setDefault(
|
||||
{ level: "user", userId: row.scopeId },
|
||||
TERMINAL,
|
||||
key,
|
||||
value,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function moveMetricsLevels(): Promise<void> {
|
||||
for (const row of await storedRows(METRICS, "admin", "enabledForNewHosts")) {
|
||||
if (decode(row.value) === false) {
|
||||
await setDefault({ level: "admin" }, METRICS, "metricsEnabled", false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function moveDesktopLevels(): Promise<void> {
|
||||
for (const key of DESKTOP_DISPLAY_KEYS) {
|
||||
for (const row of await storedRows(DESKTOP, "user", key)) {
|
||||
const value = decode(row.value);
|
||||
if (typeof value !== "string" || value === "inherit" || !row.scopeId) {
|
||||
continue;
|
||||
}
|
||||
await setDefault(
|
||||
{ level: "user", userId: row.scopeId },
|
||||
DESKTOP,
|
||||
key,
|
||||
value,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hosts saved before host defaults (by an older release, or 2.8 after a
|
||||
* downgrade), which still carry the old meaning. A host saved since has its
|
||||
* overrides set, and a namespace it lacks is classified as it stands.
|
||||
*/
|
||||
function unclassified(rows: HostRow[]): HostRow[] {
|
||||
return rows.filter(
|
||||
(row) =>
|
||||
!row.sharedSource && parseDefaultOverrides(row.defaultOverrides) === null,
|
||||
);
|
||||
}
|
||||
|
||||
async function userValues(
|
||||
pluginId: string,
|
||||
key: string,
|
||||
): Promise<Map<string, unknown>> {
|
||||
const result = new Map<string, unknown>();
|
||||
for (const row of await storedRows(pluginId, "user", key)) {
|
||||
if (row.scopeId) result.set(row.scopeId, decode(row.value));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function manifestDefault(manifest: PluginManifest, key: string): unknown {
|
||||
return manifest.contributes?.settings?.host?.fields.find(
|
||||
(field) => field.key === key,
|
||||
)?.default;
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes one host value, or removes the row when it is the manifest default
|
||||
* so the host reads that default the usual way.
|
||||
*/
|
||||
async function writeHostValue(
|
||||
manifest: PluginManifest,
|
||||
hostId: number,
|
||||
key: string,
|
||||
value: unknown,
|
||||
): Promise<void> {
|
||||
const settings = createCurrentPluginSettingsRepository();
|
||||
if (defaultValuesEqual(value, manifestDefault(manifest, key))) {
|
||||
await settings.delete(manifest.id, "host", String(hostId), key);
|
||||
} else {
|
||||
await settings.set(
|
||||
manifest.id,
|
||||
"host",
|
||||
String(hostId),
|
||||
key,
|
||||
JSON.stringify(value),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function bakeTerminalHosts(
|
||||
manifest: PluginManifest,
|
||||
rows: HostRow[],
|
||||
): Promise<void> {
|
||||
const hosts = unclassified(rows);
|
||||
if (hosts.length === 0) return;
|
||||
const settings = createCurrentPluginSettingsRepository();
|
||||
const lookByUser = await userValues(TERMINAL, "terminalDefaults");
|
||||
const modesByUser = new Map(
|
||||
await Promise.all(
|
||||
Object.keys(TERMINAL_USER_MODES).map(
|
||||
async (key) => [key, await userValues(TERMINAL, key)] as const,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
for (const host of hosts) {
|
||||
const stored = new Map(
|
||||
(await settings.getAll(TERMINAL, "host", String(host.id))).map((row) => [
|
||||
row.key,
|
||||
decode(row.value),
|
||||
]),
|
||||
);
|
||||
if (stored.get("inheritAppearance") !== false) {
|
||||
const look = asObject(lookByUser.get(host.userId)) ?? {};
|
||||
for (const key of TERMINAL_APPEARANCE_KEYS) {
|
||||
const value = look[key] ?? manifestDefault(manifest, key);
|
||||
await writeHostValue(manifest, host.id, key, value);
|
||||
}
|
||||
}
|
||||
for (const [key, fallback] of Object.entries(TERMINAL_USER_MODES)) {
|
||||
const own = stored.get(key);
|
||||
if (own !== undefined && own !== "default") continue;
|
||||
const user = modesByUser.get(key)?.get(host.userId);
|
||||
await writeHostValue(
|
||||
manifest,
|
||||
host.id,
|
||||
key,
|
||||
typeof user === "string" && user !== "default" ? user : fallback,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function bakeDesktopHosts(
|
||||
manifest: PluginManifest,
|
||||
rows: HostRow[],
|
||||
): Promise<void> {
|
||||
const hosts = unclassified(rows);
|
||||
if (hosts.length === 0) return;
|
||||
const byKey = new Map(
|
||||
await Promise.all(
|
||||
DESKTOP_DISPLAY_KEYS.map(
|
||||
async (key) => [key, await userValues(DESKTOP, key)] as const,
|
||||
),
|
||||
),
|
||||
);
|
||||
for (const host of hosts) {
|
||||
for (const key of DESKTOP_DISPLAY_KEYS) {
|
||||
const value = byKey.get(key)?.get(host.userId);
|
||||
if (typeof value !== "string" || value === "inherit") continue;
|
||||
await writeHostValue(manifest, host.id, key, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A credential host with no credential of its own used its folder's. */
|
||||
async function bakeFolderCredentials(rows: HostRow[]): Promise<void> {
|
||||
const hosts = unclassified(rows).filter(
|
||||
(row) => row.authType === "credential" && !row.credentialId && row.folder,
|
||||
);
|
||||
if (hosts.length === 0) return;
|
||||
const repository = createCurrentHostDefaultsRepository();
|
||||
const { createCurrentHostResolutionRepository } =
|
||||
await import("../database/repositories/factory.js");
|
||||
const resolution = createCurrentHostResolutionRepository();
|
||||
const patches: Array<{ id: number; values: Partial<HostRow> }> = [];
|
||||
for (const host of hosts) {
|
||||
if (folderChainPaths(host.folder).length === 0) continue;
|
||||
const credentialId = await resolution.findFolderCredentialId(
|
||||
host.userId,
|
||||
host.folder!,
|
||||
);
|
||||
if (credentialId) patches.push({ id: host.id, values: { credentialId } });
|
||||
}
|
||||
await repository.updateHosts(patches);
|
||||
}
|
||||
|
||||
export async function runHostDefaultsMigration(): Promise<void> {
|
||||
const manifests = new Map(
|
||||
hostSettingsPlugins().map((manifest) => [manifest.id, manifest]),
|
||||
);
|
||||
try {
|
||||
await once("core", async () => {
|
||||
await moveCoreLevels();
|
||||
await moveFolderCredentials();
|
||||
});
|
||||
if (manifests.has(TERMINAL)) await once(TERMINAL, moveTerminalLevels);
|
||||
if (manifests.has(METRICS)) await once(METRICS, moveMetricsLevels);
|
||||
if (manifests.has(DESKTOP)) await once(DESKTOP, moveDesktopLevels);
|
||||
|
||||
const rows = await createCurrentHostDefaultsRepository().listHosts({
|
||||
all: true,
|
||||
});
|
||||
await bakeFolderCredentials(rows);
|
||||
const terminal = manifests.get(TERMINAL);
|
||||
if (terminal) await bakeTerminalHosts(terminal, rows);
|
||||
const desktop = manifests.get(DESKTOP);
|
||||
if (desktop) await bakeDesktopHosts(desktop, rows);
|
||||
} catch (error) {
|
||||
databaseLogger.warn("Host defaults migration failed", {
|
||||
operation: "host_defaults_migration",
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,338 @@
|
||||
/**
|
||||
* Host defaults: the keys a host can inherit and how their values compare.
|
||||
*
|
||||
* Shared by the backend (resolution, materializing, the upgrade) and the host
|
||||
* editor (which fields show an inherited badge). A key is namespaced:
|
||||
* "core.<key>" for core's own fields, "<pluginId>.<field>" for a plugin's host
|
||||
* setting. Values are in the host API's shape, never in column form.
|
||||
*/
|
||||
|
||||
export type HostDefaultsLevel = "admin" | "user" | "folder";
|
||||
|
||||
export const HOST_DEFAULTS_LEVELS: readonly HostDefaultsLevel[] = [
|
||||
"admin",
|
||||
"user",
|
||||
"folder",
|
||||
];
|
||||
|
||||
export const CORE_NAMESPACE = "core";
|
||||
|
||||
/** Namespace -> keys the host sets itself. */
|
||||
export type DefaultOverrides = Record<string, string[]>;
|
||||
|
||||
export interface HostDefaultSource {
|
||||
level: HostDefaultsLevel | "builtin";
|
||||
folderId?: number;
|
||||
folderName?: string;
|
||||
}
|
||||
|
||||
export interface ResolvedHostDefault {
|
||||
value: unknown;
|
||||
source: HostDefaultSource;
|
||||
}
|
||||
|
||||
export interface HostAuthDefault {
|
||||
authType: string;
|
||||
credentialId: number | null;
|
||||
overrideCredentialUsername: boolean;
|
||||
agentSocketPath: string | null;
|
||||
agentIdentity: string | null;
|
||||
}
|
||||
|
||||
export interface HostSocks5Default {
|
||||
useSocks5: boolean;
|
||||
socks5Host: string | null;
|
||||
socks5Port: number | null;
|
||||
socks5Username: string | null;
|
||||
socks5ProxyChain: unknown[] | null;
|
||||
}
|
||||
|
||||
export interface CoreHostDefault {
|
||||
key: string;
|
||||
/** Levels allowed to set it. A value that points at a user's row is never admin. */
|
||||
levels: readonly HostDefaultsLevel[];
|
||||
/** What a host gets when no level sets it. Undefined means nothing to inherit. */
|
||||
builtin: unknown;
|
||||
/** One canonical form, so two spellings of the same value compare equal. */
|
||||
normalize: (value: unknown) => unknown;
|
||||
}
|
||||
|
||||
const ALL_LEVELS = HOST_DEFAULTS_LEVELS;
|
||||
const OWN_LEVELS: readonly HostDefaultsLevel[] = ["user", "folder"];
|
||||
|
||||
function toBoolean(value: unknown, fallback: boolean): boolean {
|
||||
if (value === true || value === 1 || value === "true" || value === "1")
|
||||
return true;
|
||||
if (value === false || value === 0 || value === "false" || value === "0")
|
||||
return false;
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function toNumber(value: unknown, fallback: number | null): number | null {
|
||||
if (value === null || value === undefined || value === "") return fallback;
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : fallback;
|
||||
}
|
||||
|
||||
function toText(value: unknown): string | null {
|
||||
return typeof value === "string" && value.trim() !== "" ? value : null;
|
||||
}
|
||||
|
||||
function toArray(value: unknown): unknown[] {
|
||||
let parsed = value;
|
||||
if (typeof parsed === "string") {
|
||||
try {
|
||||
parsed = JSON.parse(parsed);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
return Array.isArray(parsed) ? parsed : [];
|
||||
}
|
||||
|
||||
function toObject(value: unknown): Record<string, unknown> | null {
|
||||
let parsed = value;
|
||||
if (typeof parsed === "string") {
|
||||
try {
|
||||
parsed = JSON.parse(parsed);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return parsed && typeof parsed === "object" && !Array.isArray(parsed)
|
||||
? (parsed as Record<string, unknown>)
|
||||
: null;
|
||||
}
|
||||
|
||||
/** Auth types whose login lives on the host itself, so it cannot be a default. */
|
||||
export const SECRET_AUTH_TYPES = ["password", "key"];
|
||||
|
||||
export function normalizeAuthDefault(value: unknown): HostAuthDefault | null {
|
||||
const source = toObject(value);
|
||||
if (!source || typeof source.authType !== "string" || !source.authType)
|
||||
return null;
|
||||
const authType = source.authType;
|
||||
const credentialId = toNumber(source.credentialId, null);
|
||||
return {
|
||||
authType,
|
||||
credentialId: authType === "credential" ? credentialId : null,
|
||||
overrideCredentialUsername:
|
||||
authType === "credential"
|
||||
? toBoolean(source.overrideCredentialUsername, false)
|
||||
: false,
|
||||
agentSocketPath:
|
||||
authType === "agent" ? toText(source.agentSocketPath) : null,
|
||||
agentIdentity: authType === "agent" ? toText(source.agentIdentity) : null,
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeSocks5Default(value: unknown): HostSocks5Default {
|
||||
const source = toObject(value) ?? {};
|
||||
if (!toBoolean(source.useSocks5, false)) {
|
||||
return {
|
||||
useSocks5: false,
|
||||
socks5Host: null,
|
||||
socks5Port: null,
|
||||
socks5Username: null,
|
||||
socks5ProxyChain: null,
|
||||
};
|
||||
}
|
||||
const chain = toArray(source.socks5ProxyChain);
|
||||
if (chain.length > 0) {
|
||||
return {
|
||||
useSocks5: true,
|
||||
socks5Host: null,
|
||||
socks5Port: null,
|
||||
socks5Username: null,
|
||||
socks5ProxyChain: chain.map((node) => {
|
||||
const own = toObject(node) ?? {};
|
||||
// A proxy's password stays on the host.
|
||||
const { password: _password, ...rest } = own;
|
||||
return rest;
|
||||
}),
|
||||
};
|
||||
}
|
||||
return {
|
||||
useSocks5: true,
|
||||
socks5Host: toText(source.socks5Host),
|
||||
socks5Port: toNumber(source.socks5Port, 1080),
|
||||
socks5Username: toText(source.socks5Username),
|
||||
socks5ProxyChain: null,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeJumpHosts(value: unknown): Array<{ hostId: number }> {
|
||||
return toArray(value)
|
||||
.map((entry) => toNumber(toObject(entry)?.hostId, null))
|
||||
.filter((hostId): hostId is number => hostId !== null)
|
||||
.map((hostId) => ({ hostId }));
|
||||
}
|
||||
|
||||
function normalizePortKnock(value: unknown): unknown[] {
|
||||
return toArray(value).map((entry) => {
|
||||
const own = toObject(entry) ?? {};
|
||||
return {
|
||||
port: toNumber(own.port, 0),
|
||||
protocol: own.protocol === "udp" ? "udp" : "tcp",
|
||||
delay: toNumber(own.delay, 0),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeEnvironment(value: unknown): unknown[] {
|
||||
return toArray(value).map((entry) => {
|
||||
const own = toObject(entry) ?? {};
|
||||
return { key: String(own.key ?? ""), value: String(own.value ?? "") };
|
||||
});
|
||||
}
|
||||
|
||||
export const CORE_HOST_DEFAULTS: readonly CoreHostDefault[] = [
|
||||
{
|
||||
key: "username",
|
||||
levels: ALL_LEVELS,
|
||||
builtin: undefined,
|
||||
normalize: toText,
|
||||
},
|
||||
{
|
||||
key: "sshPort",
|
||||
levels: ALL_LEVELS,
|
||||
builtin: 22,
|
||||
normalize: (value) => {
|
||||
const port = toNumber(value, 22);
|
||||
return port !== null && Number.isInteger(port) && port > 0 && port < 65536
|
||||
? port
|
||||
: 22;
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "auth",
|
||||
levels: ALL_LEVELS,
|
||||
builtin: undefined,
|
||||
normalize: normalizeAuthDefault,
|
||||
},
|
||||
{
|
||||
key: "forceKeyboardInteractive",
|
||||
levels: ALL_LEVELS,
|
||||
builtin: false,
|
||||
normalize: (value) => toBoolean(value, false),
|
||||
},
|
||||
{
|
||||
key: "keepaliveInterval",
|
||||
levels: ALL_LEVELS,
|
||||
builtin: 60,
|
||||
normalize: (value) => toNumber(value, 60),
|
||||
},
|
||||
{
|
||||
key: "keepaliveCountMax",
|
||||
levels: ALL_LEVELS,
|
||||
builtin: 5,
|
||||
normalize: (value) => toNumber(value, 5),
|
||||
},
|
||||
{
|
||||
key: "allowLegacyAlgorithms",
|
||||
levels: ALL_LEVELS,
|
||||
builtin: true,
|
||||
normalize: (value) => toBoolean(value, true),
|
||||
},
|
||||
{
|
||||
key: "agentForwarding",
|
||||
levels: ALL_LEVELS,
|
||||
builtin: false,
|
||||
normalize: (value) => toBoolean(value, false),
|
||||
},
|
||||
{
|
||||
key: "environmentVariables",
|
||||
levels: ALL_LEVELS,
|
||||
builtin: [],
|
||||
normalize: normalizeEnvironment,
|
||||
},
|
||||
{
|
||||
key: "socks5",
|
||||
levels: ALL_LEVELS,
|
||||
builtin: normalizeSocks5Default(null),
|
||||
normalize: normalizeSocks5Default,
|
||||
},
|
||||
{
|
||||
key: "jumpHosts",
|
||||
levels: OWN_LEVELS,
|
||||
builtin: [],
|
||||
normalize: normalizeJumpHosts,
|
||||
},
|
||||
{
|
||||
key: "portKnockSequence",
|
||||
levels: ALL_LEVELS,
|
||||
builtin: [],
|
||||
normalize: normalizePortKnock,
|
||||
},
|
||||
{
|
||||
key: "statusCheckEnabled",
|
||||
levels: ALL_LEVELS,
|
||||
builtin: true,
|
||||
normalize: (value) => toBoolean(value, true),
|
||||
},
|
||||
{
|
||||
key: "statusCheckInterval",
|
||||
levels: ALL_LEVELS,
|
||||
builtin: null,
|
||||
normalize: (value) => {
|
||||
const interval = toNumber(value, null);
|
||||
return interval !== null && interval > 0 ? Math.round(interval) : null;
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export function findCoreHostDefault(key: string): CoreHostDefault | undefined {
|
||||
return CORE_HOST_DEFAULTS.find((entry) => entry.key === key);
|
||||
}
|
||||
|
||||
/** "core.sshPort" -> ["core", "sshPort"]. The plugin id never has a dot. */
|
||||
export function splitDefaultKey(fullKey: string): [string, string] {
|
||||
const dot = fullKey.indexOf(".");
|
||||
if (dot <= 0) return [CORE_NAMESPACE, fullKey];
|
||||
return [fullKey.slice(0, dot), fullKey.slice(dot + 1)];
|
||||
}
|
||||
|
||||
export function joinDefaultKey(namespace: string, key: string): string {
|
||||
return `${namespace}.${key}`;
|
||||
}
|
||||
|
||||
/** JSON with sorted object keys, so equal values stringify the same. */
|
||||
export function stableStringify(value: unknown): string {
|
||||
if (value === undefined) return "undefined";
|
||||
if (value === null || typeof value !== "object") return JSON.stringify(value);
|
||||
if (Array.isArray(value)) {
|
||||
return `[${value.map((item) => stableStringify(item)).join(",")}]`;
|
||||
}
|
||||
const entries = Object.entries(value as Record<string, unknown>)
|
||||
.filter(([, item]) => item !== undefined)
|
||||
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
|
||||
return `{${entries
|
||||
.map(([key, item]) => `${JSON.stringify(key)}:${stableStringify(item)}`)
|
||||
.join(",")}}`;
|
||||
}
|
||||
|
||||
export function defaultValuesEqual(a: unknown, b: unknown): boolean {
|
||||
return stableStringify(a) === stableStringify(b);
|
||||
}
|
||||
|
||||
/** A stored or sent overrides value, or null when it is not one. */
|
||||
export function parseDefaultOverrides(raw: unknown): DefaultOverrides | null {
|
||||
const source = toObject(raw);
|
||||
if (!source) return null;
|
||||
const result: DefaultOverrides = {};
|
||||
for (const [namespace, keys] of Object.entries(source)) {
|
||||
if (!Array.isArray(keys)) continue;
|
||||
result[namespace] = [
|
||||
...new Set(keys.filter((key): key is string => typeof key === "string")),
|
||||
].sort();
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function isDefaultOverridden(
|
||||
overrides: DefaultOverrides | null | undefined,
|
||||
namespace: string,
|
||||
key: string,
|
||||
): boolean {
|
||||
return !!overrides?.[namespace]?.includes(key);
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import type {
|
||||
import type { Request } from "express";
|
||||
import type { RefObject } from "react";
|
||||
import type { HostAuthOverrides } from "./auth-protocols.js";
|
||||
import type { DefaultOverrides } from "./host-defaults.js";
|
||||
|
||||
export type {
|
||||
AuthOverrideProtocol,
|
||||
@@ -120,6 +121,8 @@ export type Host = {
|
||||
|
||||
/** Enabled plugins' host-scope settings, keyed by plugin id. Secrets redacted. */
|
||||
pluginSettings?: Record<string, Record<string, unknown>>;
|
||||
/** Host default keys this host sets itself, per namespace. */
|
||||
defaultOverrides?: DefaultOverrides | null;
|
||||
};
|
||||
|
||||
export interface JumpHostData {
|
||||
@@ -141,6 +144,8 @@ export interface ProxyNode {
|
||||
}
|
||||
|
||||
export interface HostData {
|
||||
/** Host default keys the host sets itself, per namespace. Every other key follows its defaults. */
|
||||
defaultOverrides?: DefaultOverrides | null;
|
||||
name?: string;
|
||||
ip: string;
|
||||
port: number;
|
||||
|
||||
@@ -5,6 +5,7 @@ import type {
|
||||
SSHAuthType,
|
||||
} from "./index.js";
|
||||
import type { HostAuthOverrides } from "./auth-protocols.js";
|
||||
import type { DefaultOverrides } from "./host-defaults.js";
|
||||
import type { QuickConnectLogin } from "@termix/plugin-sdk/frontend";
|
||||
|
||||
export type Host = {
|
||||
@@ -89,6 +90,8 @@ export type Host = {
|
||||
|
||||
/** Host-scope plugin settings, keyed by plugin id. Secrets are redacted. */
|
||||
pluginSettings?: Record<string, Record<string, unknown>>;
|
||||
/** Host default keys this host sets itself, per namespace. */
|
||||
defaultOverrides?: DefaultOverrides | null;
|
||||
forceKeyboardInteractive?: boolean;
|
||||
|
||||
isShared?: boolean;
|
||||
|
||||
@@ -333,6 +333,24 @@ export function AppShell({
|
||||
|
||||
const [sidebarOpen, setSidebarOpen] = useState(true);
|
||||
const [railView, setRailView] = useState<RailView>("hosts");
|
||||
|
||||
// Host defaults open in the host manager, from anywhere (the admin panel,
|
||||
// a folder's menu).
|
||||
useEffect(() => {
|
||||
const handler = (e: Event) => {
|
||||
const detail = (e as CustomEvent).detail;
|
||||
setSidebarOpen(true);
|
||||
setRailView("hosts");
|
||||
setTimeout(() => {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("host-manager:edit-defaults", { detail }),
|
||||
);
|
||||
}, 0);
|
||||
};
|
||||
window.addEventListener("termix:open-host-defaults", handler);
|
||||
return () =>
|
||||
window.removeEventListener("termix:open-host-defaults", handler);
|
||||
}, []);
|
||||
const [sidebarWidth, setSidebarWidth] = useState(() => {
|
||||
const saved = localStorage.getItem("termix_sidebarWidth");
|
||||
return saved ? parseInt(saved, 10) : 291;
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
import { handleApiError, sshHostApi } from "@/main-axios";
|
||||
import { invalidateHostsAndStatusCaches } from "@/lib/hosts-request-cache";
|
||||
import type {
|
||||
HostDefaultsLevel,
|
||||
ResolvedHostDefault,
|
||||
} from "@/types/host-defaults";
|
||||
|
||||
// HOST DEFAULTS
|
||||
// ============================================================================
|
||||
|
||||
export interface HostDefaultsLevelData {
|
||||
/** Values set at this level, by "namespace.key". */
|
||||
values: Record<string, unknown>;
|
||||
/** What each key reads when this level leaves it unset. */
|
||||
inherited: Record<string, ResolvedHostDefault>;
|
||||
}
|
||||
|
||||
export interface HostDefaultsTarget {
|
||||
level: HostDefaultsLevel;
|
||||
folderId?: number;
|
||||
}
|
||||
|
||||
export interface HostDefaultsChange {
|
||||
set: Record<string, unknown>;
|
||||
unset: string[];
|
||||
}
|
||||
|
||||
function levelPath(target: HostDefaultsTarget): string {
|
||||
if (target.level === "admin") return "/defaults/admin";
|
||||
if (target.level === "user") return "/defaults/user";
|
||||
return `/defaults/folders/${target.folderId}`;
|
||||
}
|
||||
|
||||
function adminHeaders(targetUserId?: string): Record<string, string> {
|
||||
return targetUserId ? { "X-Admin-Target-User": targetUserId } : {};
|
||||
}
|
||||
|
||||
export async function getHostDefaultsLevel(
|
||||
target: HostDefaultsTarget,
|
||||
): Promise<HostDefaultsLevelData> {
|
||||
try {
|
||||
const response = await sshHostApi.get(levelPath(target));
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "fetch host defaults");
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveHostDefaultsLevel(
|
||||
target: HostDefaultsTarget,
|
||||
change: HostDefaultsChange,
|
||||
): Promise<{ changedHosts?: number; jobId?: string }> {
|
||||
try {
|
||||
const response = await sshHostApi.put(levelPath(target), change);
|
||||
invalidateHostsAndStatusCaches();
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "save host defaults");
|
||||
}
|
||||
}
|
||||
|
||||
export async function previewHostDefaultsLevel(
|
||||
target: HostDefaultsTarget,
|
||||
change: HostDefaultsChange,
|
||||
): Promise<{ changedHosts: number }> {
|
||||
try {
|
||||
const response = await sshHostApi.post("/defaults/preview", {
|
||||
...target,
|
||||
...change,
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "preview host defaults");
|
||||
}
|
||||
}
|
||||
|
||||
export async function getHostDefaultsJob(
|
||||
id: string,
|
||||
): Promise<{ status: "running" | "done" | "failed"; changedHosts: number }> {
|
||||
try {
|
||||
const response = await sshHostApi.get(`/defaults/jobs/${id}`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "fetch host defaults progress");
|
||||
}
|
||||
}
|
||||
|
||||
/** A folder's id for its defaults, created when it only exists on hosts. */
|
||||
export async function getFolderDefaultsId(name: string): Promise<number> {
|
||||
try {
|
||||
const response = await sshHostApi.post("/defaults/folders", { name });
|
||||
return response.data.id;
|
||||
} catch (error) {
|
||||
handleApiError(error, "open folder defaults");
|
||||
}
|
||||
}
|
||||
|
||||
/** What a host, or a new one in a folder or under a parent, resolves to. */
|
||||
export async function resolveHostDefaults(
|
||||
where: {
|
||||
hostId?: number;
|
||||
folder?: string | null;
|
||||
parentHostId?: number | null;
|
||||
},
|
||||
adminTargetUserId?: string,
|
||||
): Promise<Record<string, ResolvedHostDefault>> {
|
||||
try {
|
||||
const params: Record<string, string> = {};
|
||||
if (where.hostId !== undefined) params.hostId = String(where.hostId);
|
||||
if (where.folder !== undefined) params.folder = where.folder ?? "";
|
||||
if (where.parentHostId !== undefined && where.parentHostId !== null) {
|
||||
params.parentHostId = String(where.parentHostId);
|
||||
}
|
||||
const response = await sshHostApi.get("/defaults/resolve", {
|
||||
params,
|
||||
headers: adminHeaders(adminTargetUserId),
|
||||
});
|
||||
return response.data.values ?? {};
|
||||
} catch (error) {
|
||||
handleApiError(error, "resolve host defaults");
|
||||
}
|
||||
}
|
||||
|
||||
export async function resetHostToDefaults(
|
||||
hostIds: number[],
|
||||
reset: { all?: boolean; namespaces?: string[]; keys?: string[] },
|
||||
): Promise<void> {
|
||||
try {
|
||||
if (hostIds.length === 1) {
|
||||
await sshHostApi.post(`/db/host/${hostIds[0]}/reset-defaults`, reset);
|
||||
} else {
|
||||
await sshHostApi.patch("/bulk-update", {
|
||||
hostIds,
|
||||
updates: { resetDefaults: reset },
|
||||
});
|
||||
}
|
||||
invalidateHostsAndStatusCaches();
|
||||
} catch (error) {
|
||||
handleApiError(error, "reset hosts to defaults");
|
||||
}
|
||||
}
|
||||
|
||||
/** Waits for a server-wide defaults job. Resolves to the hosts it changed, or null. */
|
||||
export async function waitForDefaultsJob(
|
||||
id: string,
|
||||
intervalMs = 1500,
|
||||
maxWaitMs = 10 * 60 * 1000,
|
||||
): Promise<number | null> {
|
||||
const started = Date.now();
|
||||
while (Date.now() - started < maxWaitMs) {
|
||||
await new Promise((resolve) => setTimeout(resolve, intervalMs));
|
||||
try {
|
||||
const job = await getHostDefaultsJob(id);
|
||||
if (job.status === "done") return job.changedHosts;
|
||||
if (job.status === "failed") return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -33,7 +33,10 @@ export interface PluginSettingsField {
|
||||
group?: string;
|
||||
component?: string;
|
||||
hidden?: boolean;
|
||||
defaultFrom?: string;
|
||||
defaultable?: boolean;
|
||||
defaultLevels?: Array<"admin" | "user" | "folder">;
|
||||
personal?: boolean;
|
||||
secretKeys?: string[];
|
||||
shareRead?: "connect" | "view" | "edit" | "manage";
|
||||
ownerOnly?: boolean;
|
||||
}
|
||||
|
||||
@@ -112,37 +112,3 @@ export async function updateBranding(
|
||||
handleApiError(error, "update branding settings");
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// HOST DEFAULTS SETTINGS
|
||||
// ============================================================================
|
||||
|
||||
export type HostDefaults = {
|
||||
useSocks5?: boolean;
|
||||
socks5Host?: string;
|
||||
socks5Port?: number;
|
||||
socks5Username?: string;
|
||||
socks5Password?: string;
|
||||
credentialId?: number | null;
|
||||
statusCheckEnabled?: boolean;
|
||||
};
|
||||
|
||||
export async function getHostDefaults(): Promise<HostDefaults> {
|
||||
try {
|
||||
const response = await authApi.get("/users/host-defaults");
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "fetch host defaults");
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateHostDefaults(
|
||||
defaults: HostDefaults,
|
||||
): Promise<HostDefaults> {
|
||||
try {
|
||||
const response = await authApi.patch("/users/host-defaults", defaults);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "update host defaults");
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { DefaultsOnly, HostDefaultBadge } from "@/lib/host-defaults-context";
|
||||
import { useState } from "react";
|
||||
import type React from "react";
|
||||
|
||||
@@ -34,34 +35,43 @@ export function SettingRow({
|
||||
label,
|
||||
badge,
|
||||
description,
|
||||
defaultKey,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
badge?: string;
|
||||
description?: React.ReactNode;
|
||||
/**
|
||||
* In the host editor, the host default this row edits: a plugin's own
|
||||
* field key, or "core.<key>". Shows where the value comes from.
|
||||
*/
|
||||
defaultKey?: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
// In a gap-4 form stack the gap already spaces the row, so its own padding
|
||||
// and divider would double up.
|
||||
<div className="flex items-center justify-between gap-3 py-3 border-b border-border last:border-0 [.gap-4>&]:py-0 [.gap-4>&]:border-0">
|
||||
<div className="flex flex-col gap-0.5 min-w-0 flex-1">
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
<span className="text-sm font-medium leading-snug">{label}</span>
|
||||
{badge && (
|
||||
<span className="text-[10px] font-bold text-yellow-500 border border-yellow-500/40 px-1 shrink-0">
|
||||
{badge}
|
||||
<DefaultsOnly settingKey={defaultKey ?? ""}>
|
||||
{/* In a gap-4 form stack the gap already spaces the row, so its own
|
||||
padding and divider would double up. */}
|
||||
<div className="flex items-center justify-between gap-3 py-3 border-b border-border last:border-0 [.gap-4>&]:py-0 [.gap-4>&]:border-0">
|
||||
<div className="flex flex-col gap-0.5 min-w-0 flex-1">
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
<span className="text-sm font-medium leading-snug">{label}</span>
|
||||
{badge && (
|
||||
<span className="text-[10px] font-bold text-yellow-500 border border-yellow-500/40 px-1 shrink-0">
|
||||
{badge}
|
||||
</span>
|
||||
)}
|
||||
{defaultKey && <HostDefaultBadge settingKey={defaultKey} />}
|
||||
</div>
|
||||
{description && (
|
||||
<span className="text-xs text-muted-foreground leading-snug">
|
||||
{description}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{description && (
|
||||
<span className="text-xs text-muted-foreground leading-snug">
|
||||
{description}
|
||||
</span>
|
||||
)}
|
||||
<div className="shrink-0">{children}</div>
|
||||
</div>
|
||||
<div className="shrink-0">{children}</div>
|
||||
</div>
|
||||
</DefaultsOnly>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
/* eslint-disable react-refresh/only-export-components */
|
||||
/**
|
||||
* The host editor's view of host defaults, for the fields it draws.
|
||||
*
|
||||
* In a host, a field either follows its default (and says where from) or is
|
||||
* the host's own (and can be reset). In the defaults editor, a field is either
|
||||
* set at the level being edited (and can be cleared) or shows what it
|
||||
* inherits. Outside either editor there is no provider and nothing renders.
|
||||
*/
|
||||
|
||||
import { createContext, useContext } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { RotateCcw, X } from "lucide-react";
|
||||
import type {
|
||||
HostDefaultSource,
|
||||
HostDefaultsLevel,
|
||||
} from "@/types/host-defaults";
|
||||
import { usePluginScope } from "@/plugin-host/scope";
|
||||
|
||||
export interface HostDefaultsContextValue {
|
||||
mode: "host" | "defaults";
|
||||
/** The level being edited, in the defaults editor. */
|
||||
level?: HostDefaultsLevel;
|
||||
/** A host's own value, or a value set at the level being edited. */
|
||||
isOwn: (fullKey: string) => boolean;
|
||||
/** Where the value it would otherwise have comes from. */
|
||||
source: (fullKey: string) => HostDefaultSource | undefined;
|
||||
/** Follows the default again, or clears the value at this level. */
|
||||
reset: (fullKey: string) => void;
|
||||
/** Whether the key can have a default here at all. */
|
||||
isAvailable: (fullKey: string) => boolean;
|
||||
}
|
||||
|
||||
export const HostDefaultsContext =
|
||||
createContext<HostDefaultsContextValue | null>(null);
|
||||
|
||||
export function useHostDefaults(): HostDefaultsContextValue | null {
|
||||
return useContext(HostDefaultsContext);
|
||||
}
|
||||
|
||||
/** Whether the editor is editing a level of defaults rather than a host. */
|
||||
export function useIsDefaultsEditor(): boolean {
|
||||
return useContext(HostDefaultsContext)?.mode === "defaults";
|
||||
}
|
||||
|
||||
/**
|
||||
* A short key ("fontSize") is the calling plugin's own; core passes a full
|
||||
* one ("core.sshPort").
|
||||
*/
|
||||
export function useDefaultKey(settingKey: string | undefined): string | null {
|
||||
const scope = usePluginScope();
|
||||
if (!settingKey) return null;
|
||||
if (settingKey.includes(".")) return settingKey;
|
||||
return scope ? `${scope}.${settingKey}` : null;
|
||||
}
|
||||
|
||||
export function useSourceLabel(): (
|
||||
source: HostDefaultSource | undefined,
|
||||
) => string {
|
||||
const { t } = useTranslation();
|
||||
return (source) => {
|
||||
switch (source?.level) {
|
||||
case "folder":
|
||||
return t("hostDefaults.source.folder", {
|
||||
name: source.folderName ?? "",
|
||||
});
|
||||
case "user":
|
||||
return t("hostDefaults.source.user");
|
||||
case "admin":
|
||||
return t("hostDefaults.source.admin");
|
||||
default:
|
||||
return t("hostDefaults.source.builtin");
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The marker next to a field's label: where an inherited value comes from,
|
||||
* or a control to hand an own value back.
|
||||
*/
|
||||
export function HostDefaultBadge({ settingKey }: { settingKey: string }) {
|
||||
const { t } = useTranslation();
|
||||
const context = useHostDefaults();
|
||||
const fullKey = useDefaultKey(settingKey);
|
||||
const sourceLabel = useSourceLabel();
|
||||
if (!context || !fullKey || !context.isAvailable(fullKey)) return null;
|
||||
|
||||
const own = context.isOwn(fullKey);
|
||||
const source = context.source(fullKey);
|
||||
const from = sourceLabel(source);
|
||||
|
||||
if (own && (context.mode === "defaults" || source)) {
|
||||
const defaults = context.mode === "defaults";
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => context.reset(fullKey)}
|
||||
title={
|
||||
defaults
|
||||
? t("hostDefaults.clearTitle", { source: from })
|
||||
: t("hostDefaults.resetTitle", { source: from })
|
||||
}
|
||||
className="inline-flex items-center gap-0.5 border border-accent-brand/40 px-1 text-[10px] font-medium normal-case tracking-normal text-accent-brand hover:bg-accent-brand/10"
|
||||
>
|
||||
{defaults ? (
|
||||
<X className="size-2.5" />
|
||||
) : (
|
||||
<RotateCcw className="size-2.5" />
|
||||
)}
|
||||
{defaults ? t("hostDefaults.setHere") : t("hostDefaults.custom")}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
if (own) return null;
|
||||
return (
|
||||
<span
|
||||
title={
|
||||
source
|
||||
? t("hostDefaults.inheritedTitle", { source: from })
|
||||
: t("hostDefaults.notSetTitle")
|
||||
}
|
||||
className="border border-border px-1 text-[10px] font-normal normal-case tracking-normal text-muted-foreground"
|
||||
>
|
||||
{source ? from : t("hostDefaults.notSet")}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** Renders its children only when the key can have a default in this editor. */
|
||||
export function DefaultsOnly({
|
||||
settingKey,
|
||||
children,
|
||||
}: {
|
||||
settingKey: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const context = useHostDefaults();
|
||||
const fullKey = useDefaultKey(settingKey);
|
||||
if (
|
||||
context?.mode === "defaults" &&
|
||||
fullKey &&
|
||||
!context.isAvailable(fullKey)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
/** Renders its children only when editing a host, never a level of defaults. */
|
||||
export function HostOnly({ children }: { children: React.ReactNode }) {
|
||||
return useIsDefaultsEditor() ? null : <>{children}</>;
|
||||
}
|
||||
+42
-12
@@ -1445,19 +1445,7 @@
|
||||
"importFailed": "Import failed: {{error}}",
|
||||
"importError": "Database import failed",
|
||||
"sectionHostDefaults": "Host Defaults",
|
||||
"hostDefaultsDesc": "Settings applied automatically when creating a new host. Individual hosts can still override these.",
|
||||
"hostDefaultsSocks5": "SOCKS5 Proxy",
|
||||
"hostDefaultsUseSocks5": "Enable SOCKS5 Proxy",
|
||||
"hostDefaultsUseSocks5Desc": "Pre-fill SOCKS5 proxy on all new hosts",
|
||||
"hostDefaultsSocks5Host": "Proxy Host / Port",
|
||||
"hostDefaultsSocks5Username": "Proxy Username",
|
||||
"hostDefaultsSocks5Password": "Proxy Password",
|
||||
"hostDefaultsStatusCheckEnabled": "Enable Status Check",
|
||||
"hostDefaultsStatusCheckEnabledDesc": "Poll online/offline status on new hosts by default",
|
||||
"hostDefaultsStatus": "Status Checks",
|
||||
"minutes": "minutes",
|
||||
"hostDefaultsSaved": "Host defaults saved",
|
||||
"hostDefaultsSaveFailed": "Failed to save host defaults",
|
||||
"rolePermissions": {
|
||||
"count": "{{count}} permissions",
|
||||
"editAction": "Edit permissions",
|
||||
@@ -2005,5 +1993,47 @@
|
||||
"linked": "Linked. Your data is syncing now.",
|
||||
"signedInAgain": "Signed in. Sync is running again."
|
||||
}
|
||||
},
|
||||
"hostDefaults": {
|
||||
"source": {
|
||||
"folder": "Folder {{name}}",
|
||||
"user": "My defaults",
|
||||
"admin": "Server",
|
||||
"builtin": "Built-in"
|
||||
},
|
||||
"custom": "Custom",
|
||||
"setHere": "Set here",
|
||||
"notSet": "Not set",
|
||||
"notSetTitle": "No default is set for this",
|
||||
"resetTitle": "This host sets its own value. Click to use the default from {{source}} again",
|
||||
"clearTitle": "Set at this level. Click to clear it and use {{source}} instead",
|
||||
"inheritedTitle": "Follows the default from {{source}}",
|
||||
"titleAdmin": "Server host defaults",
|
||||
"titleUser": "My host defaults",
|
||||
"titleFolder": "Folder defaults: {{name}}",
|
||||
"descriptionAdmin": "Defaults for every host on this server. A user's own defaults, a folder's defaults or the host itself can still set a different value. Change a field to set it here, or clear it to leave it unset.",
|
||||
"descriptionUser": "Defaults for all of your hosts, over the server's. A folder or the host itself can still set a different value. Change a field to set it here, or clear it to leave it unset.",
|
||||
"descriptionFolder": "Defaults for the hosts in this folder and its subfolders, over your own. A host can still set a different value. Change a field to set it here, or clear it to leave it unset.",
|
||||
"save": "Save defaults",
|
||||
"saved_one": "Defaults saved. {{count}} host was updated.",
|
||||
"saved_other": "Defaults saved. {{count}} hosts were updated.",
|
||||
"savedInBackground": "Defaults saved. Hosts are being updated in the background.",
|
||||
"appliedToHosts_one": "{{count}} host was updated with the new defaults.",
|
||||
"appliedToHosts_other": "{{count}} hosts were updated with the new defaults.",
|
||||
"saveFailed": "Could not save the defaults",
|
||||
"loadFailed": "Could not load the defaults",
|
||||
"confirmTitle": "Save defaults?",
|
||||
"confirmChanges_one": "{{count}} host follows these defaults and will change.",
|
||||
"confirmChanges_other": "{{count}} hosts follow these defaults and will change.",
|
||||
"confirmNoChanges": "No existing host changes. New hosts will start with these defaults.",
|
||||
"menuMyDefaults": "My host defaults",
|
||||
"folderDefaultsAction": "Folder defaults",
|
||||
"adminSectionDescription": "Set the defaults every host on this server follows, for every setting a host has, including each feature's. Users and folders can set their own on top, and any host can still use its own value.",
|
||||
"editServerDefaults": "Edit server defaults",
|
||||
"resetMenu": "Reset",
|
||||
"resetAll": "Everything to defaults",
|
||||
"resetCore": "Connection and SSH to defaults",
|
||||
"resetDone_one": "{{count}} host now follows its defaults.",
|
||||
"resetDone_other": "{{count}} hosts now follow their defaults."
|
||||
}
|
||||
}
|
||||
@@ -231,6 +231,7 @@ export function createPluginApp(
|
||||
icon: withIconBoundary(pluginId, section.icon as never) as never,
|
||||
order: section.order,
|
||||
visible: section.visible,
|
||||
defaults: section.defaults,
|
||||
component: scoped(
|
||||
section.component as unknown as ComponentType<HostEditorSectionRenderProps>,
|
||||
),
|
||||
|
||||
@@ -159,6 +159,14 @@ export {
|
||||
type HostFeatureFieldsProps,
|
||||
} from "@/settings/HostPluginSections";
|
||||
|
||||
// Host defaults in a host editor section: where a field's value comes from,
|
||||
// and whether the section is editing a level of defaults rather than a host.
|
||||
export {
|
||||
HostDefaultBadge,
|
||||
DefaultsOnly as HostDefaultField,
|
||||
useIsDefaultsEditor,
|
||||
} from "@/lib/host-defaults-context";
|
||||
|
||||
// Components other plugins offer by id (app.registerComponent), rendered with
|
||||
// a fallback while their plugin is off.
|
||||
export { PluginComponent } from "@/plugin-host/component-registry";
|
||||
|
||||
@@ -63,29 +63,6 @@ function hostFieldDefaults(plugin: PluginSummary): Record<string, unknown> {
|
||||
return defaults;
|
||||
}
|
||||
|
||||
/**
|
||||
* A new host saves the fields whose default comes from an admin setting,
|
||||
* since the server only knows the manifest default for a host with no value.
|
||||
*/
|
||||
export function withNewHostDefaults(
|
||||
values: HostPluginSettings,
|
||||
plugins: PluginSummary[],
|
||||
): HostPluginSettings {
|
||||
const next: HostPluginSettings = { ...values };
|
||||
for (const plugin of plugins) {
|
||||
const fields = plugin.contributes?.settings?.host?.fields ?? [];
|
||||
for (const field of fields) {
|
||||
if (!field.defaultFrom || field.default === undefined) continue;
|
||||
if (next[plugin.id] && field.key in next[plugin.id]) continue;
|
||||
next[plugin.id] = {
|
||||
...(next[plugin.id] ?? {}),
|
||||
[field.key]: field.default,
|
||||
};
|
||||
}
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
export interface HostPluginSectionsProps {
|
||||
plugins: PluginSummary[];
|
||||
values: HostPluginSettings;
|
||||
@@ -197,6 +174,7 @@ function HostPluginSection({
|
||||
values={shown}
|
||||
setValue={setValue}
|
||||
running={running}
|
||||
defaultKey={`${plugin.id}.${enableKey}`}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -211,6 +189,7 @@ function HostPluginSection({
|
||||
values={shown}
|
||||
setValue={setValue}
|
||||
running={running}
|
||||
defaultKey={`${plugin.id}.${field.key}`}
|
||||
/>
|
||||
))}
|
||||
</SectionCard>
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
SelectValue,
|
||||
} from "@/components/select";
|
||||
import { pluginKey } from "@/lib/plugin-i18n";
|
||||
import { DefaultsOnly, HostDefaultBadge } from "@/lib/host-defaults-context";
|
||||
import { isRedactedSecret, type PluginSettingsField } from "@/api/plugins-api";
|
||||
import {
|
||||
getSettingsComponent,
|
||||
@@ -37,6 +38,8 @@ export interface SettingsFieldProps {
|
||||
/** Message from a rejected save, shown under the control. */
|
||||
error?: string;
|
||||
disabled?: boolean;
|
||||
/** In the host editor, the host default this field is ("pluginId.key"). */
|
||||
defaultKey?: string;
|
||||
}
|
||||
|
||||
export function SettingsFieldRow({
|
||||
@@ -47,6 +50,7 @@ export function SettingsFieldRow({
|
||||
running,
|
||||
error,
|
||||
disabled = false,
|
||||
defaultKey,
|
||||
}: SettingsFieldProps) {
|
||||
const { t } = useTranslation();
|
||||
// A custom component appears once its plugin's frontend has registered it.
|
||||
@@ -98,6 +102,7 @@ export function SettingsFieldRow({
|
||||
label={label(field.labelKey)}
|
||||
description={description}
|
||||
badge={error ? t("common.error") : undefined}
|
||||
defaultKey={defaultKey}
|
||||
>
|
||||
{control}
|
||||
</SettingRow>
|
||||
@@ -105,24 +110,27 @@ export function SettingsFieldRow({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5 py-3 border-b border-border last:border-0 min-w-0">
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
<span className="text-sm font-medium leading-snug">
|
||||
{label(field.labelKey)}
|
||||
</span>
|
||||
{error && (
|
||||
<span className="text-[10px] font-bold text-yellow-500 border border-yellow-500/40 px-1 shrink-0">
|
||||
{t("common.error")}
|
||||
<DefaultsOnly settingKey={defaultKey ?? ""}>
|
||||
<div className="flex flex-col gap-1.5 py-3 border-b border-border last:border-0 min-w-0">
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
<span className="text-sm font-medium leading-snug">
|
||||
{label(field.labelKey)}
|
||||
</span>
|
||||
{error && (
|
||||
<span className="text-[10px] font-bold text-yellow-500 border border-yellow-500/40 px-1 shrink-0">
|
||||
{t("common.error")}
|
||||
</span>
|
||||
)}
|
||||
{defaultKey && <HostDefaultBadge settingKey={defaultKey} />}
|
||||
</div>
|
||||
{description && (
|
||||
<span className="text-xs text-muted-foreground leading-snug">
|
||||
{description}
|
||||
</span>
|
||||
)}
|
||||
<div className="min-w-0">{control}</div>
|
||||
</div>
|
||||
{description && (
|
||||
<span className="text-xs text-muted-foreground leading-snug">
|
||||
{description}
|
||||
</span>
|
||||
)}
|
||||
<div className="min-w-0">{control}</div>
|
||||
</div>
|
||||
</DefaultsOnly>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -32,16 +32,20 @@ interface HostFormLike {
|
||||
/** Adds, updates and removes generated tabs to match the plugin list. */
|
||||
export function syncHostFeatureTabs(plugins: PluginSummary[]): string[] {
|
||||
const existing = hostEditorSectionList();
|
||||
const ownSections = new Set(
|
||||
existing
|
||||
.filter(
|
||||
(section) => section.pluginId && !section.id.startsWith(TAB_PREFIX),
|
||||
)
|
||||
const own = existing.filter(
|
||||
(section) => section.pluginId && !section.id.startsWith(TAB_PREFIX),
|
||||
);
|
||||
const ownSections = new Set(own.map((section) => section.pluginId));
|
||||
// A plugin whose own sections are all about one host still gets its
|
||||
// switches in the defaults editor, through a generated tab shown only there.
|
||||
const ownDefaultsSections = new Set(
|
||||
own
|
||||
.filter((section) => section.defaults)
|
||||
.map((section) => section.pluginId),
|
||||
);
|
||||
|
||||
const contributors = plugins.filter((plugin) => {
|
||||
if (!plugin.enabled || ownSections.has(plugin.id)) return false;
|
||||
if (!plugin.enabled || ownDefaultsSections.has(plugin.id)) return false;
|
||||
const host = plugin.contributes?.settings?.host;
|
||||
return !!host && (hasVisibleFields(host.fields) || !!host.enableKey);
|
||||
});
|
||||
@@ -65,6 +69,8 @@ export function syncHostFeatureTabs(plugins: PluginSummary[]): string[] {
|
||||
order: host.editorOrder ?? 100,
|
||||
labelKey: plugin.name,
|
||||
label: plugin.name,
|
||||
// Nothing but the plugin's host settings, so it works for defaults too.
|
||||
defaults: ownSections.has(plugin.id) ? "only" : true,
|
||||
icon: icon
|
||||
? ({ className }: { className?: string }) => (
|
||||
<PluginIcon name={icon} className={className} />
|
||||
|
||||
@@ -37,13 +37,10 @@ import {
|
||||
getUserRoles,
|
||||
} from "@/main-axios";
|
||||
import {
|
||||
getHostDefaults,
|
||||
updateHostDefaults,
|
||||
getAnalyticsEnabled,
|
||||
updateAnalyticsEnabled,
|
||||
getBranding,
|
||||
updateBranding,
|
||||
type HostDefaults,
|
||||
type BrandingSettings,
|
||||
} from "@/api/settings-api";
|
||||
import {
|
||||
@@ -128,7 +125,6 @@ export function AdminSettingsPanel({
|
||||
const [analyticsLocked, setAnalyticsLocked] = useState(false);
|
||||
const [notificationPrivateEndpoints, setNotificationPrivateEndpoints] =
|
||||
useState<string[]>([]);
|
||||
const [hostDefaults, setHostDefaults] = useState<HostDefaults>({});
|
||||
|
||||
const [brandingSettings, setBrandingSettings] =
|
||||
useState<BrandingSettings | null>(null);
|
||||
@@ -341,10 +337,6 @@ export function AdminSettingsPanel({
|
||||
// non-fatal
|
||||
}
|
||||
|
||||
getHostDefaults()
|
||||
.then((d) => setHostDefaults(d))
|
||||
.catch(() => {});
|
||||
|
||||
getTlsStatus()
|
||||
.then((s) => setTlsStatus(s))
|
||||
.catch(() => {});
|
||||
@@ -359,15 +351,6 @@ export function AdminSettingsPanel({
|
||||
});
|
||||
}
|
||||
|
||||
async function handleSaveHostDefaults() {
|
||||
try {
|
||||
await updateHostDefaults(hostDefaults);
|
||||
toast.success(t("admin.hostDefaultsSaved"));
|
||||
} catch {
|
||||
toast.error(t("admin.hostDefaultsSaveFailed"));
|
||||
}
|
||||
}
|
||||
|
||||
async function handleToggleRegistration() {
|
||||
const newVal = !allowRegistration;
|
||||
setAllowRegistration(newVal);
|
||||
@@ -881,9 +864,6 @@ export function AdminSettingsPanel({
|
||||
<AdminHostDefaultsSection
|
||||
open={openSections.has("host-defaults")}
|
||||
onToggle={() => toggle("host-defaults")}
|
||||
defaults={hostDefaults}
|
||||
setDefaults={setHostDefaults}
|
||||
handleSaveDefaults={handleSaveHostDefaults}
|
||||
/>
|
||||
|
||||
<AdminBrandingSection
|
||||
|
||||
@@ -2,11 +2,9 @@ import type { Dispatch, SetStateAction } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@/components/button";
|
||||
import { Input } from "@/components/input";
|
||||
import { PasswordInput } from "@/components/password-input";
|
||||
import { SettingRow } from "@/components/section-card";
|
||||
import { Database, Lock, RefreshCw, Server, Settings } from "lucide-react";
|
||||
import { AccordionSection, AdminToggle } from "./AdminSettingsShared";
|
||||
import type { HostDefaults } from "@/api/settings-api";
|
||||
import type { TlsStatus } from "@/api/tls-api";
|
||||
|
||||
type GeneralSettingsSectionProps = {
|
||||
@@ -338,17 +336,11 @@ export function AdminDatabaseSection({
|
||||
type AdminHostDefaultsSectionProps = {
|
||||
open: boolean;
|
||||
onToggle: () => void;
|
||||
defaults: HostDefaults;
|
||||
setDefaults: Dispatch<SetStateAction<HostDefaults>>;
|
||||
handleSaveDefaults: () => void;
|
||||
};
|
||||
|
||||
export function AdminHostDefaultsSection({
|
||||
open,
|
||||
onToggle,
|
||||
defaults,
|
||||
setDefaults,
|
||||
handleSaveDefaults,
|
||||
}: AdminHostDefaultsSectionProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -359,116 +351,23 @@ export function AdminHostDefaultsSection({
|
||||
open={open}
|
||||
onToggle={onToggle}
|
||||
>
|
||||
<div className="flex flex-col gap-4 pt-3">
|
||||
<div className="flex flex-col gap-3 pt-3">
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{t("admin.hostDefaultsDesc")}
|
||||
{t("hostDefaults.adminSectionDescription")}
|
||||
</span>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("admin.hostDefaultsSocks5")}
|
||||
</span>
|
||||
<SettingRow
|
||||
label={t("admin.hostDefaultsUseSocks5")}
|
||||
description={t("admin.hostDefaultsUseSocks5Desc")}
|
||||
>
|
||||
<AdminToggle
|
||||
on={defaults.useSocks5 ?? false}
|
||||
onToggle={() =>
|
||||
setDefaults((p) => ({ ...p, useSocks5: !p.useSocks5 }))
|
||||
}
|
||||
/>
|
||||
</SettingRow>
|
||||
{defaults.useSocks5 && (
|
||||
<div className="flex flex-col gap-3 ml-4">
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
|
||||
{t("admin.hostDefaultsSocks5Host")}
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={defaults.socks5Host ?? ""}
|
||||
onChange={(e) =>
|
||||
setDefaults((p) => ({ ...p, socks5Host: e.target.value }))
|
||||
}
|
||||
placeholder="127.0.0.1"
|
||||
className="text-xs"
|
||||
/>
|
||||
<Input
|
||||
type="number"
|
||||
value={defaults.socks5Port ?? 1080}
|
||||
onChange={(e) =>
|
||||
setDefaults((p) => ({
|
||||
...p,
|
||||
socks5Port: Number(e.target.value),
|
||||
}))
|
||||
}
|
||||
placeholder="1080"
|
||||
className="text-xs w-24 [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
|
||||
{t("admin.hostDefaultsSocks5Username")}
|
||||
</label>
|
||||
<Input
|
||||
value={defaults.socks5Username ?? ""}
|
||||
onChange={(e) =>
|
||||
setDefaults((p) => ({
|
||||
...p,
|
||||
socks5Username: e.target.value,
|
||||
}))
|
||||
}
|
||||
className="text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
|
||||
{t("admin.hostDefaultsSocks5Password")}
|
||||
</label>
|
||||
<PasswordInput
|
||||
value={defaults.socks5Password ?? ""}
|
||||
onChange={(e) =>
|
||||
setDefaults((p) => ({
|
||||
...p,
|
||||
socks5Password: e.target.value,
|
||||
}))
|
||||
}
|
||||
className="h-8 text-xs pr-8"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2 border-t border-border pt-3">
|
||||
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("admin.hostDefaultsStatus")}
|
||||
</span>
|
||||
<SettingRow
|
||||
label={t("admin.hostDefaultsStatusCheckEnabled")}
|
||||
description={t("admin.hostDefaultsStatusCheckEnabledDesc")}
|
||||
>
|
||||
<AdminToggle
|
||||
on={defaults.statusCheckEnabled ?? true}
|
||||
onToggle={() =>
|
||||
setDefaults((p) => ({
|
||||
...p,
|
||||
statusCheckEnabled: !(p.statusCheckEnabled ?? true),
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</SettingRow>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="self-start text-xs border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand h-7"
|
||||
onClick={handleSaveDefaults}
|
||||
onClick={() =>
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("termix:open-host-defaults", {
|
||||
detail: { level: "admin" },
|
||||
}),
|
||||
)
|
||||
}
|
||||
>
|
||||
{t("common.save")}
|
||||
{t("hostDefaults.editServerDefaults")}
|
||||
</Button>
|
||||
</div>
|
||||
</AccordionSection>
|
||||
|
||||
+1104
-782
File diff suppressed because it is too large.
Load diff
@@ -6,7 +6,7 @@ import {
|
||||
type HostProtocolAuthForm,
|
||||
type HostProtocolAuthSummary,
|
||||
} from "@termix/plugin-sdk/frontend";
|
||||
import type { HostDefaults } from "@/api/settings-api";
|
||||
import type { DefaultOverrides } from "@/types/host-defaults";
|
||||
import {
|
||||
listHostProtocols,
|
||||
protocolPort,
|
||||
@@ -109,11 +109,7 @@ export function protocolAuthPayload(
|
||||
return payload;
|
||||
}
|
||||
|
||||
export function createHostEditorForm(
|
||||
host: Host | null,
|
||||
defaults?: HostDefaults,
|
||||
) {
|
||||
const d = host ? undefined : defaults;
|
||||
export function createHostEditorForm(host: Host | null) {
|
||||
const sshOptions = host?.sshOptions ?? {};
|
||||
|
||||
return {
|
||||
@@ -130,12 +126,7 @@ export function createHostEditorForm(
|
||||
: (host?.keyPassword ?? ""),
|
||||
keyType: host?.keyType ?? "auto",
|
||||
keySubTab: "paste" as "paste" | "upload",
|
||||
credentialId:
|
||||
host?.credentialId != null
|
||||
? String(host.credentialId)
|
||||
: d?.credentialId != null
|
||||
? String(d.credentialId)
|
||||
: "",
|
||||
credentialId: host?.credentialId != null ? String(host.credentialId) : "",
|
||||
overrideCredentialUsername: host?.overrideCredentialUsername ?? false,
|
||||
folder: host?.folder ?? "",
|
||||
parentHostId: host?.parentHostId ?? "",
|
||||
@@ -143,11 +134,11 @@ export function createHostEditorForm(
|
||||
tagInput: "",
|
||||
notes: host?.notes ?? "",
|
||||
pin: host?.pin ?? false,
|
||||
useSocks5: host?.useSocks5 ?? d?.useSocks5 ?? false,
|
||||
socks5Host: host?.socks5Host ?? d?.socks5Host ?? "",
|
||||
socks5Port: host?.socks5Port ?? d?.socks5Port ?? 1080,
|
||||
socks5Username: host?.socks5Username ?? d?.socks5Username ?? "",
|
||||
socks5Password: host?.socks5Password ?? d?.socks5Password ?? "",
|
||||
useSocks5: host?.useSocks5 ?? false,
|
||||
socks5Host: host?.socks5Host ?? "",
|
||||
socks5Port: host?.socks5Port ?? 1080,
|
||||
socks5Username: host?.socks5Username ?? "",
|
||||
socks5Password: host?.socks5Password ?? "",
|
||||
socks5ProxyMode: ((host?.socks5ProxyChain ?? []).length > 0
|
||||
? "chain"
|
||||
: "single") as "single" | "chain",
|
||||
@@ -174,8 +165,7 @@ export function createHostEditorForm(
|
||||
([] as { port: number; protocol: "tcp" | "udp"; delay: number }[]),
|
||||
// Each plugin protocol's login, edited by that plugin's host editor tab.
|
||||
protocolAuth: protocolAuthForm(host?.protocolAuth),
|
||||
statusCheckEnabled:
|
||||
host?.statusCheckEnabled ?? d?.statusCheckEnabled ?? true,
|
||||
statusCheckEnabled: host?.statusCheckEnabled ?? true,
|
||||
statusCheckInterval: (host?.statusCheckInterval ?? null) as number | null,
|
||||
|
||||
// Host-scope plugin settings, keyed by plugin id. Loaded with the host and
|
||||
@@ -184,6 +174,12 @@ export function createHostEditorForm(
|
||||
string,
|
||||
Record<string, unknown>
|
||||
>,
|
||||
|
||||
// Host default keys the host sets itself; the rest follow its defaults.
|
||||
// Null until known, for a host saved before host defaults.
|
||||
defaultOverrides: (host
|
||||
? (host.defaultOverrides ?? null)
|
||||
: {}) as DefaultOverrides | null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -290,6 +286,9 @@ export function buildHostEditorPayload(
|
||||
portKnockSequence: form.portKnockSequence,
|
||||
statusCheckEnabled: form.statusCheckEnabled,
|
||||
statusCheckInterval: form.statusCheckInterval,
|
||||
...(form.defaultOverrides
|
||||
? { defaultOverrides: form.defaultOverrides }
|
||||
: {}),
|
||||
// Left out with SSH off, so the stored values stay as they are.
|
||||
...(protocols.enableSsh
|
||||
? {
|
||||
|
||||
@@ -15,6 +15,11 @@ import { useSyncStatus } from "@/hooks/use-sync-status";
|
||||
import type { HostEditorForm, HostProtocols } from "./HostEditorData";
|
||||
import { Select2 } from "@/components/select2";
|
||||
import { useHostProtocols } from "./host-protocols";
|
||||
import {
|
||||
DefaultsOnly,
|
||||
HostDefaultBadge,
|
||||
HostOnly,
|
||||
} from "@/lib/host-defaults-context";
|
||||
|
||||
type HostEditorSetField = <K extends keyof HostEditorForm>(
|
||||
key: K,
|
||||
@@ -102,235 +107,242 @@ export function HostEditorGeneralTab({
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Protocols — enable/disable each connection type */}
|
||||
<SectionCard
|
||||
title={t("hosts.protocols")}
|
||||
icon={<Globe className="size-3.5" />}
|
||||
>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-2 py-3">
|
||||
{[
|
||||
{
|
||||
proto: "enableSsh",
|
||||
label: t("hosts.tabSsh"),
|
||||
desc: t("hosts.secureShell"),
|
||||
icon: <Terminal className="size-4" />,
|
||||
},
|
||||
...pluginProtocols.map((protocol) => {
|
||||
const Icon = protocol.icon;
|
||||
return {
|
||||
proto: protocol.settingKey,
|
||||
label: t(protocol.titleKey),
|
||||
desc: protocol.descriptionKey ? t(protocol.descriptionKey) : "",
|
||||
icon: <Icon className="size-4" />,
|
||||
};
|
||||
}),
|
||||
].map(({ proto, label, desc, icon }) => {
|
||||
const enabled = !!protocols[proto];
|
||||
return (
|
||||
<div
|
||||
key={proto}
|
||||
className={`flex items-center gap-3 p-3 border transition-colors ${enabled ? "border-accent-brand/20 bg-accent-brand/5" : "border-border bg-muted/10"}`}
|
||||
>
|
||||
<HostOnly>
|
||||
{/* Protocols — enable/disable each connection type */}
|
||||
<SectionCard
|
||||
title={t("hosts.protocols")}
|
||||
icon={<Globe className="size-3.5" />}
|
||||
>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-2 py-3">
|
||||
{[
|
||||
{
|
||||
proto: "enableSsh",
|
||||
label: t("hosts.tabSsh"),
|
||||
desc: t("hosts.secureShell"),
|
||||
icon: <Terminal className="size-4" />,
|
||||
},
|
||||
...pluginProtocols.map((protocol) => {
|
||||
const Icon = protocol.icon;
|
||||
return {
|
||||
proto: protocol.settingKey,
|
||||
label: t(protocol.titleKey),
|
||||
desc: protocol.descriptionKey
|
||||
? t(protocol.descriptionKey)
|
||||
: "",
|
||||
icon: <Icon className="size-4" />,
|
||||
};
|
||||
}),
|
||||
].map(({ proto, label, desc, icon }) => {
|
||||
const enabled = !!protocols[proto];
|
||||
return (
|
||||
<div
|
||||
className={`size-8 flex items-center justify-center shrink-0 ${enabled ? "text-accent-brand" : "text-muted-foreground/30"}`}
|
||||
key={proto}
|
||||
className={`flex items-center gap-3 p-3 border transition-colors ${enabled ? "border-accent-brand/20 bg-accent-brand/5" : "border-border bg-muted/10"}`}
|
||||
>
|
||||
{icon}
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 flex-1 min-w-0">
|
||||
<span
|
||||
className={`text-xs font-bold ${enabled ? "text-foreground" : "text-muted-foreground/50"}`}
|
||||
<div
|
||||
className={`size-8 flex items-center justify-center shrink-0 ${enabled ? "text-accent-brand" : "text-muted-foreground/30"}`}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
<span className="text-[10px] text-muted-foreground/50">
|
||||
{desc}
|
||||
</span>
|
||||
{icon}
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 flex-1 min-w-0">
|
||||
<span
|
||||
className={`text-xs font-bold ${enabled ? "text-foreground" : "text-muted-foreground/50"}`}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
<span className="text-[10px] text-muted-foreground/50">
|
||||
{desc}
|
||||
</span>
|
||||
</div>
|
||||
<FakeSwitch
|
||||
checked={enabled}
|
||||
onChange={(v: boolean) => handleProtocolToggle(proto, v)}
|
||||
/>
|
||||
</div>
|
||||
<FakeSwitch
|
||||
checked={enabled}
|
||||
onChange={(v: boolean) => handleProtocolToggle(proto, v)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard
|
||||
title={t("hosts.connectionDetails")}
|
||||
icon={<Globe className="size-3.5" />}
|
||||
>
|
||||
<div className="flex flex-col gap-4 py-3">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("hosts.addressIp")}
|
||||
</label>
|
||||
<Input
|
||||
placeholder="10.0.0.1 or example.com"
|
||||
value={form.ip}
|
||||
onChange={(e) => setField("ip", e.target.value)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</SectionCard>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<SectionCard
|
||||
title={t("hosts.connectionDetails")}
|
||||
icon={<Globe className="size-3.5" />}
|
||||
>
|
||||
<div className="flex flex-col gap-4 py-3">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("hosts.friendlyName")}
|
||||
{t("hosts.addressIp")}
|
||||
</label>
|
||||
<Input
|
||||
placeholder="e.g. Web Server Production"
|
||||
value={form.name}
|
||||
onChange={(e) => setField("name", e.target.value)}
|
||||
placeholder="10.0.0.1 or example.com"
|
||||
value={form.ip}
|
||||
onChange={(e) => setField("ip", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SectionCard>
|
||||
|
||||
{!Object.values(protocols).some(Boolean) && (
|
||||
<div className="flex items-center gap-3 p-3 border border-border bg-muted/20 text-xs text-muted-foreground">
|
||||
<Globe className="size-4 shrink-0 text-muted-foreground/40" />
|
||||
<span>{t("hosts.enableAtLeastOneProtocol")}</span>
|
||||
</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">
|
||||
{t("hosts.friendlyName")}
|
||||
</label>
|
||||
<Input
|
||||
placeholder="e.g. Web Server Production"
|
||||
value={form.name}
|
||||
onChange={(e) => setField("name", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SectionCard>
|
||||
|
||||
{!Object.values(protocols).some(Boolean) && (
|
||||
<div className="flex items-center gap-3 p-3 border border-border bg-muted/20 text-xs text-muted-foreground">
|
||||
<Globe className="size-4 shrink-0 text-muted-foreground/40" />
|
||||
<span>{t("hosts.enableAtLeastOneProtocol")}</span>
|
||||
</div>
|
||||
)}
|
||||
</HostOnly>
|
||||
|
||||
<SectionCard
|
||||
title={t("hosts.folderAndAdvanced")}
|
||||
icon={<Tag className="size-3.5" />}
|
||||
className={simpleMode ? "hidden" : undefined}
|
||||
>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 py-3">
|
||||
<div className="flex flex-col gap-1.5 col-span-2 md:col-span-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{placementMode === "parentHost"
|
||||
? t("hosts.parentHost")
|
||||
: t("hosts.folder")}
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (placementMode === "parentHost") {
|
||||
setPlacementMode("folder");
|
||||
setField("parentHostId", "");
|
||||
} else {
|
||||
setPlacementMode("parentHost");
|
||||
setField("folder", "");
|
||||
}
|
||||
}}
|
||||
className="text-[10px] text-accent-brand hover:underline"
|
||||
>
|
||||
{placementMode === "parentHost"
|
||||
? t("hosts.useFolderInstead")
|
||||
: t("hosts.useParentHostInstead")}
|
||||
</button>
|
||||
</div>
|
||||
{placementMode === "parentHost" ? (
|
||||
<HostParentPicker
|
||||
value={String(form.parentHostId)}
|
||||
onChange={(hostId) => setField("parentHostId", hostId)}
|
||||
hosts={hosts}
|
||||
excludeHostId={host?.id}
|
||||
/>
|
||||
) : (
|
||||
<FolderPathPicker
|
||||
value={form.folder}
|
||||
onChange={(path) => setField("folder", path)}
|
||||
folderPaths={folderPaths}
|
||||
folderMeta={folderMeta}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("hosts.tags")}
|
||||
</label>
|
||||
<div className="flex flex-wrap items-center gap-1 min-h-9 px-2 py-1 border border-border bg-background focus-within:ring-1 focus-within:ring-ring">
|
||||
{form.tags.map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className="flex items-center gap-0.5 px-1.5 py-0.5 text-[10px] bg-muted border border-border/60 text-foreground"
|
||||
>
|
||||
{tag}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setField(
|
||||
"tags",
|
||||
form.tags.filter((tg) => tg !== tag),
|
||||
)
|
||||
<HostOnly>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 py-3">
|
||||
<div className="flex flex-col gap-1.5 col-span-2 md:col-span-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{placementMode === "parentHost"
|
||||
? t("hosts.parentHost")
|
||||
: t("hosts.folder")}
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (placementMode === "parentHost") {
|
||||
setPlacementMode("folder");
|
||||
setField("parentHostId", "");
|
||||
} else {
|
||||
setPlacementMode("parentHost");
|
||||
setField("folder", "");
|
||||
}
|
||||
className="text-muted-foreground hover:text-destructive ml-0.5"
|
||||
}}
|
||||
className="text-[10px] text-accent-brand hover:underline"
|
||||
>
|
||||
{placementMode === "parentHost"
|
||||
? t("hosts.useFolderInstead")
|
||||
: t("hosts.useParentHostInstead")}
|
||||
</button>
|
||||
</div>
|
||||
{placementMode === "parentHost" ? (
|
||||
<HostParentPicker
|
||||
value={String(form.parentHostId)}
|
||||
onChange={(hostId) => setField("parentHostId", hostId)}
|
||||
hosts={hosts}
|
||||
excludeHostId={host?.id}
|
||||
/>
|
||||
) : (
|
||||
<FolderPathPicker
|
||||
value={form.folder}
|
||||
onChange={(path) => setField("folder", path)}
|
||||
folderPaths={folderPaths}
|
||||
folderMeta={folderMeta}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("hosts.tags")}
|
||||
</label>
|
||||
<div className="flex flex-wrap items-center gap-1 min-h-9 px-2 py-1 border border-border bg-background focus-within:ring-1 focus-within:ring-ring">
|
||||
{form.tags.map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className="flex items-center gap-0.5 px-1.5 py-0.5 text-[10px] bg-muted border border-border/60 text-foreground"
|
||||
>
|
||||
<X className="size-2.5" />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
<input
|
||||
className="flex-1 min-w-16 text-xs bg-transparent outline-none placeholder:text-muted-foreground/50"
|
||||
placeholder={form.tags.length === 0 ? t("hosts.addTag") : ""}
|
||||
value={form.tagInput}
|
||||
onChange={(e) => setField("tagInput", e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (
|
||||
(e.key === " " || e.key === "Enter") &&
|
||||
form.tagInput.trim()
|
||||
) {
|
||||
e.preventDefault();
|
||||
const tag = form.tagInput.trim();
|
||||
if (!form.tags.includes(tag))
|
||||
setField("tags", [...form.tags, tag]);
|
||||
setField("tagInput", "");
|
||||
} else if (
|
||||
e.key === "Backspace" &&
|
||||
!form.tagInput &&
|
||||
form.tags.length > 0
|
||||
) {
|
||||
setField("tags", form.tags.slice(0, -1));
|
||||
}
|
||||
}}
|
||||
{tag}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setField(
|
||||
"tags",
|
||||
form.tags.filter((tg) => tg !== tag),
|
||||
)
|
||||
}
|
||||
className="text-muted-foreground hover:text-destructive ml-0.5"
|
||||
>
|
||||
<X className="size-2.5" />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
<input
|
||||
className="flex-1 min-w-16 text-xs bg-transparent outline-none placeholder:text-muted-foreground/50"
|
||||
placeholder={form.tags.length === 0 ? t("hosts.addTag") : ""}
|
||||
value={form.tagInput}
|
||||
onChange={(e) => setField("tagInput", e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (
|
||||
(e.key === " " || e.key === "Enter") &&
|
||||
form.tagInput.trim()
|
||||
) {
|
||||
e.preventDefault();
|
||||
const tag = form.tagInput.trim();
|
||||
if (!form.tags.includes(tag))
|
||||
setField("tags", [...form.tags, tag]);
|
||||
setField("tagInput", "");
|
||||
} else if (
|
||||
e.key === "Backspace" &&
|
||||
!form.tagInput &&
|
||||
form.tags.length > 0
|
||||
) {
|
||||
setField("tags", form.tags.slice(0, -1));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5 col-span-2">
|
||||
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("hosts.privateNotes")}
|
||||
</label>
|
||||
<textarea
|
||||
rows={3}
|
||||
placeholder={t("hosts.privateNotesPlaceholder")}
|
||||
className="w-full px-3 py-2 text-xs bg-background border border-border text-foreground placeholder:text-muted-foreground resize-none outline-none focus:ring-1 focus:ring-ring"
|
||||
value={form.notes}
|
||||
onChange={(e) => setField("notes", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5 col-span-2">
|
||||
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("hosts.privateNotes")}
|
||||
</label>
|
||||
<textarea
|
||||
rows={3}
|
||||
placeholder={t("hosts.privateNotesPlaceholder")}
|
||||
className="w-full px-3 py-2 text-xs bg-background border border-border text-foreground placeholder:text-muted-foreground resize-none outline-none focus:ring-1 focus:ring-ring"
|
||||
value={form.notes}
|
||||
onChange={(e) => setField("notes", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<SettingRow
|
||||
label={t("hosts.pinToTop")}
|
||||
description={t("hosts.pinToTopDesc")}
|
||||
>
|
||||
<FakeSwitch
|
||||
checked={form.pin}
|
||||
onChange={(v) => setField("pin", v)}
|
||||
/>
|
||||
</SettingRow>
|
||||
{syncLinked && (
|
||||
<SettingRow
|
||||
label={t("hosts.localOnly")}
|
||||
description={t("hosts.localOnlyDesc")}
|
||||
label={t("hosts.pinToTop")}
|
||||
description={t("hosts.pinToTopDesc")}
|
||||
>
|
||||
<FakeSwitch
|
||||
checked={form.localOnly}
|
||||
onChange={(v) => setField("localOnly", v)}
|
||||
checked={form.pin}
|
||||
onChange={(v) => setField("pin", v)}
|
||||
/>
|
||||
</SettingRow>
|
||||
)}
|
||||
</div>
|
||||
{syncLinked && (
|
||||
<SettingRow
|
||||
label={t("hosts.localOnly")}
|
||||
description={t("hosts.localOnlyDesc")}
|
||||
>
|
||||
<FakeSwitch
|
||||
checked={form.localOnly}
|
||||
onChange={(v) => setField("localOnly", v)}
|
||||
/>
|
||||
</SettingRow>
|
||||
)}
|
||||
</div>
|
||||
</HostOnly>
|
||||
<div className="flex flex-col gap-3 border-t border-border pt-4 pb-0">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("hosts.portKnockingSequence")}
|
||||
</span>
|
||||
<HostDefaultBadge settingKey="core.portKnockSequence" />
|
||||
<a
|
||||
href="https://docs.termix.site/features/networking/port-knocking"
|
||||
target="_blank"
|
||||
@@ -445,6 +457,7 @@ export function HostEditorGeneralTab({
|
||||
<SettingRow
|
||||
label={t("hosts.useSocks5Proxy")}
|
||||
description={t("hosts.useSocks5ProxyDesc")}
|
||||
defaultKey="core.socks5"
|
||||
>
|
||||
<FakeSwitch
|
||||
checked={form.useSocks5}
|
||||
@@ -509,19 +522,21 @@ export function HostEditorGeneralTab({
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("hosts.proxyPassword")}
|
||||
</label>
|
||||
<PasswordInput
|
||||
className="h-7 text-xs pr-8"
|
||||
placeholder={t("hosts.optional")}
|
||||
value={form.socks5Password}
|
||||
onChange={(e) =>
|
||||
setField("socks5Password", e.target.value)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<HostOnly>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("hosts.proxyPassword")}
|
||||
</label>
|
||||
<PasswordInput
|
||||
className="h-7 text-xs pr-8"
|
||||
placeholder={t("hosts.optional")}
|
||||
value={form.socks5Password}
|
||||
onChange={(e) =>
|
||||
setField("socks5Password", e.target.value)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</HostOnly>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -625,24 +640,26 @@ export function HostEditorGeneralTab({
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 col-span-2">
|
||||
<label className="text-[9px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("hosts.proxyPassword")}
|
||||
</label>
|
||||
<PasswordInput
|
||||
className="h-7 text-xs pr-8"
|
||||
placeholder={t("hosts.optional")}
|
||||
value={node.password}
|
||||
onChange={(e) => {
|
||||
const u = [...form.socks5ProxyChain];
|
||||
u[ni] = {
|
||||
...u[ni],
|
||||
password: e.target.value,
|
||||
};
|
||||
setField("socks5ProxyChain", u);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<HostOnly>
|
||||
<div className="flex flex-col gap-1 col-span-2">
|
||||
<label className="text-[9px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("hosts.proxyPassword")}
|
||||
</label>
|
||||
<PasswordInput
|
||||
className="h-7 text-xs pr-8"
|
||||
placeholder={t("hosts.optional")}
|
||||
value={node.password}
|
||||
onChange={(e) => {
|
||||
const u = [...form.socks5ProxyChain];
|
||||
u[ni] = {
|
||||
...u[ni],
|
||||
password: e.target.value,
|
||||
};
|
||||
setField("socks5ProxyChain", u);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</HostOnly>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
@@ -717,95 +734,100 @@ export function HostEditorGeneralTab({
|
||||
</div>
|
||||
)}
|
||||
{isElectron() && connectionOriginAppliesTo(protocols) && (
|
||||
<SettingRow
|
||||
label={t("hosts.connectionOrigin")}
|
||||
description={t("hosts.connectionOriginDesc")}
|
||||
>
|
||||
<select
|
||||
className="flex h-7 border border-border bg-background px-2 py-0 text-xs outline-none focus:ring-1 focus:ring-ring"
|
||||
value={form.connectionOrigin ?? ""}
|
||||
onChange={(e) =>
|
||||
setField(
|
||||
"connectionOrigin",
|
||||
(e.target.value || null) as "local" | "remote" | null,
|
||||
)
|
||||
}
|
||||
<HostOnly>
|
||||
<SettingRow
|
||||
label={t("hosts.connectionOrigin")}
|
||||
description={t("hosts.connectionOriginDesc")}
|
||||
>
|
||||
<option value="">{t("hosts.connectionOriginDefault")}</option>
|
||||
<option value="local">
|
||||
{t("hosts.connectionOriginLocal")}
|
||||
</option>
|
||||
<option value="remote">
|
||||
{t("hosts.connectionOriginRemote")}
|
||||
</option>
|
||||
</select>
|
||||
</SettingRow>
|
||||
)}
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("hosts.jumpHostChainLabel")}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-6 text-[10px] px-2 border-accent-brand/40 text-accent-brand"
|
||||
onClick={() =>
|
||||
setField("jumpHosts", [...form.jumpHosts, { hostId: "" }])
|
||||
}
|
||||
>
|
||||
<Plus className="size-3 mr-1" /> {t("hosts.addJumpBtn")}
|
||||
</Button>
|
||||
</div>
|
||||
{form.jumpHosts.length === 0 && (
|
||||
<p className="text-[10px] text-muted-foreground/50">
|
||||
{t("hosts.noJumpHosts")}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex flex-col gap-2">
|
||||
{form.jumpHosts.map((jh, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex items-center gap-2 p-2 bg-background border border-border"
|
||||
<select
|
||||
className="flex h-7 border border-border bg-background px-2 py-0 text-xs outline-none focus:ring-1 focus:ring-ring"
|
||||
value={form.connectionOrigin ?? ""}
|
||||
onChange={(e) =>
|
||||
setField(
|
||||
"connectionOrigin",
|
||||
(e.target.value || null) as "local" | "remote" | null,
|
||||
)
|
||||
}
|
||||
>
|
||||
<span className="text-[10px] font-bold text-muted-foreground shrink-0">
|
||||
{i + 1}.
|
||||
</span>
|
||||
<Select2
|
||||
className="flex h-7 flex-1 border border-border bg-background px-2 py-0 text-xs outline-none focus:ring-1 focus:ring-ring"
|
||||
value={jh.hostId}
|
||||
onChange={(e) => {
|
||||
const updated = [...form.jumpHosts];
|
||||
updated[i] = { hostId: e.target.value };
|
||||
setField("jumpHosts", updated);
|
||||
}}
|
||||
<option value="">{t("hosts.connectionOriginDefault")}</option>
|
||||
<option value="local">
|
||||
{t("hosts.connectionOriginLocal")}
|
||||
</option>
|
||||
<option value="remote">
|
||||
{t("hosts.connectionOriginRemote")}
|
||||
</option>
|
||||
</select>
|
||||
</SettingRow>
|
||||
</HostOnly>
|
||||
)}
|
||||
<DefaultsOnly settingKey="core.jumpHosts">
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="flex items-center gap-1.5 text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("hosts.jumpHostChainLabel")}
|
||||
<HostDefaultBadge settingKey="core.jumpHosts" />
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-6 text-[10px] px-2 border-accent-brand/40 text-accent-brand"
|
||||
onClick={() =>
|
||||
setField("jumpHosts", [...form.jumpHosts, { hostId: "" }])
|
||||
}
|
||||
>
|
||||
<Plus className="size-3 mr-1" /> {t("hosts.addJumpBtn")}
|
||||
</Button>
|
||||
</div>
|
||||
{form.jumpHosts.length === 0 && (
|
||||
<p className="text-[10px] text-muted-foreground/50">
|
||||
{t("hosts.noJumpHosts")}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex flex-col gap-2">
|
||||
{form.jumpHosts.map((jh, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex items-center gap-2 p-2 bg-background border border-border"
|
||||
>
|
||||
<option value="">{t("hosts.selectAServer")}</option>
|
||||
{hosts
|
||||
.filter((h) => (host ? h.id !== host.id : true))
|
||||
.map((h) => (
|
||||
<option key={h.id} value={h.id}>
|
||||
{h.name || h.ip}
|
||||
</option>
|
||||
))}
|
||||
</Select2>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-7 text-destructive"
|
||||
onClick={() =>
|
||||
setField(
|
||||
"jumpHosts",
|
||||
form.jumpHosts.filter((_, idx) => idx !== i),
|
||||
)
|
||||
}
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
<span className="text-[10px] font-bold text-muted-foreground shrink-0">
|
||||
{i + 1}.
|
||||
</span>
|
||||
<Select2
|
||||
className="flex h-7 flex-1 border border-border bg-background px-2 py-0 text-xs outline-none focus:ring-1 focus:ring-ring"
|
||||
value={jh.hostId}
|
||||
onChange={(e) => {
|
||||
const updated = [...form.jumpHosts];
|
||||
updated[i] = { hostId: e.target.value };
|
||||
setField("jumpHosts", updated);
|
||||
}}
|
||||
>
|
||||
<option value="">{t("hosts.selectAServer")}</option>
|
||||
{hosts
|
||||
.filter((h) => (host ? h.id !== host.id : true))
|
||||
.map((h) => (
|
||||
<option key={h.id} value={h.id}>
|
||||
{h.name || h.ip}
|
||||
</option>
|
||||
))}
|
||||
</Select2>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-7 text-destructive"
|
||||
onClick={() =>
|
||||
setField(
|
||||
"jumpHosts",
|
||||
form.jumpHosts.filter((_, idx) => idx !== i),
|
||||
)
|
||||
}
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DefaultsOnly>
|
||||
</div>
|
||||
</SectionCard>
|
||||
|
||||
@@ -817,6 +839,7 @@ export function HostEditorGeneralTab({
|
||||
<SettingRow
|
||||
label={t("hosts.enableStatusChecks")}
|
||||
description={t("hosts.enableStatusChecksDesc")}
|
||||
defaultKey="core.statusCheckEnabled"
|
||||
>
|
||||
<FakeSwitch
|
||||
checked={form.statusCheckEnabled}
|
||||
@@ -827,6 +850,7 @@ export function HostEditorGeneralTab({
|
||||
<SettingRow
|
||||
label={t("hosts.useGlobalInterval")}
|
||||
description={t("hosts.useGlobalIntervalDesc")}
|
||||
defaultKey="core.statusCheckInterval"
|
||||
>
|
||||
<FakeSwitch
|
||||
checked={form.statusCheckInterval === null}
|
||||
|
||||
@@ -11,6 +11,12 @@ import React, {
|
||||
type MutableRefObject,
|
||||
} from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { HostDefaultsLevel } from "@/types/host-defaults";
|
||||
import { getFolderDefaultsId } from "@/api/host-defaults-api";
|
||||
import {
|
||||
HostDefaultsEditorView,
|
||||
type HostDefaultsEditorTarget,
|
||||
} from "./host-defaults/HostDefaultsEditorView";
|
||||
import type { HostDraft } from "@termix/plugin-sdk/frontend";
|
||||
import { CredentialShareModal } from "./CredentialShareModal";
|
||||
|
||||
@@ -83,6 +89,9 @@ export function HostManager({
|
||||
useHostEditorSections();
|
||||
const { t } = useTranslation();
|
||||
const [editingHost, setEditingHost] = useState<Host | "new" | null>(null);
|
||||
// A level of host defaults open in the editor instead of a host.
|
||||
const [editingDefaults, setEditingDefaults] =
|
||||
useState<HostDefaultsEditorTarget | null>(null);
|
||||
// Fields a plugin filled in for a new host, keyed so a second draft remounts
|
||||
// the editor.
|
||||
const [hostDraft, setHostDraft] = useState<{
|
||||
@@ -223,6 +232,25 @@ export function HostManager({
|
||||
setEditingHost(null);
|
||||
setActiveCredentialTab("general");
|
||||
};
|
||||
const handleEditDefaults = (e: Event) => {
|
||||
const detail = (
|
||||
e as CustomEvent<{ level: HostDefaultsLevel; folderName?: string }>
|
||||
).detail;
|
||||
if (!detail) return;
|
||||
const open = (target: HostDefaultsEditorTarget) => {
|
||||
setEditingHost(null);
|
||||
setEditingCredential(null);
|
||||
setEditingDefaults(target);
|
||||
};
|
||||
if (detail.level === "folder" && detail.folderName) {
|
||||
const folderName = detail.folderName;
|
||||
getFolderDefaultsId(folderName)
|
||||
.then((folderId) => open({ level: "folder", folderId, folderName }))
|
||||
.catch(() => toast.error(t("hostDefaults.loadFailed")));
|
||||
} else if (detail.level !== "folder") {
|
||||
open({ level: detail.level });
|
||||
}
|
||||
};
|
||||
const handleEditHost = (e: Event) => {
|
||||
const id = (e as CustomEvent<string>).detail;
|
||||
const host = hostsRef.current.find((h) => h.id === id);
|
||||
@@ -236,7 +264,12 @@ export function HostManager({
|
||||
window.addEventListener("host-manager:add-host", handleAddHost);
|
||||
window.addEventListener("host-manager:add-credential", handleAddCredential);
|
||||
window.addEventListener("host-manager:edit-host", handleEditHost);
|
||||
window.addEventListener("host-manager:edit-defaults", handleEditDefaults);
|
||||
return () => {
|
||||
window.removeEventListener(
|
||||
"host-manager:edit-defaults",
|
||||
handleEditDefaults,
|
||||
);
|
||||
window.removeEventListener("host-manager:add-host", handleAddHost);
|
||||
window.removeEventListener(
|
||||
"host-manager:add-credential",
|
||||
@@ -386,6 +419,7 @@ export function HostManager({
|
||||
setHostEditorDirty(false);
|
||||
setShowUnsavedHostDialog(false);
|
||||
setEditingHost(null);
|
||||
setEditingDefaults(null);
|
||||
setActiveHostTab("general");
|
||||
};
|
||||
|
||||
@@ -398,6 +432,17 @@ export function HostManager({
|
||||
};
|
||||
|
||||
const renderEditorView = () => {
|
||||
if (editingDefaults) {
|
||||
return (
|
||||
<HostDefaultsEditorView
|
||||
target={editingDefaults}
|
||||
onClose={requestCloseHostEditor}
|
||||
onDirtyChange={setHostEditorDirty}
|
||||
hosts={hosts}
|
||||
credentials={credentials}
|
||||
/>
|
||||
);
|
||||
}
|
||||
const isHost = !!editingHost;
|
||||
// Simple mode keeps General and SSH -- between them they hold everything
|
||||
// needed to reach a host (name, address, username, auth) -- and hides the
|
||||
@@ -657,7 +702,7 @@ export function HostManager({
|
||||
);
|
||||
};
|
||||
|
||||
const isEditing = !!editingHost || !!editingCredential;
|
||||
const isEditing = !!editingHost || !!editingCredential || !!editingDefaults;
|
||||
|
||||
useEffect(() => {
|
||||
if (active) onEditingChange?.(isEditing);
|
||||
|
||||
@@ -80,6 +80,7 @@ export function sshHostToHost(h: SSHHostWithStatus): Host {
|
||||
(h.connectionType === "ssh" || !h.connectionType ? h.port : 22),
|
||||
protocolAuth: h.protocolAuth ?? {},
|
||||
pluginSettings: h.pluginSettings ?? {},
|
||||
defaultOverrides: h.defaultOverrides ?? null,
|
||||
jumpHosts: (parseJson<HostJumpHost[]>(h.jumpHosts) ?? []).map((j) => ({
|
||||
hostId: String(j.hostId ?? j.hostid ?? j),
|
||||
})),
|
||||
|
||||
@@ -32,6 +32,8 @@ export interface HostEditorSectionRenderProps {
|
||||
credentials?: unknown[];
|
||||
adminTargetUserId?: string;
|
||||
protocols: Record<string, boolean>;
|
||||
/** "defaults" in the host defaults editor. */
|
||||
mode?: "host" | "defaults";
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -50,6 +52,8 @@ export interface HostEditorSectionDef {
|
||||
order?: number;
|
||||
/** Whether to offer the tab for these protocols. Defaults to always. */
|
||||
visible?: (protocols: Record<string, boolean>) => boolean;
|
||||
/** Also offered in the host defaults editor; "only" for nowhere else. */
|
||||
defaults?: boolean | "only";
|
||||
component: ComponentType<HostEditorSectionRenderProps>;
|
||||
}
|
||||
|
||||
@@ -86,13 +90,15 @@ function mergeByOrder(
|
||||
group: "top" | "ssh",
|
||||
t: (key: string) => string,
|
||||
protocols: Record<string, boolean> | undefined,
|
||||
defaultsMode = false,
|
||||
): HostTab[] {
|
||||
const registered = sections
|
||||
.list()
|
||||
.filter(
|
||||
(section) =>
|
||||
section.group === group &&
|
||||
(!protocols || sectionVisible(section, protocols)),
|
||||
(defaultsMode ? !!section.defaults : section.defaults !== "only") &&
|
||||
(defaultsMode || !protocols || sectionVisible(section, protocols)),
|
||||
)
|
||||
.map((section) => {
|
||||
const Icon = section.icon;
|
||||
@@ -115,6 +121,7 @@ function mergeByOrder(
|
||||
export function makeHostTabs(
|
||||
t: (key: string) => string,
|
||||
protocols?: Record<string, boolean>,
|
||||
defaultsMode = false,
|
||||
): HostTab[] {
|
||||
return mergeByOrder(
|
||||
[
|
||||
@@ -134,12 +141,14 @@ export function makeHostTabs(
|
||||
"top",
|
||||
t,
|
||||
protocols,
|
||||
defaultsMode,
|
||||
);
|
||||
}
|
||||
|
||||
export function makeHostSshSubTabs(
|
||||
t: (key: string) => string,
|
||||
protocols?: Record<string, boolean>,
|
||||
defaultsMode = false,
|
||||
): HostTab[] {
|
||||
return mergeByOrder(
|
||||
[
|
||||
@@ -153,6 +162,7 @@ export function makeHostSshSubTabs(
|
||||
"ssh",
|
||||
t,
|
||||
protocols,
|
||||
defaultsMode,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Search,
|
||||
Settings2,
|
||||
SlidersHorizontal,
|
||||
Upload,
|
||||
X,
|
||||
@@ -927,6 +928,18 @@ export function HostsPanel({
|
||||
? t("hosts.exitSelectionTitle")
|
||||
: t("hosts.selectHosts")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("host-manager:edit-defaults", {
|
||||
detail: { level: "user" },
|
||||
}),
|
||||
)
|
||||
}
|
||||
>
|
||||
<Settings2 className="size-3.5 mr-2" />
|
||||
{t("hostDefaults.menuMyDefaults")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger className="flex items-center gap-2">
|
||||
<Upload className="size-3.5 shrink-0" />
|
||||
|
||||
@@ -6,6 +6,11 @@ import { Input } from "@/components/input";
|
||||
import type { Host } from "@/types/ui-types";
|
||||
import { getCredentials } from "@/api/credentials-api";
|
||||
import { mapCredentials } from "./HostManagerData";
|
||||
import { resolveHostDefaults } from "@/api/host-defaults-api";
|
||||
import {
|
||||
pluginSettingsFrom,
|
||||
withDefaultPluginSettings,
|
||||
} from "./host-defaults/quick-connect-defaults";
|
||||
import {
|
||||
createQuickConnectHost,
|
||||
quickConnectTargets,
|
||||
@@ -88,19 +93,32 @@ export function QuickConnectPanel({ onConnect }: QuickConnectPanelProps) {
|
||||
setProtocol(next);
|
||||
};
|
||||
|
||||
// A host that is never saved still follows the user's host defaults.
|
||||
const [defaultPluginSettings, setDefaultPluginSettings] = useState<
|
||||
Record<string, Record<string, unknown>>
|
||||
>({});
|
||||
useEffect(() => {
|
||||
resolveHostDefaults({ folder: null })
|
||||
.then((values) => setDefaultPluginSettings(pluginSettingsFrom(values)))
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
const buildHost = () =>
|
||||
createQuickConnectHost({
|
||||
ip: host.trim(),
|
||||
port: parseInt(port) || parseInt(defaultPort(protocol)),
|
||||
username,
|
||||
authType: isDesktop ? "password" : authType,
|
||||
password,
|
||||
key: privateKey,
|
||||
credentialId,
|
||||
protocol: selected,
|
||||
domain: domain || undefined,
|
||||
authFields: INLINE_AUTH_TYPES.has(authType) ? undefined : authFields,
|
||||
});
|
||||
withDefaultPluginSettings(
|
||||
createQuickConnectHost({
|
||||
ip: host.trim(),
|
||||
port: parseInt(port) || parseInt(defaultPort(protocol)),
|
||||
username,
|
||||
authType: isDesktop ? "password" : authType,
|
||||
password,
|
||||
key: privateKey,
|
||||
credentialId,
|
||||
protocol: selected,
|
||||
domain: domain || undefined,
|
||||
authFields: INLINE_AUTH_TYPES.has(authType) ? undefined : authFields,
|
||||
}),
|
||||
defaultPluginSettings,
|
||||
);
|
||||
|
||||
const targets = quickConnectTargets(allActions, buildHost());
|
||||
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import type { Host } from "@/types/ui-types";
|
||||
import type { HostDefaultsTarget } from "@/api/host-defaults-api";
|
||||
import { HostEditor } from "../HostEditor";
|
||||
import {
|
||||
isSshGroupTab,
|
||||
makeHostSshSubTabs,
|
||||
makeHostTabs,
|
||||
TabStrip,
|
||||
useHostEditorSections,
|
||||
} from "../HostManagerTabs";
|
||||
import { listHostProtocols, type HostProtocols } from "../host-protocols";
|
||||
|
||||
export interface HostDefaultsEditorTarget extends HostDefaultsTarget {
|
||||
/** The folder's path, shown as the title. */
|
||||
folderName?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The host editor as a form for one level of host defaults: the same tabs
|
||||
* and fields as a new host, minus what only one host can have (its name,
|
||||
* address, secrets, folder).
|
||||
*/
|
||||
export function HostDefaultsEditorView({
|
||||
target,
|
||||
onClose,
|
||||
onDirtyChange,
|
||||
hosts,
|
||||
credentials,
|
||||
}: {
|
||||
target: HostDefaultsEditorTarget;
|
||||
onClose: () => void;
|
||||
onDirtyChange?: (dirty: boolean) => void;
|
||||
hosts: Host[];
|
||||
credentials: { id: string; name: string; username: string }[];
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
useHostEditorSections();
|
||||
const [activeTab, setActiveTab] = useState("general");
|
||||
|
||||
// Every protocol counts as on, so every tab that has defaults shows.
|
||||
const protocols = useMemo<HostProtocols>(() => {
|
||||
const all: HostProtocols = { enableSsh: true };
|
||||
for (const protocol of listHostProtocols()) all[protocol.settingKey] = true;
|
||||
return all;
|
||||
}, []);
|
||||
const flags = protocols as unknown as Record<string, boolean>;
|
||||
const tabs = makeHostTabs(t, flags, true);
|
||||
const sshSubTabs = makeHostSshSubTabs(t, flags, true);
|
||||
const inSshGroup = isSshGroupTab(activeTab);
|
||||
|
||||
const title =
|
||||
target.level === "admin"
|
||||
? t("hostDefaults.titleAdmin")
|
||||
: target.level === "user"
|
||||
? t("hostDefaults.titleUser")
|
||||
: t("hostDefaults.titleFolder", { name: target.folderName ?? "" });
|
||||
const description =
|
||||
target.level === "admin"
|
||||
? t("hostDefaults.descriptionAdmin")
|
||||
: target.level === "user"
|
||||
? t("hostDefaults.descriptionUser")
|
||||
: t("hostDefaults.descriptionFolder");
|
||||
|
||||
return (
|
||||
<div className="flex flex-col flex-1 min-h-0">
|
||||
<div className="flex flex-col shrink-0 border-b border-border">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="flex items-center gap-2 px-3 py-2 text-xs text-muted-foreground hover:text-foreground hover:bg-muted/40 transition-colors border-b border-border/50"
|
||||
>
|
||||
<ArrowLeft className="size-3.5 shrink-0" />
|
||||
<span>{t("hosts.backToHosts")}</span>
|
||||
<span
|
||||
className="ml-auto font-semibold text-foreground truncate max-w-[220px]"
|
||||
title={title}
|
||||
>
|
||||
{title}
|
||||
</span>
|
||||
</button>
|
||||
<TabStrip
|
||||
tabs={tabs}
|
||||
activeTab={activeTab}
|
||||
onTabChange={(id) => {
|
||||
if (id === "ssh") {
|
||||
if (!inSshGroup) setActiveTab("ssh");
|
||||
} else {
|
||||
setActiveTab(id);
|
||||
}
|
||||
}}
|
||||
isActive={(id) => (id === "ssh" ? inSshGroup : activeTab === id)}
|
||||
/>
|
||||
{inSshGroup && (
|
||||
<TabStrip
|
||||
tabs={sshSubTabs}
|
||||
activeTab={activeTab}
|
||||
onTabChange={setActiveTab}
|
||||
variant="secondary"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-h-0 overflow-y-auto px-3 py-3 flex flex-col gap-3">
|
||||
<p className="text-xs text-muted-foreground border border-border bg-muted/20 px-3 py-2">
|
||||
{description}
|
||||
</p>
|
||||
<HostEditor
|
||||
key={`${target.level}:${target.folderId ?? ""}`}
|
||||
host={null}
|
||||
activeTab={activeTab}
|
||||
onBack={onClose}
|
||||
onSave={onClose}
|
||||
protocols={protocols}
|
||||
onProtocolChange={() => {}}
|
||||
onDirtyChange={onDirtyChange}
|
||||
onTabChange={setActiveTab}
|
||||
hosts={hosts}
|
||||
credentials={credentials}
|
||||
defaultsTarget={target}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* The keys the host editor can show a default for: core's own, and every
|
||||
* running plugin's host fields that can have one.
|
||||
*/
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
CORE_HOST_DEFAULTS,
|
||||
CORE_NAMESPACE,
|
||||
HOST_DEFAULTS_LEVELS,
|
||||
type HostDefaultsLevel,
|
||||
} from "@/types/host-defaults";
|
||||
import {
|
||||
getPlugins,
|
||||
type PluginSettingsField,
|
||||
type PluginSummary,
|
||||
} from "@/api/plugins-api";
|
||||
|
||||
export interface EditorDefaultKey {
|
||||
fullKey: string;
|
||||
namespace: string;
|
||||
key: string;
|
||||
levels: readonly HostDefaultsLevel[];
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
export function pluginDefaultKeys(
|
||||
plugins: PluginSummary[],
|
||||
): EditorDefaultKey[] {
|
||||
const keys: EditorDefaultKey[] = [];
|
||||
for (const plugin of plugins) {
|
||||
if (!plugin.enabled) continue;
|
||||
const host = plugin.contributes?.settings?.host;
|
||||
if (!host) continue;
|
||||
if (host.enableKey) {
|
||||
keys.push({
|
||||
fullKey: `${plugin.id}.${host.enableKey}`,
|
||||
namespace: plugin.id,
|
||||
key: host.enableKey,
|
||||
levels: HOST_DEFAULTS_LEVELS,
|
||||
});
|
||||
}
|
||||
for (const field of host.fields) {
|
||||
if (!isDefaultableField(field)) continue;
|
||||
keys.push({
|
||||
fullKey: `${plugin.id}.${field.key}`,
|
||||
namespace: plugin.id,
|
||||
key: field.key,
|
||||
levels: field.defaultLevels?.length
|
||||
? field.defaultLevels
|
||||
: HOST_DEFAULTS_LEVELS,
|
||||
});
|
||||
}
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
|
||||
export const CORE_EDITOR_KEYS: EditorDefaultKey[] = CORE_HOST_DEFAULTS.map(
|
||||
(entry) => ({
|
||||
fullKey: `${CORE_NAMESPACE}.${entry.key}`,
|
||||
namespace: CORE_NAMESPACE,
|
||||
key: entry.key,
|
||||
levels: entry.levels,
|
||||
}),
|
||||
);
|
||||
|
||||
/** Every key, by full key. Refreshed when the plugin list is. */
|
||||
export function useEditorDefaultKeys(): Map<string, EditorDefaultKey> {
|
||||
const [plugins, setPlugins] = useState<PluginSummary[]>([]);
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
void getPlugins()
|
||||
.then((loaded) => {
|
||||
if (!cancelled) setPlugins(loaded);
|
||||
})
|
||||
.catch(() => {});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
return useMemo(
|
||||
() =>
|
||||
new Map(
|
||||
[...CORE_EDITOR_KEYS, ...pluginDefaultKeys(plugins)].map((entry) => [
|
||||
entry.fullKey,
|
||||
entry,
|
||||
]),
|
||||
),
|
||||
[plugins],
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
/**
|
||||
* Between the host editor's form and host default keys: what value a key has
|
||||
* on the form, and how to put a default's value back on it.
|
||||
*
|
||||
* Core keys use the API's shape ("core.auth" is an object, "core.socks5"
|
||||
* too); plugin keys are the plugin's own field values.
|
||||
*/
|
||||
|
||||
import {
|
||||
CORE_NAMESPACE,
|
||||
CORE_HOST_DEFAULTS,
|
||||
defaultValuesEqual,
|
||||
findCoreHostDefault,
|
||||
splitDefaultKey,
|
||||
type DefaultOverrides,
|
||||
} from "@/types/host-defaults";
|
||||
import type { HostEditorForm } from "../HostEditorData";
|
||||
|
||||
type Form = HostEditorForm;
|
||||
type PluginValues = Record<string, Record<string, unknown>>;
|
||||
|
||||
export const CORE_DEFAULT_KEYS = CORE_HOST_DEFAULTS.map(
|
||||
(entry) => `${CORE_NAMESPACE}.${entry.key}`,
|
||||
);
|
||||
|
||||
function coreValue(form: Form, key: string): unknown {
|
||||
switch (key) {
|
||||
case "username":
|
||||
return form.username;
|
||||
case "sshPort":
|
||||
return form.sshPort;
|
||||
case "auth":
|
||||
return {
|
||||
authType: form.authType,
|
||||
credentialId: form.credentialId ? Number(form.credentialId) : null,
|
||||
overrideCredentialUsername: form.overrideCredentialUsername,
|
||||
agentSocketPath: form.agentSocketPath || null,
|
||||
agentIdentity: form.agentIdentity || null,
|
||||
};
|
||||
case "forceKeyboardInteractive":
|
||||
case "keepaliveInterval":
|
||||
case "keepaliveCountMax":
|
||||
case "allowLegacyAlgorithms":
|
||||
case "agentForwarding":
|
||||
case "environmentVariables":
|
||||
case "portKnockSequence":
|
||||
case "statusCheckEnabled":
|
||||
case "statusCheckInterval":
|
||||
return form[key as keyof Form];
|
||||
case "socks5":
|
||||
return {
|
||||
useSocks5: form.useSocks5,
|
||||
socks5Host:
|
||||
form.socks5ProxyMode === "single" ? form.socks5Host || null : null,
|
||||
socks5Port:
|
||||
form.socks5ProxyMode === "single" ? form.socks5Port || null : null,
|
||||
socks5Username:
|
||||
form.socks5ProxyMode === "single"
|
||||
? form.socks5Username || null
|
||||
: null,
|
||||
socks5ProxyChain:
|
||||
form.socks5ProxyMode === "chain" ? form.socks5ProxyChain : null,
|
||||
};
|
||||
case "jumpHosts":
|
||||
return form.jumpHosts.map((jump) => ({ hostId: Number(jump.hostId) }));
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/** A key's value as the form holds it, normalized for core keys. */
|
||||
export function formValueForKey(form: Form, fullKey: string): unknown {
|
||||
const [namespace, key] = splitDefaultKey(fullKey);
|
||||
if (namespace === CORE_NAMESPACE) {
|
||||
const entry = findCoreHostDefault(key);
|
||||
return entry ? entry.normalize(coreValue(form, key)) : undefined;
|
||||
}
|
||||
return (form.pluginSettings as PluginValues)?.[namespace]?.[key];
|
||||
}
|
||||
|
||||
/** The form with one key set to a default's value. */
|
||||
export function applyDefaultToForm(
|
||||
form: Form,
|
||||
fullKey: string,
|
||||
value: unknown,
|
||||
): Form {
|
||||
const [namespace, key] = splitDefaultKey(fullKey);
|
||||
if (namespace !== CORE_NAMESPACE) {
|
||||
const all = (form.pluginSettings ?? {}) as PluginValues;
|
||||
return {
|
||||
...form,
|
||||
pluginSettings: {
|
||||
...all,
|
||||
[namespace]: { ...(all[namespace] ?? {}), [key]: value },
|
||||
},
|
||||
};
|
||||
}
|
||||
const entry = findCoreHostDefault(key);
|
||||
const normalized = entry ? entry.normalize(value) : value;
|
||||
switch (key) {
|
||||
case "username":
|
||||
return { ...form, username: (normalized as string | null) ?? "" };
|
||||
case "sshPort":
|
||||
return { ...form, sshPort: normalized as number };
|
||||
case "auth": {
|
||||
const auth = normalized as {
|
||||
authType: string;
|
||||
credentialId: number | null;
|
||||
overrideCredentialUsername: boolean;
|
||||
agentSocketPath: string | null;
|
||||
agentIdentity: string | null;
|
||||
} | null;
|
||||
if (!auth) return form;
|
||||
return {
|
||||
...form,
|
||||
authType: auth.authType as Form["authType"],
|
||||
credentialId:
|
||||
auth.credentialId !== null ? String(auth.credentialId) : "",
|
||||
overrideCredentialUsername: auth.overrideCredentialUsername,
|
||||
agentSocketPath: auth.agentSocketPath ?? "",
|
||||
agentIdentity: auth.agentIdentity ?? "",
|
||||
};
|
||||
}
|
||||
case "socks5": {
|
||||
const socks = normalized as {
|
||||
useSocks5: boolean;
|
||||
socks5Host: string | null;
|
||||
socks5Port: number | null;
|
||||
socks5Username: string | null;
|
||||
socks5ProxyChain: Form["socks5ProxyChain"] | null;
|
||||
};
|
||||
const chain = socks.socks5ProxyChain ?? [];
|
||||
return {
|
||||
...form,
|
||||
useSocks5: socks.useSocks5,
|
||||
socks5Host: socks.socks5Host ?? "",
|
||||
socks5Port: socks.socks5Port ?? 1080,
|
||||
socks5Username: socks.socks5Username ?? "",
|
||||
socks5ProxyMode: chain.length > 0 ? "chain" : "single",
|
||||
socks5ProxyChain: chain.map((node, index) => ({
|
||||
...node,
|
||||
// The host's own proxy passwords stay with it.
|
||||
...(form.socks5ProxyChain[index]?.password
|
||||
? { password: form.socks5ProxyChain[index].password }
|
||||
: {}),
|
||||
})),
|
||||
};
|
||||
}
|
||||
case "jumpHosts":
|
||||
return {
|
||||
...form,
|
||||
jumpHosts: (normalized as Array<{ hostId: number }>).map((jump) => ({
|
||||
hostId: String(jump.hostId),
|
||||
})),
|
||||
};
|
||||
default:
|
||||
return { ...form, [key]: normalized } as Form;
|
||||
}
|
||||
}
|
||||
|
||||
/** Keys whose value differs between two forms. */
|
||||
export function changedKeys(
|
||||
before: Form,
|
||||
after: Form,
|
||||
keys: Iterable<string>,
|
||||
): string[] {
|
||||
const changed: string[] = [];
|
||||
for (const fullKey of keys) {
|
||||
if (
|
||||
!defaultValuesEqual(
|
||||
formValueForKey(before, fullKey),
|
||||
formValueForKey(after, fullKey),
|
||||
)
|
||||
) {
|
||||
changed.push(fullKey);
|
||||
}
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
|
||||
export function withOwnKeys(
|
||||
overrides: DefaultOverrides,
|
||||
keys: string[],
|
||||
): DefaultOverrides {
|
||||
if (keys.length === 0) return overrides;
|
||||
const next: DefaultOverrides = { ...overrides };
|
||||
for (const fullKey of keys) {
|
||||
const [namespace, key] = splitDefaultKey(fullKey);
|
||||
const own = new Set(next[namespace] ?? []);
|
||||
own.add(key);
|
||||
next[namespace] = [...own].sort();
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
export function withoutOwnKey(
|
||||
overrides: DefaultOverrides,
|
||||
fullKey: string,
|
||||
): DefaultOverrides {
|
||||
const [namespace, key] = splitDefaultKey(fullKey);
|
||||
return {
|
||||
...overrides,
|
||||
[namespace]: (overrides[namespace] ?? []).filter((own) => own !== key),
|
||||
};
|
||||
}
|
||||
|
||||
export function isOwnDefault(
|
||||
overrides: DefaultOverrides | null | undefined,
|
||||
fullKey: string,
|
||||
): boolean {
|
||||
const [namespace, key] = splitDefaultKey(fullKey);
|
||||
return !!overrides?.[namespace]?.includes(key);
|
||||
}
|
||||
|
||||
/** Every key the overrides mark, as "namespace.key". */
|
||||
export function ownDefaultKeys(
|
||||
overrides: DefaultOverrides | null | undefined,
|
||||
): string[] {
|
||||
return Object.entries(overrides ?? {}).flatMap(([namespace, keys]) =>
|
||||
keys.map((key) => `${namespace}.${key}`),
|
||||
);
|
||||
}
|
||||
|
||||
function isEmpty(value: unknown): boolean {
|
||||
if (value === undefined || value === null || value === "") return true;
|
||||
return Array.isArray(value) && value.length === 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Which keys a form's values make its own: those that differ from what
|
||||
* resolves, and those with a value where nothing resolves.
|
||||
*/
|
||||
export function classifyForm(
|
||||
form: Form,
|
||||
resolved: Record<string, { value: unknown }>,
|
||||
keys: Iterable<string>,
|
||||
): DefaultOverrides {
|
||||
const overrides: DefaultOverrides = { [CORE_NAMESPACE]: [] };
|
||||
for (const fullKey of keys) {
|
||||
const [namespace, key] = splitDefaultKey(fullKey);
|
||||
overrides[namespace] ??= [];
|
||||
const value = formValueForKey(form, fullKey);
|
||||
const entry = resolved[fullKey];
|
||||
const own = entry
|
||||
? !defaultValuesEqual(value, entry.value)
|
||||
: !isEmpty(value);
|
||||
if (own) overrides[namespace].push(key);
|
||||
}
|
||||
return overrides;
|
||||
}
|
||||
|
||||
/** Every namespace the editor knows is listed, so none is left to guess. */
|
||||
export function withAllNamespaces(
|
||||
overrides: DefaultOverrides,
|
||||
keys: Map<string, { namespace: string }>,
|
||||
): DefaultOverrides {
|
||||
const next: DefaultOverrides = { [CORE_NAMESPACE]: [], ...overrides };
|
||||
for (const entry of keys.values()) next[entry.namespace] ??= [];
|
||||
return next;
|
||||
}
|
||||
|
||||
/**
|
||||
* The plugin values a save sends: a host's own values, and every value no
|
||||
* default covers (a protocol switch). The server writes the rest.
|
||||
*/
|
||||
export function ownPluginValues(
|
||||
values: PluginValues | undefined,
|
||||
overrides: DefaultOverrides | null,
|
||||
keys: Map<string, unknown>,
|
||||
): PluginValues | undefined {
|
||||
if (!values || overrides === null) return values;
|
||||
const result: PluginValues = {};
|
||||
for (const [pluginId, fields] of Object.entries(values)) {
|
||||
const kept = Object.fromEntries(
|
||||
Object.entries(fields ?? {}).filter(([key]) => {
|
||||
const fullKey = `${pluginId}.${key}`;
|
||||
return !keys.has(fullKey) || isOwnDefault(overrides, fullKey);
|
||||
}),
|
||||
);
|
||||
if (Object.keys(kept).length > 0) result[pluginId] = kept;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** A plugin's draft for a new host makes what it filled in the host's own. */
|
||||
export function draftOwnKeys(
|
||||
draft: { port?: unknown; username?: unknown; authType?: unknown } | undefined,
|
||||
): DefaultOverrides {
|
||||
const own: string[] = [];
|
||||
if (draft?.port !== undefined) own.push("sshPort");
|
||||
if (draft?.username !== undefined) own.push("username");
|
||||
if (draft?.authType !== undefined) own.push("auth");
|
||||
return { [CORE_NAMESPACE]: own };
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import {
|
||||
CORE_NAMESPACE,
|
||||
splitDefaultKey,
|
||||
type ResolvedHostDefault,
|
||||
} from "@/types/host-defaults";
|
||||
import type { Host } from "@/types/ui-types";
|
||||
|
||||
type PluginValues = Record<string, Record<string, unknown>>;
|
||||
|
||||
/** The plugin keys of a resolved set, in the host payload's shape. */
|
||||
export function pluginSettingsFrom(
|
||||
resolved: Record<string, ResolvedHostDefault>,
|
||||
): PluginValues {
|
||||
const result: PluginValues = {};
|
||||
for (const [fullKey, entry] of Object.entries(resolved)) {
|
||||
const [namespace, key] = splitDefaultKey(fullKey);
|
||||
if (namespace === CORE_NAMESPACE) continue;
|
||||
result[namespace] = { ...(result[namespace] ?? {}), [key]: entry.value };
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** A quick connect host with the defaults under whatever it set itself. */
|
||||
export function withDefaultPluginSettings(
|
||||
host: Host,
|
||||
defaults: PluginValues,
|
||||
): Host {
|
||||
const own = host.pluginSettings ?? {};
|
||||
const merged: PluginValues = { ...defaults };
|
||||
for (const [pluginId, values] of Object.entries(own)) {
|
||||
merged[pluginId] = { ...(defaults[pluginId] ?? {}), ...values };
|
||||
}
|
||||
return { ...host, pluginSettings: merged };
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { FolderOpen, Pencil, Share2, Trash2 } from "lucide-react";
|
||||
import { FolderOpen, Pencil, Settings2, Share2, Trash2 } from "lucide-react";
|
||||
import type { HostFolder } from "@/types/ui-types";
|
||||
|
||||
export function FolderActions({
|
||||
@@ -44,6 +44,24 @@ export function FolderActions({
|
||||
<Share2 className="size-3" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
title={t("hostDefaults.folderDefaultsAction")}
|
||||
className={actionButtonClass}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("host-manager:edit-defaults", {
|
||||
detail: {
|
||||
level: "folder",
|
||||
folderName: folder.path ?? folder.name,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}}
|
||||
>
|
||||
<Settings2 className="size-3" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
title={t("hosts.editFolder")}
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
useState,
|
||||
} from "react";
|
||||
import { updatePluginHostSettings } from "@/api/plugins-api";
|
||||
import { resetHostToDefaults } from "@/api/host-defaults-api";
|
||||
import { PluginIcon } from "@/lib/plugin-icon";
|
||||
import { usePluginHostSections } from "@/settings/HostPluginSections";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -18,6 +19,7 @@ import {
|
||||
FolderOpen,
|
||||
Loader2,
|
||||
Plus,
|
||||
RotateCcw,
|
||||
Server,
|
||||
Terminal,
|
||||
} from "lucide-react";
|
||||
@@ -117,7 +119,8 @@ export function SidebarTree({
|
||||
hostClickBehavior?: HostClickBehavior;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const hostSwitchPlugins = usePluginHostSections().filter(
|
||||
const hostSettingPlugins = usePluginHostSections();
|
||||
const hostSwitchPlugins = hostSettingPlugins.filter(
|
||||
(plugin) => !!plugin.contributes?.settings?.host?.enableKey,
|
||||
);
|
||||
// Knobs with no other owner come straight from the interface preset; the
|
||||
@@ -1185,6 +1188,53 @@ export function SidebarTree({
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
className="text-[10px] text-muted-foreground hover:text-foreground px-1.5 py-1 hover:bg-muted rounded transition-colors flex items-center gap-1 disabled:opacity-40"
|
||||
disabled={selectedHostIds.size === 0}
|
||||
>
|
||||
{t("hostDefaults.resetMenu")}{" "}
|
||||
<ChevronDown className="size-2.5" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="text-xs">
|
||||
{[
|
||||
{ id: "all", label: t("hostDefaults.resetAll") },
|
||||
{ id: "core", label: t("hostDefaults.resetCore") },
|
||||
...hostSettingPlugins.map((plugin) => ({
|
||||
id: plugin.id,
|
||||
label: plugin.name,
|
||||
})),
|
||||
].map((option) => (
|
||||
<DropdownMenuItem
|
||||
key={option.id}
|
||||
onClick={async () => {
|
||||
const ids = Array.from(selectedHostIds).map(Number);
|
||||
try {
|
||||
await resetHostToDefaults(
|
||||
ids,
|
||||
option.id === "all"
|
||||
? { all: true }
|
||||
: { namespaces: [option.id] },
|
||||
);
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("termix:hosts-changed"),
|
||||
);
|
||||
toast.success(
|
||||
t("hostDefaults.resetDone", { count: ids.length }),
|
||||
);
|
||||
} catch {
|
||||
toast.error(t("hosts.bulkUpdateFailed"));
|
||||
}
|
||||
}}
|
||||
>
|
||||
<RotateCcw className="size-3.5 mr-2" />
|
||||
{option.label}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import {
|
||||
HostDefaultBadge,
|
||||
HostDefaultsContext,
|
||||
HostOnly,
|
||||
type HostDefaultsContextValue,
|
||||
} from "@/lib/host-defaults-context";
|
||||
import { SettingRow } from "@/components/section-card";
|
||||
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, options?: Record<string, unknown>) =>
|
||||
options?.name ? `${key}:${options.name}` : key,
|
||||
}),
|
||||
}));
|
||||
|
||||
function context(
|
||||
partial: Partial<HostDefaultsContextValue> = {},
|
||||
): HostDefaultsContextValue {
|
||||
return {
|
||||
mode: "host",
|
||||
isOwn: () => false,
|
||||
source: () => ({ level: "user" }),
|
||||
reset: vi.fn(),
|
||||
isAvailable: () => true,
|
||||
...partial,
|
||||
};
|
||||
}
|
||||
|
||||
function withContext(value: HostDefaultsContextValue, ui: React.ReactNode) {
|
||||
return render(
|
||||
<HostDefaultsContext.Provider value={value}>
|
||||
{ui}
|
||||
</HostDefaultsContext.Provider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("HostDefaultBadge", () => {
|
||||
it("renders nothing outside the host editor", () => {
|
||||
const { container } = render(
|
||||
<HostDefaultBadge settingKey="core.sshPort" />,
|
||||
);
|
||||
expect(container.textContent).toBe("");
|
||||
});
|
||||
|
||||
it("says where an inherited value comes from", () => {
|
||||
withContext(
|
||||
context({
|
||||
source: () => ({ level: "folder", folderName: "Prod" }),
|
||||
}),
|
||||
<HostDefaultBadge settingKey="core.sshPort" />,
|
||||
);
|
||||
expect(screen.getByText("hostDefaults.source.folder:Prod")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("offers a reset for a host's own value", () => {
|
||||
const value = context({ isOwn: () => true });
|
||||
withContext(value, <HostDefaultBadge settingKey="core.sshPort" />);
|
||||
fireEvent.click(screen.getByText("hostDefaults.custom"));
|
||||
expect(value.reset).toHaveBeenCalledWith("core.sshPort");
|
||||
});
|
||||
|
||||
it("offers a clear for a value set at the level being edited", () => {
|
||||
withContext(
|
||||
context({ mode: "defaults", isOwn: () => true, source: () => undefined }),
|
||||
<HostDefaultBadge settingKey="core.sshPort" />,
|
||||
);
|
||||
expect(screen.getByText("hostDefaults.setHere")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("says when nothing is set", () => {
|
||||
withContext(
|
||||
context({ source: () => undefined }),
|
||||
<HostDefaultBadge settingKey="core.username" />,
|
||||
);
|
||||
expect(screen.getByText("hostDefaults.notSet")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("in the defaults editor", () => {
|
||||
it("hides a row whose key cannot be a default at this level", () => {
|
||||
withContext(
|
||||
context({ mode: "defaults", isAvailable: () => false }),
|
||||
<SettingRow label="Jump hosts" defaultKey="core.jumpHosts">
|
||||
<span />
|
||||
</SettingRow>,
|
||||
);
|
||||
expect(screen.queryByText("Jump hosts")).toBeNull();
|
||||
});
|
||||
|
||||
it("hides what only one host can have", () => {
|
||||
withContext(
|
||||
context({ mode: "defaults" }),
|
||||
<HostOnly>
|
||||
<span>Address</span>
|
||||
</HostOnly>,
|
||||
);
|
||||
expect(screen.queryByText("Address")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -105,7 +105,7 @@ describe("syncHostFeatureTabs", () => {
|
||||
expect(getHostEditorSection(hostFeatureTabId("docker"))?.group).toBe("top");
|
||||
});
|
||||
|
||||
it("skips a plugin that registered its own host editor section", () => {
|
||||
it("gives a plugin with its own section a tab only in the defaults editor", () => {
|
||||
registerHostEditorSection({
|
||||
id: "docker-own",
|
||||
pluginId: "docker",
|
||||
@@ -113,10 +113,34 @@ describe("syncHostFeatureTabs", () => {
|
||||
labelKey: "k",
|
||||
component: () => null,
|
||||
});
|
||||
expect(syncHostFeatureTabs([plugin()])).toEqual([
|
||||
hostFeatureTabId("docker"),
|
||||
]);
|
||||
expect(getHostEditorSection(hostFeatureTabId("docker"))?.defaults).toBe(
|
||||
"only",
|
||||
);
|
||||
});
|
||||
|
||||
it("skips a plugin whose own section already covers the defaults editor", () => {
|
||||
registerHostEditorSection({
|
||||
id: "docker-own",
|
||||
pluginId: "docker",
|
||||
group: "top",
|
||||
labelKey: "k",
|
||||
defaults: true,
|
||||
component: () => null,
|
||||
});
|
||||
expect(syncHostFeatureTabs([plugin()])).toEqual([]);
|
||||
expect(getHostEditorSection(hostFeatureTabId("docker"))).toBeUndefined();
|
||||
});
|
||||
|
||||
it("offers a generated tab to both the host and the defaults editor", () => {
|
||||
syncHostFeatureTabs([plugin()]);
|
||||
expect(getHostEditorSection(hostFeatureTabId("docker"))?.defaults).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("registers nothing for a plugin whose host fields are all hidden", () => {
|
||||
const own = plugin({
|
||||
contributes: {
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createHostEditorForm } from "@/sidebar/HostEditorData";
|
||||
import {
|
||||
applyDefaultToForm,
|
||||
changedKeys,
|
||||
classifyForm,
|
||||
draftOwnKeys,
|
||||
formValueForKey,
|
||||
ownPluginValues,
|
||||
withAllNamespaces,
|
||||
withOwnKeys,
|
||||
withoutOwnKey,
|
||||
} from "@/sidebar/host-defaults/form-mapping";
|
||||
|
||||
const form = () => createHostEditorForm(null);
|
||||
|
||||
describe("host defaults on the editor form", () => {
|
||||
it("reads core keys in the API's shape", () => {
|
||||
const base = {
|
||||
...form(),
|
||||
authType: "credential" as const,
|
||||
credentialId: "7",
|
||||
};
|
||||
expect(formValueForKey(base, "core.auth")).toEqual({
|
||||
authType: "credential",
|
||||
credentialId: 7,
|
||||
overrideCredentialUsername: false,
|
||||
agentSocketPath: null,
|
||||
agentIdentity: null,
|
||||
});
|
||||
expect(formValueForKey(base, "core.sshPort")).toBe(22);
|
||||
});
|
||||
|
||||
it("puts a default's value back on the form", () => {
|
||||
let next = applyDefaultToForm(form(), "core.socks5", {
|
||||
useSocks5: true,
|
||||
socks5ProxyChain: [{ host: "a", port: 1080, type: "socks5" }],
|
||||
});
|
||||
expect(next.useSocks5).toBe(true);
|
||||
expect(next.socks5ProxyMode).toBe("chain");
|
||||
next = applyDefaultToForm(next, "core.jumpHosts", [{ hostId: 4 }]);
|
||||
expect(next.jumpHosts).toEqual([{ hostId: "4" }]);
|
||||
next = applyDefaultToForm(next, "term.fontSize", 18);
|
||||
expect(next.pluginSettings.term.fontSize).toBe(18);
|
||||
});
|
||||
|
||||
it("finds the keys an edit changed", () => {
|
||||
const before = form();
|
||||
const after = { ...before, sshPort: 2222, keySubTab: "upload" as const };
|
||||
expect(
|
||||
changedKeys(before, after, ["core.sshPort", "core.auth", "term.x"]),
|
||||
).toEqual(["core.sshPort"]);
|
||||
});
|
||||
|
||||
it("classifies what differs from the defaults as the host's own", () => {
|
||||
const current = { ...form(), sshPort: 2222 };
|
||||
expect(
|
||||
classifyForm(
|
||||
current,
|
||||
{
|
||||
"core.sshPort": { value: 22 },
|
||||
"core.statusCheckEnabled": { value: true },
|
||||
},
|
||||
["core.sshPort", "core.statusCheckEnabled", "core.username"],
|
||||
),
|
||||
).toEqual({ core: ["sshPort", "username"] });
|
||||
});
|
||||
|
||||
it("adds and removes own keys", () => {
|
||||
const own = withOwnKeys({}, ["core.sshPort", "term.a"]);
|
||||
expect(own).toEqual({ core: ["sshPort"], term: ["a"] });
|
||||
expect(withoutOwnKey(own, "term.a")).toEqual({
|
||||
core: ["sshPort"],
|
||||
term: [],
|
||||
});
|
||||
expect(
|
||||
withAllNamespaces(own, new Map([["x.y", { namespace: "x" }]])),
|
||||
).toEqual({ core: ["sshPort"], term: ["a"], x: [] });
|
||||
});
|
||||
|
||||
it("sends only the plugin values a host sets itself", () => {
|
||||
const keys = new Map<string, unknown>([
|
||||
["term.a", {}],
|
||||
["term.b", {}],
|
||||
]);
|
||||
expect(
|
||||
ownPluginValues(
|
||||
{ term: { a: 1, b: 2, enableRdp: true } },
|
||||
{ term: ["a"] },
|
||||
keys,
|
||||
),
|
||||
).toEqual({ term: { a: 1, enableRdp: true } });
|
||||
expect(ownPluginValues({ term: { a: 1 } }, null, keys)).toEqual({
|
||||
term: { a: 1 },
|
||||
});
|
||||
});
|
||||
|
||||
it("makes what a plugin's draft filled in the new host's own", () => {
|
||||
expect(draftOwnKeys({ port: 2222, username: "u" })).toEqual({
|
||||
core: ["sshPort", "username"],
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user