feat: move the classic layout into its own bundled plugin

This commit is contained in:
LukeGus committed 2026-10-18 17:25:59 -05:00
1 parent 86161d66e2
commit d049588f7f
234 files changed
+3066 -76205

No files matched your search

+57
View File
@@ -2281,6 +2281,63 @@ router.post(
},
);
/**
* @openapi
* /plugins/{id}/restore-bundled:
* post:
* summary: Restore the bundled copy of a plugin
* description: >
* Drops any update downloaded over a plugin that ships with Termix and
* loads the copy from the image again, so a broken release can be rolled
* back from the layout repair view. The shipped version is pinned so
* auto update does not bring the release back. Dependents restart.
* tags:
* - Plugins
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: string
* responses:
* 200:
* description: The restored version and the plugin's state.
* 400:
* description: Invalid plugin id.
* 403:
* description: The caller lacks admin.plugins.manage.
* 409:
* description: The plugin has no bundled copy, or a linked server manages it.
*/
router.post(
"/:id/restore-bundled",
authenticateJWT,
requireManagePlugins,
async (req: Request, res: Response) => {
const pluginId = String(req.params.id);
if (!ID_PATTERN.test(pluginId)) {
res.status(400).json({ error: "Invalid plugin id" });
return;
}
if (await refuseOnLinkedDesktop(res)) return;
try {
const { restoreBundledPlugin } = await import("../../plugins/manage.js");
const result = await restoreBundledPlugin(pluginId);
await auditPluginAction(req, "restore_bundled_plugin", pluginId, {
version: result.version,
});
res.json(result);
} catch (error) {
sendManageError(
res,
error,
"Failed to restore the bundled copy",
"plugin_restore_bundled",
);
}
},
);
/**
* @openapi
* /plugins/{id}:
+76 -23
View File
@@ -1644,41 +1644,94 @@ export function installUpload(
});
}
/**
* Loads the copy in the bundled folder again. Running dependents stop first
* and start again after. `prepare` runs once the plugin is stopped, for work
* that needs its files out of use.
*/
async function reloadBundledUnlocked(
pluginId: string,
prepare?: (previous: LoadedPlugin | undefined) => Promise<void>,
): Promise<{ id: string; version: string; state: string }> {
const { loader } = getPluginRuntime();
const previous = loader.get(pluginId);
if (previous && previous.source !== "bundled" && !prepare) {
throw new PluginManageError(
`${pluginId} is not loaded from the bundled folder`,
409,
"NOT_BUNDLED",
);
}
const dependents = previous
? dependentChain(pluginId, await enabledIds()).filter(
(id) => loader.get(id)?.state === "active",
)
: [];
for (const id of dependents) await deactivatePlugin(id);
await unloadPlugin(pluginId);
if (prepare) await prepare(previous);
const plugin = await loader.loadBundled(pluginId);
await registerLoadedPlugins([plugin]);
let state = "stopped";
if ((await enabledIds()).has(pluginId)) {
const { missing } = dependencyChain(pluginId);
state = missing.length > 0 ? "blocked" : await startAndRecord(pluginId);
}
for (const id of [...dependents].reverse()) await startAndRecord(id);
return { id: pluginId, version: plugin.manifest.version, state };
}
/**
* Loads the copy in the bundled folder again after it was rebuilt, for the
* dev runner. Running dependents stop first and start again after.
* dev runner.
*/
export function reloadBundledPlugin(
pluginId: string,
): Promise<{ id: string; version: string; state: string }> {
return serialized(() => reloadBundledUnlocked(pluginId));
}
/**
* Puts a bundled plugin back to the copy that shipped with this install,
* dropping any update downloaded over it. The shipped version is pinned so
* auto update does not bring a broken release straight back; unpin it in the
* Plugins tab to follow updates again.
*/
export function restoreBundledPlugin(
pluginId: string,
): Promise<{ id: string; version: string; state: string }> {
return serialized(async () => {
if (!ID_PATTERN.test(pluginId)) {
throw new PluginManageError("Invalid plugin id", 400, "INVALID_ID");
}
const { loader } = getPluginRuntime();
const previous = loader.get(pluginId);
if (previous && previous.source !== "bundled") {
if (!loader.bundledIds().has(pluginId) || !isBundledOnDisk(pluginId)) {
throw new PluginManageError(
`${pluginId} is not loaded from the bundled folder`,
"This plugin has no bundled copy",
409,
"NOT_BUNDLED",
"NO_BUNDLED_COPY",
);
}
const dependents = previous
? dependentChain(pluginId, await enabledIds()).filter(
(id) => loader.get(id)?.state === "active",
)
: [];
for (const id of dependents) await deactivatePlugin(id);
await unloadPlugin(pluginId);
const plugin = await loader.loadBundled(pluginId);
await registerLoadedPlugins([plugin]);
let state = "stopped";
if ((await enabledIds()).has(pluginId)) {
const { missing } = dependencyChain(pluginId);
state = missing.length > 0 ? "blocked" : await startAndRecord(pluginId);
}
for (const id of [...dependents].reverse()) await startAndRecord(id);
return { id: pluginId, version: plugin.manifest.version, state };
const { createCurrentPluginRepository } = await repos();
const repository = createCurrentPluginRepository();
const result = await reloadBundledUnlocked(pluginId, async (previous) => {
await removeArtifactFiles(pluginId, previous?.artifact);
await fs.promises.rm(path.join(getUnpackedPluginsDir(), pluginId), {
recursive: true,
force: true,
});
});
await repository.update(pluginId, {
autoUpdate: false,
pinnedVersion: result.version,
});
await applyPin(pluginId, result.version);
pluginLogger.info(`Restored the bundled copy of ${pluginId}`, {
operation: "plugin_restore_bundled",
});
return result;
});
}
@@ -169,6 +169,7 @@ const manageMock = vi.hoisted(() => ({
updatePlugin: vi.fn(),
updateAllPlugins: vi.fn(),
uninstallPlugin: vi.fn(),
restoreBundledPlugin: vi.fn(),
setPluginOptions: vi.fn(),
setAllPluginChannels: vi.fn(),
getPluginDataSummary: vi.fn(),
@@ -1141,6 +1142,35 @@ describe("plugins route", () => {
expect(manageMock.uninstallPlugin).toHaveBeenCalledWith("docker");
});
it("restores the bundled copy", async () => {
manageMock.restoreBundledPlugin.mockResolvedValue({
id: "docker",
version: "1.0.0",
state: "active",
});
const res = await post("/plugins/docker/restore-bundled");
expect(res.status).toBe(200);
expect(await res.json()).toEqual({
id: "docker",
version: "1.0.0",
state: "active",
});
expect(manageMock.restoreBundledPlugin).toHaveBeenCalledWith("docker");
});
it("answers 409 when a plugin has no bundled copy", async () => {
manageMock.restoreBundledPlugin.mockRejectedValue(
new PluginManageError(
"This plugin has no bundled copy",
409,
"NO_BUNDLED_COPY",
),
);
const res = await post("/plugins/docker/restore-bundled");
expect(res.status).toBe(409);
expect((await res.json()).code).toBe("NO_BUNDLED_COPY");
});
it("refuses changes on a linked desktop", async () => {
manageMock.isLinkedDesktop.mockResolvedValue(true);
const res = await post("/plugins/docker/install");
+48
View File
@@ -661,6 +661,54 @@ describe("reloading a bundled plugin for the dev runner", () => {
});
});
describe("restoring the bundled copy", () => {
it("drops a downloaded update, loads the shipped copy and pins it", async () => {
createFixturePlugin({ id: "docker", root: bundled });
const loader = await boot();
// What a signed update leaves behind next to the shipped copy.
const artifact = path.join(data, "plugins", "docker.tmxplug");
fs.mkdirSync(path.dirname(artifact), { recursive: true });
fs.writeFileSync(artifact, "update");
fs.writeFileSync(`${artifact}.sig`, "sig");
const result = await manage.restoreBundledPlugin("docker");
expect(result).toEqual({ id: "docker", version: "1.0.0", state: "active" });
expect(loader.get("docker")?.manifest.version).toBe("1.0.0");
expect(fs.existsSync(artifact)).toBe(false);
expect(fs.existsSync(`${artifact}.sig`)).toBe(false);
expect(db.rows.get("docker")).toMatchObject({
pinnedVersion: "1.0.0",
autoUpdate: false,
});
});
it("keeps a plugin that is turned off stopped", async () => {
createFixturePlugin({ id: "docker", root: bundled });
const loader = await boot();
await manage.setPluginState("docker", false);
const result = await manage.restoreBundledPlugin("docker");
expect(result.state).toBe("stopped");
expect(loader.get("docker")?.state).toBe("loaded");
});
it("refuses a plugin that has no bundled copy", async () => {
createFixturePlugin({ id: "notes", root: path.join(data, "plugins") });
await boot();
await expect(manage.restoreBundledPlugin("notes")).rejects.toMatchObject({
code: "NO_BUNDLED_COPY",
status: 409,
});
});
it("refuses a bad id", async () => {
await expect(manage.restoreBundledPlugin("../x")).rejects.toMatchObject({
code: "INVALID_ID",
status: 400,
});
});
});
describe("consent", () => {
it("refuses an install without the consented list, before any download", async () => {
registry.index.plugins = [registryEntry("notes", [{ version: "1.0.0" }])];
+9
View File
@@ -519,6 +519,15 @@ export async function uninstallPlugin(pluginId: string): Promise<void> {
}
}
/** Puts a bundled plugin back to the copy that shipped with this install. */
export async function restoreBundledPlugin(pluginId: string): Promise<void> {
try {
await rbacApi.post(`${pluginPath(pluginId)}/restore-bundled`);
} finally {
announcePluginsChanged();
}
}
export async function previewPluginState(
pluginId: string,
enabled: boolean,
File diff suppressed because it is too large. Load diff
-448
View File
@@ -1,448 +0,0 @@
import { useIsMobile } from "@/hooks/use-mobile";
import { useTranslation } from "react-i18next";
import {
ExternalLink,
KeyRound,
Plus,
Server,
Settings,
User,
} from "lucide-react";
import { ComponentSlot } from "@/shell/ActionSlot";
import { VersionBadge } from "@/components/version-badge";
import { Facts } from "@/components/panel-layout";
import { enabledHostProtocols, protocolPort } from "@/sidebar/host-protocols";
import { activityTarget } from "@/lib/activity-types";
import {
defaultConnectAction,
hostActionsFor,
listHostActions,
} from "@/sidebar/host-contributions";
import {
getStatusClasses,
useStatusColorScheme,
} from "@/hooks/use-status-color-scheme";
import { getDefaultConnectionTab } from "@/lib/host-connection-tabs";
import type { RecentActivityItem } from "@/main-axios";
import type { Host, TabType } from "@/types/ui-types";
import { quarterBorders } from "./quarter-borders";
import { parseServerTime } from "@/lib/server-time";
export type VersionStatus =
"up_to_date" | "requires_update" | "beta" | "unknown";
function Stat({
label,
aside,
children,
}: {
label: string;
/** Sits beside the label, like the release channel. */
aside?: React.ReactNode;
children: React.ReactNode;
}) {
return (
<div className="flex min-w-0 flex-col justify-center gap-1 px-3 py-2">
{children}
<div className="flex min-w-0 items-center gap-1.5">
<span className="truncate text-[10px] text-muted-foreground">
{label}
</span>
{aside}
</div>
</div>
);
}
export function StatsStrip({
hosts,
uptimeFormatted,
versionText,
versionStatus,
releaseUrl,
dbHealth,
}: {
hosts: Host[];
uptimeFormatted: string;
versionText: string;
versionStatus: VersionStatus;
releaseUrl: string;
dbHealth: "healthy" | "error";
}) {
const { t } = useTranslation();
const online = hosts.filter((h) => h.status === "online").length;
const big = "text-2xl font-bold leading-none tracking-tight";
return (
<div className="grid h-full grid-cols-2 divide-x divide-border md:grid-cols-4">
<Stat
label={t("dashboard.version")}
aside={
<VersionBadge
status={versionStatus}
releaseUrl={releaseUrl}
className="shrink-0 px-1 py-px text-[9px]"
/>
}
>
<span className={`${big} truncate`} title={versionText || undefined}>
{versionText || "-"}
</span>
</Stat>
<Stat label={t("dashboard.uptime")}>
<span className={big}>{uptimeFormatted || "-"}</span>
</Stat>
<Stat label={t("dashboard.database")}>
<span
className={`${big} ${dbHealth === "healthy" ? "text-accent-brand" : "text-destructive"}`}
>
{dbHealth === "healthy"
? t("dashboard.healthy")
: t("dashboard.error")}
</span>
</Stat>
<Stat label={t("dashboardTab.hostsAvailable")}>
<div className="flex items-baseline gap-1">
<span className={big}>{online}</span>
<span className="text-sm leading-none text-muted-foreground">
/{hosts.length}
</span>
</div>
</Stat>
</div>
);
}
export function CountersStrip({
hosts,
credentialCount,
onOpenSingletonTab,
}: {
hosts: Host[];
credentialCount: number;
onOpenSingletonTab: (type: TabType, pendingEvent?: string) => void;
}) {
const { t } = useTranslation();
const item =
"flex items-center gap-2 px-3 py-2 text-left transition-colors hover:bg-muted/50";
return (
<div className="grid h-full auto-cols-fr grid-flow-col divide-x divide-border">
<button
type="button"
onClick={() => onOpenSingletonTab("host-manager")}
className={item}
>
<Server className="size-3.5 shrink-0 text-muted-foreground" />
<span className="text-xl font-bold leading-none tracking-tight">
{hosts.length}
</span>
<span className="truncate text-[11px] text-muted-foreground">
{t("dashboard.totalHosts")}
</span>
</button>
<button
type="button"
onClick={() =>
onOpenSingletonTab("host-manager", "host-manager:show-credentials")
}
className={item}
>
<KeyRound className="size-3.5 shrink-0 text-muted-foreground" />
<span className="text-xl font-bold leading-none tracking-tight">
{credentialCount}
</span>
<span className="truncate text-[11px] text-muted-foreground">
{t("dashboard.totalCredentials")}
</span>
</button>
<ComponentSlot slotId="dashboard.counters" />
</div>
);
}
function ActionButton({
icon: Icon,
label,
hint,
onClick,
className,
}: {
icon: React.ElementType;
label: string;
hint: string;
onClick: () => void;
className?: string;
}) {
return (
<button
type="button"
onClick={onClick}
className={`group/btn flex h-full min-w-0 items-center gap-2 px-3 py-2 text-left transition-colors hover:bg-muted/50 ${className ?? ""}`}
>
<span className="flex size-6 shrink-0 items-center justify-center border border-border bg-muted transition-colors group-hover/btn:border-accent-brand/40 group-hover/btn:bg-accent-brand/20">
<Icon className="size-3 text-accent-brand" />
</span>
<span className="flex min-w-0 flex-col">
<span className="truncate text-xs font-medium">{label}</span>
<span className="truncate text-[10px] text-muted-foreground">
{hint}
</span>
</span>
</button>
);
}
export function QuickActions({
onOpenSingletonTab,
hosts,
onOpenTab,
isAdmin,
}: {
onOpenSingletonTab: (type: TabType, pendingEvent?: string) => void;
hosts: Host[];
onOpenTab: (host: Host, type: TabType) => void;
isAdmin: boolean;
}) {
const { t } = useTranslation();
const pinnedHosts = hosts.filter((h) => h.pin).slice(0, 4);
const endpoint = (host: Host) => {
const protocol = host.enableSsh ? undefined : enabledHostProtocols(host)[0];
const port = host.enableSsh
? host.sshPort
: protocol
? protocolPort(host.pluginSettings, protocol)
: host.port;
return `${host.ip}:${port ?? host.port}`;
};
const actions = [
{
icon: Plus,
label: t("dashboard.addHost"),
hint: t("dashboardTab.registerNewServer"),
onClick: () =>
onOpenSingletonTab("host-manager", "host-manager:add-host"),
},
{
icon: KeyRound,
label: t("dashboard.addCredential"),
hint: t("dashboardTab.storeSshKeysOrPasswords"),
onClick: () =>
onOpenSingletonTab("host-manager", "host-manager:add-credential"),
},
...(isAdmin
? [
{
icon: Settings,
label: t("dashboard.adminSettings"),
hint: t("dashboardTab.manageUsersAndRoles"),
onClick: () => onOpenSingletonTab("admin-settings"),
},
]
: []),
{
icon: User,
label: t("dashboard.userProfile"),
hint: t("dashboardTab.manageYourAccount"),
onClick: () => onOpenSingletonTab("user-profile"),
},
];
return (
<div className="flex min-h-full flex-1 flex-col">
<div className="grid flex-1 auto-rows-fr grid-cols-1 sm:grid-cols-2">
{actions.map((action, i) => (
<ActionButton
key={action.label}
{...action}
className={quarterBorders(i, actions.length)}
/>
))}
</div>
{pinnedHosts.length > 0 && (
<div className="flex flex-col border-t border-border">
{pinnedHosts.map((host) => {
const Icon =
defaultConnectAction(listHostActions(), host)?.icon ?? Server;
return (
<button
key={host.id}
type="button"
onClick={() => {
const type = getDefaultConnectionTab(host);
if (type) onOpenTab(host, type);
}}
className="flex items-center gap-2 border-b border-border/60 px-3 py-1.5 text-left transition-colors last:border-b-0 hover:bg-muted/50"
>
<Icon className="size-3 shrink-0 text-accent-brand" />
<span className="truncate text-xs font-medium">
{host.name || host.ip}
</span>
<span className="ml-auto truncate font-mono text-[10px] text-muted-foreground">
{endpoint(host)}
</span>
</button>
);
})}
</div>
)}
</div>
);
}
export function HostStatusList({
hosts,
onOpenTab,
statusLoading,
}: {
hosts: Host[];
onOpenTab: (host: Host, type: TabType) => void;
statusLoading?: boolean;
}) {
const { t } = useTranslation();
const scheme = useStatusColorScheme();
// A phone has no room for the metric bars next to the host name.
const isMobile = useIsMobile();
if (hosts.length === 0) {
return (
<div className="flex flex-1 items-center justify-center py-8 text-xs text-muted-foreground/60">
{t("dashboardTab.noHostsConfigured")}
</div>
);
}
return (
<div className="flex flex-col">
{hosts.map((host) => {
const availability = host.status ?? "offline";
return (
<div
key={host.id}
role="button"
tabIndex={0}
onClick={() => {
// An overview action (a metrics view) wins over connecting.
const actions = hostActionsFor(listHostActions(), host);
const target =
actions.find((action) => action.overview)?.tabType ??
defaultConnectAction(actions, host)?.tabType;
if (target) onOpenTab(host, target);
}}
className="group/row flex min-w-0 cursor-pointer items-center gap-2 px-3 py-1.5 hover:bg-muted/50"
>
<span
className={`size-1.5 shrink-0 rounded-full ${getStatusClasses(availability, scheme, "dot", statusLoading)}`}
/>
<span className="min-w-0 flex-1">
<Facts className="min-w-0">
<span
className="truncate text-xs font-medium"
title={host.name}
>
{host.name}
</span>
<span
className="truncate font-mono text-[10px] text-muted-foreground"
title={host.ip}
>
{host.ip}
</span>
</Facts>
</span>
{/* Plugins add live details to a host row here. */}
{!isMobile && (
<ComponentSlot
slotId="dashboard.hostRow"
props={{
hostId: Number(host.id),
host,
online: availability === "online",
}}
/>
)}
<span
className={`shrink-0 border px-1.5 py-0.5 text-[10px] font-semibold ${getStatusClasses(availability, scheme, "badge", statusLoading)}`}
>
{statusLoading || availability === "unknown"
? t("dashboardTab.checking")
: availability === "online"
? t("hosts.status.online")
: t("hosts.status.offline")}
</span>
<ExternalLink className="size-2.5 shrink-0 text-muted-foreground/0 transition-colors group-hover/row:text-muted-foreground/60" />
</div>
);
})}
</div>
);
}
export function RecentActivityList({
activity,
hosts,
onOpenTab,
statusLoading,
}: {
activity: RecentActivityItem[];
hosts: Host[];
onOpenTab: (host: Host, type: TabType) => void;
statusLoading?: boolean;
}) {
const { t } = useTranslation();
const scheme = useStatusColorScheme();
const ago = (ts: string) => {
const diff = Math.floor(
(Date.now() - parseServerTime(ts).getTime()) / 1000,
);
if (diff < 60) return t("dashboard.justNow");
if (diff < 3600) return `${Math.floor(diff / 60)}m`;
if (diff < 86400) return `${Math.floor(diff / 3600)}h`;
return `${Math.floor(diff / 86400)}d`;
};
if (activity.length === 0) {
return (
<div className="flex flex-1 items-center justify-center py-8 text-xs text-muted-foreground/60">
{t("dashboard.noRecentActivity")}
</div>
);
}
return (
<div className="flex flex-col">
{activity.map((item) => {
const host = hosts.find((h) => h.id === String(item.hostId));
const target = activityTarget(item.type);
const Icon = target?.icon ?? Server;
return (
<div
key={item.id}
role="button"
tabIndex={0}
onClick={() => {
if (host && target) onOpenTab(host, target.tab);
}}
className="flex cursor-pointer items-center gap-2 px-3 py-1.5 hover:bg-muted/50"
>
<span
className={`size-1.5 shrink-0 rounded-full ${getStatusClasses(host?.status ?? false, scheme, "dot", statusLoading)}`}
/>
<Facts className="min-w-0">
<span
className="truncate text-xs font-medium"
title={item.hostName}
>
{item.hostName}
</span>
<span className="flex min-w-0 items-center gap-1 text-[10px] text-muted-foreground">
<Icon className="size-2.5 shrink-0" />
<span className="truncate">
{target?.labelKey
? t(target.labelKey)
: item.type.replace("_", " ")}
</span>
</span>
</Facts>
<span className="ml-auto shrink-0 text-[10px] tabular-nums text-muted-foreground">
{ago(item.timestamp)}
</span>
</div>
);
})}
</div>
);
}
-15
View File
@@ -1,15 +0,0 @@
/**
* Lines only between cells, so the actions read as quarters: one column on
* small screens, two above. An odd last cell spans the full row.
*/
export function quarterBorders(index: number, count: number): string {
const classes = ["border-border"];
const last = index === count - 1;
if (!last) classes.push("border-b");
if (Math.floor(index / 2) === Math.floor((count - 1) / 2)) {
classes.push("sm:border-b-0");
}
if (index % 2 === 0 && !last) classes.push("sm:border-r");
if (last && count % 2 === 1) classes.push("sm:col-span-2");
return classes.join(" ");
}
+3
View File
@@ -0,0 +1,3 @@
/** The version of this build, empty in a dev build. */
export const APP_VERSION: string | undefined =
import.meta.env.VITE_APP_VERSION || undefined;
+7 -3
View File
@@ -1,4 +1,8 @@
import type { TFunction } from "i18next";
/** The part of a translate function these helpers use. */
export type Translator = (
key: string,
options?: Record<string, unknown>,
) => string;
/**
* Where a plugin's i18n keys resolve from.
@@ -28,7 +32,7 @@ export function pluginKey(
* listed in a registry, shows the manifest name.
*/
export function pluginName(
t: TFunction,
t: Translator,
plugin: { id: string; name: string },
): string {
return t(pluginKey(plugin.id, "plugin.name"), { defaultValue: plugin.name });
@@ -36,7 +40,7 @@ export function pluginName(
/** Same as pluginName, for "plugin.description". */
export function pluginDescription(
t: TFunction,
t: Translator,
plugin: { id: string; description?: string | null },
): string {
return t(pluginKey(plugin.id, "plugin.description"), {
+2 -2
View File
@@ -1,8 +1,8 @@
import type { TFunction } from "i18next";
import type { Translator } from "./plugin-i18n";
/** System roles store an i18n key as their display name. */
export function roleLabel<T extends string | null | undefined>(
t: TFunction,
t: Translator,
displayName: T,
): T | string {
return typeof displayName === "string" &&
+36 -855
View File
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File renamed without changes.
+24 -47
View File
@@ -2,16 +2,15 @@ import { useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import {
ArrowLeft,
Check,
Copy,
Download,
LayoutTemplate,
Palette,
History,
Power,
RefreshCw,
RotateCcw,
Settings,
TriangleAlert,
} from "lucide-react";
import { Button } from "@/components/button";
@@ -23,11 +22,8 @@ import { PluginConsentPrompt } from "@/plugins/PluginConsentPrompt";
import { usePluginsManager } from "@/plugins/use-plugins-manager";
import { PluginIconBox } from "@/plugins/plugin-bits";
import { getPluginRecord } from "@/plugin-host/plugin-store";
import {
ShellConfirmCapture,
useShellController,
} from "@/shell/controller/ShellControllerProvider";
import { TabStage } from "@/shell/stage/TabStage";
import { restoreBundledPlugin } from "@/api/plugins-api";
import { ShellConfirmCapture } from "@/shell/controller/ShellControllerProvider";
import {
chooseDefaultLook,
chooseLayout,
@@ -50,10 +46,12 @@ function AdminTools({
status,
installOpen,
setInstallOpen,
onRetry,
}: {
status: RepairStatus;
installOpen: boolean;
setInstallOpen: (open: boolean) => void;
onRetry: () => void;
}) {
const { t } = useTranslation();
const manager = usePluginsManager();
@@ -94,6 +92,21 @@ function AdminTools({
{t("looks.repair.disable", { name: entry.name })}
</Button>
)}
{entry?.installed && entry.bundled && !entry.managedByServer && (
<Button
size="sm"
variant="outline"
onClick={() => {
void restoreBundledPlugin(entry.id)
.then(onRetry)
.catch(() => toast.error(t("looks.repair.restoreFailed")));
}}
disabled={manager.busy.has(entry.id)}
>
<History />
{t("looks.repair.restore", { name: entry.name })}
</Button>
)}
<Button size="sm" variant="outline" onClick={() => setInstallOpen(true)}>
<Download />
{t("looks.repair.install")}
@@ -162,7 +175,6 @@ export function RepairView({
onRetry: () => void;
}) {
const { t } = useTranslation();
const shell = useShellController();
const { isAdmin } = usePermissions();
const { theme } = useTheme();
const chooseTheme = useChooseTheme();
@@ -170,12 +182,13 @@ export function RepairView({
const themes = useThemes();
const [copied, setCopied] = useState(false);
const [installOpen, setInstallOpen] = useState(false);
const [settingsOpen, setSettingsOpen] = useState(false);
const record = status.pluginId ? getPluginRecord(status.pluginId) : null;
const layoutName =
layouts.find((layout) => layout.id === status.layoutId) ?? null;
const name = layoutName ? layoutLabel(layoutName, t) : status.layoutId;
const name = layoutName
? layoutLabel(layoutName, t)
: status.layoutId || t("looks.repair.noLayout");
const details = [
`${t("looks.repair.layout")}: ${name} (${status.layoutId})`,
status.pluginId
@@ -214,30 +227,6 @@ export function RepairView({
chooseDefaultLook({ layout: null, theme: null }),
);
if (settingsOpen) {
return (
<div
className="flex flex-col w-screen bg-background"
style={{ height: "100dvh" }}
>
<div className="flex h-10 shrink-0 items-center gap-2 border-b border-border px-2">
<Button
size="sm"
variant="ghost"
onClick={() => setSettingsOpen(false)}
>
<ArrowLeft />
{t("looks.repair.back")}
</Button>
</div>
<SurfaceScope kind="tab" className="overflow-hidden">
<ShellConfirmCapture />
<TabStage />
</SurfaceScope>
</div>
);
}
const safe = status.reason === "safe-url" || status.reason === "safe-env";
return (
@@ -298,24 +287,12 @@ export function RepairView({
{t("looks.repair.resetEveryone")}
</Button>
)}
<Button
size="sm"
variant="outline"
onClick={() => {
shell.tabs.openSingleton("settings", undefined, undefined, {
section: "appearance",
});
setSettingsOpen(true);
}}
>
<Settings />
{t("looks.repair.settings")}
</Button>
{isAdmin && (
<AdminTools
status={status}
installOpen={installOpen}
setInstallOpen={setInstallOpen}
onRetry={onRetry}
/>
)}
</div>
+7 -20
View File
@@ -2,13 +2,10 @@ import { useSyncExternalStore, type ComponentType } from "react";
import type { TFunction } from "i18next";
import { createRegistry } from "@/lib/registry";
/** The layout core ships with, used when nothing else is chosen or installed. */
export const BUILTIN_LAYOUT_ID = "default";
/** A layout someone can choose, known from a manifest before its code loads. */
export interface LayoutDescriptor {
id: string;
/** i18n key: core's for the built-in, the plugin namespace's for plugins. */
/** i18n key in the plugin namespace, or plain text. */
label: string;
description?: string;
defaultTheme?: string;
@@ -16,7 +13,7 @@ export interface LayoutDescriptor {
pluginId?: string;
}
/** A layout whose code is running, from app.registerLayout or core. */
/** A layout whose code is running, from app.registerLayout. */
export interface RegisteredLayout {
id: string;
pluginId?: string;
@@ -26,13 +23,7 @@ export interface RegisteredLayout {
defaultTheme?: string;
}
const BUILTIN_DESCRIPTOR: LayoutDescriptor = {
id: BUILTIN_LAYOUT_ID,
label: "looks.builtinLayout",
};
let pluginLayouts: LayoutDescriptor[] = [];
let descriptorSnapshot: LayoutDescriptor[] = [BUILTIN_DESCRIPTOR];
let descriptorSnapshot: LayoutDescriptor[] = [];
const descriptorListeners = new Set<() => void>();
const registered = createRegistry<RegisteredLayout>();
@@ -42,7 +33,7 @@ export const getRegisteredLayout = registered.get;
export const useRegisteredLayouts = registered.useList;
export const resetRegisteredLayouts = registered.reset;
/** Every layout that can be chosen: the built-in, then plugin layouts. */
/** Every layout that can be chosen, from the installed plugins. */
export function listLayouts(): LayoutDescriptor[] {
return descriptorSnapshot;
}
@@ -57,12 +48,8 @@ export function setPluginLayouts(layouts: LayoutDescriptor[]): void {
JSON.stringify(
list.map((l) => [l.id, l.pluginId, l.label, l.defaultTheme]),
);
if (key(layouts) === key(pluginLayouts)) return;
pluginLayouts = layouts;
descriptorSnapshot = [
BUILTIN_DESCRIPTOR,
...layouts.filter((layout) => layout.id !== BUILTIN_LAYOUT_ID),
];
if (key(layouts) === key(descriptorSnapshot)) return;
descriptorSnapshot = layouts;
for (const listener of descriptorListeners) listener();
}
@@ -78,7 +65,7 @@ export function useLayouts(): LayoutDescriptor[] {
}
export function layoutLabel(layout: LayoutDescriptor, t: TFunction): string {
if (!layout.pluginId) return t(layout.label);
if (!layout.pluginId) return layout.label;
return t(`${layout.pluginId}:${layout.label}`, {
defaultValue: layout.label,
});
+5 -10
View File
@@ -1,25 +1,20 @@
import type { LookResponse } from "@/api/look-api";
import { BUILTIN_LAYOUT_ID } from "./layout-registry";
/** The theme a fresh install starts in when nothing else says otherwise. */
export const FALLBACK_THEME = "dark";
/**
* The layout to show: the user's choice, else the admin default, else the
* default look, else core's built-in. Without an answer from the server the
* last layout this browser showed stands in.
* default look. Empty when none of them names one, which the repair view
* answers. Without an answer from the server the last layout this browser
* showed stands in.
*/
export function resolveLayoutId(
look: LookResponse | null,
lastShown: string | null = null,
): string {
if (!look) return lastShown ?? BUILTIN_LAYOUT_ID;
return (
look.layoutId ??
look.defaults.layout ??
look.defaultLook.layout ??
BUILTIN_LAYOUT_ID
);
if (!look) return lastShown ?? "";
return look.layoutId ?? look.defaults.layout ?? look.defaultLook.layout ?? "";
}
/**
-411
View File
@@ -1,411 +0,0 @@
/* eslint-disable react-refresh/only-export-components */
import type React from "react";
import {
createContext,
useCallback,
useContext,
useEffect,
useLayoutEffect,
useRef,
useState,
type ReactNode,
type RefObject,
} from "react";
import { useTranslation } from "react-i18next";
import { Search } from "lucide-react";
import { GroupHeading, PANEL, PanelSearch } from "@/components/panel-layout";
import { TabStrip } from "@/components/tab-strip";
import { EmptyState } from "@/components/empty-state";
import { HIDDEN_ATTR, useSettingsPageFilter } from "@/settings/page-filter";
import { cn } from "@/lib/utils";
/** True while the editor is searched, so every lazy section mounts. */
const SearchingContext = createContext(false);
export interface EditorNavItem {
id: string;
label: string;
icon?: ReactNode;
/** Marks a section holding something that needs fixing. */
error?: boolean;
}
export interface EditorNavBand {
id: string;
label: string;
items: EditorNavItem[];
}
/**
* The element that really scrolls: the editor's own area, or the page around
* it when the editor is embedded somewhere that scrolls itself.
*/
function scroller(el: HTMLElement): HTMLElement {
let node: HTMLElement | null = el;
while (node) {
const overflow = getComputedStyle(node).overflowY;
if (
(overflow === "auto" || overflow === "scroll") &&
node.scrollHeight > node.clientHeight + 1
) {
return node;
}
node = node.parentElement;
}
return el;
}
/** Which section is in view, and a way to jump to one. */
export function useScrollSpy(
containerRef: RefObject<HTMLElement | null>,
ids: string[],
): { active: string; scrollTo: (id: string) => void } {
const [active, setActive] = useState(ids[0] ?? "");
const idsKey = ids.join("|");
// A jump scrolls past every section between; the spy waits it out.
const lockUntil = useRef(0);
useEffect(() => {
const container = containerRef.current;
if (!container) return;
const onScroll = () => {
if (Date.now() < lockUntil.current) return;
const area = scroller(container);
const top = area.getBoundingClientRect().top;
let current = ids[0] ?? "";
for (const id of ids) {
const el = container.querySelector<HTMLElement>(
`[data-section="${id}"]`,
);
if (!el) continue;
if (el.getBoundingClientRect().top - top <= 24) current = id;
}
if (
area.scrollHeight > area.clientHeight &&
area.scrollTop + area.clientHeight >= area.scrollHeight - 8
) {
current = ids[ids.length - 1] ?? current;
}
setActive(current);
};
onScroll();
// Captured on the document so an outer scroller is heard too.
document.addEventListener("scroll", onScroll, {
passive: true,
capture: true,
});
return () =>
document.removeEventListener("scroll", onScroll, { capture: true });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [containerRef, idsKey]);
return {
active: ids.includes(active) ? active : (ids[0] ?? ""),
scrollTo: (id: string) => {
const container = containerRef.current;
const el = container?.querySelector<HTMLElement>(
`[data-section="${id}"]`,
);
if (!container || !el) return;
lockUntil.current = Date.now() + 600;
setActive(id);
const area = scroller(container);
const target = () =>
el.getBoundingClientRect().top -
area.getBoundingClientRect().top +
area.scrollTop -
8;
let lastTop = target();
let lastHeight = area.scrollHeight;
area.scrollTo?.({ top: lastTop, behavior: "smooth" });
// Sections above can still be mounting, which moves the target or
// cuts the scroll short. Follow it until the scroll lands and the
// layout stops changing, unless the user scrolls first.
const started = Date.now();
let changedAt = started;
let userScrolled = false;
const onUser = () => {
userScrolled = true;
};
area.addEventListener("wheel", onUser, { passive: true });
area.addEventListener("touchstart", onUser, { passive: true });
const done = () => {
area.removeEventListener("wheel", onUser);
area.removeEventListener("touchstart", onUser);
};
const follow = () => {
const now = Date.now();
if (userScrolled || !el.isConnected || now - started > 5000) {
return done();
}
const top = target();
// Keep the spy quiet for the whole trip, not just the first scroll.
lockUntil.current = now + 600;
if (Math.abs(top - lastTop) > 4 || area.scrollHeight !== lastHeight) {
lastTop = top;
lastHeight = area.scrollHeight;
changedAt = now;
area.scrollTo?.({ top, behavior: "smooth" });
}
const reachable = Math.min(
lastTop,
area.scrollHeight - area.clientHeight,
);
if (
Math.abs(area.scrollTop - reachable) <= 2 &&
now - changedAt > 400
) {
setActive(id);
return done();
}
requestAnimationFrame(follow);
};
requestAnimationFrame(follow);
},
};
}
/** Resolves once the element's height holds still for a few frames. */
function untilLayoutSettles(el: HTMLElement | null, max = 800): Promise<void> {
return new Promise((resolve) => {
if (!el || typeof requestAnimationFrame === "undefined") return resolve();
const started = Date.now();
let last = -1;
let still = 0;
const check = () => {
const height = el.scrollHeight;
still = height === last ? still + 1 : 0;
last = height;
if (still >= 3 || Date.now() - started > max) return resolve();
requestAnimationFrame(check);
};
requestAnimationFrame(check);
});
}
/** Wraps a section so the nav can find it. */
export function EditorSection({
id,
label,
showHeading = true,
children,
}: {
id: string;
label?: string;
showHeading?: boolean;
children: ReactNode;
}) {
return (
<section data-section={id} className={`flex flex-col ${PANEL.gap}`}>
{showHeading && label && <GroupHeading title={label} className="pt-1" />}
{children}
</section>
);
}
/**
* Mounts its children the first time they scroll near view, so a long editor
* does not start every section's requests at once.
*/
export function LazySection({ children }: { children: ReactNode }) {
const ref = useRef<HTMLDivElement>(null);
const searching = useContext(SearchingContext);
const [shown, setShown] = useState(
() => typeof IntersectionObserver === "undefined",
);
useEffect(() => {
if (searching) setShown(true);
}, [searching]);
useLayoutEffect(() => {
if (shown) return;
const el = ref.current;
if (!el) return;
const observer = new IntersectionObserver(
(entries) => {
if (entries.some((entry) => entry.isIntersecting)) {
setShown(true);
observer.disconnect();
}
},
{ rootMargin: "400px 0px" },
);
observer.observe(el);
return () => observer.disconnect();
}, [shown]);
return (
<div ref={ref} className={shown ? "contents" : "min-h-24"}>
{shown && children}
</div>
);
}
/**
* An editor laid out like the settings screen: a section nav beside one
* scrolling form, and the actions pinned under it.
*/
export function EditorPane({
bands,
banner,
footer,
errorCount = 0,
revealRef,
children,
className,
}: {
bands: EditorNavBand[];
/** Filled with a function that scrolls a section into view. */
revealRef?: React.MutableRefObject<((id: string) => void) | null>;
/** Sits above the form, inside the scroll area. */
banner?: ReactNode;
/** Usually Cancel and Save, pinned at the bottom. */
footer?: ReactNode;
/** Shown beside the footer so a problem in a hidden section is not missed. */
errorCount?: number;
children: ReactNode;
className?: string;
}) {
const { t } = useTranslation();
const scrollRef = useRef<HTMLDivElement>(null);
const formRef = useRef<HTMLDivElement>(null);
const [query, setQuery] = useState("");
const [hiddenIds, setHiddenIds] = useState<Set<string>>(new Set());
const searching = query.trim().length > 0;
const onFiltered = useCallback((root: HTMLElement) => {
const next = new Set<string>();
for (const el of root.querySelectorAll<HTMLElement>(
`[data-section][${HIDDEN_ATTR}]`,
)) {
if (el.dataset.section) next.add(el.dataset.section);
}
setHiddenIds((prev) =>
prev.size === next.size && [...next].every((id) => prev.has(id))
? prev
: next,
);
}, []);
const anyShown = useSettingsPageFilter(
formRef,
query,
"editor",
"[data-section]",
onFiltered,
);
const visibleBands = bands
.map((band) => ({
...band,
items: band.items.filter((item) => !hiddenIds.has(item.id)),
}))
.filter((band) => band.items.length > 0);
const ids = visibleBands.flatMap((band) => band.items.map((item) => item.id));
const { active, scrollTo } = useScrollSpy(scrollRef, ids);
const showNav = bands.flatMap((band) => band.items).length > 1;
// A jump mounts every lazy section first and scrolls once the form stops
// growing. Mounting them on the way down made the scroll stall and restart.
const [mountAll, setMountAll] = useState(false);
const jump = (id: string) => {
if (mountAll) return scrollTo(id);
setMountAll(true);
void untilLayoutSettles(scrollRef.current).then(() => scrollTo(id));
};
if (revealRef) revealRef.current = jump;
return (
<div className={cn("flex min-h-0 flex-1", className)}>
{showNav && (
<nav
aria-label={t("manage.sections")}
className="hidden w-52 shrink-0 flex-col gap-3 overflow-y-auto border-r border-border py-2.5 thin-scrollbar lg:flex"
>
{visibleBands.map((band) => (
<div key={band.id} className="flex flex-col">
<GroupHeading title={band.label} className="px-2.5 pb-1.5" />
{band.items.map((item) => {
const on = item.id === active;
return (
<button
key={item.id}
type="button"
aria-current={on ? "true" : undefined}
onClick={() => jump(item.id)}
className={cn(
"flex w-full items-center gap-2 border-l-2 py-1.5 pl-2 pr-2 text-left transition-colors focus-visible:relative focus-visible:z-10 focus-visible:ring-1 focus-visible:ring-ring",
on
? "border-accent-brand bg-accent-brand/10 text-accent-brand"
: "border-transparent text-muted-foreground hover:bg-muted hover:text-foreground",
)}
>
{item.icon && (
<span className="flex shrink-0 [&_svg]:size-3.5">
{item.icon}
</span>
)}
<span className="truncate text-xs font-medium">
{item.label}
</span>
{item.error && (
<span
title={t("manage.sectionNeedsAttention")}
className="ml-auto size-1.5 shrink-0 rounded-full bg-destructive"
/>
)}
</button>
);
})}
</div>
))}
</nav>
)}
<div className="flex min-h-0 min-w-0 flex-1 flex-col">
<div className={`shrink-0 border-b border-border ${PANEL.band}`}>
<div className="mx-auto w-full max-w-3xl">
<PanelSearch
value={query}
onChange={setQuery}
placeholder={t("manage.searchSettings")}
fill
/>
</div>
</div>
{showNav && (
<div className="shrink-0 border-b border-border px-1 lg:hidden">
<TabStrip
tabs={visibleBands.flatMap((band) =>
band.items.map((item) => ({ id: item.id, label: item.label })),
)}
activeTab={active}
onTabChange={jump}
/>
</div>
)}
<div
ref={scrollRef}
className={`min-h-0 flex-1 overflow-y-auto thin-scrollbar ${PANEL.body}`}
>
{!anyShown && (
<EmptyState icon={Search} title={t("manage.noMatchingSettings")} />
)}
<div
ref={formRef}
className={`mx-auto flex max-w-3xl flex-col ${PANEL.gap} pb-6`}
>
{banner}
<SearchingContext.Provider value={searching || mountAll}>
{children}
</SearchingContext.Provider>
</div>
</div>
{(footer || errorCount > 0) && (
<div className="flex shrink-0 items-center gap-3 border-t border-border px-3 py-2">
{errorCount > 0 && (
<span className="text-[10px] text-destructive">
{t("manage.needsAttention", { count: errorCount })}
</span>
)}
<div className="ml-auto flex items-center gap-2">{footer}</div>
</div>
)}
</div>
</div>
);
}
-467
View File
@@ -1,467 +0,0 @@
import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import {
ChevronDown,
ChevronRight,
Folder,
FolderOpen,
Globe,
KeyRound,
Plus,
Server,
SlidersHorizontal,
User,
} from "lucide-react";
import { Button } from "@/components/button";
import { EmptyState } from "@/components/empty-state";
import { PanelSearch } from "@/components/panel-layout";
import { cn } from "@/lib/utils";
import { rem } from "@/lib/rem";
import type { Credential, Host } from "@/types/ui-types";
import { defaultsRows, type DefaultsRow } from "./defaults-rows";
import {
allFolderPaths,
allParentIds,
buildCredentialManageTree,
buildHostManageTree,
flattenTree,
type ManageRow,
} from "./manage-tree";
import type { ManageMode } from "./manage-requests";
import {
getStatusClasses,
useStatusColorScheme,
} from "@/hooks/use-status-color-scheme";
function indent(depth: number) {
return 6 + depth * 13;
}
/** Rules marking each open ancestor, so deep rows still read as nested. */
function Guides({ depth }: { depth: number }) {
if (depth === 0) return null;
return (
<>
{Array.from({ length: depth }, (_, i) => (
<span
key={i}
aria-hidden
className="absolute inset-y-0 w-px bg-border"
style={{ left: rem(indent(i) + 7) }}
/>
))}
</>
);
}
const rowFocus =
"focus-visible:relative focus-visible:z-10 focus-visible:ring-1 focus-visible:ring-ring";
/** The list column of the Manage tab. */
export function ManageList({
editing,
mode,
onMode,
hosts,
credentials,
canEditInstanceDefaults,
isOnline,
selectedKey,
onPickHost,
onPickCredential,
onPickDefaults,
onAdd,
}: {
editing: boolean;
mode: ManageMode;
onMode: (mode: ManageMode) => void;
hosts: Host[];
credentials: Credential[];
canEditInstanceDefaults: boolean;
isOnline: (host: Host) => boolean;
/** "h:<id>", "c:<id>" or a defaults row key. */
selectedKey: string | null;
onPickHost: (host: Host) => void;
onPickCredential: (credential: Credential) => void;
onPickDefaults: (row: DefaultsRow) => void;
onAdd: () => void;
}) {
const { t } = useTranslation();
const [query, setQuery] = useState("");
const needle = query.trim().toLowerCase();
const tree = useMemo(
() =>
mode === "credentials"
? buildCredentialManageTree(credentials)
: buildHostManageTree(hosts, isOnline),
[mode, hosts, credentials, isOnline],
);
// Everything starts open; closing something is remembered while mounted.
const [open, setOpen] = useState<Set<string>>(new Set());
useEffect(() => {
setOpen(new Set([...allFolderPaths(tree), ...allParentIds(tree)]));
// Rebuilt only when the side changes, so toggles survive an edit.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [mode]);
const toggle = (key: string) =>
setOpen((prev) => {
const next = new Set(prev);
if (next.has(key)) next.delete(key);
else next.add(key);
return next;
});
const rows = useMemo(
() => (mode === "defaults" ? [] : flattenTree(tree, open, needle)),
[mode, tree, open, needle],
);
const defaults = useMemo(
() =>
defaultsRows(hosts, canEditInstanceDefaults, t).filter(
(row) => !needle || row.label.toLowerCase().includes(needle),
),
[hosts, canEditInstanceDefaults, t, needle],
);
const pick = (id: string) => {
if (mode === "hosts") {
const host = hosts.find((h) => h.id === id);
if (host) onPickHost(host);
} else {
const credential = credentials.find((c) => c.id === id);
if (credential) onPickCredential(credential);
}
};
const empty = mode === "defaults" ? defaults.length === 0 : rows.length === 0;
return (
<div
className={cn(
editing ? "hidden md:flex" : "flex",
"w-full shrink-0 flex-col border-r border-border bg-background md:w-72",
)}
>
<nav
aria-label={t("manage.sections")}
className="flex shrink-0 flex-col border-b border-border py-1"
>
{(
[
{
value: "hosts",
label: t("nav.hosts"),
icon: Server,
count: hosts.length,
},
{
value: "credentials",
label: t("nav.credentials"),
icon: KeyRound,
count: credentials.length,
},
{
value: "defaults",
label: t("manage.defaults"),
icon: SlidersHorizontal,
},
] as {
value: ManageMode;
label: string;
icon: typeof Server;
count?: number;
}[]
).map((item) => {
const active = item.value === mode;
const Icon = item.icon;
return (
<button
key={item.value}
type="button"
aria-current={active ? "page" : undefined}
onClick={() => onMode(item.value)}
className={cn(
"flex w-full items-center gap-2 border-l-2 py-1.5 pl-2 pr-2.5 text-left transition-colors",
rowFocus,
active
? "border-accent-brand bg-accent-brand/10 text-accent-brand"
: "border-transparent text-muted-foreground hover:bg-muted hover:text-foreground",
)}
>
<Icon className="size-3.5 shrink-0" />
<span className="min-w-0 flex-1 truncate text-xs font-medium">
{item.label}
</span>
{item.count !== undefined && (
<span className="shrink-0 text-[10px] tabular-nums opacity-60">
{item.count}
</span>
)}
</button>
);
})}
</nav>
<div className="flex flex-col gap-2 border-b border-border px-2.5 py-2">
<div className="flex items-center gap-2">
<PanelSearch
value={query}
onChange={setQuery}
placeholder={
mode === "hosts"
? t("manage.searchHosts")
: mode === "credentials"
? t("manage.searchCredentials")
: t("manage.searchDefaults")
}
fill
/>
{mode !== "defaults" && (
<Button
variant="outline"
size="icon"
title={
mode === "hosts"
? t("hosts.addHost")
: t("manage.addCredential")
}
aria-label={
mode === "hosts"
? t("hosts.addHost")
: t("manage.addCredential")
}
onClick={onAdd}
className="shrink-0 border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
>
<Plus className="size-3.5" />
</Button>
)}
</div>
</div>
<div className="min-h-0 flex-1 overflow-y-auto py-1 thin-scrollbar">
{empty ? (
<EmptyState
icon={mode === "credentials" ? KeyRound : Server}
title={needle ? t("manage.nothingMatches") : t("manage.nothingYet")}
hint={
needle
? undefined
: mode === "hosts"
? t("manage.emptyHostsHint")
: t("manage.emptyCredentialsHint")
}
/>
) : mode === "defaults" ? (
defaults.map((row) => {
const Icon =
row.level === "admin"
? Globe
: row.level === "user"
? User
: Folder;
const selected = selectedKey === row.key;
return (
<button
key={row.key}
type="button"
onClick={() => onPickDefaults(row)}
className={cn(
"flex w-full items-center gap-2 border-l-2 px-2.5 py-1.5 text-left transition-colors",
rowFocus,
selected
? "border-accent-brand bg-accent-brand/10 text-accent-brand"
: "border-transparent text-foreground hover:bg-muted",
)}
>
<Icon
className={cn(
"size-3.5 shrink-0",
!selected && "text-muted-foreground",
)}
/>
<span className="min-w-0 flex-1 truncate text-xs font-medium">
{row.label}
</span>
<SlidersHorizontal className="size-3 shrink-0 text-muted-foreground/60" />
</button>
);
})
) : (
rows.map((row) =>
row.kind === "folder" ? (
<FolderRow
key={`f:${row.folder.path}`}
row={row}
showOnline={mode === "hosts"}
open={!!needle || open.has(row.folder.path)}
locked={!!needle}
onToggle={() => toggle(row.folder.path)}
/>
) : (
<ItemRow
key={`i:${row.item.id}`}
row={row}
isHost={mode === "hosts"}
selected={
selectedKey ===
`${mode === "hosts" ? "h" : "c"}:${row.item.id}`
}
open={!!needle || open.has(row.item.id)}
locked={!!needle}
onToggle={() => toggle(row.item.id)}
onPick={() => pick(row.item.id)}
/>
),
)
)}
</div>
</div>
);
}
function FolderRow({
row,
open,
locked,
showOnline,
onToggle,
}: {
row: Extract<ManageRow, { kind: "folder" }>;
open: boolean;
/** Search forces folders open, so the chevron stops responding. */
locked: boolean;
showOnline: boolean;
onToggle: () => void;
}) {
const Icon = open ? FolderOpen : Folder;
return (
<div className="relative">
<Guides depth={row.depth} />
<button
type="button"
aria-expanded={open}
onClick={locked ? undefined : onToggle}
style={{ paddingLeft: rem(indent(row.depth)) }}
className={cn(
"group flex w-full items-center gap-1.5 py-1.5 pr-2.5 text-left transition-colors hover:bg-muted",
rowFocus,
)}
>
{open ? (
<ChevronDown className="size-3 shrink-0 text-muted-foreground" />
) : (
<ChevronRight className="size-3 shrink-0 text-muted-foreground" />
)}
<Icon className="size-3.5 shrink-0 text-muted-foreground" />
<span className="min-w-0 flex-1 truncate text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">
{row.folder.name}
</span>
<span className="shrink-0 text-[10px] tabular-nums text-muted-foreground/60">
{showOnline ? `${row.online}/${row.count}` : row.count}
</span>
</button>
</div>
);
}
function ItemRow({
row,
isHost,
selected,
open,
locked,
onToggle,
onPick,
}: {
row: Extract<ManageRow, { kind: "item" }>;
isHost: boolean;
selected: boolean;
open: boolean;
locked: boolean;
onToggle: () => void;
onPick: () => void;
}) {
const { t } = useTranslation();
const scheme = useStatusColorScheme();
const nests = row.item.children.length > 0;
const Icon = isHost ? Server : KeyRound;
return (
<div className="relative">
<Guides depth={row.depth} />
<div
className={cn(
"flex items-center border-l-2 transition-colors",
selected
? "border-accent-brand bg-accent-brand/10"
: "border-transparent hover:bg-muted",
)}
>
<div
className="flex shrink-0 items-center justify-center"
style={{ paddingLeft: rem(indent(row.depth)) }}
>
{nests ? (
<button
type="button"
onClick={locked ? undefined : onToggle}
aria-expanded={open}
title={open ? t("manage.collapse") : t("manage.expand")}
aria-label={open ? t("manage.collapse") : t("manage.expand")}
className={cn(
"flex size-4 items-center justify-center text-muted-foreground hover:text-foreground",
rowFocus,
)}
>
{open ? (
<ChevronDown className="size-3" />
) : (
<ChevronRight className="size-3" />
)}
</button>
) : (
<span aria-hidden className="size-4" />
)}
</div>
<button
type="button"
onClick={onPick}
aria-current={selected ? "true" : undefined}
className={cn(
"flex min-w-0 flex-1 items-center gap-2 py-1.5 pl-1 pr-2.5 text-left transition-colors",
rowFocus,
selected ? "text-accent-brand" : "text-foreground",
)}
>
<Icon
className={cn(
"size-3.5 shrink-0",
!selected && "text-muted-foreground",
)}
/>
<span className="min-w-0 flex-1 truncate text-xs font-medium">
{row.item.name}
</span>
<span className="shrink-0 truncate font-mono text-[10px] text-muted-foreground">
{row.item.note}
</span>
{isHost && (
<span
aria-hidden
title={row.item.online ? t("common.online") : t("common.offline")}
className={cn(
"size-1.5 shrink-0 rounded-full",
getStatusClasses(!!row.item.online, scheme, "dot"),
)}
/>
)}
</button>
</div>
</div>
);
}
-588
View File
@@ -1,588 +0,0 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { KeyRound, LibraryBig, Plus, Server, Trash2 } from "lucide-react";
import { toast } from "sonner";
import type { HostDraft } from "@termix-ssh/plugin-sdk/frontend";
import { Button } from "@/components/button";
import { EmptyState } from "@/components/empty-state";
import { FakeSwitch } from "@/components/section-card";
import { BackButton, PanelShell } from "@/components/panel-layout";
import { useConfirm } from "@/components/surface/surface-scope";
import { useUiPreference } from "@/contexts/UiPreferencesContext";
import { usePermissions } from "@/hooks/use-permissions";
import { useServerStatus } from "@/lib/ServerStatusContext";
import {
deleteCredential,
deleteSSHHost,
getCredentialDetails,
getCredentials,
getSSHHosts,
type SSHHostWithStatus,
} from "@/main-axios";
import { getFolderDefaultsId } from "@/api/host-defaults-api";
import { invalidateHostsAndStatusCaches } from "@/lib/hosts-request-cache";
import { registerTabCloseGuard } from "@/shell/tab-close-guards";
import { HostEditor } from "@/sidebar/HostEditor";
import { CredentialEditorView } from "@/sidebar/CredentialEditorView";
import {
HostDefaultsEditorView,
hostDefaultsTitle,
type HostDefaultsEditorTarget,
} from "@/sidebar/host-defaults/HostDefaultsEditorView";
import { mapCredentials, sshHostToHost } from "@/sidebar/HostManagerData";
import {
hostProtocolFlags,
type HostProtocols,
} from "@/sidebar/host-protocols";
import { canDeleteHost } from "@/sidebar/host-permissions";
import type { Credential, Host } from "@/types/ui-types";
import { ManageList } from "./ManageList";
import type { DefaultsRow } from "./defaults-rows";
import {
MANAGE_REQUEST_EVENT,
takePendingManageRequest,
type ManageMode,
type ManageRequest,
} from "./manage-requests";
import { docsUrl } from "@/lib/docs";
type Editing =
| {
kind: "host";
host: Host | null;
draft?: HostDraft;
key: number;
protocols: HostProtocols;
}
| { kind: "credential"; credential: Credential | null; key: number }
| { kind: "defaults"; target: HostDefaultsEditorTarget; rowKey: string };
const TAB_ID = "host-manager";
/** Fetches a credential with its secrets marked as stored, for the editor. */
async function loadCredentialForEdit(cred: Credential): Promise<Credential> {
try {
const full = (await getCredentialDetails(Number(cred.id))) as {
hasKey?: boolean;
hasKeyPassword?: boolean;
password?: string;
certPublicKey?: string;
};
return {
...cred,
value: full.hasKey ? "existing_key" : (full.password ?? ""),
password: full.password ?? "",
passphrase: full.hasKeyPassword ? "existing_key_password" : "",
publicKey: full.certPublicKey ?? cred.publicKey,
};
} catch {
return cred;
}
}
/**
* Hosts, credentials and host defaults, edited at full width: a list on the
* left, the thing you picked on the right.
*/
export function ManageTab() {
const { t } = useTranslation();
const confirm = useConfirm();
const { has } = usePermissions();
const { statuses } = useServerStatus();
const presetMode = useUiPreference("hostEditor", "mode");
const [mode, setMode] = useState<ManageMode>("hosts");
const [hosts, setHosts] = useState<Host[]>([]);
const [credentials, setCredentials] = useState<Credential[]>([]);
const [editing, setEditing] = useState<Editing | null>(null);
const [dirty, setDirty] = useState(false);
// Starts from the preset and stays where the user puts it this session.
const [advanced, setAdvanced] = useState(presetMode !== "simple");
// The host a credential was opened from, so Back returns to it.
const [returnHost, setReturnHost] = useState<Host | "new" | null>(null);
const hostsRef = useRef(hosts);
hostsRef.current = hosts;
const credentialsRef = useRef(credentials);
credentialsRef.current = credentials;
const dirtyRef = useRef(dirty);
dirtyRef.current = dirty;
const reloadHosts = useCallback(() => {
return getSSHHosts()
.then((raw) => {
const list = raw.map(sshHostToHost);
hostsRef.current = list;
setHosts(list);
})
.catch(() => {});
}, []);
const reloadCredentials = useCallback(() => {
return getCredentials()
.then((res) => {
const list = mapCredentials(res);
credentialsRef.current = list;
setCredentials(list);
})
.catch(() => {});
}, []);
const loaded = useRef<Promise<unknown>>(Promise.resolve());
useEffect(() => {
loaded.current = Promise.all([reloadHosts(), reloadCredentials()]);
}, [reloadHosts, reloadCredentials]);
useEffect(() => {
window.addEventListener("termix:hosts-changed", reloadHosts);
window.addEventListener("ssh-hosts:changed", reloadHosts);
window.addEventListener("hosts:refresh", reloadHosts);
window.addEventListener("termix:credentials-changed", reloadCredentials);
return () => {
window.removeEventListener("termix:hosts-changed", reloadHosts);
window.removeEventListener("ssh-hosts:changed", reloadHosts);
window.removeEventListener("hosts:refresh", reloadHosts);
window.removeEventListener(
"termix:credentials-changed",
reloadCredentials,
);
};
}, [reloadHosts, reloadCredentials]);
/** Asks before throwing away unsaved changes. True means go ahead. */
const confirmDiscard = useCallback(async () => {
if (!dirtyRef.current) return true;
return confirm({
title: t("common.unsavedChanges"),
description: t("hosts.unsavedChangesDescription"),
confirmLabel: t("hosts.discardChanges"),
cancelLabel: t("hosts.keepEditing"),
});
}, [confirm, t]);
useEffect(
() => registerTabCloseGuard(TAB_ID, confirmDiscard),
[confirmDiscard],
);
const openHost = useCallback((host: Host | null, draft?: HostDraft) => {
setMode("hosts");
setReturnHost(null);
setDirty(false);
setEditing({
kind: "host",
host,
draft,
key: Date.now(),
protocols: hostProtocolFlags(host),
});
}, []);
const openCredential = useCallback(async (credential: Credential | null) => {
setMode("credentials");
setDirty(false);
const full = credential ? await loadCredentialForEdit(credential) : null;
setEditing({ kind: "credential", credential: full, key: Date.now() });
}, []);
const openDefaults = useCallback(
async (row: Pick<DefaultsRow, "level" | "folderName">) => {
setMode("defaults");
setReturnHost(null);
setDirty(false);
const rowKey =
row.level === "folder" ? `folder:${row.folderName}` : row.level;
if (row.level === "folder" && row.folderName) {
try {
const folderId = await getFolderDefaultsId(row.folderName);
setEditing({
kind: "defaults",
rowKey,
target: { level: "folder", folderId, folderName: row.folderName },
});
} catch {
toast.error(t("hostDefaults.loadFailed"));
}
return;
}
setEditing({ kind: "defaults", rowKey, target: { level: row.level } });
},
[t],
);
const apply = useCallback(
async (request: ManageRequest | null) => {
if (!request) return;
if (!(await confirmDiscard())) return;
await loaded.current;
if (request.kind === "host") {
const find = () =>
hostsRef.current.find((h) => h.id === request.hostId) ?? null;
let host = request.hostId ? find() : null;
// Our list can lag the sidebar's, so refetch once before giving up.
if (request.hostId && !host) {
invalidateHostsAndStatusCaches();
await reloadHosts();
host = find();
if (!host) {
toast.error(t("manage.hostNotFound"));
return;
}
}
openHost(host, request.draft);
} else if (request.kind === "credential") {
const find = () =>
credentialsRef.current.find(
(c) => String(c.id) === String(request.credentialId),
) ?? null;
let credential = request.credentialId ? find() : null;
if (request.credentialId && !credential) {
await reloadCredentials();
credential = find();
}
void openCredential(credential);
} else if (request.kind === "defaults") {
void openDefaults(request);
} else {
setDirty(false);
setEditing(null);
setMode(request.mode);
}
},
[
confirmDiscard,
openHost,
openCredential,
openDefaults,
reloadHosts,
reloadCredentials,
t,
],
);
// The request that opened this tab was parked before it mounted.
useEffect(() => {
void apply(takePendingManageRequest());
const onRequest = () => void apply(takePendingManageRequest());
window.addEventListener(MANAGE_REQUEST_EVENT, onRequest);
return () => window.removeEventListener(MANAGE_REQUEST_EVENT, onRequest);
}, [apply]);
const guarded =
<A extends unknown[]>(fn: (...args: A) => void) =>
async (...args: A) => {
if (await confirmDiscard()) fn(...args);
};
const close = () => {
setDirty(false);
setEditing(null);
};
const backFromCredential = () => {
if (returnHost) {
const host = returnHost;
setReturnHost(null);
openHost(host === "new" ? null : host);
return;
}
close();
};
const editCredentialFromHost = async (credentialId: string) => {
const cred = credentials.find((c) => String(c.id) === String(credentialId));
if (!cred || editing?.kind !== "host") return;
if (!(await confirmDiscard())) return;
setReturnHost(editing.host ?? "new");
await openCredential(cred);
};
const deleteCurrent = async () => {
if (editing?.kind === "host" && editing.host) {
const host = editing.host;
const ok = await confirm({
title: t("hosts.deleteHostConfirm", { name: host.name || host.ip }),
description: t("manage.cannotBeUndone"),
});
if (!ok) return;
try {
await deleteSSHHost(Number(host.id));
window.dispatchEvent(new CustomEvent("termix:hosts-changed"));
toast.success(t("hosts.deletedCount", { count: 1 }));
close();
} catch {
toast.error(t("hosts.failedToDeleteCount", { count: 1 }));
}
} else if (editing?.kind === "credential" && editing.credential) {
const cred = editing.credential;
const ok = await confirm({
title: t("credentials.deleteCredentialConfirm", { name: cred.name }),
description: t("manage.cannotBeUndone"),
});
if (!ok) return;
try {
await deleteCredential(Number(cred.id));
setCredentials((prev) => prev.filter((c) => c.id !== cred.id));
window.dispatchEvent(new CustomEvent("termix:credentials-changed"));
toast.success(t("credentials.deletedCredential", { name: cred.name }));
close();
} catch {
toast.error(t("credentials.failedToDeleteCredential"));
}
}
};
const isOnline = useCallback(
(host: Host) => statuses.get(Number(host.id))?.status === "online",
[statuses],
);
const existingFolders = useMemo(
() =>
[
...new Set(
credentials.map((c) => c.folder).filter((f): f is string => !!f),
),
].sort(),
[credentials],
);
const selectedKey = !editing
? null
: editing.kind === "host"
? editing.host
? `h:${editing.host.id}`
: null
: editing.kind === "credential"
? editing.credential
? `c:${editing.credential.id}`
: null
: editing.rowKey;
const status = !editing
? mode === "hosts"
? t("manage.hostCount", { count: hosts.length })
: mode === "credentials"
? t("manage.credentialCount", { count: credentials.length })
: t("manage.defaults")
: editing.kind === "host"
? editing.host
? editing.host.name || editing.host.ip
: t("manage.newHost")
: editing.kind === "credential"
? editing.credential
? editing.credential.name
: t("manage.newCredential")
: hostDefaultsTitle(editing.target, t);
const canDelete =
(editing?.kind === "host" &&
!!editing.host &&
canDeleteHost(editing.host)) ||
(editing?.kind === "credential" && !!editing.credential);
const renderEditor = () => {
if (!editing) return null;
if (editing.kind === "defaults") {
return (
<HostDefaultsEditorView
key={editing.rowKey}
target={editing.target}
onClose={guarded(close)}
onDirtyChange={setDirty}
hosts={hosts}
credentials={credentials}
/>
);
}
if (editing.kind === "credential") {
return (
<CredentialEditorView
key={`c:${editing.credential?.id ?? "new"}:${editing.key}`}
credential={editing.credential}
existingFolders={existingFolders}
saveAsNewHost={returnHost ?? undefined}
onBack={backFromCredential}
onSave={(saved, options) => {
const id = String((saved as { id: number | string }).id);
void reloadCredentials();
if (options?.assignToHost && returnHost) {
const host = returnHost;
setReturnHost(null);
setDirty(false);
setMode("hosts");
const next =
host === "new" ? null : { ...host, credentialId: id };
setEditing({
kind: "host",
host: next,
key: Date.now(),
protocols: hostProtocolFlags(next),
});
return;
}
if (returnHost) {
backFromCredential();
return;
}
close();
}}
/>
);
}
return (
<HostEditor
key={`h:${editing.host?.id ?? "new"}:${editing.key}`}
host={editing.host}
draft={editing.host ? undefined : editing.draft}
advanced={advanced}
onBack={guarded(close)}
onSave={(saved) => {
const updated = sshHostToHost(saved as unknown as SSHHostWithStatus);
setHosts((prev) => {
const idx = prev.findIndex((h) => h.id === updated.id);
if (idx < 0) return [...prev, updated];
const next = [...prev];
next[idx] = updated;
return next;
});
window.dispatchEvent(new CustomEvent("termix:hosts-changed"));
close();
}}
protocols={editing.protocols}
onProtocolChange={(p) =>
setEditing((current) =>
current?.kind === "host"
? { ...current, protocols: { ...current.protocols, ...p } }
: current,
)
}
onDirtyChange={setDirty}
hosts={hosts}
credentials={credentials}
onEditCredential={(id) => void editCredentialFromHost(id)}
/>
);
};
return (
<PanelShell
icon={<LibraryBig className="size-4" />}
title={t("nav.manage")}
status={status}
scroll={false}
docs={docsUrl(
mode === "credentials"
? "credentials"
: mode === "hosts"
? "hosts"
: "hostDefaults",
)}
leading={
editing ? (
<span className="flex h-full md:hidden">
<BackButton
onClick={() =>
void guarded(
editing.kind === "credential" ? backFromCredential : close,
)()
}
/>
</span>
) : undefined
}
actions={
<>
{editing?.kind === "host" && (
<label className="flex items-center gap-2 px-1">
<span className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
{t("manage.advanced")}
</span>
<FakeSwitch checked={advanced} onChange={setAdvanced} />
</label>
)}
{canDelete && (
<Button
variant="ghost"
size="icon-sm"
title={t("common.delete")}
aria-label={t("common.delete")}
onClick={() => void deleteCurrent()}
className="text-muted-foreground hover:text-destructive"
>
<Trash2 className="size-3.5" />
</Button>
)}
</>
}
>
<div className="flex min-h-0 flex-1">
<ManageList
editing={!!editing}
mode={mode}
onMode={(next) =>
void guarded(() => {
setMode(next);
close();
})()
}
hosts={hosts}
credentials={credentials}
canEditInstanceDefaults={has("admin.settings.manage")}
isOnline={isOnline}
selectedKey={selectedKey}
onPickHost={(host) => void guarded(() => openHost(host))()}
onPickCredential={(cred) =>
void guarded(() => void openCredential(cred))()
}
onPickDefaults={(row) => void guarded(() => void openDefaults(row))()}
onAdd={() =>
void guarded(() =>
mode === "credentials"
? void openCredential(null)
: openHost(null),
)()
}
/>
{editing ? (
<div className="flex min-h-0 min-w-0 flex-1 flex-col">
{renderEditor()}
</div>
) : (
<div className="hidden min-h-0 flex-1 items-center justify-center md:flex">
<EmptyState
icon={mode === "credentials" ? KeyRound : Server}
title={
mode === "hosts"
? t("manage.pickHost")
: mode === "credentials"
? t("manage.pickCredential")
: t("manage.pickDefaults")
}
hint={t("manage.pickHint")}
action={
mode !== "defaults" ? (
<Button
variant="outline"
size="sm"
onClick={() =>
mode === "hosts"
? openHost(null)
: void openCredential(null)
}
>
<Plus className="mr-1 size-3" />
{mode === "hosts"
? t("hosts.addHost")
: t("manage.addCredential")}
</Button>
) : undefined
}
/>
</div>
)}
</div>
</PanelShell>
);
}
-43
View File
@@ -1,43 +0,0 @@
import type { Host } from "@/types/ui-types";
import type { HostDefaultsLevel } from "@/types/host-defaults";
export interface DefaultsRow {
key: string;
level: HostDefaultsLevel;
folderName?: string;
label: string;
}
/** The levels of host defaults a user can open: instance, their own, folders. */
export function defaultsRows(
hosts: Host[],
canEditInstance: boolean,
t: (key: string, options?: Record<string, unknown>) => string,
): DefaultsRow[] {
const rows: DefaultsRow[] = [];
if (canEditInstance)
rows.push({
key: "admin",
level: "admin",
label: t("hostDefaults.titleAdmin"),
});
rows.push({ key: "user", level: "user", label: t("hostDefaults.titleUser") });
const folders = new Set<string>();
for (const host of hosts) {
if (!host.folder) continue;
let path = "";
for (const part of host.folder.split(" / ")) {
path = path ? `${path} / ${part}` : part;
folders.add(path);
}
}
for (const folder of [...folders].sort((a, b) => a.localeCompare(b))) {
rows.push({
key: `folder:${folder}`,
level: "folder",
folderName: folder,
label: folder,
});
}
return rows;
}
-21
View File
@@ -1,21 +0,0 @@
import type { HostEditorForm, HostProtocols } from "@/sidebar/HostEditorData";
/** Field keys mapped to an i18n key saying what is wrong. */
export type HostFormErrors = Partial<Record<"ip" | "sshPort", string>>;
function validPort(port: unknown): boolean {
const n = Number(port);
return Number.isInteger(n) && n >= 1 && n <= 65535;
}
/** What has to be fixed before a host can be saved. */
export function validateHostForm(
form: Pick<HostEditorForm, "ip" | "sshPort">,
protocols: Pick<HostProtocols, "enableSsh">,
): HostFormErrors {
const errors: HostFormErrors = {};
if (!form.ip.trim()) errors.ip = "manage.errorAddressRequired";
if (protocols.enableSsh && !validPort(form.sshPort))
errors.sshPort = "manage.errorPortRange";
return errors;
}
-256
View File
@@ -1,256 +0,0 @@
import type { Credential, Host } from "@/types/ui-types";
/**
* The Manage list as a tree: folders from the " / " path, sub-hosts under
* their parent host.
*/
export type ManageItem = {
id: string;
name: string;
/** IP for a host, username for a credential. */
note: string;
/** Extra search text, e.g. tags. */
search: string;
online?: boolean;
children: ManageItem[];
};
export type ManageFolder = {
/** Full " / " path, unique across the tree. */
path: string;
/** Last segment, what the row shows. */
name: string;
folders: ManageFolder[];
items: ManageItem[];
};
export type ManageRow =
| {
kind: "folder";
depth: number;
folder: ManageFolder;
count: number;
online: number;
}
| { kind: "item"; depth: number; item: ManageItem; folderPath: string };
function emptyFolder(path: string, name: string): ManageFolder {
return { path, name, folders: [], items: [] };
}
/** Walks to the folder at `path`, creating each missing segment on the way. */
function folderAt(
root: ManageFolder,
index: Map<string, ManageFolder>,
path: string,
) {
if (!path) return root;
const existing = index.get(path);
if (existing) return existing;
let current = root;
let accumulated = "";
for (const part of path.split(" / ")) {
const trimmed = part.trim();
if (!trimmed) continue;
accumulated = accumulated ? `${accumulated} / ${trimmed}` : trimmed;
let next = index.get(accumulated);
if (!next) {
next = emptyFolder(accumulated, trimmed);
index.set(accumulated, next);
current.folders.push(next);
}
current = next;
}
return current;
}
export function buildHostManageTree(
hosts: Host[],
isOnline: (host: Host) => boolean = (host) => host.online,
): ManageFolder {
const root = emptyFolder("", "");
const index = new Map<string, ManageFolder>();
const toItem = (host: Host): ManageItem => ({
id: host.id,
name: host.name || host.ip,
note: host.ip,
search: `${host.username} ${(host.tags ?? []).join(" ")}`,
online: isOnline(host),
children: [],
});
const items = new Map(hosts.map((h) => [h.id, toItem(h)]));
// A parent that is gone or would close a loop drops the host back to its
// folder, the same as buildHostTree.
const byId = new Map(hosts.map((h) => [h.id, h]));
const isDescendant = (candidate: string, ancestor: string) => {
const seen = new Set<string>();
let current: string | null | undefined = candidate;
while (current) {
if (current === ancestor) return true;
if (seen.has(current)) return false;
seen.add(current);
current = byId.get(current)?.parentHostId;
}
return false;
};
const nested = new Set<string>();
for (const host of hosts) {
if (!host.parentHostId) continue;
const parent = items.get(host.parentHostId);
if (!parent) continue;
if (isDescendant(host.parentHostId, host.id)) continue;
parent.children.push(items.get(host.id)!);
nested.add(host.id);
}
for (const host of hosts) {
if (nested.has(host.id)) continue;
folderAt(root, index, host.folder ?? "").items.push(items.get(host.id)!);
}
return sortFolder(root);
}
export function buildCredentialManageTree(
credentials: Credential[],
): ManageFolder {
const root = emptyFolder("", "");
const index = new Map<string, ManageFolder>();
for (const cred of credentials) {
folderAt(root, index, cred.folder ?? "").items.push({
id: cred.id,
name: cred.name,
note: cred.username,
search: `${cred.type} ${(cred.tags ?? []).join(" ")}`,
children: [],
});
}
return sortFolder(root);
}
function sortFolder(folder: ManageFolder): ManageFolder {
folder.folders.sort((a, b) => a.name.localeCompare(b.name));
folder.items.sort((a, b) => a.name.localeCompare(b.name));
folder.folders.forEach(sortFolder);
return folder;
}
function itemMatches(item: ManageItem, needle: string): boolean {
if (`${item.name} ${item.note} ${item.search}`.toLowerCase().includes(needle))
return true;
return item.children.some((child) => itemMatches(child, needle));
}
/** Total items in a folder and its subfolders, plus how many are online. */
export function folderCounts(folder: ManageFolder): {
count: number;
online: number;
} {
let count = 0;
let online = 0;
const walk = (item: ManageItem) => {
count++;
if (item.online) online++;
item.children.forEach(walk);
};
folder.items.forEach(walk);
for (const child of folder.folders) {
const sub = folderCounts(child);
count += sub.count;
online += sub.online;
}
return { count, online };
}
/** The rows actually drawn. While searching, folders holding a match open on their own. */
export function flattenTree(
folder: ManageFolder,
open: Set<string>,
needle: string,
depth = 0,
rows: ManageRow[] = [],
): ManageRow[] {
for (const child of folder.folders) {
const counts = folderCounts(child);
const matching = filterFolder(child, needle);
if (!matching) continue;
rows.push({
kind: "folder",
depth,
folder: child,
count: counts.count,
online: counts.online,
});
if (needle || open.has(child.path))
flattenTree(matching, open, needle, depth + 1, rows);
}
for (const item of folder.items) {
if (needle && !itemMatches(item, needle)) continue;
pushItem(item, folder.path, depth, open, needle, rows);
}
return rows;
}
function pushItem(
item: ManageItem,
folderPath: string,
depth: number,
open: Set<string>,
needle: string,
rows: ManageRow[],
) {
rows.push({ kind: "item", depth, item, folderPath });
if (item.children.length === 0) return;
if (!needle && !open.has(item.id)) return;
for (const child of item.children) {
if (needle && !itemMatches(child, needle)) continue;
pushItem(child, folderPath, depth + 1, open, needle, rows);
}
}
/** Drops folders with nothing matching, so search never leaves empty headers. */
function filterFolder(
folder: ManageFolder,
needle: string,
): ManageFolder | null {
if (!needle) return folder;
const folders = folder.folders
.map((child) => filterFolder(child, needle))
.filter((child): child is ManageFolder => child !== null);
const items = folder.items.filter((item) => itemMatches(item, needle));
if (folders.length === 0 && items.length === 0) return null;
return { ...folder, folders, items };
}
/** Every folder path in the tree, for expand-all and the default open set. */
export function allFolderPaths(
folder: ManageFolder,
out: string[] = [],
): string[] {
for (const child of folder.folders) {
out.push(child.path);
allFolderPaths(child, out);
}
return out;
}
/** Every parent host id, so sub-hosts can start expanded too. */
export function allParentIds(
folder: ManageFolder,
out: string[] = [],
): string[] {
const walk = (item: ManageItem) => {
if (item.children.length > 0) out.push(item.id);
item.children.forEach(walk);
};
folder.items.forEach(walk);
folder.folders.forEach((child) => allParentIds(child, out));
return out;
}
+494
View File
@@ -353,3 +353,497 @@ export {
type ConnectionGateProps,
} from "@/components/connection/ConnectionGate";
export { type ConnectionUnavailable } from "@/components/connection/ConnectionScreen";
// For shell-level plugins such as a layout: the API client, shared types and
// the shell's own state. These reach into core, so they may change with a core
// release. An ordinary plugin should not need them.
export { APP_VERSION } from "@/lib/app-version";
// The palette's own input has to share the Command it sits in, so cmdk is
// the shell's copy.
export { Command as CommandPrimitive } from "cmdk";
export {
buildExportPayload,
hostKey,
maskSecrets,
pluginSecretKeys,
type ExportPayload,
type FieldGroup,
} from "@/sidebar/host-export-payload";
export {
type AuthOverrideProtocol,
SSH_AUTH_PROTOCOL,
} from "@/types/auth-protocols";
export {
CREDENTIAL_ROW_FIELD_KEYS,
type CredentialDensity,
type CredentialRowFields,
type CredentialSidebarFilterState,
type CredentialSidebarPreferences,
type CredentialSortKey,
type CredentialTrayTrigger,
defaultCredentialRowFields,
defaultCredentialSidebarPreferences,
sanitizeCredentialSidebarPreferences,
} from "@/types/credential-sidebar-preferences";
export {
CORE_HOST_DEFAULTS,
CORE_NAMESPACE,
type DefaultOverrides,
defaultValuesEqual,
findCoreHostDefault,
type HostDefaultsLevel,
type ResolvedHostDefault,
splitDefaultKey,
} from "@/types/host-defaults";
export {
defaultHostRowFields,
defaultHostSidebarPreferences,
type HostClickBehavior,
type HostDensity,
type HostGroupKey,
type HostRowFields,
type HostSidebarFilterState,
type HostSidebarPreferences,
type HostTrayTrigger,
sanitizeHostSidebarPreferences,
type SortKey,
} from "@/types/host-sidebar-preferences";
export type {
HostData,
HostProtocolAuthInput,
ProxyNode,
SSHHost,
SSHHostData,
} from "@/types";
export {
UI_AREA_KEYS,
type UiAreaKey,
type UiPreset,
} from "@/types/ui-preferences";
export type {
AdminSection,
Credential,
DashboardCardId,
FontSizeId,
Host,
HostFolder,
SharePermissionLevel,
Tab,
TabType,
ThemeId,
ToolsTab,
UiFontId,
} from "@/types/ui-types";
export {
type AuditLog,
type AuditLogFilters,
getAuditForwarding,
getAuditLogActions,
getAuditLogs,
updateAuditForwarding,
} from "@/api/audit-log-api";
export {
getUserSecondFactors,
resetUserSecondFactors,
type UserSecondFactorSummary,
} from "@/api/auth-methods-api";
export { getCredentials } from "@/api/credentials-api";
export {
getFolderDefaultsId,
getHostDefaultsLevel,
type HostDefaultsChange,
type HostDefaultsTarget,
previewHostDefaultsLevel,
resetHostToDefaults,
resolveHostDefaults,
saveHostDefaultsLevel,
waitForDefaultsJob,
} from "@/api/host-defaults-api";
export { getHostTags, saveHostTags } from "@/api/host-tags-api";
export {
getSessionTimeoutMinutes,
getUserPreferences,
parseCustomKeybindings,
saveUserPreferences,
} from "@/api/open-tabs-api";
export {
getDeveloperMode,
getPluginAdminSettings,
getPluginHostSettings,
getPlugins,
getPluginUserSettings,
type PluginSettingsErrors,
type PluginSettingsField,
PluginSettingsValidationError,
type PluginSummary,
setAllPluginChannels,
setDeveloperMode,
setPluginOptions,
updatePluginAdminSettings,
updatePluginHostSettings,
updatePluginUserSettings,
} from "@/api/plugins-api";
export {
getNotificationPrivateEndpoints,
setNotificationPrivateEndpoints,
} from "@/api/private-endpoints-api";
export {
type CredentialPermissionLevel,
type FolderAccessRule,
getCredentialAccess,
getFolderAccess,
getRoles,
revokeCredentialAccess,
revokeFolderAccess,
shareCredential,
type ShareTarget,
} from "@/api/rbac-api";
export {
type BrandingSettings,
getBranding,
updateBranding,
} from "@/api/settings-api";
export {
getSyncConflicts,
getSyncErrors,
retrySyncErrors,
settleSyncConflict,
type SyncBasicAuth,
type SyncConflictItem,
type SyncEntityInfo,
type SyncErrorItem,
syncNow,
type SyncProxyHeader,
type SyncStatus,
unlinkServer,
updateSyncSettings,
} from "@/api/sync-api";
export {
getChannelReleases,
getUpdateChannel,
type ReleaseInfo,
setUpdateChannel,
type UpdateChannelState,
} from "@/api/system-status-api";
export {
getTlsStatus,
type TlsStatus,
uploadTlsCertificate,
} from "@/api/tls-api";
export {
Command,
CommandEmpty,
CommandGroup,
CommandItem,
CommandList,
} from "@/components/command";
export { usePluginDocsUrl } from "@/components/docs-link";
export {
DEFAULT_FOLDER_COLOR,
DEFAULT_FOLDER_ICON,
FolderIconEl,
HexColorPicker,
IconPicker,
} from "@/components/folder-style";
export { useTheme } from "@/components/theme-provider";
export { VersionBadge } from "@/components/version-badge";
export { useBranding } from "@/contexts/BrandingContext";
export {
useAreaPreferences,
useUiPreference,
useUiPreferencesContext,
} from "@/contexts/UiPreferencesContext";
export {
getRegisteredDashboardCard,
useRegisteredDashboardCards,
} from "@/dashboard/dashboard-cards-registry";
export { useActionSlot } from "@/hooks/use-action-slot";
export { useInstalledPlugins } from "@/hooks/use-installed-plugins";
export { usePageVisibleInterval } from "@/hooks/use-page-visible-interval";
export { usePermissions } from "@/hooks/use-permissions";
export {
getStatusClasses,
useStatusColorScheme,
} from "@/hooks/use-status-color-scheme";
export { useSyncStatus } from "@/hooks/use-sync-status";
export { useSshAuthProviders } from "@/hooks/useSshAuthProviders";
export { changeAppLanguage, normalizeLanguageCode } from "@/i18n/i18n";
export { LANGUAGES } from "@/i18n/languages";
export {
getLiveHostStatus,
useHostStatus,
useServerStatus,
useServerStatusMeta,
} from "@/lib/ServerStatusContext";
export { activityTarget } from "@/lib/activity-types";
export { applyPresetSideEffects } from "@/lib/apply-ui-preset";
export { getDatabaseTransferUrl } from "@/lib/database-transfer-url";
export {
canConfigureUpdates,
type DesktopUpdateMode,
desktopUpdates,
type DesktopUpdateState,
useDesktopUpdate,
} from "@/lib/desktop-updates";
export { type DocsPage, docsUrl } from "@/lib/docs";
export { getErrorMessage } from "@/lib/error-message";
export {
getDefaultConnectionTab,
resolveHostTabType,
} from "@/lib/host-connection-tabs";
export {
DefaultsOnly,
HostDefaultsContext,
type HostDefaultsContextValue,
HostOnly,
} from "@/lib/host-defaults-context";
export {
invalidateHostsAndStatusCaches,
invalidateServerStatusCache,
} from "@/lib/hosts-request-cache";
export { reportBetaFeedbackUrl, reportCoreIssueUrl } from "@/lib/issue-url";
export { notifySyncChanged } from "@/lib/linked-server";
export {
clearLocalAdaptivePreferences,
getPreferredHostAction,
recordHostActionPreference,
} from "@/lib/local-adaptive-preferences";
export { pluginKey, pluginName } from "@/lib/plugin-i18n";
export { PluginIcon } from "@/lib/plugin-icon";
export { rem } from "@/lib/rem";
export { getConnectedRemoteApi } from "@/lib/remote-server-api";
export { roleLabel } from "@/lib/role-label";
export type { StatusValue } from "@/lib/server-status-store";
export { parseServerTime } from "@/lib/server-time";
export { refreshSyncStatus, setSyncStatus } from "@/lib/sync-status";
export { tabJumpHotkeyKeys } from "@/lib/tab-jump-hotkey";
export {
ACCENT_PRESET_COLORS,
applyAccentColor,
applyFontSize,
applyUiFont,
DASHBOARD_CARDS,
FONT_SIZES,
UI_FONTS,
} from "@/lib/theme";
export { registerLayout } from "@/look/layout-registry";
export { useChooseTheme } from "@/look/use-look-theme";
export {
type AccessRecord,
type ActiveSessionInfo,
adminCreateUser,
adminCreateUserCredential,
adminCreateUserHost,
adminDeleteUserCredential,
adminDeleteUserHost,
adminExportUserData,
adminGetHostPassword,
adminGetUserCredentials,
adminGetUserHosts,
adminResetUserPassword,
adminUpdateUserCredential,
adminUpdateUserHost,
type ApiKey,
assignRoleToUser,
bulkImportSSHHosts,
bulkUpdateSSHHosts,
changePassword,
createApiKey,
createCredential,
type CreatedApiKey,
createRole,
createSSHHost,
deleteAccount,
deleteAllHostsInFolder,
deleteApiKey,
deleteCredential,
deleteOpenTab,
deleteRole,
deleteSSHHost,
deleteUser,
deployCredentialToHost,
duplicateCredential,
exportAllSSHHosts,
generateKeyPair,
generatePublicKeyFromPrivate,
getActiveSessions,
getApiKeys,
getCredentialDetails,
getCredentialSidebarPreferences,
getExternalAutoProvision,
getHostAccess,
getHostAuthOverride,
getHostPassword,
getHostSidebarPreferences,
getLogLevel,
getPasswordLoginAllowed,
getPasswordResetAllowed,
getPermissionsCatalog,
getRegistrationAllowed,
getSecondFactorAfterExternalLogin,
getSessions,
getSessionTimeout,
getSSHFolders,
getSSHHosts,
getStatusCheckSettings,
getUserInfo,
getUserList,
getUserRoles,
importSSHConfigHosts,
type LinkedAccountInfo,
linkExternalToPasswordAccount,
logoutUser,
makeUserAdmin,
type OpenTabRecord,
type PermissionCatalogEntry,
releaseUrlFrom,
removeAdminStatus,
removeRoleFromUser,
renameCredentialFolder,
renameFolder,
reorderCredentials,
reorderFolders,
reorderSSHHosts,
resetRecentActivity,
revokeAllUserSessions,
revokeHostAccess,
revokeSession,
type Role,
saveCredentialSidebarPreferences,
saveHostSidebarPreferences,
setHostAuthOverride,
shareFolder,
shareHost,
type SSHHostWithStatus,
unlinkExternalFromPasswordAccount,
updateCredential,
updateExternalAutoProvision,
updateFolderMetadata,
updateHostAccess,
updateLogLevel,
updatePasswordLoginAllowed,
updatePasswordResetAllowed,
updateRegistrationAllowed,
updateRole,
updateSecondFactorAfterExternalLogin,
updateSessionTimeout,
updateSSHHost,
updateStatusCheckSettings,
type UserPreferences,
type UserRole,
} from "@/main-axios";
export {
MANAGE_REQUEST_EVENT,
type ManageMode,
type ManageRequest,
requestFromLegacyEvent,
requestManage,
takePendingManageRequest,
} from "@/manage/manage-requests";
export { useSshAuthEditors } from "@/plugin-host/auth-registry";
export { usePluginStore } from "@/plugin-host/plugin-store";
export { shell } from "@/plugin-host/shell-bridge";
export { OPEN_PLUGINS_EVENT } from "@/plugins/open-plugins";
export { isPrerelease } from "@/plugins/plugin-model";
export { usePluginHostSections } from "@/settings/HostPluginSections";
export { SettingsFieldRow } from "@/settings/SettingsFields";
export { LookSettings } from "@/look/LookSettings";
export { useThemeOptions } from "@/settings/appearance/appearance-controls";
export {
hasVisibleFields,
isFieldShown,
} from "@/settings/settings-fields-util";
export { LinkServerDialog } from "@/settings/sync/LinkServerDialog";
export { ProxySettingsFields } from "@/settings/sync/ProxySettingsFields";
export { useShellController } from "@/shell/controller/ShellControllerProvider";
export {
type KeybindingActionDef,
useKeybindingActions,
useKeybindingDefaults,
} from "@/shell/keybinding-registry";
export {
filterPaletteItems,
loadPaletteGroup,
paletteEntriesFor,
type PaletteItemDef,
usePaletteEntries,
usePaletteGroups,
} from "@/shell/palette-registry";
export { getPanel, usePanels } from "@/shell/panel-registry";
export { SplitLayoutMenu } from "@/shell/split/SplitLayoutMenu";
export {
beginSplitDrag,
endSplitDrag,
isSplitDragging,
moveSplitDrag,
} from "@/shell/split/split-drag";
export {
canJoinSplit,
isSplitTab,
type SplitSummary,
type TabSplitAction,
} from "@/shell/split/split-tabs";
export {
openInSplit,
type SplitOpenTarget,
useSplitTargets,
} from "@/shell/split/split-targets";
export { MAX_PANES } from "@/shell/split/split-tree";
export { registerTabCloseGuard } from "@/shell/tab-close-guards";
export { useTabHandlesVersion } from "@/shell/tab-handles";
export {
getTabType,
isPersistentTabType,
isSessionTabType,
type TabShellCallbacks,
} from "@/shell/tab-registry";
export {
markTabSurfaceUsed,
preloadTabSurface,
tabIcon,
} from "@/shell/tabUtils";
export { AuthEnrollmentSections } from "@/sidebar/AuthEnrollmentSections";
export { mapCredentials, sshHostToHost } from "@/sidebar/HostManagerData";
export {
getHostEditorSection,
makeHostSshSubTabs,
makeHostTabs,
useHostEditorSections,
} from "@/sidebar/HostManagerTabs";
export {
actionOrder,
defaultConnectAction,
type HostActionDef,
hostActionsFor,
hostBadgesFor,
hostMenuItemsFor,
listHostActions,
runHostAction,
useHostActions,
useHostBadges,
useHostContextMenuItems,
} from "@/sidebar/host-contributions";
export { useEditorDefaultKeys } from "@/sidebar/host-defaults/catalog";
export {
enabledHostProtocols,
type HostProtocolDef,
hostProtocolFlags,
type HostProtocols,
listHostProtocols,
protocolEnabled,
protocolPort,
useHostProtocols,
withProtocolSettings,
} from "@/sidebar/host-protocols";
export { quickConnectTargets } from "@/sidebar/quick-connect-host";
export {
groupRailItems,
isCoreRailView,
moveRailItem,
promotableIds,
type RailGroup,
type RailItemDef,
railItemLabel,
rightDockableIds,
useRailItems,
} from "@/sidebar/rail-items";
+41
View File
@@ -1,3 +1,4 @@
/* eslint-disable react-refresh/only-export-components */
/**
* The Termix side of @termix-ssh/plugin-sdk/testing's renderWithApp.
*
@@ -462,3 +463,43 @@ export async function renderPlugin(
},
};
}
// Registries a shell-level plugin's own tests fill directly, since they
// render parts of the shell without activating a plugin.
export {
registerHostAction,
resetHostContributions,
} from "@/sidebar/host-contributions";
export { registerHostProtocol } from "@/sidebar/host-protocols";
export {
RAIL_ITEMS,
registerRailItem,
resetRegisteredRailItems,
} from "@/sidebar/rail-items";
export {
declareActionSlot,
registerAction,
registerSlotContribution,
resetActionRegistry,
} from "@/shell/action-registry";
export { trackTabHandle } from "@/shell/tab-handles";
export {
registerKeybindingAction,
registerKeybindingDefault,
resetKeybindingRegistry,
} from "@/shell/keybinding-registry";
export { LOCAL_ADAPTIVE_PREFERENCES_KEY } from "@/lib/local-adaptive-preferences";
export { HostPluginSections } from "@/settings/HostPluginSections";
export {
registerDashboardCard,
resetDashboardCards,
} from "@/dashboard/dashboard-cards-registry";
export {
resetPluginStore,
setPluginSummaries,
} from "@/plugin-host/plugin-store";
export { setPermissionsForTesting } from "@/hooks/use-permissions";
export { getRegisteredLayout } from "@/look/layout-registry";
export { ShellControllerProvider } from "@/shell/controller/ShellControllerProvider";
export { ServerStatusProvider } from "@/lib/ServerStatusContext";
export { TabPortals } from "@/shell/stage/TabPortals";
-162
View File
@@ -1,162 +0,0 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { ExternalLink, MonitorDown } from "lucide-react";
import { Button } from "@/components/button";
import { SectionCard, SettingRow } from "@/components/section-card";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/select";
import {
canConfigureUpdates,
desktopUpdates,
useDesktopUpdate,
type DesktopUpdateMode,
type DesktopUpdateState,
} from "@/lib/desktop-updates";
const MODES: DesktopUpdateMode[] = ["automatic", "prompt", "never"];
function StatusText({ state }: { state: DesktopUpdateState }) {
const { t } = useTranslation();
const version = state.version;
switch (state.status) {
case "checking":
return <>{t("desktopUpdates.statusChecking")}</>;
case "available":
return <>{t("desktopUpdates.statusAvailable", { version })}</>;
case "downloading":
return (
<>
{t("desktopUpdates.statusDownloading", {
version,
progress: state.progress ?? 0,
})}
</>
);
case "downloaded":
return <>{t("desktopUpdates.statusReady", { version })}</>;
case "up-to-date":
return <>{t("desktopUpdates.statusUpToDate")}</>;
case "error":
return <>{t("desktopUpdates.statusError")}</>;
default:
return <>{t("desktopUpdates.statusIdle")}</>;
}
}
/** Desktop only: how this install updates itself. */
export function DesktopUpdatesCard() {
const { t } = useTranslation();
const state = useDesktopUpdate();
const [busy, setBusy] = useState(false);
if (!canConfigureUpdates(state)) return null;
const bridge = desktopUpdates();
const run = async (action: () => Promise<unknown> | undefined) => {
setBusy(true);
try {
await action();
} catch {
toast.error(t("settings.updates.saveFailed"));
} finally {
setBusy(false);
}
};
const modeLabel = (mode: DesktopUpdateMode) =>
mode === "automatic"
? t("desktopUpdates.modeAutomatic")
: mode === "prompt"
? t("desktopUpdates.modePrompt")
: t("desktopUpdates.modeNever");
return (
<SectionCard
title={t("desktopUpdates.title")}
icon={<MonitorDown className="size-3.5" />}
rowId="updates-desktop"
>
<SettingRow
label={t("desktopUpdates.mode")}
description={
state.kind === "manual"
? t("desktopUpdates.manualModeHint")
: t("desktopUpdates.modeHint")
}
>
<Select
disabled={busy}
value={state.mode}
onValueChange={(next) =>
void run(() => bridge?.setMode(next as DesktopUpdateMode))
}
>
<SelectTrigger className="h-8 w-40 text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
{MODES.map((mode) => (
<SelectItem key={mode} value={mode}>
{modeLabel(mode)}
</SelectItem>
))}
</SelectContent>
</Select>
</SettingRow>
<SettingRow
label={t("desktopUpdates.status", { version: state.currentVersion })}
description={<StatusText state={state} />}
>
{state.status === "downloaded" ? (
<Button
size="sm"
className="h-8"
onClick={() => void bridge?.install()}
>
{t("desktopUpdates.restart")}
</Button>
) : state.status === "available" && state.kind === "auto" ? (
<Button
size="sm"
className="h-8"
disabled={busy}
onClick={() => void run(() => bridge?.download())}
>
{t("desktopUpdates.download")}
</Button>
) : state.status === "available" && state.releaseUrl ? (
<Button size="sm" variant="outline" className="h-8 gap-1.5" asChild>
<a
href={state.releaseUrl}
target="_blank"
rel="noopener noreferrer"
>
{t("desktopUpdates.download")}
<ExternalLink className="size-3" />
</a>
</Button>
) : (
<Button
size="sm"
variant="outline"
className="h-8"
disabled={
busy ||
state.status === "checking" ||
state.status === "downloading"
}
onClick={() => void run(() => bridge?.check())}
>
{t("desktopUpdates.checkNow")}
</Button>
)}
</SettingRow>
</SectionCard>
);
}
-232
View File
@@ -1,232 +0,0 @@
/* eslint-disable react-refresh/only-export-components */
/**
* Manifest-declared settings shown as ordinary sections in the profile and
* admin panels, one per feature, named after it.
*/
import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { Loader2 } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/button";
import { SectionCard } from "@/components/section-card";
import { AccordionSection } from "@/sidebar/AdminSettingsShared";
import { pluginKey, pluginName } from "@/lib/plugin-i18n";
import { PluginIcon } from "@/lib/plugin-icon";
import {
getPluginAdminSettings,
getPluginUserSettings,
updatePluginAdminSettings,
updatePluginUserSettings,
PluginSettingsValidationError,
type PluginSettingsErrors,
type PluginSettingsField,
type PluginSummary,
} from "@/api/plugins-api";
import { useInstalledPlugins } from "@/hooks/use-installed-plugins";
import { SettingsFieldRow } from "./SettingsFields";
import { hasVisibleFields, isFieldShown } from "./settings-fields-util";
export type FeatureSettingsScope = "admin" | "user";
export type FeatureSectionId = `feature:${string}`;
export function featureSectionId(pluginId: string): FeatureSectionId {
return `feature:${pluginId}`;
}
/** Enabled features with fields in this scope, sorted by name. */
export function useFeatureSettings(
scope: FeatureSettingsScope,
): PluginSummary[] {
const plugins = useInstalledPlugins();
return useMemo(
() =>
plugins
.filter(
(plugin) =>
plugin.enabled &&
hasVisibleFields(plugin.contributes?.settings?.[scope] ?? []),
)
.sort((a, b) => a.name.localeCompare(b.name)),
[plugins, scope],
);
}
export function FeatureSettingsSection({
plugin,
scope,
open,
onToggle,
}: {
plugin: PluginSummary;
scope: FeatureSettingsScope;
open: boolean;
onToggle: () => void;
}) {
const { t } = useTranslation();
return (
<AccordionSection
label={pluginName(t, plugin)}
icon={<PluginIcon name={plugin.icon} className="size-3.5" />}
open={open}
onToggle={onToggle}
>
<FeatureSettingsForm
plugin={plugin}
scope={scope}
fields={plugin.contributes?.settings?.[scope] ?? []}
/>
</AccordionSection>
);
}
export function FeatureSettingsForm({
plugin,
scope,
fields,
}: {
plugin: PluginSummary;
scope: FeatureSettingsScope;
fields: PluginSettingsField[];
}) {
const { t } = useTranslation();
const [values, setValues] = useState<Record<string, unknown>>({});
const [errors, setErrors] = useState<PluginSettingsErrors>({});
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [dirty, setDirty] = useState(false);
const running = plugin.enabled && plugin.state !== "failed";
const load = useCallback(async () => {
setLoading(true);
try {
const loaded =
scope === "admin"
? await getPluginAdminSettings(plugin.id)
: await getPluginUserSettings(plugin.id);
setValues(loaded);
setErrors({});
setDirty(false);
} catch {
toast.error(t("settings.pluginSettingsLoadFailed"));
} finally {
setLoading(false);
}
}, [plugin.id, scope, t]);
useEffect(() => {
void load();
}, [load]);
const setValue = (key: string, value: unknown) => {
setValues((current) => ({ ...current, [key]: value }));
setErrors((current) => {
if (!current[key]) return current;
const next = { ...current };
delete next[key];
return next;
});
setDirty(true);
};
const save = async () => {
setSaving(true);
try {
const saved =
scope === "admin"
? await updatePluginAdminSettings(plugin.id, values)
: await updatePluginUserSettings(plugin.id, values);
setValues(saved);
setErrors({});
setDirty(false);
toast.success(t("settings.pluginSettingsSaved"));
} catch (error) {
if (error instanceof PluginSettingsValidationError) {
setErrors(error.errors);
toast.error(t("settings.pluginSettingsInvalid"));
} else {
toast.error(t("settings.pluginSettingsSaveFailed"));
}
} finally {
setSaving(false);
}
};
// Groups appear in the order of their first field, so the manifest sets the layout.
const groups = useMemo(() => {
const ordered: { key?: string; fields: PluginSettingsField[] }[] = [];
for (const field of fields) {
const existing = ordered.find((group) => group.key === field.group);
if (existing) existing.fields.push(field);
else ordered.push({ key: field.group, fields: [field] });
}
return ordered;
}, [fields]);
if (loading) {
return (
<div className="flex items-center gap-2 py-3 text-xs text-muted-foreground">
<Loader2 className="size-3.5 animate-spin" />
{t("common.loading")}
</div>
);
}
const rows = (group: PluginSettingsField[]) =>
group
.filter((field) => isFieldShown(field, values))
.map((field) => (
<SettingsFieldRow
key={field.key}
pluginId={plugin.id}
field={field}
values={values}
setValue={setValue}
running={running}
error={errors[field.key]}
/>
));
const icon = <PluginIcon name={plugin.icon} className="size-3.5" />;
return (
<div className="flex flex-col gap-2 pt-2">
{!running && (
<div className="border border-warning/40 bg-warning/10 px-3 py-2 text-xs text-warning">
{t("settings.featureUnavailable", {
name: pluginName(t, plugin),
})}
</div>
)}
{groups.map((group, index) =>
group.key ? (
<SectionCard
key={group.key}
title={t(pluginKey(plugin.id, group.key))}
icon={icon}
>
{rows(group.fields)}
</SectionCard>
) : (
<div key={`group-${index}`}>{rows(group.fields)}</div>
),
)}
<div className="flex justify-end">
<Button
variant="outline"
size="sm"
className="h-7 text-xs border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand dark:border-accent-brand/40 dark:bg-transparent dark:hover:bg-accent-brand/10"
disabled={!dirty || saving}
onClick={() => void save()}
>
{saving && <Loader2 className="size-3 animate-spin mr-1.5" />}
{t("common.save")}
</Button>
</div>
</div>
);
}
-478
View File
@@ -1,478 +0,0 @@
import { Select2 } from "@/components/select2";
import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import {
getUserPreferences,
parseCustomKeybindings,
} from "@/api/open-tabs-api";
import { saveUserPreferences } from "@/main-axios";
import {
useKeybindingActions,
useKeybindingDefaults,
type KeybindingActionDef,
} from "@/shell/keybinding-registry";
import type {
CustomKeybinding,
KeybindingAction,
KeyCombo,
} from "@/types/keybindings";
import { InlineView, useConfirm } from "@/components/surface/surface-scope";
import { GroupHeading } from "@/components/panel-layout";
import { Button } from "@/components/button";
import { Input } from "@/components/input";
import { Keyboard, Pencil, Plus, RotateCcw, Trash2 } from "lucide-react";
import { FixedShortcutsList } from "@/sidebar/FixedShortcutsList";
function formatCombo(combo: KeyCombo): string {
const parts: string[] = [];
if (combo.ctrl) parts.push("Ctrl");
if (combo.alt) parts.push("Alt");
if (combo.shift) parts.push("Shift");
if (combo.meta) parts.push("Cmd");
parts.push(combo.key.length === 1 ? combo.key.toUpperCase() : combo.key);
return parts.join(" + ");
}
function generateId(): string {
return typeof crypto !== "undefined" && "randomUUID" in crypto
? crypto.randomUUID()
: `kb-${Date.now()}-${Math.random().toString(36).slice(2)}`;
}
/** An action without the keys an editor cleared, so it saves clean. */
function withoutEmpty(action: KeybindingAction): KeybindingAction {
const out: KeybindingAction = { type: action.type };
for (const [key, value] of Object.entries(action)) {
if (key !== "type" && value !== undefined && value !== null) {
out[key] = value;
}
}
return out;
}
/**
* Settings > Keybindings. The actions come from the keybinding registry:
* the shell's own and whatever the running plugins register, each with its
* own parameter editor. A saved binding whose plugin is off stays in the
* list and keeps working once the plugin is back.
*/
export function KeybindingsSettings() {
const { t } = useTranslation();
const confirm = useConfirm();
const actions = useKeybindingActions();
const defaults = useKeybindingDefaults();
const [bindings, setBindings] = useState<CustomKeybinding[]>([]);
const [loading, setLoading] = useState(false);
const [formOpen, setFormOpen] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null);
const [overridesDefaultId, setOverridesDefaultId] = useState<
string | undefined
>(undefined);
const [recordedCombo, setRecordedCombo] = useState<KeyCombo | null>(null);
const [recording, setRecording] = useState(false);
const [draft, setDraft] = useState<KeybindingAction>({ type: "" });
const actionById = useMemo(
() => new Map(actions.map((action) => [action.id, action])),
[actions],
);
const draftDef: KeybindingActionDef | undefined = actionById.get(draft.type);
useEffect(() => {
setLoading(true);
getUserPreferences()
.then((prefs) =>
setBindings(parseCustomKeybindings(prefs.customKeybindings)),
)
.catch(() => setBindings([]))
.finally(() => setLoading(false));
}, []);
const conflictWarning = useMemo(() => {
if (!recordedCombo) return null;
const conflict = bindings.find(
(kb) =>
kb.id !== editingId &&
kb.enabled &&
kb.combo.ctrl === recordedCombo.ctrl &&
kb.combo.alt === recordedCombo.alt &&
kb.combo.shift === recordedCombo.shift &&
kb.combo.meta === recordedCombo.meta &&
kb.combo.key === recordedCombo.key,
);
if (conflict) {
return t("newUi.sidebar.keybindings.conflictWarning", {
combo: formatCombo(conflict.combo),
});
}
return null;
}, [recordedCombo, bindings, editingId, t]);
function actionLabel(type: string): string {
const def = actionById.get(type);
return def
? t(def.labelKey)
: t("newUi.sidebar.keybindings.unavailableAction", { type });
}
function resetForm() {
setEditingId(null);
setOverridesDefaultId(undefined);
setRecordedCombo(null);
setRecording(false);
setDraft({ type: actions[0]?.id ?? "" });
}
function openAddForm() {
resetForm();
setFormOpen(true);
}
function openEditForm(kb: CustomKeybinding) {
setEditingId(kb.id);
setOverridesDefaultId(kb.overridesDefaultId);
setRecordedCombo(kb.combo);
setRecording(false);
setDraft({ ...kb.action });
setFormOpen(true);
}
function openOverrideDefaultForm(defaultId: string, combo: KeyCombo) {
resetForm();
setOverridesDefaultId(defaultId);
setRecordedCombo(combo);
setFormOpen(true);
}
async function persist(updated: CustomKeybinding[]) {
try {
await saveUserPreferences({
customKeybindings: JSON.stringify(updated),
});
setBindings(updated);
window.dispatchEvent(new Event("customKeybindingsChanged"));
} catch {
toast.error(t("newUi.sidebar.keybindings.saveError"));
}
}
async function handleDelete(id: string) {
const ok = await confirm({
title: t("newUi.sidebar.keybindings.deleteConfirm"),
});
if (ok) persist(bindings.filter((kb) => kb.id !== id));
}
async function handleResetAll() {
const ok = await confirm({
title: t("newUi.sidebar.keybindings.resetAllConfirm"),
confirmLabel: t("newUi.sidebar.keybindings.resetAllToDefaults"),
});
if (ok) persist([]);
}
function startRecording(inputEl: HTMLInputElement) {
setRecording(true);
const handler = (e: KeyboardEvent) => {
e.preventDefault();
e.stopPropagation();
if (["Control", "Alt", "Shift", "Meta"].includes(e.key)) {
return;
}
const isAlnum = /^[a-zA-Z0-9]$/.test(e.key);
const combo: KeyCombo = {
key: isAlnum ? e.key.toLowerCase() : e.code,
isCode: !isAlnum,
ctrl: e.ctrlKey,
alt: e.altKey,
shift: e.shiftKey,
meta: e.metaKey,
};
setRecordedCombo(combo);
setRecording(false);
inputEl.removeEventListener("keydown", handler, true);
inputEl.blur();
};
inputEl.addEventListener("keydown", handler, true);
}
function handleSave() {
if (!recordedCombo) {
toast.error(t("newUi.sidebar.keybindings.comboRequiredError"));
return;
}
if (!draftDef) {
toast.error(t("newUi.sidebar.keybindings.actionRequiredError"));
return;
}
const action = withoutEmpty(draft);
const problem = draftDef.validate?.(action);
if (problem) {
toast.error(t(problem));
return;
}
const now = new Date().toISOString();
const existing = editingId
? bindings.find((kb) => kb.id === editingId)
: undefined;
const newBinding: CustomKeybinding = {
id: editingId ?? generateId(),
combo: recordedCombo,
action,
enabled: true,
overridesDefaultId,
createdAt: existing?.createdAt ?? now,
updatedAt: now,
};
const updated = editingId
? bindings.map((kb) => (kb.id === editingId ? newBinding : kb))
: [...bindings, newBinding];
persist(updated);
setFormOpen(false);
resetForm();
}
const customOnlyBindings = bindings.filter((kb) => !kb.overridesDefaultId);
const Editor = draftDef?.editor;
return (
<>
<div className="flex flex-col gap-4 border border-border bg-card p-3">
<p className="text-xs text-muted-foreground">
{t("newUi.sidebar.keybindings.description")}
</p>
{defaults.length > 0 && (
<div className="flex flex-col gap-1.5">
<GroupHeading
title={t("newUi.sidebar.keybindings.defaultsHeading")}
/>
{defaults.map((def) => {
const override = bindings.find(
(kb) => kb.overridesDefaultId === def.id,
);
return (
<div
key={def.id}
className="flex items-center justify-between gap-2 border border-border bg-background px-2.5 py-2"
>
<div className="flex flex-col min-w-0">
<span className="text-xs font-mono">
{override
? formatCombo(override.combo)
: formatCombo(def.combo)}
</span>
<span className="text-xs text-muted-foreground truncate">
{t(def.descriptionKey)}
</span>
</div>
<div className="flex items-center gap-1 shrink-0">
{override ? (
<>
<span className="text-[10px] font-bold text-accent-brand border border-accent-brand/40 px-1">
{t("newUi.sidebar.keybindings.customizedBadge")}
</span>
<Button
variant="ghost"
size="icon"
className="size-7"
onClick={() => openEditForm(override)}
>
<Pencil className="size-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
className="size-7 text-muted-foreground hover:text-destructive"
onClick={() => void handleDelete(override.id)}
title={t("newUi.sidebar.keybindings.resetToDefault")}
>
<RotateCcw className="size-3.5" />
</Button>
</>
) : (
<>
<span className="text-[10px] text-muted-foreground border border-border px-1">
{t("newUi.sidebar.keybindings.defaultBadge")}
</span>
<Button
variant="ghost"
size="icon"
className="size-7"
onClick={() =>
openOverrideDefaultForm(def.id, def.combo)
}
title={t("newUi.sidebar.keybindings.customize")}
>
<Pencil className="size-3.5" />
</Button>
</>
)}
</div>
</div>
);
})}
</div>
)}
<div className="flex flex-col gap-1.5">
<GroupHeading
title={t("newUi.sidebar.keybindings.customHeading")}
action={
<Button variant="outline" size="sm" onClick={openAddForm}>
<Plus className="size-3.5 mr-1" />
{t("newUi.sidebar.keybindings.addBinding")}
</Button>
}
/>
{loading ? (
<span className="text-xs text-muted-foreground">
{t("newUi.sidebar.keybindings.loading")}
</span>
) : customOnlyBindings.length === 0 ? (
<span className="text-xs text-muted-foreground/60">
{t("newUi.sidebar.keybindings.noCustomBindings")}
</span>
) : (
customOnlyBindings.map((kb) => {
const Summary = actionById.get(kb.action.type)?.summary;
return (
<div
key={kb.id}
className="flex items-center justify-between gap-2 border border-border bg-background px-2.5 py-2"
>
<div className="flex flex-col min-w-0">
<span className="text-xs font-mono">
{formatCombo(kb.combo)}
</span>
<span className="text-xs text-muted-foreground truncate">
{actionLabel(kb.action.type)}
{Summary && (
<>
{" "}
<Summary action={kb.action} onChange={() => {}} />
</>
)}
</span>
</div>
<div className="flex items-center gap-1 shrink-0">
<Button
variant="ghost"
size="icon"
className="size-7"
onClick={() => openEditForm(kb)}
>
<Pencil className="size-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
className="size-7 text-muted-foreground hover:text-destructive"
onClick={() => void handleDelete(kb.id)}
>
<Trash2 className="size-3.5" />
</Button>
</div>
</div>
);
})
)}
</div>
<FixedShortcutsList />
<Button
variant="ghost"
size="sm"
className="self-start text-muted-foreground"
onClick={() => void handleResetAll()}
disabled={bindings.length === 0}
>
<RotateCcw className="size-3.5 mr-1" />
{t("newUi.sidebar.keybindings.resetAllToDefaults")}
</Button>
</div>
<InlineView
open={formOpen}
onOpenChange={setFormOpen}
icon={<Keyboard className="size-4" />}
title={
editingId || overridesDefaultId
? t("newUi.sidebar.keybindings.editBindingTitle")
: t("newUi.sidebar.keybindings.addBindingTitle")
}
footer={
<div className="ml-auto flex items-center gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => setFormOpen(false)}
>
{t("newUi.sidebar.keybindings.cancel")}
</Button>
<Button
variant="outline"
size="sm"
className="border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
onClick={handleSave}
>
{t("newUi.sidebar.keybindings.saveBinding")}
</Button>
</div>
}
>
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-1.5">
<label className="text-xs font-semibold">
{t("newUi.sidebar.keybindings.pressKeysToRecord")}{" "}
<span className="text-accent-brand">*</span>
</label>
<Input
data-keybinding-recorder
readOnly
value={
recording
? t("newUi.sidebar.keybindings.recording")
: recordedCombo
? formatCombo(recordedCombo)
: ""
}
placeholder={t("newUi.sidebar.keybindings.pressKeysPlaceholder")}
onFocus={(e) => startRecording(e.currentTarget)}
className="font-mono cursor-pointer"
/>
{conflictWarning && (
<span className="text-xs text-warning">{conflictWarning}</span>
)}
</div>
<div className="flex flex-col gap-1.5">
<label className="text-xs font-semibold">
{t("newUi.sidebar.keybindings.actionLabel")}
</label>
<Select2
value={draft.type}
onChange={(e) => setDraft({ type: e.target.value })}
className="h-8 border border-border bg-background px-2 text-xs outline-none focus:ring-1 focus:ring-ring"
>
{!draftDef && draft.type && (
<option value={draft.type}>{actionLabel(draft.type)}</option>
)}
{actions.map((action) => (
<option key={action.id} value={action.id}>
{t(action.labelKey)}
</option>
))}
</Select2>
</div>
{Editor && <Editor action={draft} onChange={setDraft} />}
</div>
</InlineView>
</>
);
}
-429
View File
@@ -1,429 +0,0 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import {
Bug,
ChevronDown,
ExternalLink,
Search,
Settings as SettingsIcon,
} from "lucide-react";
import {
GroupHeading,
PANEL,
PanelSearch,
PanelShell,
} from "@/components/panel-layout";
import { SurfaceScope } from "@/components/surface/surface-scope";
import { EmptyState } from "@/components/empty-state";
import { isElectron } from "@/lib/electron";
import { reportCoreIssueUrl } from "@/lib/issue-url";
import { cn } from "@/lib/utils";
import { pluginKey, pluginName } from "@/lib/plugin-i18n";
import { PluginIcon } from "@/lib/plugin-icon";
import type { Host } from "@/types/ui-types";
import type { UserPreferences } from "@/main-axios";
import { UserProfilePanel } from "@/sidebar/UserProfilePanel";
import { AdminSettingsPanel } from "@/sidebar/AdminSettingsPanel";
import {
FeatureSettingsForm,
useFeatureSettings,
} from "./FeatureSettingsSections";
import { KeybindingsSettings } from "./KeybindingsSettings";
import { UpdatesSettings } from "./UpdatesSettings";
import { SettingsEmbedContext } from "./settings-embed";
import { useSettingsPageFilter } from "./page-filter";
import {
CORE_SETTINGS_PAGES,
SETTINGS_BAND_ORDER,
searchSettingsPages,
visibleSettingsPages,
type SettingsBand,
type SettingsPage,
} from "./settings-pages";
import { usePluginDocsUrl } from "@/components/docs-link";
import { docsUrl } from "@/lib/docs";
export interface SettingsTabProps {
/** The page to open, from the tab's data. */
section?: string;
/** Changes on every open request, so asking for the same page again still goes there. */
sectionAt?: string | number;
/** An element id on that page to scroll to, as "id@nonce". */
reveal?: string;
username: string;
isAdmin: boolean;
onLogout?: () => void;
userPrefs?: UserPreferences;
onPrefsChange?: (prefs: Partial<UserPreferences>) => void;
onOpenHostTab?: (host: Host) => void;
}
const BAND_LABEL: Record<SettingsBand, string> = {
you: "settings.bandYou",
app: "settings.bandApp",
plugins: "settings.bandPlugins",
instance: "settings.bandInstance",
};
/** Scrolls to the first text on the page containing the query and marks it. */
function revealText(container: HTMLElement, needle: string): boolean {
const lower = needle.toLowerCase();
const walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT);
let node = walker.nextNode();
while (node) {
if (node.textContent?.toLowerCase().includes(lower)) {
const el = node.parentElement;
if (el && el.offsetParent !== null) {
el.scrollIntoView?.({ block: "center", behavior: "smooth" });
el.classList.add("settings-search-hit");
window.setTimeout(
() => el.classList.remove("settings-search-hit"),
1600,
);
return true;
}
}
node = walker.nextNode();
}
return false;
}
/** Everything that configures the app, one page at a time. */
export function SettingsTab({
section,
sectionAt,
reveal,
username,
isAdmin,
onLogout,
userPrefs,
onPrefsChange,
onOpenHostTab,
}: SettingsTabProps) {
const { t } = useTranslation();
const userFeatures = useFeatureSettings("user");
const adminFeatures = useFeatureSettings("admin");
const [query, setQuery] = useState("");
const [navOpen, setNavOpen] = useState(false);
const [pageQuery, setPageQuery] = useState("");
const contentRef = useRef<HTMLDivElement>(null);
const pageRef = useRef<HTMLDivElement>(null);
const pages = useMemo(() => {
const plugins = new Map<string, SettingsPage>();
for (const plugin of [...userFeatures, ...(isAdmin ? adminFeatures : [])]) {
if (plugins.has(plugin.id)) continue;
const fields = [
...(plugin.contributes?.settings?.user ?? []),
...(isAdmin ? (plugin.contributes?.settings?.admin ?? []) : []),
];
plugins.set(plugin.id, {
id: `plugin:${plugin.id}`,
band: "plugins",
labelKey: pluginKey(plugin.id, "plugin.name"),
label: pluginName(t, plugin),
icon: SettingsIcon,
pluginId: plugin.id,
searchKeys: fields.flatMap((field) =>
[field.labelKey, field.descriptionKey]
.filter((key): key is string => !!key)
.map((key) => pluginKey(plugin.id, key)),
),
});
}
return visibleSettingsPages(
[
...CORE_SETTINGS_PAGES,
...[...plugins.values()].sort((a, b) =>
(a.label ?? "").localeCompare(b.label ?? ""),
),
],
{ isAdmin, isElectron: isElectron() },
);
}, [userFeatures, adminFeatures, isAdmin, t]);
const [current, setCurrent] = useState(section ?? "account");
useEffect(() => {
if (section) setCurrent(section);
}, [section, sectionAt]);
// The page renders lazily, so the target may take a moment to appear.
useEffect(() => {
const target = reveal?.split("@")[0];
if (!target) return;
let tries = 0;
let timer = 0;
const attempt = () => {
const el = contentRef.current?.querySelector<HTMLElement>(
`#${CSS.escape(target)}`,
);
if (el) {
el.scrollIntoView?.({ block: "start", behavior: "smooth" });
el.classList.add("settings-search-hit");
timer = window.setTimeout(
() => el.classList.remove("settings-search-hit"),
1600,
);
return;
}
if (++tries < 15) timer = window.setTimeout(attempt, 120);
};
timer = window.setTimeout(attempt, 60);
return () => window.clearTimeout(timer);
}, [reveal]);
const page =
pages.find((p) => p.id === current) ??
pages.find((p) => p.id === "account")!;
const pageHasMatches = useSettingsPageFilter(pageRef, pageQuery, page.id);
const hits = searchSettingsPages(pages, query, (key) => t(key));
const pageLabel = (p: SettingsPage) => p.label ?? t(p.labelKey);
const go = (id: string) => {
setCurrent(id);
setNavOpen(false);
setPageQuery("");
contentRef.current?.scrollTo?.({ top: 0 });
const needle = query.trim();
if (!needle) return;
let tries = 0;
const attempt = () => {
if (!contentRef.current) return;
if (revealText(contentRef.current, needle) || ++tries > 12) return;
window.setTimeout(attempt, 150);
};
window.setTimeout(attempt, 60);
};
const pluginDocs = usePluginDocsUrl(page.pluginId);
const pageDocs = page.pluginId
? pluginDocs
: page.docs
? docsUrl(page.docs)
: null;
const pluginFeature = page.pluginId
? {
user: userFeatures.find((p) => p.id === page.pluginId),
admin: isAdmin
? adminFeatures.find((p) => p.id === page.pluginId)
: undefined,
}
: null;
const nav = (
<div className="flex flex-col gap-3 py-2.5">
<div className="px-2.5">
<PanelSearch
value={query}
onChange={setQuery}
placeholder={t("settings.searchPlaceholder")}
fill
/>
</div>
{query.trim() && hits.length === 0 && (
<p className="px-3 text-xs text-muted-foreground">
{t("settings.noResults")}
</p>
)}
{SETTINGS_BAND_ORDER.map((band) => {
const items = hits.filter((hit) => hit.page.band === band);
const reportBug =
band === "you" &&
(!query.trim() ||
t("dashboard.reportBug")
.toLowerCase()
.includes(query.trim().toLowerCase()));
if (items.length === 0 && !reportBug) return null;
return (
<div key={band} className="flex flex-col">
<GroupHeading
title={t(BAND_LABEL[band])}
className="px-2.5 pb-1.5"
/>
{items.map(({ page: item, match }) => {
const on = item.id === page.id;
const Icon = item.icon;
return (
<button
key={item.id}
type="button"
onClick={() => go(item.id)}
aria-current={on ? "page" : undefined}
className={cn(
"flex w-full flex-col border-l-2 py-1.5 pl-2 pr-2 text-left transition-colors focus-visible:relative focus-visible:z-10 focus-visible:ring-1 focus-visible:ring-ring",
on
? "border-accent-brand bg-accent-brand/10 text-accent-brand"
: "border-transparent text-muted-foreground hover:bg-muted hover:text-foreground",
)}
>
<span className="flex items-center gap-2">
{item.pluginId ? (
<PluginIcon
name={
[...userFeatures, ...adminFeatures].find(
(p) => p.id === item.pluginId,
)?.icon
}
className="size-3.5 shrink-0"
/>
) : (
<Icon className="size-3.5 shrink-0" />
)}
<span className="truncate text-xs font-medium">
{pageLabel(item)}
</span>
</span>
{match && (
<span className="truncate pl-5.5 text-[10px] text-muted-foreground">
{match}
</span>
)}
</button>
);
})}
{reportBug && (
<a
href={reportCoreIssueUrl(
import.meta.env.VITE_APP_VERSION || undefined,
)}
target="_blank"
rel="noopener noreferrer"
className="flex w-full items-center gap-2 border-l-2 border-transparent py-1.5 pl-2 pr-2 text-left text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:relative focus-visible:z-10 focus-visible:ring-1 focus-visible:ring-ring"
>
<Bug className="size-3.5 shrink-0" />
<span className="truncate text-xs font-medium">
{t("dashboard.reportBug")}
</span>
<ExternalLink className="ml-auto size-3 shrink-0" />
</a>
)}
</div>
);
})}
</div>
);
const showProfile = !!page.profileSection && page.id !== "keybindings";
const showAdmin = !!page.adminSection;
return (
<PanelShell
icon={<SettingsIcon className="size-4" />}
title={t("nav.settings")}
status={pageLabel(page)}
scroll={false}
docs={pageDocs}
actions={
<button
type="button"
onClick={() => setNavOpen((v) => !v)}
aria-expanded={navOpen}
className="flex items-center gap-1 px-2 py-1 text-xs text-muted-foreground hover:text-foreground md:hidden"
>
{t("settings.pages")}
<ChevronDown
className={cn(
"size-3.5 transition-transform",
navOpen && "rotate-180",
)}
/>
</button>
}
>
<div className="relative flex min-h-0 flex-1">
<nav
aria-label={t("settings.pages")}
className={cn(
"w-full shrink-0 overflow-y-auto border-r border-border bg-background thin-scrollbar md:static md:block md:w-60",
navOpen ? "absolute inset-0 z-20 block" : "hidden",
)}
>
{nav}
</nav>
<SurfaceScope kind="tab" className="min-w-0">
<div className={`shrink-0 border-b border-border ${PANEL.band}`}>
<div className="mx-auto w-full max-w-3xl">
<PanelSearch
value={pageQuery}
onChange={setPageQuery}
placeholder={t("settings.searchPage")}
fill
/>
</div>
</div>
<div
ref={contentRef}
className={`min-h-0 flex-1 overflow-y-auto thin-scrollbar ${PANEL.body}`}
>
{!pageHasMatches && (
<EmptyState icon={Search} title={t("settings.noPageMatches")} />
)}
<div
ref={pageRef}
className={`mx-auto flex w-full max-w-3xl flex-col ${PANEL.gap} pb-6`}
>
<SettingsEmbedContext.Provider value>
<div className={showProfile ? "contents" : "hidden"}>
<UserProfilePanel
section={page.profileSection ?? "__none__"}
username={username}
onLogout={onLogout}
userPrefs={userPrefs}
onPrefsChange={onPrefsChange}
onOpenSettingsPage={go}
/>
</div>
{isAdmin && (
<div className={showAdmin ? "contents" : "hidden"}>
<AdminSettingsPanel
section={page.adminSection ?? ("__none__" as never)}
onOpenHostTab={onOpenHostTab}
/>
</div>
)}
</SettingsEmbedContext.Provider>
{page.id === "keybindings" && <KeybindingsSettings />}
{page.id === "updates" && <UpdatesSettings isAdmin={isAdmin} />}
{pluginFeature && (
<>
{pluginFeature.user && (
<FeatureSettingsForm
plugin={pluginFeature.user}
scope="user"
fields={
pluginFeature.user.contributes?.settings?.user ?? []
}
/>
)}
{pluginFeature.admin && (
<>
<GroupHeading
title={t("settings.instanceSettings")}
className="pt-2"
/>
<FeatureSettingsForm
plugin={pluginFeature.admin}
scope="admin"
fields={
pluginFeature.admin.contributes?.settings?.admin ?? []
}
/>
</>
)}
{!pluginFeature.user && !pluginFeature.admin && (
<EmptyState
icon={SettingsIcon}
title={t("settings.nothingHere")}
/>
)}
</>
)}
</div>
</div>
</SurfaceScope>
</div>
</PanelShell>
);
}
-330
View File
@@ -1,330 +0,0 @@
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { Copy, ExternalLink, FlaskConical, Puzzle, Tag } from "lucide-react";
import { Button } from "@/components/button";
import { FakeSwitch, SectionCard, SettingRow } from "@/components/section-card";
import {
getChannelReleases,
getUpdateChannel,
setUpdateChannel,
type ReleaseInfo,
type UpdateChannelState,
} from "@/api/system-status-api";
import {
getPlugins,
setAllPluginChannels,
setPluginOptions,
type PluginSummary,
} from "@/api/plugins-api";
import { reportBetaFeedbackUrl } from "@/lib/issue-url";
import { isElectron } from "@/lib/electron";
import { isPrerelease } from "@/plugins/plugin-model";
import { OPEN_PLUGINS_EVENT } from "@/plugins/open-plugins";
import { docsUrl } from "@/lib/docs";
import { pluginName } from "@/lib/plugin-i18n";
import { DesktopUpdatesCard } from "./DesktopUpdatesCard";
const DOCKER_BETA_IMAGE = "ghcr.io/termix-ssh/termix:beta";
function onBeta(plugin: PluginSummary): boolean {
return plugin.channel === "beta" || isPrerelease(plugin.version);
}
function ReleaseRow({
label,
release,
}: {
label: string;
release: ReleaseInfo | null;
}) {
const { t } = useTranslation();
return (
<SettingRow
label={label}
description={
release
? new Date(release.publishedAt).toLocaleDateString()
: t("settings.updates.noRelease")
}
>
{release && (
<a
href={release.url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 text-xs font-medium text-accent-brand hover:underline"
>
v{release.version}
<ExternalLink className="size-3" />
</a>
)}
</SettingRow>
);
}
function InstanceUpdates() {
const { t } = useTranslation();
const [channel, setChannel] = useState<UpdateChannelState | null>(null);
const [releases, setReleases] = useState<{
stable: ReleaseInfo | null;
beta: ReleaseInfo | null;
} | null>(null);
const [plugins, setPlugins] = useState<PluginSummary[]>([]);
const [busy, setBusy] = useState(false);
const version = import.meta.env.VITE_APP_VERSION || undefined;
const loadPlugins = useCallback(async () => {
try {
setPlugins(await getPlugins());
} catch {
setPlugins([]);
}
}, []);
useEffect(() => {
getUpdateChannel()
.then(setChannel)
.catch(() => setChannel(null));
getChannelReleases()
.then(setReleases)
.catch(() => setReleases({ stable: null, beta: null }));
void loadPlugins();
}, [loadPlugins]);
const betaPlugins = plugins.filter(onBeta);
const runningBetas = plugins
.filter((plugin) => isPrerelease(plugin.version))
.map((plugin) => ({ id: plugin.id, version: plugin.version }));
const joined = channel?.channel === "beta";
const changeChannel = async (next: boolean) => {
setBusy(true);
try {
setChannel(await setUpdateChannel(next ? "beta" : "stable"));
} catch {
toast.error(t("settings.updates.saveFailed"));
} finally {
setBusy(false);
}
};
const changePlugins = async (action: () => Promise<unknown>) => {
setBusy(true);
try {
await action();
} catch {
toast.error(t("settings.updates.saveFailed"));
} finally {
await loadPlugins();
setBusy(false);
}
};
const copyImage = async () => {
try {
await navigator.clipboard.writeText(DOCKER_BETA_IMAGE);
toast.success(t("settings.updates.copied"));
} catch {
toast.error(t("settings.updates.copyFailed"));
}
};
return (
<div className="flex flex-col gap-3">
<SectionCard
title={t("settings.updates.betaProgram")}
icon={<FlaskConical className="size-3.5" />}
rowId="updates-beta"
>
<p className="py-3 text-xs leading-relaxed text-muted-foreground">
{t("settings.updates.betaIntro")}
</p>
<SettingRow
label={t("settings.updates.termixBetas")}
description={
channel?.runningBeta
? t("settings.updates.runningBetaBuild")
: t("settings.updates.termixBetasHint")
}
>
<FakeSwitch
checked={joined}
disabled={busy || !channel || channel.runningBeta}
onChange={(next) => void changeChannel(next)}
/>
</SettingRow>
{joined && !channel?.runningBeta && (
<div className="flex flex-col gap-2 py-3 text-xs">
<span className="font-medium">
{t("settings.updates.howToInstall")}
</span>
{isElectron() ? (
<span className="text-muted-foreground">
{t("settings.updates.desktopInstall")}{" "}
{releases?.beta && (
<a
href={releases.beta.url}
target="_blank"
rel="noopener noreferrer"
className="text-accent-brand hover:underline"
>
{t("settings.updates.downloadBeta", {
version: releases.beta.version,
})}
</a>
)}
</span>
) : (
<>
<span className="text-muted-foreground">
{t("settings.updates.dockerInstall")}
</span>
<div className="flex items-center gap-2">
<code className="flex-1 truncate border border-border bg-muted/40 px-2 py-1.5 font-mono text-[11px]">
{DOCKER_BETA_IMAGE}
</code>
<Button
size="sm"
variant="outline"
className="h-8 gap-1.5"
onClick={() => void copyImage()}
>
<Copy className="size-3.5" />
{t("settings.updates.copy")}
</Button>
</div>
</>
)}
<a
href={docsUrl("betas")}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 text-accent-brand hover:underline"
>
{t("settings.updates.learnMore")}
<ExternalLink className="size-3" />
</a>
</div>
)}
<SettingRow
label={t("settings.updates.feedback")}
description={t("settings.updates.feedbackHint")}
>
<Button size="sm" variant="outline" className="h-8 gap-1.5" asChild>
<a
href={reportBetaFeedbackUrl(version, runningBetas)}
target="_blank"
rel="noopener noreferrer"
>
{t("settings.updates.sendFeedback")}
</a>
</Button>
</SettingRow>
</SectionCard>
<SectionCard
title={t("settings.updates.releases")}
icon={<Tag className="size-3.5" />}
rowId="updates-releases"
>
<SettingRow label={t("settings.updates.thisVersion")}>
<span className="text-xs font-medium">
{version ? `v${version}` : "-"}
</span>
</SettingRow>
<ReleaseRow
label={t("settings.updates.newestStable")}
release={releases?.stable ?? null}
/>
<ReleaseRow
label={t("settings.updates.newestBeta")}
release={releases?.beta ?? null}
/>
</SectionCard>
<SectionCard
title={t("settings.updates.pluginBetas")}
icon={<Puzzle className="size-3.5" />}
rowId="updates-plugins"
>
<p className="py-3 text-xs leading-relaxed text-muted-foreground">
{t("settings.updates.pluginBetasIntro")}{" "}
<button
type="button"
className="text-accent-brand hover:underline"
onClick={() =>
window.dispatchEvent(new CustomEvent(OPEN_PLUGINS_EVENT))
}
>
{t("settings.updates.openPlugins")}
</button>
</p>
{betaPlugins.map((plugin) => (
<SettingRow
key={plugin.id}
label={pluginName(t, plugin)}
description={
plugin.channel === "beta"
? `v${plugin.version}`
: t("settings.updates.waitingForStable", {
version: plugin.version,
})
}
>
{plugin.channel === "beta" && (
<Button
size="sm"
variant="outline"
className="h-8"
disabled={busy}
onClick={() =>
void changePlugins(() =>
setPluginOptions(plugin.id, { channel: "stable" }),
)
}
>
{t("settings.updates.backToStable")}
</Button>
)}
</SettingRow>
))}
{plugins.length > 0 && (
<div className="flex flex-wrap gap-2 py-3">
<Button
size="sm"
variant="outline"
className="h-8"
disabled={busy}
onClick={() =>
void changePlugins(() => setAllPluginChannels("beta"))
}
>
{t("settings.updates.allPluginsBeta")}
</Button>
<Button
size="sm"
variant="outline"
className="h-8"
disabled={busy || betaPlugins.length === 0}
onClick={() =>
void changePlugins(() => setAllPluginChannels("stable"))
}
>
{t("settings.updates.allPluginsStable")}
</Button>
</div>
)}
</SectionCard>
</div>
);
}
export function UpdatesSettings({ isAdmin }: { isAdmin: boolean }) {
return (
<div className="flex flex-col gap-3">
<DesktopUpdatesCard />
{isAdmin && <InstanceUpdates />}
</div>
);
}
-139
View File
@@ -1,139 +0,0 @@
import { useEffect, useState, type RefObject } from "react";
export const HIDDEN_ATTR = "data-search-hidden";
const HIGHLIGHT = "settings-search";
const BLOCK = ".bg-card, [data-setting-row]";
function matches(el: Element, needle: string) {
return (el.textContent ?? "").toLowerCase().includes(needle);
}
/** Visible blocks with no other block above them inside root. */
function topBlocks(root: HTMLElement, blocks: Element[], selector: string) {
return blocks.filter((el) => {
let parent = el.parentElement;
while (parent && parent !== root) {
if (parent.matches(selector) || parent.classList.contains("hidden"))
return false;
parent = parent.parentElement;
}
return true;
});
}
/**
* Hides the cards and rows on a settings page that do not contain the query.
* A card whose own text matches keeps every row; otherwise only its matching
* rows stay. Returns how many cards are still showing.
*/
export function filterSettingsPage(
root: HTMLElement,
query: string,
blockSelector: string = BLOCK,
): number {
for (const el of root.querySelectorAll(`[${HIDDEN_ATTR}]`)) {
el.removeAttribute(HIDDEN_ATTR);
}
const needle = query.trim().toLowerCase();
const blocks = [...root.querySelectorAll(blockSelector)];
const cards = topBlocks(root, blocks, blockSelector);
if (!needle) return cards.length;
let shown = 0;
for (const card of cards) {
if (!matches(card, needle)) {
card.setAttribute(HIDDEN_ATTR, "");
continue;
}
shown++;
const rows = [...card.querySelectorAll("[data-setting-row]")].filter(
(row) => row !== card,
);
if (!rows.some((row) => matches(row, needle))) continue;
for (const row of rows) {
if (!matches(row, needle)) row.setAttribute(HIDDEN_ATTR, "");
}
for (const inner of card.querySelectorAll(".bg-card")) {
if (inner !== card && !matches(inner, needle)) {
inner.setAttribute(HIDDEN_ATTR, "");
}
}
}
return shown;
}
type HighlightRegistry = Map<string, unknown>;
function highlights(): HighlightRegistry | undefined {
const css = (globalThis as { CSS?: { highlights?: HighlightRegistry } }).CSS;
return css?.highlights;
}
/** Marks every match with the CSS highlight API where the browser has it. */
function highlightMatches(root: HTMLElement, query: string) {
const registry = highlights();
const Highlight = (
globalThis as { Highlight?: new (...r: Range[]) => unknown }
).Highlight;
if (!registry || !Highlight) return;
registry.delete(HIGHLIGHT);
const needle = query.trim().toLowerCase();
if (!needle) return;
const ranges: Range[] = [];
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
for (let node = walker.nextNode(); node; node = walker.nextNode()) {
const text = node.textContent?.toLowerCase() ?? "";
let at = text.indexOf(needle);
while (at !== -1) {
const range = document.createRange();
range.setStart(node, at);
range.setEnd(node, at + needle.length);
ranges.push(range);
at = text.indexOf(needle, at + needle.length);
}
}
registry.set(HIGHLIGHT, new Highlight(...ranges));
}
/**
* Keeps a settings page filtered while its content loads and changes.
* Returns false when the query hides everything.
*/
export function useSettingsPageFilter(
rootRef: RefObject<HTMLElement | null>,
query: string,
pageKey: string,
blockSelector: string = BLOCK,
onFiltered?: (root: HTMLElement) => void,
): boolean {
const [anyShown, setAnyShown] = useState(true);
useEffect(() => {
const root = rootRef.current;
if (!root) return;
let frame = 0;
const run = () => {
cancelAnimationFrame(frame);
frame = requestAnimationFrame(() => {
setAnyShown(
filterSettingsPage(root, query, blockSelector) > 0 || !query.trim(),
);
highlightMatches(root, query);
onFiltered?.(root);
});
};
run();
if (!query.trim()) return () => cancelAnimationFrame(frame);
const observer = new MutationObserver(run);
observer.observe(root, {
childList: true,
subtree: true,
characterData: true,
});
return () => {
observer.disconnect();
cancelAnimationFrame(frame);
highlights()?.delete(HIGHLIGHT);
};
}, [rootRef, query, pageKey, blockSelector, onFiltered]);
return anyShown;
}
-11
View File
@@ -1,11 +0,0 @@
import { createContext, useContext } from "react";
/**
* True inside a Settings tab page. Panels built from accordion sections then
* show only the open one, flat, since the page already names it.
*/
export const SettingsEmbedContext = createContext(false);
export function useSettingsEmbedded(): boolean {
return useContext(SettingsEmbedContext);
}
-264
View File
@@ -1,264 +0,0 @@
import {
Database,
FileText,
FlaskConical,
Globe,
KeyRound,
Keyboard,
Lock,
Monitor,
Palette,
Server,
Settings,
Shield,
SlidersHorizontal,
User,
Users,
type LucideIcon,
} from "lucide-react";
import type { AdminSection } from "@/types/ui-types";
import type { DocsPage } from "@/lib/docs";
import { SETTINGS_SEARCH_INDEX } from "./settings-search-index";
export type SettingsBand = "you" | "app" | "plugins" | "instance";
export const SETTINGS_BAND_ORDER: SettingsBand[] = [
"you",
"app",
"plugins",
"instance",
];
export interface SettingsPage {
id: string;
band: SettingsBand;
labelKey: string;
/** Already translated, for plugin pages named after the plugin. */
label?: string;
icon: LucideIcon;
/** Only for admins. */
admin?: boolean;
/** Also shown to non-admins in the desktop app. */
desktopToo?: boolean;
/** Left out of the desktop app. */
browserOnly?: boolean;
/** The profile section this page shows. */
profileSection?: string;
/** The admin section this page shows. */
adminSection?: AdminSection;
/** A plugin's settings page. */
pluginId?: string;
/** The docs page for this settings page. Plugin pages use the plugin's docs. */
docs?: DocsPage;
/** i18n keys whose text search matches. */
searchKeys: string[];
}
function core(
page: Omit<SettingsPage, "searchKeys"> & { indexId?: string },
): SettingsPage {
return {
...page,
searchKeys: SETTINGS_SEARCH_INDEX[page.indexId ?? page.id] ?? [],
};
}
export const CORE_SETTINGS_PAGES: SettingsPage[] = [
core({
id: "account",
docs: "account",
band: "you",
labelKey: "settings.account",
icon: User,
profileSection: "account",
}),
core({
id: "security",
docs: "accountSecurity",
band: "you",
labelKey: "settings.security",
icon: Shield,
profileSection: "security",
browserOnly: true,
}),
core({
id: "api-keys",
docs: "apiKeys",
band: "you",
labelKey: "settings.apiKeys",
icon: KeyRound,
profileSection: "api-keys",
}),
core({
id: "data",
docs: "accountData",
band: "you",
labelKey: "settings.data",
icon: Database,
profileSection: "data",
}),
core({
id: "interface",
docs: "interfaceSettings",
band: "app",
labelKey: "settings.interface",
icon: SlidersHorizontal,
profileSection: "interface",
}),
core({
id: "appearance",
docs: "appearance",
band: "app",
labelKey: "settings.appearance",
icon: Palette,
profileSection: "appearance",
}),
core({
id: "keybindings",
docs: "keybindings",
band: "app",
labelKey: "settings.keybindings",
icon: Keyboard,
profileSection: "keybindings",
}),
core({
id: "admin-general",
docs: "adminGeneral",
band: "instance",
labelKey: "settings.adminGeneral",
icon: Settings,
admin: true,
adminSection: "general",
}),
core({
id: "updates",
docs: "updating",
band: "instance",
labelKey: "settings.updates.title",
icon: FlaskConical,
admin: true,
desktopToo: true,
}),
core({
id: "admin-users",
docs: "users",
band: "instance",
labelKey: "settings.adminUsers",
icon: Users,
admin: true,
adminSection: "users",
}),
core({
id: "admin-sessions",
docs: "sessions",
band: "instance",
labelKey: "settings.adminSessions",
icon: Monitor,
admin: true,
adminSection: "sessions",
}),
core({
id: "admin-roles",
docs: "roles",
band: "instance",
labelKey: "settings.adminRoles",
icon: Lock,
admin: true,
adminSection: "roles",
}),
core({
id: "admin-host-defaults",
docs: "hostDefaults",
band: "instance",
labelKey: "settings.adminHostDefaults",
icon: Server,
admin: true,
adminSection: "host-defaults",
}),
core({
id: "admin-branding",
docs: "branding",
band: "instance",
labelKey: "settings.adminBranding",
icon: Palette,
admin: true,
adminSection: "branding",
}),
core({
id: "admin-database",
docs: "adminDatabase",
band: "instance",
labelKey: "settings.adminDatabase",
icon: Database,
admin: true,
adminSection: "database",
}),
core({
id: "admin-ssl",
docs: "adminSsl",
band: "instance",
labelKey: "settings.adminSsl",
icon: Globe,
admin: true,
adminSection: "ssl",
}),
core({
id: "admin-api-keys",
docs: "apiKeys",
band: "instance",
labelKey: "settings.adminApiKeys",
icon: KeyRound,
admin: true,
adminSection: "api-keys",
}),
core({
id: "admin-audit-log",
docs: "auditLog",
band: "instance",
labelKey: "settings.adminAuditLog",
icon: FileText,
admin: true,
adminSection: "audit-log",
}),
];
/** The pages this user can reach, in nav order. */
export function visibleSettingsPages(
pages: SettingsPage[],
{ isAdmin, isElectron }: { isAdmin: boolean; isElectron: boolean },
): SettingsPage[] {
return pages.filter(
(page) =>
(!page.admin || isAdmin || (page.desktopToo && isElectron)) &&
(!page.browserOnly || !isElectron),
);
}
export interface SettingsSearchHit {
page: SettingsPage;
/** The first matching phrase, to show under the page name. */
match: string;
}
/** Pages whose name or any label on them contains the query. */
export function searchSettingsPages(
pages: SettingsPage[],
query: string,
translate: (key: string) => string,
): SettingsSearchHit[] {
const needle = query.trim().toLowerCase();
if (!needle) return pages.map((page) => ({ page, match: "" }));
const hits: SettingsSearchHit[] = [];
for (const page of pages) {
const label = page.label ?? translate(page.labelKey);
if (label.toLowerCase().includes(needle)) {
hits.push({ page, match: "" });
continue;
}
const phrase = page.searchKeys
.map((key) => translate(key))
.find((text) => text.toLowerCase().includes(needle));
if (phrase) hits.push({ page, match: phrase });
}
return hits;
}
-488
View File
@@ -1,488 +0,0 @@
// Generated by scripts/generate-settings-search-index.cjs. Do not edit.
/** The i18n keys each core settings section shows, for search. */
export const SETTINGS_SEARCH_INDEX: Record<string, string[]> = {
"account": [
"newUi.sidebar.userProfile.authMethodLabel",
"newUi.sidebar.userProfile.cliDescription",
"newUi.sidebar.userProfile.cliLearnMore",
"newUi.sidebar.userProfile.cliTitle",
"newUi.sidebar.userProfile.deleteAccount",
"newUi.sidebar.userProfile.deleteAccountDescription",
"newUi.sidebar.userProfile.deleteButton",
"newUi.sidebar.userProfile.desktopProfileDescription",
"newUi.sidebar.userProfile.desktopProfileTitle",
"newUi.sidebar.userProfile.linkedProfileDescription",
"newUi.sidebar.userProfile.linkedProfileTitle",
"newUi.sidebar.userProfile.manageSync",
"newUi.sidebar.userProfile.onBetaDescription",
"newUi.sidebar.userProfile.onBetaTitle",
"newUi.sidebar.userProfile.openUpdates",
"newUi.sidebar.userProfile.roleLabel",
"newUi.sidebar.userProfile.sectionAccount",
"newUi.sidebar.userProfile.sendBetaFeedback",
"newUi.sidebar.userProfile.setUpSync",
"newUi.sidebar.userProfile.tryBetaDescription",
"newUi.sidebar.userProfile.tryBetaTitle",
"newUi.sidebar.userProfile.twoFaLabel",
"newUi.sidebar.userProfile.twoFaOff",
"newUi.sidebar.userProfile.twoFaOn",
"newUi.sidebar.userProfile.usernameLabel",
"newUi.sidebar.userProfile.versionLabel",
],
"admin-api-keys": [
"admin.apiKeyCreatedWarning",
"admin.apiKeyExpiresAt",
"admin.apiKeyName",
"admin.apiKeyNoExpiry",
"admin.apiKeySelectUser",
"admin.apiKeyUser",
"admin.apiKeysCliHint",
"admin.apiKeysCliLink",
"admin.apiKeysCount_other",
"admin.apiKeysDocsLink",
"admin.copiedToClipboard",
"admin.createKey",
"admin.createRole",
"admin.creating",
"admin.done",
"admin.newApiKey",
"admin.revokeKey",
"admin.revokeKeyConfirm",
"admin.revokeKeyFailed",
"admin.revokeKeySuccess",
"admin.revokedBadge",
"admin.sectionApiKeys",
"common.cancel",
"placeholders.apiKeyName",
],
"admin-audit-log": [
"admin.auditForwardingDesc",
"admin.auditForwardingEnvNotice",
"admin.auditForwardingSaveError",
"admin.auditForwardingSaved",
"admin.auditForwardingTitle",
"admin.auditForwardingToken",
"admin.auditForwardingTokenStored",
"admin.auditLogClearFilters",
"admin.auditLogEmpty",
"admin.auditLogFailed",
"admin.auditLogFilterAction",
"admin.auditLogFilterAll",
"admin.auditLogFilterFrom",
"admin.auditLogFilterResourceType",
"admin.auditLogFilterStatus",
"admin.auditLogFilterTo",
"admin.auditLogFilterUser",
"admin.auditLogIp",
"admin.auditLogPage",
"admin.auditLogResourceId",
"admin.auditLogSuccess",
"admin.auditLogTotal",
"admin.sectionAuditLog",
"common.save",
],
"admin-branding": [
"admin.brandingAppName",
"admin.brandingAppNameDesc",
"admin.brandingLogo",
"admin.brandingLogoDesc",
"admin.brandingLogoInvalid",
"admin.brandingLogoReset",
"admin.brandingLogoUpload",
"admin.brandingTagline",
"admin.brandingTaglineDesc",
"admin.sectionBranding",
"common.loading",
"common.save",
"common.saving",
],
"admin-database": [
"admin.changeFile",
"admin.export",
"admin.exportDatabase",
"admin.exportDatabaseDesc",
"admin.exporting",
"admin.import",
"admin.importDatabase",
"admin.importDatabaseDesc",
"admin.importDatabaseSelected",
"admin.importing",
"admin.sectionDatabase",
"admin.selectFile",
],
"admin-general": [
"admin.allowPasswordLogin",
"admin.allowPasswordLoginDesc",
"admin.allowPasswordReset",
"admin.allowPasswordResetDesc",
"admin.allowRegistration",
"admin.allowRegistrationDesc",
"admin.externalAutoProvision",
"admin.externalAutoProvisionDesc",
"admin.hours",
"admin.logLevel",
"admin.monitoringDefaults",
"admin.notificationPrivateEndpoints",
"admin.notificationPrivateEndpointsDesc",
"admin.passwordLoginForced",
"admin.sec",
"admin.secondFactorAfterExternalLogin",
"admin.secondFactorAfterExternalLoginDesc",
"admin.sectionGeneral",
"admin.sessionTimeout",
"admin.sessionTimeoutRange",
"admin.statusCheck",
"common.save",
"plugins.manager.developer.enable",
"plugins.manager.developer.enableBody",
"plugins.manager.developer.enableTitle",
"plugins.manager.developer.hint",
"plugins.manager.developer.signedOnly",
"plugins.manager.developer.title",
"plugins.manager.errors.developerMode",
],
"admin-host-defaults": [
"admin.hostTags",
"admin.hostTagsDesc",
"admin.hostTagsError",
"admin.sectionHostDefaults",
"common.remove",
"hostDefaults.adminSectionDescription",
"hostDefaults.editServerDefaults",
"hosts.addTag",
],
"admin-roles": [
"admin.createRole",
"admin.creating",
"admin.customBadge",
"admin.deleteRoleConfirm",
"admin.deleteRoleSuccess",
"admin.newRole",
"admin.roleDescription",
"admin.roleDisplayName",
"admin.roleName",
"admin.rolePermissions.count_other",
"admin.rolePermissions.editAction",
"admin.rolePermissions.loadError",
"admin.rolePermissions.pluginDisabled",
"admin.rolePermissions.save",
"admin.rolePermissions.saveError",
"admin.rolePermissions.saved",
"admin.rolePermissions.saving",
"admin.rolesCount_other",
"admin.sectionRoles",
"admin.systemBadge",
"common.cancel",
"common.optional",
],
"admin-sessions": [
"admin.revokeAll",
"admin.revokeAllSessionsConfirm",
"admin.revokeAllSessionsFailed",
"admin.revokeAllSessionsSuccess",
"admin.revokeSession",
"admin.revokeSessionConfirm",
"admin.revokeSessionFailed",
"admin.sectionSessions",
"admin.sessionActive",
"admin.sessionExpires",
"admin.sessionsActive_other",
"admin.youBadge",
],
"admin-ssl": [
"admin.sectionSsl",
"admin.sslCertExpiresAt",
"admin.sslCertStatus",
"admin.sslCertStatusExpired",
"admin.sslCertStatusExpiring",
"admin.sslCertStatusNone",
"admin.sslCertStatusValid",
"admin.sslDescription",
"admin.sslDocsLink",
"admin.sslInfoNote",
"admin.sslIssuer",
"admin.sslManualCert",
"admin.sslManualCertPlaceholder",
"admin.sslManualDesc",
"admin.sslManualKey",
"admin.sslManualKeyPlaceholder",
"admin.sslManualTitle",
"admin.sslManualUpload",
"admin.sslManualUploadLoading",
"admin.sslNames",
"admin.sslNotRenewed",
"admin.sslRenewedBy",
"admin.sslSelfSigned",
],
"admin-users": [
"admin.addRole",
"admin.adminBadge",
"admin.adminStatusAdministrator",
"admin.adminStatusRegularUser",
"admin.assignRoleFailed",
"admin.authTypeDual",
"admin.authTypeExternal",
"admin.authTypeLocal",
"admin.createUser",
"admin.createUserDesc",
"admin.createUserEnterUsername",
"admin.createUserPassword",
"admin.createUserPasswordHint",
"admin.createUserSubmit",
"admin.createUserTitle",
"admin.createUserUsername",
"admin.creating",
"admin.deleteUser",
"admin.deleteUserConfirm",
"admin.deleteUserFailed",
"admin.deleteUserSuccess",
"admin.deleteUserWarning",
"admin.deleting",
"admin.editUserAdminStatus",
"admin.editUserAuthType",
"admin.editUserDesc",
"admin.editUserTitle",
"admin.editUserUserId",
"admin.editUserUsername",
"admin.linkAccountDesc",
"admin.linkAccountDescLocal",
"admin.linkAccountEffect1",
"admin.linkAccountEffect2",
"admin.linkAccountEffect3",
"admin.linkAccountExternalNotFound",
"admin.linkAccountExternalPlaceholder",
"admin.linkAccountExternalUsername",
"admin.linkAccountFailed",
"admin.linkAccountInProgress",
"admin.linkAccountSuccess",
"admin.linkAccountTargetPlaceholder",
"admin.linkAccountTargetUsername",
"admin.linkAccountTitle",
"admin.linkAccountWarningTitle",
"admin.linkAccounts",
"admin.manageUserData",
"admin.noCustomRoles",
"admin.noUsers",
"admin.noUsersMatch",
"admin.pageOf",
"admin.removeRoleFailed",
"admin.revoke",
"admin.revokeAllUserSessions",
"admin.revokeAllUserSessionsDesc",
"admin.searchUsers",
"admin.sectionUsers",
"admin.unlinkAccount",
"admin.unlinkAccountDesc",
"admin.unlinkAccountFailed",
"admin.unlinkAccountInProgress",
"admin.unlinkAccountSuccess",
"admin.unlinkAccountTitle",
"admin.unlinkAccountWarning",
"admin.userAdminAccess",
"admin.userAdminAccessDesc",
"admin.userRoles",
"admin.usersCount_other",
"common.cancel",
"common.loading",
"common.next",
"common.previous",
"manage.cannotBeUndone",
"placeholders.enterPassword",
],
"api-keys": [
"admin.revokeKey",
"admin.revokeKeyConfirm",
"newUi.sidebar.userProfile.apiKeyActive",
"newUi.sidebar.userProfile.apiKeyCount_other",
"newUi.sidebar.userProfile.apiKeyCreateFailed",
"newUi.sidebar.userProfile.apiKeyCreated",
"newUi.sidebar.userProfile.apiKeyCreatedWarning",
"newUi.sidebar.userProfile.apiKeyExpires",
"newUi.sidebar.userProfile.apiKeyNameLabel",
"newUi.sidebar.userProfile.apiKeyNamePlaceholder",
"newUi.sidebar.userProfile.apiKeyNameRequired",
"newUi.sidebar.userProfile.apiKeyPermissionsHint",
"newUi.sidebar.userProfile.apiKeyRevokeFailed",
"newUi.sidebar.userProfile.apiKeyRevoked",
"newUi.sidebar.userProfile.apiKeyUsageHint",
"newUi.sidebar.userProfile.apiKeyUsageHintHeader",
"newUi.sidebar.userProfile.apiKeyUser",
"newUi.sidebar.userProfile.cancel",
"newUi.sidebar.userProfile.copiedToClipboard",
"newUi.sidebar.userProfile.copyApiKey",
"newUi.sidebar.userProfile.createApiKeyDescription",
"newUi.sidebar.userProfile.createApiKeyTitle",
"newUi.sidebar.userProfile.createKey",
"newUi.sidebar.userProfile.done",
"newUi.sidebar.userProfile.expiryDateLabel",
"newUi.sidebar.userProfile.newKey",
"newUi.sidebar.userProfile.noApiKeys",
"newUi.sidebar.userProfile.optional",
"newUi.sidebar.userProfile.sectionApiKeys",
],
"appearance": [
"looks.layoutLabel",
"newUi.sidebar.userProfile.accentColorLabel",
"newUi.sidebar.userProfile.appRailHoverPushesContent",
"newUi.sidebar.userProfile.appRailHoverPushesContentDesc",
"newUi.sidebar.userProfile.colorPickerTooltip",
"newUi.sidebar.userProfile.commandPalette",
"newUi.sidebar.userProfile.commandPaletteDesc",
"newUi.sidebar.userProfile.confirmTabClose",
"newUi.sidebar.userProfile.confirmTabCloseDesc",
"newUi.sidebar.userProfile.disableUpdateChecks",
"newUi.sidebar.userProfile.disableUpdateChecksDesc",
"newUi.sidebar.userProfile.expandAppRailOnHover",
"newUi.sidebar.userProfile.expandAppRailOnHoverDesc",
"newUi.sidebar.userProfile.fontSizeLabel",
"newUi.sidebar.userProfile.interfaceFontDescription",
"newUi.sidebar.userProfile.interfaceFontLabel",
"newUi.sidebar.userProfile.keyboardShortcuts",
"newUi.sidebar.userProfile.keyboardShortcutsDescription",
"newUi.sidebar.userProfile.languageLabel",
"newUi.sidebar.userProfile.manageShortcuts",
"newUi.sidebar.userProfile.navigationTabsDesc",
"newUi.sidebar.userProfile.pinAppRail",
"newUi.sidebar.userProfile.pinAppRailDesc",
"newUi.sidebar.userProfile.reopenTabsOnLogin",
"newUi.sidebar.userProfile.reopenTabsOnLoginDesc",
"newUi.sidebar.userProfile.sectionAppearance",
"newUi.sidebar.userProfile.settingsAppRail",
"newUi.sidebar.userProfile.settingsNavigation",
"newUi.sidebar.userProfile.settingsTabsAndShortcuts",
"newUi.sidebar.userProfile.settingsUpdates",
"newUi.sidebar.userProfile.showPinAppRailButton",
"newUi.sidebar.userProfile.showPinAppRailButtonDesc",
"newUi.sidebar.userProfile.showTabNumbers",
"newUi.sidebar.userProfile.showTabNumbersDesc",
"newUi.sidebar.userProfile.sidebarSettingsMoved",
"newUi.sidebar.userProfile.themeLabel",
],
"data": [
"newUi.sidebar.userProfile.cancel",
"newUi.sidebar.userProfile.changeFile",
"newUi.sidebar.userProfile.confirmPasswordDeletePlaceholder",
"newUi.sidebar.userProfile.confirmPasswordLabel",
"newUi.sidebar.userProfile.deleteAccount",
"newUi.sidebar.userProfile.deleteAccountPermanent",
"newUi.sidebar.userProfile.deleteAccountWarning",
"newUi.sidebar.userProfile.deleting",
"newUi.sidebar.userProfile.export",
"newUi.sidebar.userProfile.exportData",
"newUi.sidebar.userProfile.exportDataDesc",
"newUi.sidebar.userProfile.exporting",
"newUi.sidebar.userProfile.import",
"newUi.sidebar.userProfile.importData",
"newUi.sidebar.userProfile.importDataDesc",
"newUi.sidebar.userProfile.importDataSelected",
"newUi.sidebar.userProfile.importing",
"newUi.sidebar.userProfile.sectionData",
"newUi.sidebar.userProfile.selectFile",
],
"interface": [
"newUi.sidebar.userProfile.interfacePresetDesc",
"newUi.sidebar.userProfile.presetClearOverrides",
"newUi.sidebar.userProfile.presetConfirmApply",
"newUi.sidebar.userProfile.presetConfirmDesc",
"newUi.sidebar.userProfile.presetConfirmTitle",
"newUi.sidebar.userProfile.presetOverridesTitle",
"newUi.sidebar.userProfile.presetRevertArea",
"newUi.sidebar.userProfile.runSetupAgainButton",
"newUi.sidebar.userProfile.runSetupAgainDesc",
"newUi.sidebar.userProfile.sectionInterface",
],
"keybindings": [
"newUi.sidebar.keybindings.actionLabel",
"newUi.sidebar.keybindings.actionRequiredError",
"newUi.sidebar.keybindings.addBinding",
"newUi.sidebar.keybindings.addBindingTitle",
"newUi.sidebar.keybindings.cancel",
"newUi.sidebar.keybindings.comboRequiredError",
"newUi.sidebar.keybindings.conflictWarning",
"newUi.sidebar.keybindings.customHeading",
"newUi.sidebar.keybindings.customize",
"newUi.sidebar.keybindings.customizedBadge",
"newUi.sidebar.keybindings.defaultBadge",
"newUi.sidebar.keybindings.defaultsHeading",
"newUi.sidebar.keybindings.deleteConfirm",
"newUi.sidebar.keybindings.description",
"newUi.sidebar.keybindings.editBindingTitle",
"newUi.sidebar.keybindings.loading",
"newUi.sidebar.keybindings.noCustomBindings",
"newUi.sidebar.keybindings.pressKeysPlaceholder",
"newUi.sidebar.keybindings.pressKeysToRecord",
"newUi.sidebar.keybindings.recording",
"newUi.sidebar.keybindings.resetAllConfirm",
"newUi.sidebar.keybindings.resetAllToDefaults",
"newUi.sidebar.keybindings.resetToDefault",
"newUi.sidebar.keybindings.saveBinding",
"newUi.sidebar.keybindings.saveError",
"newUi.sidebar.keybindings.unavailableAction",
],
"security": [
"newUi.sidebar.userProfile.changePassword",
"newUi.sidebar.userProfile.confirmPasswordLabel",
"newUi.sidebar.userProfile.confirmPasswordPlaceholder",
"newUi.sidebar.userProfile.currentPasswordLabel",
"newUi.sidebar.userProfile.currentPasswordPlaceholder",
"newUi.sidebar.userProfile.newPasswordLabel",
"newUi.sidebar.userProfile.newPasswordPlaceholder",
"newUi.sidebar.userProfile.passwordFieldsRequired",
"newUi.sidebar.userProfile.passwordMismatch",
"newUi.sidebar.userProfile.passwordTooShort",
"newUi.sidebar.userProfile.passwordUpdateFailed",
"newUi.sidebar.userProfile.passwordUpdated",
"newUi.sidebar.userProfile.sectionSecurity",
"newUi.sidebar.userProfile.updatePassword",
],
"updates": [
"desktopUpdates.checkNow",
"desktopUpdates.download",
"desktopUpdates.manualModeHint",
"desktopUpdates.mode",
"desktopUpdates.modeAutomatic",
"desktopUpdates.modeHint",
"desktopUpdates.modeNever",
"desktopUpdates.modePrompt",
"desktopUpdates.restart",
"desktopUpdates.status",
"desktopUpdates.statusAvailable",
"desktopUpdates.statusChecking",
"desktopUpdates.statusDownloading",
"desktopUpdates.statusError",
"desktopUpdates.statusIdle",
"desktopUpdates.statusReady",
"desktopUpdates.statusUpToDate",
"desktopUpdates.title",
"settings.updates.allPluginsBeta",
"settings.updates.allPluginsStable",
"settings.updates.backToStable",
"settings.updates.betaIntro",
"settings.updates.betaProgram",
"settings.updates.copied",
"settings.updates.copy",
"settings.updates.copyFailed",
"settings.updates.desktopInstall",
"settings.updates.dockerInstall",
"settings.updates.downloadBeta",
"settings.updates.feedback",
"settings.updates.feedbackHint",
"settings.updates.howToInstall",
"settings.updates.learnMore",
"settings.updates.newestBeta",
"settings.updates.newestStable",
"settings.updates.noRelease",
"settings.updates.openPlugins",
"settings.updates.pluginBetas",
"settings.updates.pluginBetasIntro",
"settings.updates.releases",
"settings.updates.runningBetaBuild",
"settings.updates.saveFailed",
"settings.updates.sendFeedback",
"settings.updates.termixBetas",
"settings.updates.termixBetasHint",
"settings.updates.thisVersion",
"settings.updates.waitingForStable",
],
};
-635
View File
@@ -1,635 +0,0 @@
import { InlineView } from "@/components/surface/surface-scope";
import {
useCallback,
useEffect,
useMemo,
useState,
type ReactNode,
} from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import {
AlertTriangle,
Cloud,
CloudOff,
Loader2,
RefreshCw,
Unlink,
} from "lucide-react";
import { Button } from "@/components/button";
import { Switch } from "@/components/switch";
import { useSyncStatus } from "@/hooks/use-sync-status";
import { refreshSyncStatus, setSyncStatus } from "@/lib/sync-status";
import { notifySyncChanged } from "@/lib/linked-server";
import { invalidateServerStatusCache } from "@/lib/hosts-request-cache";
import {
getSyncConflicts,
getSyncErrors,
retrySyncErrors,
settleSyncConflict,
syncNow,
unlinkServer,
updateSyncSettings,
type SyncBasicAuth,
type SyncConflictItem,
type SyncEntityInfo,
type SyncErrorItem,
type SyncProxyHeader,
type SyncStatus,
} from "@/api/sync-api";
import { LinkServerDialog } from "./LinkServerDialog";
import { ProxySettingsFields } from "./ProxySettingsFields";
type DesktopSettings = { defaultConnectionOrigin: "local" | "remote" };
function Section({
title,
description,
children,
}: {
title: string;
description?: string;
children: ReactNode;
}) {
return (
<div className="border border-border bg-muted/10 p-3 flex flex-col gap-3">
<div className="flex flex-col gap-0.5">
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{title}
</span>
{description && (
<span className="text-[10px] text-muted-foreground leading-relaxed">
{description}
</span>
)}
</div>
{children}
</div>
);
}
function useRelativeTime() {
const { t } = useTranslation();
return (value: string | null | undefined) => {
if (!value) return t("sync.never");
const seconds = Math.max(
0,
Math.round((Date.now() - new Date(value).getTime()) / 1000),
);
if (seconds < 60) return t("sync.justNow");
if (seconds < 3600)
return t("sync.minutesAgo", { count: Math.round(seconds / 60) });
if (seconds < 86400)
return t("sync.hoursAgo", { count: Math.round(seconds / 3600) });
return new Date(value).toLocaleString();
};
}
function entityLabel(
t: (key: string, options?: Record<string, unknown>) => string,
entity: SyncEntityInfo,
): string {
return t(`sync.entities.${entity.type}`, { defaultValue: entity.type });
}
export function SyncPanel() {
const { t } = useTranslation();
const status = useSyncStatus();
const relativeTime = useRelativeTime();
const [linkOpen, setLinkOpen] = useState(false);
const [reloginOpen, setReloginOpen] = useState(false);
const [unlinkOpen, setUnlinkOpen] = useState(false);
const [keepData, setKeepData] = useState(true);
const [busy, setBusy] = useState<string | null>(null);
const [conflicts, setConflicts] = useState<SyncConflictItem[]>([]);
const [errors, setErrors] = useState<SyncErrorItem[]>([]);
const [desktopSettings, setDesktopSettings] = useState<DesktopSettings>({
defaultConnectionOrigin: "local",
});
const [proxyDraft, setProxyDraft] = useState<{
customHeaders: SyncProxyHeader[];
basicAuth: SyncBasicAuth | null;
allowInvalidCertificate: boolean;
} | null>(null);
const apply = useCallback((next: SyncStatus) => {
setSyncStatus(next);
notifySyncChanged();
}, []);
useEffect(() => {
window.electronAPI
?.invoke?.("get-desktop-settings")
.then((settings) => {
if (settings) setDesktopSettings(settings as DesktopSettings);
})
.catch(() => {});
}, []);
const conflictCount = status?.conflicts ?? 0;
const errorCount = status?.errors ?? 0;
useEffect(() => {
if (!status?.linked) return;
getSyncConflicts()
.then(setConflicts)
.catch(() => {});
}, [status?.linked, conflictCount, status?.lastSyncAt]);
useEffect(() => {
if (!status?.linked) return;
getSyncErrors()
.then(setErrors)
.catch(() => {});
}, [status?.linked, errorCount, status?.lastSyncAt]);
const run = async (key: string, action: () => Promise<void>) => {
setBusy(key);
try {
await action();
} catch {
toast.error(t("sync.actionFailed"));
} finally {
setBusy(null);
}
};
const groups = useMemo(() => {
const byOwner = new Map<string, SyncEntityInfo[]>();
for (const entity of status?.entities ?? []) {
const key = entity.owner === "core" ? "core" : entity.owner;
const list = byOwner.get(key) ?? [];
list.push(entity);
byOwner.set(key, list);
}
return [...byOwner.entries()].sort(([a], [b]) =>
a === "core" ? -1 : b === "core" ? 1 : a.localeCompare(b),
);
}, [status?.entities]);
if (!status) {
return (
<div className="flex items-center justify-center p-8">
<Loader2 className="size-5 animate-spin text-muted-foreground" />
</div>
);
}
const linkDialog = (
<LinkServerDialog
open={linkOpen}
onOpenChange={setLinkOpen}
onLinked={apply}
/>
);
if (!status.linked) {
return (
<div className="flex flex-col gap-3 p-3">
<div className="border border-border bg-muted/10 p-4 flex flex-col gap-3">
<div className="flex items-center gap-2">
<CloudOff className="size-4 text-muted-foreground" />
<span className="text-sm font-bold">{t("sync.unlinkedTitle")}</span>
</div>
<p className="text-xs text-muted-foreground leading-relaxed">
{t("sync.unlinkedBody")}
</p>
<ul className="text-xs text-muted-foreground list-disc pl-4 flex flex-col gap-1">
<li>{t("sync.unlinkedPointHosts")}</li>
<li>{t("sync.unlinkedPointOffline")}</li>
<li>{t("sync.unlinkedPointAccount")}</li>
</ul>
<Button
variant="outline"
className="self-start rounded-none border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand dark:border-accent-brand/40 dark:bg-transparent dark:hover:bg-accent-brand/10"
onClick={() => setLinkOpen(true)}
>
<Cloud className="size-4" />
{t("sync.linkButton")}
</Button>
</div>
{linkDialog}
</div>
);
}
const state = status.status ?? "idle";
const stateText =
state === "syncing"
? t("sync.state.syncing")
: state === "offline"
? t("sync.state.offline")
: state === "error"
? t("sync.state.error")
: state === "signed_out"
? t("sync.state.signedOut")
: t("sync.state.synced", { time: relativeTime(status.lastSyncAt) });
const toggleType = (type: string, enabled: boolean) =>
run(`type:${type}`, async () => {
const disabled = new Set<string>(
(status.entities ?? [])
.filter((entity) => !entity.enabled)
.map((entity) => entity.type),
);
if (enabled) disabled.delete(type);
else disabled.add(type);
apply(await updateSyncSettings({ disabledTypes: [...disabled] }));
});
const handleOriginChange = async (origin: "local" | "remote") => {
const next = { ...desktopSettings, defaultConnectionOrigin: origin };
setDesktopSettings(next);
await window.electronAPI?.invoke?.("save-desktop-settings", next);
invalidateServerStatusCache();
window.dispatchEvent(new CustomEvent("hosts:refresh"));
};
const draft = proxyDraft ?? {
customHeaders: status.customHeaders ?? [],
basicAuth: status.basicAuth ?? null,
allowInvalidCertificate: !!status.allowInvalidCertificate,
};
return (
<div className="flex flex-col gap-3 p-3">
<div className="border border-border bg-muted/10 p-3 flex flex-col gap-3">
<div className="flex items-start gap-2">
<Cloud className="size-4 text-accent-brand mt-0.5 shrink-0" />
<div className="flex flex-col min-w-0">
<span className="text-sm font-bold truncate">
{status.serverName || t("sync.server")}
</span>
<span className="text-[10px] text-muted-foreground break-all">
{status.serverUrl}
</span>
</div>
</div>
<div className="grid grid-cols-2 gap-2 text-xs">
<div className="flex flex-col">
<span className="text-[10px] uppercase tracking-widest text-muted-foreground font-semibold">
{t("sync.account")}
</span>
<span className="font-semibold truncate">
{status.account?.username || "-"}
</span>
</div>
<div className="flex flex-col">
<span className="text-[10px] uppercase tracking-widest text-muted-foreground font-semibold">
{t("sync.status")}
</span>
<span
className={`font-semibold flex items-center gap-1 ${
state === "idle" || state === "syncing"
? ""
: "text-destructive"
}`}
>
{state === "syncing" && (
<Loader2 className="size-3 animate-spin" />
)}
{stateText}
</span>
</div>
</div>
{status.lastError && state !== "idle" && state !== "syncing" && (
<p className="text-[10px] text-muted-foreground break-words">
{status.lastError}
</p>
)}
{!!status.pending && (
<p className="text-[10px] text-muted-foreground">
{t("sync.pending", { count: status.pending })}
</p>
)}
<div className="flex flex-wrap gap-2">
{state === "signed_out" ? (
<Button
variant="outline"
size="sm"
className="h-7 text-[10px] rounded-none border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand dark:border-accent-brand/40 dark:bg-transparent dark:hover:bg-accent-brand/10"
onClick={() => setReloginOpen(true)}
>
{t("sync.signInAgain")}
</Button>
) : (
<Button
size="sm"
variant="outline"
className="h-7 text-[10px] rounded-none"
disabled={busy === "sync" || state === "syncing"}
onClick={() =>
run("sync", async () => {
apply(await syncNow());
})
}
>
{busy === "sync" || state === "syncing" ? (
<Loader2 className="size-3 animate-spin" />
) : (
<RefreshCw className="size-3" />
)}
{t("sync.syncNow")}
</Button>
)}
<Button
size="sm"
variant="outline"
className="h-7 text-[10px] rounded-none"
onClick={() => {
setKeepData(true);
setUnlinkOpen(true);
}}
>
<Unlink className="size-3" />
{t("sync.unlink")}
</Button>
</div>
</div>
{conflicts.length > 0 && (
<Section
title={t("sync.conflictsTitle")}
description={t("sync.conflictsDescription")}
>
{conflicts.map((conflict) => (
<div
key={conflict.id}
className="flex items-center justify-between gap-2 border border-border p-2"
>
<div className="flex flex-col min-w-0">
<span className="text-xs font-semibold truncate">
{conflict.name}
</span>
<span className="text-[10px] text-muted-foreground">
{t(`sync.entities.${conflict.entityType}`, {
defaultValue: conflict.entityType,
})}
</span>
</div>
<div className="flex gap-1 shrink-0">
<Button
size="sm"
variant="outline"
className="h-6 text-[10px] rounded-none"
disabled={busy === `conflict:${conflict.id}`}
onClick={() =>
run(`conflict:${conflict.id}`, async () => {
await settleSyncConflict(conflict.id, "mine");
refreshSyncStatus();
})
}
>
{t("sync.keepMine")}
</Button>
<Button
size="sm"
variant="outline"
className="h-6 text-[10px] rounded-none"
disabled={busy === `conflict:${conflict.id}`}
onClick={() =>
run(`conflict:${conflict.id}`, async () => {
await settleSyncConflict(conflict.id, "server");
refreshSyncStatus();
})
}
>
{t("sync.keepServer")}
</Button>
</div>
</div>
))}
</Section>
)}
{errors.length > 0 && (
<Section
title={t("sync.errorsTitle")}
description={t("sync.errorsDescription")}
>
{errors.map((error) => (
<div
key={`${error.entityType}:${error.syncId}`}
className="flex items-start gap-2 border border-border p-2"
>
<AlertTriangle className="size-3.5 text-destructive shrink-0 mt-0.5" />
<div className="flex flex-col min-w-0">
<span className="text-xs font-semibold">
{t(`sync.entities.${error.entityType}`, {
defaultValue: error.entityType,
})}
</span>
<span className="text-[10px] text-muted-foreground break-words">
{error.reason === "permission"
? t("sync.errorPermission")
: error.reason}
</span>
</div>
</div>
))}
<Button
size="sm"
variant="outline"
className="self-start h-7 text-[10px] rounded-none"
disabled={busy === "retry"}
onClick={() =>
run("retry", async () => {
apply(await retrySyncErrors());
})
}
>
{t("sync.retry")}
</Button>
</Section>
)}
<Section
title={t("sync.whatSyncs")}
description={t("sync.whatSyncsDescription")}
>
{groups.map(([owner, entities]) => (
<div key={owner} className="flex flex-col gap-1.5">
<span className="text-[10px] font-semibold text-muted-foreground">
{owner === "core"
? t("sync.coreGroup")
: (entities[0]?.ownerName ?? owner)}
</span>
{entities.map((entity) => (
<div
key={entity.type}
className="flex items-center justify-between gap-3"
>
<div className="flex flex-col min-w-0">
<span className="text-xs">{entityLabel(t, entity)}</span>
{!entity.onServer && (
<span className="text-[10px] text-muted-foreground">
{t("sync.notOnServer")}
</span>
)}
{entity.onServer && !entity.onDevice && (
<span className="text-[10px] text-muted-foreground">
{t("sync.notOnDevice")}
</span>
)}
</div>
<Switch
checked={entity.enabled}
disabled={busy === `type:${entity.type}`}
onCheckedChange={(checked) =>
void toggleType(entity.type, checked)
}
/>
</div>
))}
</div>
))}
<p className="text-[10px] text-muted-foreground leading-relaxed">
{t("sync.localOnlyHint")}
</p>
</Section>
<Section
title={t("sync.originTitle")}
description={t("sync.originDescription")}
>
<div className="flex border border-border overflow-hidden w-fit">
{(["local", "remote"] as const).map((origin) => (
<button
key={origin}
type="button"
onClick={() => void handleOriginChange(origin)}
className={`px-3 py-1.5 text-[10px] font-bold uppercase tracking-widest transition-colors ${
desktopSettings.defaultConnectionOrigin === origin
? "bg-accent-brand text-background"
: "text-muted-foreground hover:text-foreground hover:bg-muted"
}`}
>
{origin === "local"
? t("sync.originLocal")
: t("sync.originRemote")}
</button>
))}
</div>
</Section>
<Section
title={t("sync.proxy.title")}
description={t("sync.proxy.description")}
>
<ProxySettingsFields
customHeaders={draft.customHeaders}
basicAuth={draft.basicAuth}
onChange={(next) => setProxyDraft({ ...draft, ...next })}
/>
<div className="flex items-center justify-between gap-3">
<div className="flex flex-col gap-0.5">
<span className="text-xs">
{t("sync.proxy.allowInvalidCertificate")}
</span>
<span className="text-[10px] text-muted-foreground">
{t("sync.proxy.allowInvalidCertificateHint")}
</span>
</div>
<Switch
checked={draft.allowInvalidCertificate}
onCheckedChange={(checked) =>
setProxyDraft({ ...draft, allowInvalidCertificate: checked })
}
/>
</div>
{proxyDraft && (
<Button
variant="outline"
size="sm"
className="self-start h-7 text-[10px] rounded-none border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand dark:border-accent-brand/40 dark:bg-transparent dark:hover:bg-accent-brand/10"
disabled={busy === "proxy"}
onClick={() =>
run("proxy", async () => {
apply(await updateSyncSettings(proxyDraft));
setProxyDraft(null);
toast.success(t("sync.proxy.saved"));
})
}
>
{t("common.save")}
</Button>
)}
</Section>
<LinkServerDialog
open={reloginOpen}
onOpenChange={setReloginOpen}
onLinked={apply}
relogin={status.serverUrl ? { serverUrl: status.serverUrl } : undefined}
/>
<InlineView
open={unlinkOpen}
onOpenChange={setUnlinkOpen}
title={t("sync.unlinkTitle")}
footer={
<div className="ml-auto flex flex-wrap items-center justify-end gap-2">
<Button
variant="outline"
className="rounded-none"
onClick={() => setUnlinkOpen(false)}
>
{t("common.cancel")}
</Button>
<Button
variant="destructive"
className="rounded-none"
disabled={busy === "unlink"}
onClick={() =>
run("unlink", async () => {
apply(await unlinkServer(keepData));
setUnlinkOpen(false);
toast.success(t("sync.unlinked"));
})
}
>
{busy === "unlink" && <Loader2 className="size-4 animate-spin" />}
{t("sync.unlink")}
</Button>
</div>
}
>
<p className="text-xs text-muted-foreground">
{t("sync.unlinkDescription")}
</p>
<div className="flex flex-col gap-2">
{(
[
{
value: true,
title: t("sync.unlinkKeepTitle"),
body: t("sync.unlinkKeepBody"),
},
{
value: false,
title: t("sync.unlinkRemoveTitle"),
body: t("sync.unlinkRemoveBody"),
},
] as const
).map((option) => (
<button
key={String(option.value)}
type="button"
onClick={() => setKeepData(option.value)}
className={`text-left border p-3 flex flex-col gap-1 transition-colors ${
keepData === option.value
? "border-accent-brand bg-accent-brand/10"
: "border-border hover:bg-muted/40"
}`}
>
<span className="text-sm font-semibold">{option.title}</span>
<span className="text-xs text-muted-foreground">
{option.body}
</span>
</button>
))}
</div>
</InlineView>
{linkDialog}
</div>
);
}
-604
View File
@@ -1,604 +0,0 @@
import React, { useEffect, useRef, useState } from "react";
import { Command as CommandPrimitive } from "cmdk";
import { useTranslation } from "react-i18next";
import { cn } from "@/lib/utils";
import { Kbd } from "@/components/kbd";
import {
Command,
CommandItem,
CommandList,
CommandGroup,
CommandEmpty,
} from "@/components/command";
import {
BookOpen,
Bug,
Clock,
FlaskConical,
Folder,
Globe,
KeyRound,
MessagesSquare,
Pencil,
Plus,
Search,
Server,
Settings,
ShieldCheck,
} from "lucide-react";
import { getRecentActivity, type RecentActivityItem } from "@/main-axios";
import type { Host, TabType, Tab } from "@/types/ui-types";
import { canEditHost } from "@/sidebar/host-permissions";
import { useRailItems } from "@/sidebar/rail-items";
import {
defaultConnectAction,
hostActionsFor,
runHostAction,
useHostActions,
} from "@/sidebar/host-contributions";
import {
filterPaletteItems,
loadPaletteGroup,
paletteEntriesFor,
usePaletteEntries,
usePaletteGroups,
type PaletteItemDef,
} from "./palette-registry";
import { activityTarget } from "@/lib/activity-types";
import { reportBetaFeedbackUrl, reportCoreIssueUrl } from "@/lib/issue-url";
import { isPrerelease } from "@/plugins/plugin-model";
import { shell } from "@/plugin-host/shell-bridge";
import { getLiveHostStatus } from "@/lib/ServerStatusContext";
import { docsUrl } from "@/lib/docs";
import { parseServerTime } from "@/lib/server-time";
interface CommandPaletteProps {
/** Opens settings, optionally at one section. */
onOpenSettings?: (section?: string) => void;
/** Opens a host in the editor. */
onEditHost?: (host: Host) => void;
isOpen: boolean;
setIsOpen: (isOpen: boolean) => void;
hosts: Host[];
terminalTabs?: Tab[];
activeTabId?: string;
onOpenTab: (type: TabType, label?: string, pendingEvent?: string) => void;
/** Opens a sidebar panel. Kept separate from onOpenTab, which is TabType-shaped. */
onOpenPanel?: (view: string) => void;
}
export function CommandPalette({
isOpen,
setIsOpen,
hosts,
terminalTabs = [],
activeTabId = "",
onOpenTab,
onOpenPanel,
onOpenSettings,
onEditHost,
}: CommandPaletteProps) {
const { t } = useTranslation();
const railItems = useRailItems();
const hostActions = useHostActions();
const paletteEntries = usePaletteEntries();
const inputRef = useRef<HTMLInputElement>(null);
const [search, setSearch] = useState("");
const [recentActivity, setRecentActivity] = useState<RecentActivityItem[]>(
[],
);
const paletteGroups = usePaletteGroups();
const [groupItems, setGroupItems] = useState<
Record<string, PaletteItemDef[]>
>({});
const [selectedValue, setSelectedValue] = useState("");
useEffect(() => {
if (isOpen) {
setTimeout(() => inputRef.current?.focus(), 50);
setSearch("");
getRecentActivity(5)
.then(setRecentActivity)
.catch(() => {});
}
}, [isOpen]);
useEffect(() => {
if (!isOpen) return;
let cancelled = false;
for (const group of paletteGroups) {
void loadPaletteGroup(group).then((items) => {
if (!cancelled) {
setGroupItems((prev) => ({ ...prev, [group.id]: items }));
}
});
}
return () => {
cancelled = true;
};
}, [isOpen, paletteGroups]);
useEffect(() => {
if (!isOpen) return;
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
e.preventDefault();
e.stopPropagation();
setIsOpen(false);
}
};
window.addEventListener("keydown", handleKeyDown, true);
return () => window.removeEventListener("keydown", handleKeyDown, true);
}, [isOpen, setIsOpen]);
const query = search.trim().toLowerCase();
const searching = query.length > 0;
// Without a query the list is a short starting point, not every host.
const filteredHosts = searching
? hosts.filter(
(h) =>
h.name.toLowerCase().includes(query) ||
h.ip.toLowerCase().includes(query) ||
h.username.toLowerCase().includes(query) ||
h.folder?.toLowerCase().includes(query) ||
h.tags?.some((tag) => tag.toLowerCase().includes(query)),
)
: hosts.slice(0, 6);
// Group hosts by folder; ungrouped hosts appear first.
const groupedHosts: { folder: string | null; hosts: Host[] }[] = [];
const folderMap = new Map<string, Host[]>();
const ungrouped: Host[] = [];
for (const h of filteredHosts) {
if (h.folder && searching) {
if (!folderMap.has(h.folder)) folderMap.set(h.folder, []);
folderMap.get(h.folder)!.push(h);
} else {
ungrouped.push(h);
}
}
if (ungrouped.length > 0)
groupedHosts.push({ folder: null, hosts: ungrouped });
for (const [folder, fhosts] of folderMap) {
groupedHosts.push({ folder, hosts: fhosts });
}
useEffect(() => {
if (!isOpen) return;
const firstHost = filteredHosts[0];
setSelectedValue(searching && firstHost ? `host-${firstHost.id}` : "");
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isOpen, search]);
const activeTargetTab =
terminalTabs.find((tab) => tab.id === activeTabId) ?? terminalTabs[0];
const handleAction = (action: () => void) => {
action();
setIsOpen(false);
};
const matches = (...texts: (string | undefined)[]) =>
!searching || texts.some((text) => text?.toLowerCase().includes(query));
const destinations = railItems.filter((item) => matches(t(item.labelKey)));
const quickActions = [
{
id: "quick-action-add-host",
label: t("commandPalette.addNewHost"),
icon: Plus,
run: () => onOpenTab("host-manager", undefined, "host-manager:add-host"),
},
{
id: "quick-action-add-credential",
label: t("commandPalette.addCredential"),
icon: KeyRound,
run: () =>
onOpenTab("host-manager", undefined, "host-manager:add-credential"),
},
{
id: "quick-action-settings",
label: t("nav.settings"),
icon: Settings,
run: () =>
onOpenSettings ? onOpenSettings() : onOpenTab("user-profile"),
},
{
id: "quick-action-admin-settings",
label: t("commandPalette.adminSettings"),
icon: ShieldCheck,
run: () =>
onOpenSettings
? onOpenSettings("admin-general")
: onOpenTab("admin-settings"),
},
].filter((action) => matches(action.label));
const globalEntries = paletteEntriesFor(paletteEntries, "global").filter(
(entry) => matches(t(entry.titleKey), ...(entry.keywords ?? [])),
);
const links = [
{
id: "link-docs",
label: t("commandPalette.documentation"),
icon: BookOpen,
url: docsUrl(),
},
{
id: "link-github",
label: "GitHub",
icon: Globe,
url: "https://github.com/Termix-SSH/Termix",
},
{
id: "link-discord",
label: "Discord",
icon: MessagesSquare,
url: "https://discord.com/invite/jVQGdvHDrf",
},
{
id: "link-report-bug",
label: t("dashboard.reportBug"),
icon: Bug,
url: reportCoreIssueUrl(import.meta.env.VITE_APP_VERSION || undefined),
},
...(isPrerelease(import.meta.env.VITE_APP_VERSION)
? [
{
id: "link-beta-feedback",
label: t("newUi.sidebar.userProfile.sendBetaFeedback"),
icon: FlaskConical,
url: reportBetaFeedbackUrl(import.meta.env.VITE_APP_VERSION),
},
]
: []),
].filter((link) => matches(link.label));
const rowClass = "group gap-2 rounded-none";
const iconClass = "size-3.5 shrink-0 text-muted-foreground";
const hostButton =
"flex size-6 items-center justify-center border border-border text-muted-foreground transition-colors hover:border-accent-brand hover:text-accent-brand";
if (!isOpen) return null;
return (
<div
className="fixed inset-0 z-[100] flex items-start justify-center bg-background/70 pt-[15vh]"
onClick={() => setIsOpen(false)}
>
<div
className="motion-context-enter mx-4 w-full max-w-2xl overflow-hidden border border-border bg-card shadow-2xl"
onClick={(e) => e.stopPropagation()}
>
<Command
className="rounded-none"
shouldFilter={false}
loop
value={selectedValue}
onValueChange={setSelectedValue}
>
<div className="flex items-center border-b border-border px-3">
<Search className="mr-2 size-4 shrink-0 text-muted-foreground" />
<CommandPrimitive.Input
ref={inputRef}
value={search}
onValueChange={setSearch}
placeholder={t("commandPalette.searchPlaceholder")}
className="h-12 flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground"
/>
<Kbd className="ml-2">ESC</Kbd>
</div>
<CommandList className="thin-scrollbar max-h-[60vh] [contain:content]">
<CommandEmpty className="py-8 text-center text-xs text-muted-foreground">
{t("commandPalette.noHostsFound", { search })}
</CommandEmpty>
{!searching && recentActivity.length > 0 && (
<CommandGroup heading={t("commandPalette.recentActivity")}>
{recentActivity.slice(0, 4).map((item) => {
const Icon = activityTarget(item.type)?.icon ?? Server;
return (
<CommandItem
key={item.id}
value={`recent-activity-${item.id}`}
onSelect={() =>
handleAction(() => {
const target = activityTarget(item.type);
if (target) onOpenTab(target.tab, item.hostName);
})
}
className={rowClass}
>
<Icon className={iconClass} />
<span className="truncate">{item.hostName}</span>
<span className="ml-auto flex shrink-0 items-center gap-1 text-[10px] text-muted-foreground">
<Clock className="size-3" />
{parseServerTime(item.timestamp).toLocaleDateString()}
</span>
</CommandItem>
);
})}
</CommandGroup>
)}
{filteredHosts.length > 0 && (
<CommandGroup heading={t("commandPalette.serversAndHosts")}>
{groupedHosts.map(({ folder, hosts: groupHosts }) => (
<div key={folder ?? "__root__"}>
{folder && (
<div className="flex items-center gap-1.5 px-2 py-1 text-[10px] font-semibold uppercase tracking-widest text-muted-foreground/60">
<Folder className="size-3" />
{folder}
</div>
)}
{groupHosts.map((host) => {
const online =
getLiveHostStatus(Number(host.id)) === "online";
return (
<CommandItem
key={host.id}
value={`host-${host.id}`}
onSelect={() =>
handleAction(() => {
const action = defaultConnectAction(
hostActions,
host,
);
if (action) runHostAction(action, host, shell);
})
}
className={rowClass}
>
<Server
className={cn(
"size-3.5 shrink-0",
online
? "text-accent-brand"
: "text-muted-foreground",
)}
/>
<span className="truncate">{host.name}</span>
<span className="truncate font-mono text-[11px] text-muted-foreground">
{host.username}@{host.ip}
</span>
{host.isShared && (
<span className="shrink-0 border border-accent-brand/30 bg-accent-brand/10 px-1 text-[9px] uppercase leading-none tracking-wider text-accent-brand">
{t("hosts.sharing.sharedBadge")}
</span>
)}
<span
className={cn(
"ml-auto flex shrink-0 items-center gap-1",
!searching &&
"opacity-0 transition-opacity group-hover:opacity-100 group-data-[selected=true]:opacity-100",
)}
>
{hostActionsFor(hostActions, host).map((action) => {
const Icon = action.icon;
const label =
action.label?.(host) ?? t(action.titleKey);
return (
<button
key={action.id}
type="button"
title={label}
aria-label={label}
onClick={(e) => {
e.stopPropagation();
handleAction(() =>
runHostAction(action, host, shell),
);
}}
className={hostButton}
>
<Icon className="size-3" />
</button>
);
})}
{paletteEntriesFor(
paletteEntries,
"host",
host,
).map((entry) => {
const Icon = entry.icon;
return (
<button
key={entry.id}
type="button"
title={t(entry.titleKey)}
aria-label={t(entry.titleKey)}
onClick={(e) => {
e.stopPropagation();
handleAction(() => entry.run(shell, host));
}}
className={hostButton}
>
{Icon && <Icon className="size-3" />}
</button>
);
})}
{canEditHost(host) && (
<button
type="button"
title={t("hosts.editHost")}
aria-label={t("hosts.editHost")}
onClick={(e) => {
e.stopPropagation();
handleAction(() => onEditHost?.(host));
}}
className={hostButton}
>
<Pencil className="size-3" />
</button>
)}
</span>
</CommandItem>
);
})}
</div>
))}
</CommandGroup>
)}
{paletteGroups.map((group) => {
const items = filterPaletteItems(
groupItems[group.id] ?? [],
search,
group.showWhenEmpty,
);
if (items.length === 0) return null;
return (
<CommandGroup
key={`group-${group.id}`}
heading={t(group.titleKey)}
>
{items.map((item) => {
const Icon = item.icon;
const blocked = !!item.needsTarget && !activeTargetTab;
return (
<CommandItem
key={`${group.id}-${item.id}`}
value={`group-${group.id}-${item.id}`}
onSelect={() => {
if (blocked) return;
handleAction(() =>
item.run({ targetTab: activeTargetTab, shell }),
);
}}
className={cn(
rowClass,
blocked && "pointer-events-none opacity-50",
)}
>
{Icon && <Icon className={iconClass} />}
<span className="truncate">{item.title}</span>
{item.description && (
<span className="truncate font-mono text-[11px] text-muted-foreground">
{item.description}
</span>
)}
{(blocked || item.hint) && (
<span className="ml-auto shrink-0 text-[10px] text-muted-foreground/60">
{blocked
? t("commandPalette.noTargetTab")
: item.hint}
</span>
)}
</CommandItem>
);
})}
</CommandGroup>
);
})}
{onOpenPanel && destinations.length > 0 && (
<CommandGroup heading={t("commandPalette.navigation")}>
{destinations.map((item) => {
const Icon = item.icon;
return (
<CommandItem
key={`nav-${item.id}`}
value={`nav-${item.id}`}
onSelect={() =>
handleAction(() =>
item.kind === "tab"
? onOpenTab(item.id as TabType)
: onOpenPanel(item.id),
)
}
className={rowClass}
>
<Icon className={iconClass} />
<span className="truncate">{t(item.labelKey)}</span>
</CommandItem>
);
})}
</CommandGroup>
)}
{quickActions.length > 0 && (
<CommandGroup heading={t("commandPalette.quickActions")}>
{quickActions.map((action) => {
const Icon = action.icon;
return (
<CommandItem
key={action.id}
value={action.id}
onSelect={() => handleAction(action.run)}
className={rowClass}
>
<Icon className={iconClass} />
{action.label}
</CommandItem>
);
})}
</CommandGroup>
)}
{globalEntries.length > 0 && (
<CommandGroup heading={t("commandPalette.pluginActions")}>
{globalEntries.map((entry) => {
const Icon = entry.icon;
return (
<CommandItem
key={`plugin-${entry.id}`}
value={`plugin-${entry.id}`}
onSelect={() => handleAction(() => entry.run(shell))}
className={rowClass}
>
{Icon && <Icon className={iconClass} />}
{t(entry.titleKey)}
</CommandItem>
);
})}
</CommandGroup>
)}
{links.length > 0 && (
<CommandGroup heading={t("commandPalette.links")}>
{links.map((link) => {
const Icon = link.icon;
return (
<CommandItem
key={link.id}
value={link.id}
onSelect={() => window.open(link.url, "_blank")}
className={rowClass}
>
<Icon className={iconClass} />
{link.label}
</CommandItem>
);
})}
</CommandGroup>
)}
</CommandList>
<div className="flex items-center gap-3 border-t border-border px-3 py-2 text-[10px] text-muted-foreground">
<span className="flex items-center gap-1">
<Kbd className="h-4">↑↓</Kbd>
{t("commandPalette.navigate")}
</span>
<span className="flex items-center gap-1">
<Kbd className="h-4">↵</Kbd>
{t("commandPalette.select")}
</span>
<span className="ml-auto flex items-center gap-1">
<Kbd className="h-4">Ctrl</Kbd>
<span className="opacity-50">+</span>
<Kbd className="h-4">K</Kbd>
<span className="px-1 opacity-50">
{t("commandPalette.orShortcut")}
</span>
<Kbd className="h-4">⇧⇧</Kbd>
</span>
</div>
</Command>
</div>
</div>
);
}
-175
View File
@@ -1,175 +0,0 @@
/* eslint-disable react-refresh/only-export-components */
import type React from "react";
import { useTranslation } from "react-i18next";
import {
ChevronLeft,
ChevronRight,
PanelRight,
RotateCcw,
SquareArrowOutUpRight,
} from "lucide-react";
import { PANEL } from "@/components/panel-layout";
import type { EditingWidth } from "@/components/surface/surface-scope";
import { cn } from "@/lib/utils";
import { rem } from "@/lib/rem";
export const DOCK_DEFAULT_WIDTH = 291;
export const DOCK_MIN_WIDTH = 160;
export const DOCK_MAX_WIDTH = 480;
const EDITING_WIDTH = 560;
const WIDE_EDITING_WIDTH = 760;
/** The width a dock renders at, in Normal-size pixels. */
export function dockWidth(width: number, editing: EditingWidth): number {
if (editing === "wide") return WIDE_EDITING_WIDTH;
if (editing) return Math.max(width, EDITING_WIDTH);
return width;
}
function HeaderButton({
title,
onClick,
children,
}: {
title: string;
onClick: () => void;
children: React.ReactNode;
}) {
return (
<button
type="button"
title={title}
aria-label={title}
onClick={onClick}
className="flex h-full w-12.5 shrink-0 items-center justify-center border-l border-border text-muted-foreground transition-colors hover:bg-muted/40 hover:text-foreground focus-visible:ring-1 focus-visible:ring-ring"
>
{children}
</button>
);
}
/** The shared header and frame of the left sidebar and the right dock. */
export function DockPanel({
side,
title,
open,
width,
editing = false,
dragging = false,
fill = false,
onResizeStart,
onClose,
onOpenAsTab,
onMoveToRightDock,
onResetWidth,
children,
}: {
side: "left" | "right";
title: string;
open: boolean;
width: number;
editing?: EditingWidth;
dragging?: boolean;
/** Fill the parent instead of sizing itself, as in the mobile sheet. */
fill?: boolean;
onResizeStart?: (e: React.MouseEvent) => void;
onClose: () => void;
onOpenAsTab?: () => void;
onMoveToRightDock?: () => void;
onResetWidth?: () => void;
children: React.ReactNode;
}) {
const { t } = useTranslation();
const CollapseIcon = side === "left" ? ChevronLeft : ChevronRight;
return (
<div
className={cn(
"relative flex min-h-0 flex-col overflow-hidden bg-sidebar",
fill ? "flex-1" : "shrink-0",
open &&
!fill && [
side === "left" ? "border-r" : "border-l",
"transition-colors",
dragging ? "border-accent-brand/60" : "border-border",
],
)}
style={
fill
? undefined
: {
width: open ? rem(dockWidth(width, editing)) : 0,
transition: dragging ? "none" : "width 0.2s",
}
}
>
<div
className={`flex ${PANEL.header} shrink-0 flex-row items-center border-b border-border`}
>
<span className="min-w-0 flex-1 truncate whitespace-nowrap px-3 text-base font-bold tracking-tight text-foreground">
{title}
</span>
{onOpenAsTab && (
<HeaderButton title={t("nav.openAsTab")} onClick={onOpenAsTab}>
<SquareArrowOutUpRight className="size-3.5" />
</HeaderButton>
)}
{onMoveToRightDock && (
<HeaderButton
title={t("nav.openInRightDock")}
onClick={onMoveToRightDock}
>
<PanelRight className="size-3.5" />
</HeaderButton>
)}
{onResetWidth && (
<HeaderButton
title={t("nav.resetSidebarWidth")}
onClick={onResetWidth}
>
<RotateCcw className="size-3.5" />
</HeaderButton>
)}
<HeaderButton
title={
side === "left" ? t("nav.collapseSidebar") : t("nav.closeRightDock")
}
onClick={onClose}
>
<CollapseIcon className="size-4" />
</HeaderButton>
</div>
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
{children}
</div>
{open && onResizeStart && !editing && (
<div
onMouseDown={onResizeStart}
className={cn(
"absolute bottom-0 top-0 z-30 w-1 cursor-col-resize transition-colors",
side === "left" ? "right-0" : "left-0",
dragging ? "bg-accent-brand/60" : "hover:bg-accent-brand/40",
)}
/>
)}
</div>
);
}
/** The thin strip that reopens a collapsed left dock. */
export function DockReopenStrip({ onClick }: { onClick: () => void }) {
const { t } = useTranslation();
return (
<button
type="button"
onClick={onClick}
title={t("nav.openSidebar")}
aria-label={t("nav.openSidebar")}
className="absolute bottom-0 left-0 top-0 z-20 flex w-6 items-center justify-center border-r border-border bg-sidebar text-muted-foreground transition-colors hover:bg-accent-brand/5 hover:text-accent-brand"
>
<ChevronRight className="size-3.5" />
</button>
);
}
-301
View File
@@ -1,301 +0,0 @@
/* eslint-disable react-refresh/only-export-components */
import { useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import {
LogOut,
MoreHorizontal,
Search,
Settings,
SquareArrowOutUpRight,
X,
} from "lucide-react";
import type { TabType } from "@/types/ui-types";
import type { RailView } from "@/sidebar/AppRail";
import {
groupRailItems,
useRailItems,
type RailItemDef,
} from "@/sidebar/rail-items";
import { useHiddenRailTabs } from "@/sidebar/hidden-rail-tabs";
import { RailBadge } from "@/sidebar/RailBadge";
import { cn } from "@/lib/utils";
import { isElectron } from "@/lib/electron";
const PRIMARY_COUNT = 4;
/** The four bar slots: flagged items first, then the rest in rail order. */
export function pickPrimaryItems(items: RailItemDef[]): RailItemDef[] {
const usable = items.filter((item) => item.kind !== "tab");
return [
...usable.filter((item) => item.mobilePrimary),
...usable.filter((item) => !item.mobilePrimary),
].slice(0, PRIMARY_COUNT);
}
/**
* The rail on a phone: the most used destinations on the bar, everything else
* in a sheet behind More with search, settings and logout.
*/
export function MobileBar({
railView,
sidebarOpen,
username,
onRailClick,
onOpenTab,
onOpenSettings,
onOpenPalette,
onLogout,
}: {
railView: RailView;
sidebarOpen: boolean;
username: string;
onRailClick: (view: RailView) => void;
onOpenTab: (type: TabType) => void;
onOpenSettings: () => void;
onOpenPalette: () => void;
onLogout: () => void;
}) {
const { t } = useTranslation();
const [moreOpen, setMoreOpen] = useState(false);
const railItems = useRailItems();
const hidden = useHiddenRailTabs();
const visible = useMemo(
() => railItems.filter((item) => !hidden.has(item.id)),
[railItems, hidden],
);
const main = visible.filter((item) => item.placement !== "footer");
const footer = visible.filter((item) => item.placement === "footer");
const primary = useMemo(() => pickPrimaryItems(main), [main]);
const bands = useMemo(() => groupRailItems(main), [main]);
const badged = footer.find((item) => item.useBadge);
const close = () => setMoreOpen(false);
const open = (item: RailItemDef) => {
close();
if (item.kind === "tab") onOpenTab(item.id as TabType);
else onRailClick(item.id as RailView);
};
return (
<>
<nav
aria-label={t("nav.mobileNavigation")}
className="flex shrink-0 items-stretch border-t border-border bg-sidebar safe-bottom md:hidden"
>
{primary.map((item) => (
<BarButton
key={item.id}
icon={item.icon}
label={t(item.labelKey)}
active={sidebarOpen && railView === item.id}
useBadge={item.useBadge}
onClick={() => open(item)}
/>
))}
<BarButton
icon={MoreHorizontal}
label={t("common.more")}
active={moreOpen}
useBadge={badged?.useBadge}
onClick={() => setMoreOpen(true)}
/>
</nav>
{moreOpen && (
<div className="fixed inset-0 z-[200] flex flex-col justify-end md:hidden">
<button
type="button"
aria-label={t("common.close")}
className="absolute inset-0 bg-overlay"
onClick={close}
/>
<div
role="dialog"
aria-label={t("common.more")}
className="motion-panel-enter relative flex max-h-[80dvh] flex-col border-t border-border bg-sidebar safe-bottom"
>
<div className="flex h-12.5 shrink-0 items-center border-b border-border px-3">
<span className="flex-1 text-base font-bold tracking-tight text-foreground">
{t("common.more")}
</span>
<button
type="button"
onClick={close}
aria-label={t("common.close")}
className="flex size-9 items-center justify-center text-muted-foreground"
>
<X className="size-4" />
</button>
</div>
<div className="min-h-0 flex-1 overflow-y-auto py-2">
<SheetRow
icon={Search}
label={t("nav.search")}
onClick={() => {
close();
onOpenPalette();
}}
/>
{bands.map((band) => (
<div key={band.group} className="mt-1 flex flex-col">
<div className="px-3 pb-1 pt-2 text-[10px] font-semibold uppercase tracking-widest text-muted-foreground/60">
{t(`nav.group.${band.group}`)}
</div>
{band.items.map((item) => (
<SheetRow
key={item.id}
icon={item.icon}
label={t(item.labelKey)}
useBadge={item.useBadge}
active={sidebarOpen && railView === item.id}
onOpenAsTab={
item.promotable
? () => {
close();
onOpenTab(item.id as TabType);
}
: undefined
}
onClick={() => open(item)}
/>
))}
</div>
))}
<div className="mt-2 border-t border-border pt-2">
{footer.map((item) => (
<SheetRow
key={item.id}
icon={item.icon}
label={t(item.labelKey)}
useBadge={item.useBadge}
active={sidebarOpen && railView === item.id}
onClick={() => open(item)}
/>
))}
<SheetRow
icon={Settings}
label={t("nav.settings")}
hint={username}
onClick={() => {
close();
onOpenSettings();
}}
/>
{!isElectron() && (
<SheetRow
icon={LogOut}
label={t("common.logout")}
onClick={() => {
close();
onLogout();
}}
/>
)}
</div>
</div>
</div>
</div>
)}
</>
);
}
function BarButton({
icon: Icon,
label,
active,
useBadge,
onClick,
}: {
icon: React.ElementType;
label: string;
active?: boolean;
useBadge?: () => number | null | undefined;
onClick: () => void;
}) {
return (
<button
type="button"
onClick={onClick}
aria-label={label}
aria-pressed={!!active}
className={cn(
"relative flex h-14 flex-1 flex-col items-center justify-center gap-1 transition-colors",
active
? "bg-accent-brand/10 text-accent-brand"
: "text-muted-foreground active:bg-muted/60",
)}
>
<span className="relative flex items-center justify-center">
<Icon className="size-5" />
{useBadge && (
<RailBadge useBadge={useBadge} className="-right-2.5 -top-1.5" />
)}
</span>
<span className="max-w-full truncate px-1 text-[10px] font-medium leading-none">
{label}
</span>
</button>
);
}
function SheetRow({
icon: Icon,
label,
hint,
useBadge,
active,
onClick,
onOpenAsTab,
}: {
icon: React.ElementType;
label: string;
hint?: string;
useBadge?: () => number | null | undefined;
active?: boolean;
onClick: () => void;
onOpenAsTab?: () => void;
}) {
const { t } = useTranslation();
return (
<div className="flex items-stretch">
<button
type="button"
onClick={onClick}
className={cn(
"flex min-h-11 flex-1 items-center gap-3 border-l-2 px-3 text-left transition-colors",
active
? "border-accent-brand bg-accent-brand/10 text-accent-brand"
: "border-transparent text-foreground active:bg-muted",
)}
>
<span className="relative flex shrink-0">
<Icon className="size-4 text-muted-foreground" />
{useBadge && (
<RailBadge useBadge={useBadge} className="-right-2 -top-1.5" />
)}
</span>
<span className="flex-1 truncate text-sm font-medium">{label}</span>
{hint && (
<span className="shrink-0 truncate text-[11px] text-muted-foreground">
{hint}
</span>
)}
</button>
{onOpenAsTab && (
<button
type="button"
onClick={onOpenAsTab}
aria-label={t("nav.openAsTab")}
title={t("nav.openAsTab")}
className="flex w-11 shrink-0 items-center justify-center text-muted-foreground active:bg-muted"
>
<SquareArrowOutUpRight className="size-3.5" />
</button>
)}
</div>
);
}
+2 -68
View File
@@ -1,80 +1,15 @@
import { lazy, Suspense } from "react";
import { isElectron } from "@/lib/electron";
import { ServerStatusProvider } from "@/lib/ServerStatusContext";
import { ComponentSlot } from "@/shell/ActionSlot";
import { OnboardingHost } from "@/onboarding/OnboardingHost";
import { DesktopUpdateToasts } from "@/shell/DesktopUpdateToasts";
import { getTabType } from "@/shell/tab-registry";
import { TabPortals } from "@/shell/stage/TabPortals";
import { LayoutHost } from "@/shell/layout/LayoutHost";
import {
ShellControllerProvider,
useShellController,
} from "@/shell/controller/ShellControllerProvider";
// Not needed for first paint.
const CommandPalette = lazy(() =>
import("@/shell/CommandPalette").then((m) => ({
default: m.CommandPalette,
})),
);
/** Core's palette, opened by any layout through the shell's palette.open(). */
function ShellPalette() {
const shell = useShellController();
const { tabs, hosts, palette } = shell;
if (!palette.isOpen) return null;
return (
<Suspense fallback={null}>
<CommandPalette
isOpen={palette.isOpen}
setIsOpen={palette.setOpen}
hosts={hosts.list}
terminalTabs={tabs.terminalTabs}
activeTabId={tabs.activeId}
onOpenPanel={(view) => shell.regions.railClick(view)}
onEditHost={hosts.edit}
onOpenSettings={(section) =>
tabs.openSingleton(
"settings",
undefined,
undefined,
section ? { section } : undefined,
)
}
onOpenTab={(type, label, pendingEvent) => {
if (
[
"dashboard",
"host-manager",
"user-profile",
"admin-settings",
"plugins",
].includes(type)
) {
tabs.openSingleton(type, pendingEvent);
} else if (getTabType(type)?.multiInstance) {
tabs.openMultiInstance(type);
} else if (getTabType(type)?.singleton) {
// A singleton plugin tab, optionally preselecting a host.
tabs.openSingleton(
type,
undefined,
label ? hosts.list.find((h) => h.name === label) : undefined,
);
} else if (label) {
const host = hosts.list.find((h) => h.name === label);
if (host) tabs.open(host, type);
}
}}
/>
</Suspense>
);
}
import { ShellControllerProvider } from "@/shell/controller/ShellControllerProvider";
/**
* The signed-in app: the shell's state, the chosen layout, every tab's
* content and the pieces core always owns (palette, overlays, onboarding).
* content and the pieces core always owns (overlays, onboarding).
*/
export function ShellRoot({
username,
@@ -89,7 +24,6 @@ export function ShellRoot({
<LayoutHost />
<TabPortals />
<ComponentSlot slotId="shell.overlay" />
<ShellPalette />
<OnboardingHost />
{isElectron() && <DesktopUpdateToasts />}
</ShellControllerProvider>
-905
View File
@@ -1,905 +0,0 @@
import {
useRef,
useEffect,
useLayoutEffect,
useState,
useCallback,
} from "react";
import { useTranslation } from "react-i18next";
import { useReducedMotion } from "motion/react";
import { Button } from "@/components/button";
import { Separator } from "@/components/separator";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuTrigger,
} from "@/components/dropdown-menu";
import {
ChevronDown,
ChevronUp,
RefreshCw,
X,
Plus,
Pencil,
Columns2,
Rows2,
Ungroup,
Maximize2,
Minimize2,
PanelRight,
} from "lucide-react";
import { tabIcon } from "@/shell/tabUtils";
import { isSessionTabType } from "@/shell/tab-registry";
import { isElectron } from "@/lib/electron";
import type { Tab, TabType } from "@/types/ui-types";
import { ActionSlot } from "@/shell/ActionSlot";
import { useTabHandlesVersion } from "@/shell/tab-handles";
import {
canJoinSplit,
isSplitTab,
type SplitSummary,
type TabSplitAction,
} from "@/shell/split/split-tabs";
import { MAX_PANES } from "@/shell/split/split-tree";
import { SplitLayoutMenu } from "@/shell/split/SplitLayoutMenu";
import {
beginSplitDrag,
endSplitDrag,
isSplitDragging,
moveSplitDrag,
} from "@/shell/split/split-drag";
/** How far below the bar a dragged tab has to go before it drags into the view. */
const DRAG_OUT_DISTANCE = 16;
/**
* Tabs holding a live connection that can be refreshed: the registered
* session tabs.
*/
function isConnectionTab(type: TabType): boolean {
return isSessionTabType(type);
}
export function TabBar({
tabs,
activeTabId,
splits,
activeSplitFull,
onSetActiveTab,
onCloseTab,
onRefreshTab,
onReconnectDisconnected,
onReorderTabs,
onSplitAction,
onRenameTab,
isAppFullscreen,
onToggleAppFullscreen,
rightDockOpen,
onToggleRightDock,
showTabNumbers,
}: {
tabs: Tab[];
activeTabId: string;
/** Every split tab and its panes, for the "Add to split" entries. */
splits: SplitSummary[];
/** The active tab is a split with no room for another pane. */
activeSplitFull: boolean;
onSetActiveTab: (id: string) => void;
onCloseTab: (id: string) => void;
onRefreshTab: (id: string) => void;
onReconnectDisconnected?: () => void;
onReorderTabs: (tabs: Tab[]) => void;
onSplitAction: (action: TabSplitAction) => void;
onRenameTab?: (tabId: string, newLabel: string) => void;
isAppFullscreen: boolean;
onToggleAppFullscreen: () => void;
rightDockOpen?: boolean;
onToggleRightDock?: () => void;
showTabNumbers?: boolean;
}) {
const { t } = useTranslation();
// Re-renders when a tab's handle attaches, for buttons whose when reads it.
useTabHandlesVersion();
const reduceMotion = useReducedMotion();
const [open, setOpen] = useState(true);
const [dragTabId, setDragTabId] = useState<string | null>(null);
const [dragTargetIndex, setDragTargetIndex] = useState<number | null>(null);
const [dragPos, setDragPos] = useState<{ x: number; y: number } | null>(null);
const [draggingOut, setDraggingOut] = useState(false);
const [contextTabId, setContextTabId] = useState<string | null>(null);
const [contextPos, setContextPos] = useState<{ x: number; y: number } | null>(
null,
);
const [renamingTabId, setRenamingTabId] = useState<string | null>(null);
const [renameValue, setRenameValue] = useState("");
const renameInputRef = useRef<HTMLInputElement>(null);
const tabBarRef = useRef<HTMLDivElement>(null);
const tabEls = useRef<Map<string, HTMLDivElement>>(new Map());
const dragData = useRef<{
id: string;
index: number;
startX: number;
startY: number;
offsetX: number;
width: number;
barTop: number;
barHeight: number;
x: number;
y: number;
} | null>(null);
const dragTargetRef = useRef<number | null>(null);
const didDrag = useRef(false);
// Hand-rolled instead of a Framer layoutId: a shared layoutId matched
// multiple tabs sharing the same indicator element across re-renders, and
// reordering tabs (which doesn't change which tab is active) could make it
// measure the wrong tab's rect or animate a spurious slide. Measuring the
// active tab's own DOM node directly is always correct.
const [indicatorRect, setIndicatorRect] = useState<{
left: number;
width: number;
} | null>(null);
const skipIndicatorAnimRef = useRef(false);
const activeIsSplit = isSplitTab(tabs.find((tab) => tab.id === activeTabId));
const measureIndicator = useCallback(() => {
const el = activeTabId ? tabEls.current.get(activeTabId) : null;
if (!el) {
setIndicatorRect(null);
return;
}
setIndicatorRect({ left: el.offsetLeft, width: el.offsetWidth });
}, [activeTabId]);
// useLayoutEffect so the indicator is measured for the new tab order before
// paint -- with useEffect there was a frame right after a drag-drop reorder
// where the indicator (which stays hidden during the drag) reappeared at
// its pre-reorder position before this caught up, flashing at the old spot.
useLayoutEffect(() => {
measureIndicator();
}, [measureIndicator, tabs, dragTargetIndex]);
useEffect(() => {
const el = tabBarRef.current;
if (!el || typeof ResizeObserver === "undefined") return;
const observer = new ResizeObserver(measureIndicator);
observer.observe(el);
return () => observer.disconnect();
}, [measureIndicator]);
useEffect(() => {
if (!skipIndicatorAnimRef.current) return;
const id = requestAnimationFrame(() => {
skipIndicatorAnimRef.current = false;
});
return () => cancelAnimationFrame(id);
}, [indicatorRect]);
useEffect(() => {
const el = tabBarRef.current;
if (!el) return;
const handleWheel = (e: WheelEvent) => {
if (e.deltaY !== 0) {
e.preventDefault();
el.scrollLeft += e.deltaY;
}
};
el.addEventListener("wheel", handleWheel, { passive: false });
return () => el.removeEventListener("wheel", handleWheel);
}, []);
useEffect(() => {
if (!dragTabId) return;
function onPointerMove(e: PointerEvent) {
if (!dragData.current || !tabBarRef.current) return;
const d = dragData.current;
if (Math.abs(e.clientX - d.startX) > 5) didDrag.current = true;
const barRect = tabBarRef.current.getBoundingClientRect();
// Dragged down out of the bar: the tab heads for the view to be split.
const dragged = tabs.find((t) => t.id === d.id);
if (
canJoinSplit(dragged) &&
e.clientY > barRect.bottom + DRAG_OUT_DISTANCE
) {
didDrag.current = true;
if (isSplitDragging()) moveSplitDrag(e.clientX, e.clientY);
else
beginSplitDrag(
{
kind: "tab",
tabId: d.id,
label: dragged!.customLabel || dragged!.label,
},
e.clientX,
e.clientY,
);
setDraggingOut(true);
dragTargetRef.current = d.index;
setDragTargetIndex(d.index);
return;
}
if (isSplitDragging()) {
endSplitDrag(false);
setDraggingOut(false);
}
const x = Math.max(
barRect.left + 2,
Math.min(barRect.right - d.width - 6, e.clientX - d.offsetX),
);
const y = d.barTop;
setDragPos({ x, y });
const centerX = e.clientX - d.offsetX + d.width / 2;
let newTarget = d.index;
tabEls.current.forEach((el, id) => {
if (id === d.id) return;
const rect = el.getBoundingClientRect();
const mid = rect.left + rect.width / 2;
const idx = tabs.findIndex((t) => t.id === id);
if (idx < d.index && centerX < mid)
newTarget = Math.min(newTarget, idx);
if (idx > d.index && centerX > mid)
newTarget = Math.max(newTarget, idx);
});
if (tabs[0].type === "dashboard") newTarget = Math.max(1, newTarget);
dragTargetRef.current = newTarget;
setDragTargetIndex(newTarget);
}
function endDrag(commit: boolean) {
if (!dragData.current) return;
const { id, index } = dragData.current;
const to = dragTargetRef.current ?? index;
const droppedOut = isSplitDragging();
if (droppedOut) endSplitDrag(commit);
setDraggingOut(false);
if (commit && !droppedOut && to !== index) {
const next = [...tabs];
if (next[0].id !== id) next.splice(to, 0, next.splice(index, 1)[0]);
skipIndicatorAnimRef.current = true;
onReorderTabs(next);
}
dragData.current = null;
dragTargetRef.current = null;
setDragTabId(null);
setDragTargetIndex(null);
setDragPos(null);
setTimeout(() => {
didDrag.current = false;
}, 0);
}
function onPointerUp() {
endDrag(true);
}
// The browser can abort a pointer gesture without ever firing pointerup
// (OS/browser cancels it, focus leaves the window, capture is lost some
// other way). Without this, dragTabId stays stuck non-null forever and
// every tab keeps rendering with a stale translateX from the aborted
// drag, which can visually butt two tabs together with no seam between
// them until something else forces a re-render.
function onPointerCancel() {
endDrag(false);
}
window.addEventListener("pointermove", onPointerMove);
window.addEventListener("pointerup", onPointerUp);
window.addEventListener("pointercancel", onPointerCancel);
window.addEventListener("lostpointercapture", onPointerCancel);
return () => {
window.removeEventListener("pointermove", onPointerMove);
window.removeEventListener("pointerup", onPointerUp);
window.removeEventListener("pointercancel", onPointerCancel);
window.removeEventListener("lostpointercapture", onPointerCancel);
};
}, [dragTabId, tabs, onReorderTabs]);
useEffect(() => {
if (!contextTabId) return;
function onDown(e: MouseEvent) {
if (!(e.target as HTMLElement).closest("[data-context-menu]")) {
setContextTabId(null);
setContextPos(null);
}
}
window.addEventListener("mousedown", onDown);
return () => window.removeEventListener("mousedown", onDown);
}, [contextTabId]);
useEffect(() => {
if (renamingTabId) {
setTimeout(() => renameInputRef.current?.focus(), 0);
}
}, [renamingTabId]);
function commitRename() {
if (!renamingTabId) return;
const trimmed = renameValue.trim();
if (trimmed) onRenameTab?.(renamingTabId, trimmed);
setRenamingTabId(null);
}
const dragIdx = tabs.findIndex((t) => t.id === dragTabId);
const target = dragTargetIndex ?? dragIdx;
return (
<div className="flex flex-col shrink-0 min-w-0">
<div
className={`flex items-end bg-sidebar min-w-0 transition-[height,border-color] duration-200 ${open ? "h-12.5 border-b border-border" : "h-0 overflow-hidden"}`}
>
<div
ref={tabBarRef}
className="relative flex h-full flex-1 min-w-0 overflow-x-auto scrollbar-none pl-px"
>
{indicatorRect && !dragTabId && (
<span
data-tab-indicator={activeTabId}
className="pointer-events-none absolute bottom-0 h-0.5 bg-accent-brand z-10"
style={{
left: indicatorRect.left,
width: indicatorRect.width,
transition:
reduceMotion || skipIndicatorAnimRef.current
? "none"
: "left 200ms ease, width 200ms ease",
}}
/>
)}
{tabs.map((tab, index) => {
const active = tab.id === activeTabId;
const isDragging = dragTabId === tab.id;
let translateX = 0;
if (
dragTabId &&
!isDragging &&
dragIdx !== -1 &&
target !== null &&
target !== dragIdx
) {
const draggedWidth =
tabEls.current.get(dragTabId)?.offsetWidth ?? 0;
if (dragIdx < target && index > dragIdx && index <= target)
translateX = -draggedWidth;
else if (dragIdx > target && index < dragIdx && index >= target)
translateX = draggedWidth;
}
return (
<div
key={tab.id}
ref={(el) => {
if (el) tabEls.current.set(tab.id, el);
else tabEls.current.delete(tab.id);
}}
onClick={() =>
!dragTabId && !didDrag.current && onSetActiveTab(tab.id)
}
onMouseDown={(e) => {
if (e.button === 1 && tab.type !== "dashboard") {
e.preventDefault();
onCloseTab(tab.id);
}
}}
onContextMenu={(e) => {
if (tab.type === "dashboard") return;
e.preventDefault();
setContextTabId(tab.id);
setContextPos({ x: e.clientX, y: e.clientY });
}}
onPointerDown={(e) => {
if (e.button !== 0 || tab.type === "dashboard") return;
e.preventDefault();
const el = tabEls.current.get(tab.id);
if (!el || !tabBarRef.current) return;
const rect = el.getBoundingClientRect();
const barRect = tabBarRef.current.getBoundingClientRect();
dragData.current = {
id: tab.id,
index,
startX: e.clientX,
startY: e.clientY,
offsetX: e.clientX - rect.left,
width: rect.width,
barTop: barRect.top,
barHeight: barRect.height,
x: rect.left,
y: barRect.top,
};
setDragTabId(tab.id);
setDragTargetIndex(index);
setDragPos({ x: rect.left, y: barRect.top });
(e.currentTarget as HTMLElement).setPointerCapture(
e.pointerId,
);
}}
style={{
// Only set a transform while a drag is actually shifting
// tabs around. Leaving a permanent translateX(0) on every
// tab gives each one its own transform node, which opts it
// out of the pixel snapping a plain box gets when painted.
// The 1px seam then lands on a fractional device pixel at
// non-100% zoom and anti-aliases down to nothing, so two
// tabs look merged into one.
transform:
dragTabId && !isDragging
? `translateX(${translateX}px)`
: undefined,
transition:
dragTabId && !isDragging ? "transform 200ms ease" : "none",
opacity: isDragging ? 0 : 1,
cursor:
tab.type === "dashboard"
? "pointer"
: isDragging
? "grabbing"
: "grab",
userSelect: "none",
}}
className={`group/tab relative flex items-center gap-2 shrink-0 transition-colors border-r border-border text-sm
${index === 0 && tab.type !== "dashboard" ? "border-l border-border" : ""}
${
tab.type === "dashboard"
? `px-2.5 md:px-3.5 ${active ? "bg-surface text-foreground" : "text-muted-foreground hover:text-foreground hover:bg-surface"}`
: `px-2.5 md:px-4 font-medium ${active ? "bg-surface text-foreground" : "text-muted-foreground hover:text-foreground hover:bg-surface"}`
}`}
>
{showTabNumbers && tab.type !== "dashboard" && (
<span className="shrink-0 text-[10px] tabular-nums text-muted-foreground/70">
{tabs.slice(0, index).filter((t) => t.type !== "dashboard")
.length + 1}
</span>
)}
{tabIcon(tab.type)}
{tab.type !== "dashboard" && renamingTabId === tab.id ? (
<input
ref={renameInputRef}
value={renameValue}
onChange={(e) => setRenameValue(e.target.value)}
onBlur={commitRename}
onKeyDown={(e) => {
if (e.key === "Enter") commitRename();
else if (e.key === "Escape") setRenamingTabId(null);
}}
onPointerDown={(e) => e.stopPropagation()}
onClick={(e) => e.stopPropagation()}
className="bg-transparent border-b border-accent-brand outline-none text-sm w-28 min-w-0"
style={{ fontWeight: "inherit" }}
/>
) : (
tab.type !== "dashboard" && tab.label
)}
{tab.type !== "dashboard" && renamingTabId !== tab.id && (
<div
className={`flex items-center gap-0.5 ml-1 ${active ? "opacity-100" : "opacity-0 group-hover/tab:opacity-100"}`}
>
{isConnectionTab(tab.type) && (
<button
onPointerDown={(e) => e.stopPropagation()}
onClick={(e) => {
e.stopPropagation();
onRefreshTab(tab.id);
}}
title={t("nav.refreshTab")}
className="flex items-center justify-center size-5 md:size-4 rounded-sm transition-colors text-muted-foreground hover:text-foreground hover:bg-muted"
>
<RefreshCw className="size-3" />
</button>
)}
{/* Plugins add small buttons here, invoked with the tab's surface handle. */}
<ActionSlot
slotId="tab.inline"
when={{ tab, handle: tab.terminalRef?.current }}
context={() => [tab.terminalRef?.current, tab]}
renderItem={(contribution, invoke) => (
<button
onPointerDown={(e) => e.stopPropagation()}
onClick={(e) => {
e.stopPropagation();
invoke();
}}
title={t(contribution.titleKey)}
className="flex items-center justify-center size-5 md:size-4 rounded-sm transition-colors text-muted-foreground hover:text-foreground hover:bg-muted"
>
{contribution.icon && (
<contribution.icon className="size-3" />
)}
</button>
)}
/>
<button
onPointerDown={(e) => e.stopPropagation()}
onClick={(e) => {
e.stopPropagation();
onCloseTab(tab.id);
}}
className="flex items-center justify-center size-5 md:size-4 rounded-sm transition-colors text-muted-foreground hover:text-foreground hover:bg-muted"
>
<X className="size-3" />
</button>
</div>
)}
</div>
);
})}
{dragTabId &&
dragPos &&
!draggingOut &&
(() => {
const tab = tabs.find((t) => t.id === dragTabId)!;
const active = tab.id === activeTabId;
const dragTabWidth = tabEls.current.get(dragTabId)?.offsetWidth;
const dragTabHeight = tabEls.current.get(dragTabId)?.offsetHeight;
return (
<div
style={{
position: "fixed",
left: dragPos.x,
top: dragPos.y,
width:
dragTabWidth !== undefined ? dragTabWidth + 2 : undefined,
height:
dragTabHeight !== undefined
? dragTabHeight + 1
: undefined,
pointerEvents: "none",
zIndex: 9999,
opacity: 0.85,
}}
className={`flex items-center gap-2 shrink-0 border-x border-b border-border text-sm shadow-lg
${
tab.type === "dashboard"
? `px-3.5 ${active ? "border-b-2 border-b-accent-brand bg-surface text-foreground" : "bg-sidebar text-muted-foreground"}`
: `px-4 font-medium ${active ? "border-b-2 border-b-accent-brand bg-surface text-foreground" : "bg-sidebar text-muted-foreground"}`
}`}
>
{tabIcon(tab.type)}
{tab.type !== "dashboard" && tab.label}
</div>
);
})()}
</div>
<div
className={`flex items-center h-full shrink-0 ${open ? "" : "invisible"}`}
>
<Separator orientation="vertical" />
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-full w-12.5 rounded-none text-muted-foreground hover:text-foreground"
>
<ChevronDown className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
sideOffset={1}
className="w-56 border-t-0 [clip-path:inset(0px_-4px_-4px_-4px)] p-0"
>
{tabs.map((tab, index) => (
<div
key={tab.id}
onClick={() => onSetActiveTab(tab.id)}
className={`flex items-center justify-between px-2 py-2 text-xs cursor-default hover:bg-accent hover:text-accent-foreground ${tab.id === activeTabId ? "text-foreground" : "text-muted-foreground"}`}
>
<div className="flex items-center gap-2 flex-1 min-w-0">
{showTabNumbers && tab.type !== "dashboard" && (
<span className="shrink-0 text-[10px] tabular-nums text-muted-foreground/70">
{tabs
.slice(0, index)
.filter((t) => t.type !== "dashboard").length + 1}
</span>
)}
{tabIcon(tab.type)}
<span className="truncate">
{tab.type === "dashboard"
? t("nav.dashboard")
: tab.label}
</span>
</div>
{tab.type !== "dashboard" && (
<button
onClick={(e) => {
e.stopPropagation();
onCloseTab(tab.id);
}}
className="shrink-0 ml-2 text-muted-foreground hover:text-foreground"
>
<X className="size-3" />
</button>
)}
</div>
))}
</DropdownMenuContent>
</DropdownMenu>
<Separator orientation="vertical" />
<SplitLayoutMenu
activeIsSplit={activeIsSplit}
canAddPane={!activeSplitFull}
onSplit={(edge) => onSplitAction({ kind: "splitActive", edge })}
onPreset={(presetId) => onSplitAction({ kind: "preset", presetId })}
/>
{onToggleRightDock && (
<>
<Separator orientation="vertical" />
<Button
variant="ghost"
size="icon"
className={`h-full w-12.5 rounded-none ${rightDockOpen ? "text-accent-brand bg-accent-brand/10" : "text-muted-foreground hover:text-foreground"}`}
title={t("nav.toggleRightDock")}
aria-label={t("nav.toggleRightDock")}
aria-pressed={!!rightDockOpen}
onClick={onToggleRightDock}
>
<PanelRight className="size-4" />
</Button>
</>
)}
{!isElectron() && (
<>
<Separator orientation="vertical" />
<Button
variant="ghost"
size="icon"
className="h-full w-12.5 rounded-none text-muted-foreground hover:text-foreground"
title={
isAppFullscreen
? t("nav.exitFullscreen")
: t("nav.enterFullscreen")
}
aria-label={
isAppFullscreen
? t("nav.exitFullscreen")
: t("nav.enterFullscreen")
}
onClick={onToggleAppFullscreen}
>
{isAppFullscreen ? (
<Minimize2 className="size-4" />
) : (
<Maximize2 className="size-4" />
)}
</Button>
</>
)}
<Separator orientation="vertical" />
<Button
variant="ghost"
size="icon"
className="h-full w-12.5 rounded-none text-muted-foreground hover:text-foreground"
onClick={() => setOpen((o) => !o)}
>
<ChevronUp
className={`size-4 transition-transform ${open ? "" : "rotate-180"}`}
/>
</Button>
</div>
</div>
{!open && (
<button
onClick={() => setOpen(true)}
className="flex items-center justify-center w-full h-6 bg-sidebar border-b border-border text-muted-foreground hover:text-accent-brand hover:bg-accent-brand/5 transition-colors shrink-0"
>
<ChevronDown className="size-3.5" />
</button>
)}
{/* Right-click context menu */}
{contextTabId &&
contextPos &&
(() => {
const ctxTab = tabs.find((t) => t.id === contextTabId);
if (!ctxTab) return null;
const joinable = canJoinSplit(ctxTab);
const itemClass =
"flex items-center gap-2 w-full px-3 py-1.5 text-xs text-left hover:bg-accent hover:text-accent-foreground disabled:opacity-50 disabled:pointer-events-none";
const runSplit = (action: TabSplitAction) => {
onSplitAction(action);
setContextTabId(null);
};
const menuTop = Math.min(contextPos.y, window.innerHeight - 160);
const menuLeft = Math.min(contextPos.x, window.innerWidth - 240);
return (
<div
data-context-menu
style={{
position: "fixed",
left: Math.max(4, menuLeft),
top: Math.max(4, menuTop),
maxHeight: window.innerHeight - Math.max(4, menuTop) - 8,
zIndex: 10000,
}}
className="bg-popover border border-border shadow-lg py-1 min-w-[200px] max-w-[260px] overflow-y-auto"
>
<div className="px-2 py-1 text-xs font-semibold text-muted-foreground truncate max-w-[200px]">
{ctxTab.label}
</div>
<div className="h-px bg-border my-1" />
{isConnectionTab(ctxTab.type) && (
<button
className="flex items-center gap-2 w-full px-3 py-1.5 text-xs text-left hover:bg-accent hover:text-accent-foreground"
onClick={() => {
onRefreshTab(contextTabId);
setContextTabId(null);
}}
>
<RefreshCw className="size-3" />
{t("nav.refreshTab")}
</button>
)}
{onReconnectDisconnected && (
<button
className="flex items-center gap-2 w-full px-3 py-1.5 text-xs text-left hover:bg-accent hover:text-accent-foreground"
onClick={() => {
onReconnectDisconnected();
setContextTabId(null);
}}
>
<RefreshCw className="size-3" />
{t("nav.reconnectDisconnectedTerminals")}
</button>
)}
{/* Plugins add entries here, invoked with the tab's surface handle. */}
<ActionSlot
slotId="tab.menu"
when={{ tab: ctxTab, handle: ctxTab.terminalRef?.current }}
context={() => [ctxTab.terminalRef?.current, ctxTab]}
renderItem={(contribution, invoke) => (
<button
className="flex items-center gap-2 w-full px-3 py-1.5 text-xs text-left hover:bg-accent hover:text-accent-foreground"
onClick={() => {
invoke();
setContextTabId(null);
}}
>
{contribution.icon && (
<contribution.icon className="size-3" />
)}
{t(contribution.titleKey)}
</button>
)}
/>
<button
className="flex items-center gap-2 w-full px-3 py-1.5 text-xs text-left hover:bg-accent hover:text-accent-foreground"
onClick={() => {
setRenameValue(ctxTab.label);
setRenamingTabId(contextTabId);
setContextTabId(null);
setContextPos(null);
}}
>
<Pencil className="size-3" />
{t("nav.renameTab")}
</button>
{isSplitTab(ctxTab) && (
<>
<div className="h-px bg-border my-1" />
<button
className={itemClass}
onClick={() =>
runSplit({ kind: "unsplit", splitTabId: ctxTab.id })
}
>
<Ungroup className="size-3" />
{t("splitScreen.unsplit")}
</button>
</>
)}
{joinable && (
<>
<div className="h-px bg-border my-1" />
<button
className={itemClass}
onClick={() =>
runSplit({
kind: "split",
tabId: ctxTab.id,
edge: "right",
})
}
>
<Columns2 className="size-3" />
{t("splitScreen.splitRight")}
</button>
<button
className={itemClass}
onClick={() =>
runSplit({
kind: "split",
tabId: ctxTab.id,
edge: "bottom",
})
}
>
<Rows2 className="size-3" />
{t("splitScreen.splitDown")}
</button>
{splits.map((split) => (
<div key={split.id} className="flex flex-col">
<div className="h-px bg-border my-1" />
<div className="px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground truncate">
{t("splitScreen.addToSplit", { split: split.label })}
</div>
{split.panes.map((pane) => (
<button
key={pane.id}
className={`${itemClass} pl-5`}
onClick={() =>
runSplit({
kind: "addToPane",
tabId: ctxTab.id,
splitTabId: split.id,
paneId: pane.id,
})
}
>
<span
className={`truncate ${pane.label ? "" : "text-muted-foreground"}`}
>
{pane.label
? t("splitScreen.paneItem", {
index: pane.index,
label: pane.label,
})
: t("splitScreen.paneEmptyItem", {
index: pane.index,
})}
</span>
</button>
))}
{(["right", "bottom"] as const).map((edge) => (
<button
key={edge}
className={`${itemClass} pl-5`}
disabled={split.full}
title={
split.full
? t("splitScreen.maxPanes", { count: MAX_PANES })
: undefined
}
onClick={() =>
runSplit({
kind: "addPane",
tabId: ctxTab.id,
splitTabId: split.id,
edge,
})
}
>
<Plus className="size-3" />
{edge === "right"
? t("splitScreen.newPaneRight")
: t("splitScreen.newPaneBelow")}
</button>
))}
</div>
))}
</>
)}
<div className="h-px bg-border my-1" />
<button
className="flex items-center gap-2 w-full px-3 py-1.5 text-xs text-left hover:bg-accent hover:text-accent-foreground text-destructive"
onClick={() => {
onCloseTab(contextTabId);
setContextTabId(null);
}}
>
<X className="size-3" />
{t("nav.close")}
</button>
</div>
);
})()}
</div>
);
}
@@ -21,7 +21,11 @@ import type { Host, TabType } from "@/types/ui-types";
import { requestManage } from "@/manage/manage-requests";
import { useRailItems } from "@/sidebar/rail-items";
import { usePanels } from "@/shell/panel-registry";
import { getTabType, type TabShellCallbacks } from "@/shell/tab-registry";
import {
getTabType,
useTabTypes,
type TabShellCallbacks,
} from "@/shell/tab-registry";
import { usePluginStore } from "@/plugin-host/plugin-store";
import { setShellCallbacks } from "@/plugin-host/shell-bridge";
import {
@@ -83,6 +87,7 @@ export function ShellControllerProvider({
// Re-render when plugins add or remove rail items, panels or tabs.
useRailItems();
usePanels();
useTabTypes();
const { settled: pluginsSettled } = usePluginStore();
const isMobile = useIsMobile();
const [stageStore] = useState(createTabStageStore);
+3 -1
View File
@@ -3,7 +3,6 @@ import { useCallback, useEffect, useRef, useState } from "react";
import type { EditingWidth } from "@/components/surface/surface-scope";
import { remScale } from "@/lib/rem";
import { rightDockableIds } from "@/sidebar/rail-items";
import { DOCK_DEFAULT_WIDTH } from "@/shell/DockPanel";
import type { ShellRegions } from "./types";
const MIN_WIDTH = 160;
@@ -48,6 +47,9 @@ function startDockDrag(
}
/** The left sidebar and the right dock: what they show, open state and size. */
/** A dock's width after Reset width, in Normal-size pixels. */
export const DOCK_DEFAULT_WIDTH = 291;
export function useRegions(isMobile: boolean): ShellRegions {
const [sidebarOpen, setSidebarOpen] = useState(true);
const [railView, setRailView] = useState<string>("hosts");
-3
View File
@@ -12,9 +12,6 @@ import { layoutStatus } from "@/look/layout-status";
import { safeLayoutInUrl } from "@/look/resolve-look";
import { useLookTheme } from "@/look/use-look-theme";
import { RepairView } from "@/look/RepairView";
import { registerBuiltinLayout } from "./builtin";
registerBuiltinLayout();
/** Catches a crash while a layout draws, so the repair view can take over. */
class LayoutErrorBoundary extends Component<
@@ -1,396 +0,0 @@
import { lazy, Suspense } from "react";
import { useTranslation } from "react-i18next";
import { Sheet, SheetContent } from "@/components/sheet";
import { SurfaceScope } from "@/components/surface/surface-scope";
import { MobileBar } from "@/shell/MobileBar";
import { AppRail } from "@/sidebar/AppRail";
import { ComponentSlot } from "@/shell/ActionSlot";
import {
isCoreRailView,
railItemLabel,
promotableIds,
rightDockableIds,
} from "@/sidebar/rail-items";
import { DockPanel, DockReopenStrip } from "@/shell/DockPanel";
import { TabBar } from "@/shell/TabBar";
import { requestManage, type ManageMode } from "@/manage/manage-requests";
import { getTabType } from "@/shell/tab-registry";
import { getPanel, usePanels } from "@/shell/panel-registry";
import { PluginViewPlaceholder } from "@/plugin-host/PluginViewPlaceholder";
import type { TabType } from "@/types/ui-types";
import {
ShellConfirmCapture,
useShellController,
} from "@/shell/controller/ShellControllerProvider";
import { TabStage } from "@/shell/stage/TabStage";
const HostsPanel = lazy(() =>
import("@/sidebar/HostsPanel").then((m) => ({ default: m.HostsPanel })),
);
const QuickConnectPanel = lazy(() =>
import("@/sidebar/QuickConnectPanel").then((m) => ({
default: m.QuickConnectPanel,
})),
);
const SyncPanel = lazy(() =>
import("@/settings/sync/SyncPanel").then((m) => ({
default: m.SyncPanel,
})),
);
const CredentialsPanel = lazy(() =>
import("@/sidebar/CredentialsPanel").then((m) => ({
default: m.CredentialsPanel,
})),
);
const ConnectionsPanel = lazy(() =>
import("@/sidebar/ConnectionsPanel").then((m) => ({
default: m.ConnectionsPanel,
})),
);
function SidebarPanelFallback() {
return (
<div className="flex flex-1 items-center justify-center p-6">
<div className="size-5 rounded-full border-2 border-muted-foreground/30 border-t-muted-foreground/70 animate-spin" />
</div>
);
}
/**
* The look Termix has always had: icon rail, left sidebar, tab bar, right
* dock and the mobile bottom bar. Built in until it moves to its own plugin.
*/
export function DefaultLayout() {
const { t } = useTranslation();
const shell = useShellController();
const registeredPanels = usePanels();
const { tabs, regions, hosts, isMobile } = shell;
const { left, right } = regions;
const closeOnMobile = () => {
if (isMobile) left.setOpen(false);
};
// Every panel gets its own surface, so its inline views and confirms stay
// inside it. Only the left dock widens for them.
const panelScope = (
view: string,
owned: boolean,
shown: boolean,
children: React.ReactNode,
className = "",
) => (
<SurfaceScope
key={view}
kind="panel"
onEditingChange={owned ? regions.scopeEditingSetter(view) : undefined}
className={`${shown ? "" : "hidden"} ${className}`}
>
{children}
</SurfaceScope>
);
/**
* Sidebar panel content, shared between the desktop sidebar, the mobile
* sheet and the right dock. Takes the view rather than reading the left
* dock's so both docks can render from the same code.
*
* `owned` marks the dock responsible for the panels that stay mounted while
* hidden (hosts, credentials, fleets). Only one dock may own them, otherwise
* two live instances fight over the shared editing state.
*/
const renderSidebarPanels = (railView: string, owned = true) => (
<Suspense fallback={<SidebarPanelFallback />}>
<div className="flex flex-col flex-1 min-h-0 overflow-hidden">
{owned && (
<>
{panelScope(
"hosts",
owned,
railView === "hosts",
<HostsPanel
onOpenTab={(host, type, options) => {
tabs.connectHost(host, type, options);
closeOnMobile();
}}
onEditHost={hosts.edit}
onCloseSidebar={() => left.setOpen(false)}
hostTree={hosts.tree ?? undefined}
loading={hosts.loading}
onEditingChange={regions.panelEditingSetter("hosts")}
active={railView === "hosts"}
/>,
)}
{panelScope(
"credentials",
owned,
railView === "credentials",
<CredentialsPanel
onEditingChange={regions.panelEditingSetter("credentials")}
active={railView === "credentials"}
/>,
)}
</>
)}
{railView === "quick-connect" &&
panelScope(
"quick-connect",
owned,
true,
<QuickConnectPanel
onConnect={(host, type) => {
tabs.open(host, type);
closeOnMobile();
}}
/>,
)}
{registeredPanels.map((panel) => {
const shown = railView === panel.id;
// A kept-mounted panel lives in the owning dock only, so two live
// copies never fight over the same state.
if (panel.keepMounted ? !owned : !shown) return null;
const Panel = panel.component;
return panelScope(
panel.id,
owned,
shown,
<div className="flex flex-col flex-1 min-h-0 overflow-y-auto">
<Panel
targetTab={tabs.targetTerminal}
active={shown}
shell={shell.panelCallbacks}
setEditing={
owned ? regions.panelEditingSetter(panel.id) : () => undefined
}
activeTabType={tabs.active?.type ?? undefined}
placement={owned ? "left" : "right"}
/>
</div>,
);
})}
{!isCoreRailView(railView) && !getPanel(railView) && (
<PluginViewPlaceholder kind="panel" viewId={railView} compact />
)}
{railView === "connections" &&
panelScope(
"connections",
owned,
true,
<div className="flex-1 min-h-0 overflow-y-auto">
<ConnectionsPanel
tabs={tabs.list}
activeTabId={tabs.activeId}
allHosts={hosts.list}
backgroundTabRecords={tabs.background}
onSwitchToTab={(tabId) => {
tabs.setActive(tabId);
closeOnMobile();
}}
onCloseTab={tabs.close}
onReopenTab={(record, restoredSessionId) => {
const host = record.hostId
? hosts.list.find((h) => h.id === String(record.hostId))
: undefined;
if (!host && !getTabType(record.tabType)?.hostless) return;
tabs.setBackground((prev) =>
prev.filter((r) => r.id !== record.id),
);
if (host) {
const effectiveSessionId =
restoredSessionId ?? record.backendSessionId ?? null;
tabs.open(host, record.tabType as TabType, {
instanceId: record.id,
restoredSessionId: effectiveSessionId,
savedLabel: record.label,
});
} else {
tabs.openSingleton(record.tabType as TabType);
}
closeOnMobile();
}}
onForgetBackground={(recordId) => {
tabs.setBackground((prev) =>
prev.filter((r) => r.id !== recordId),
);
}}
onRenameTab={tabs.rename}
onReorderTabs={tabs.setList}
/>
</div>,
)}
{railView === "sync" &&
panelScope(
"sync",
owned,
true,
<div className="flex-1 min-h-0 overflow-y-auto">
<SyncPanel />
</div>,
)}
</div>
</Suspense>
);
const sidebarPanelContent = renderSidebarPanels(left.view);
const dockProps = (view: string) => ({
// Titles come from the shared rail definitions so they stay translated
// and in step with the rail itself.
title: railItemLabel(view, t),
onOpenAsTab: isMobile
? undefined
: view === "hosts" || view === "credentials"
? () => requestManage({ kind: "browse", mode: view as ManageMode })
: promotableIds().includes(view)
? () => tabs.openSingleton(view as TabType)
: undefined,
});
return (
<div
className="flex flex-col w-screen bg-background"
style={{ height: "100dvh" }}
>
<div className="flex flex-1 min-h-0">
{/* Skinny icon rail, desktop only */}
<AppRail
railView={left.view}
sidebarOpen={left.open}
username={shell.username}
isAdmin={shell.isAdmin}
pluginsSettled={shell.pluginsSettled}
onRailClick={regions.railClick}
onOpenTab={tabs.openSingleton}
onOpenInRightDock={regions.openInRightDock}
onOpenSettings={() => tabs.openSingleton("settings")}
onOpenNavigationSettings={() =>
tabs.openSingleton("settings", undefined, undefined, {
section: "appearance",
reveal: "settings-navigation",
revealAt: Date.now(),
})
}
onOpenPalette={shell.palette.open}
onLogout={shell.logout}
/>
{/* Desktop: inline resizable sidebar */}
{!isMobile && (
<DockPanel
side="left"
{...dockProps(left.view)}
open={left.open}
width={left.width}
editing={left.editing}
dragging={left.dragging}
onResizeStart={left.startResize}
onClose={() => left.setOpen(false)}
onMoveToRightDock={
rightDockableIds().includes(left.view)
? () => regions.openInRightDock(left.view)
: undefined
}
onResetWidth={left.resetWidth}
>
{sidebarPanelContent}
</DockPanel>
)}
{/* Mobile: sidebar as overlay sheet */}
{isMobile && (
<Sheet open={left.open} onOpenChange={left.setOpen}>
<SheetContent
side="left"
showCloseButton={false}
className={`p-0 flex flex-col min-h-0 max-w-full bg-sidebar border-r border-border gap-0 ${left.editing ? "w-screen" : "w-[min(85vw,360px)]"}`}
style={{ height: "100dvh" }}
>
<DockPanel
side="left"
{...dockProps(left.view)}
open
fill
width={left.width}
onClose={() => left.setOpen(false)}
>
{sidebarPanelContent}
</DockPanel>
</SheetContent>
</Sheet>
)}
{/* Main content area */}
<div
className={`relative flex flex-col flex-1 min-w-0 overflow-hidden transition-[padding] duration-200 ${!isMobile && !left.open ? "pl-6" : ""}`}
>
{!isMobile && !left.open && (
<DockReopenStrip onClick={() => left.setOpen(true)} />
)}
<SurfaceScope kind="tab" className="overflow-hidden">
<ShellConfirmCapture />
<div className="flex flex-col flex-1 min-w-0 min-h-0 overflow-hidden">
<TabBar
tabs={tabs.topLevel}
activeTabId={tabs.activeId}
splits={shell.split.summaries}
activeSplitFull={shell.split.activeFull}
onSetActiveTab={tabs.setActive}
onCloseTab={tabs.close}
onRefreshTab={tabs.refresh}
onReconnectDisconnected={tabs.reconnectAll}
onReorderTabs={tabs.reorder}
onSplitAction={shell.split.handleAction}
onRenameTab={tabs.rename}
isAppFullscreen={shell.fullscreen.active}
onToggleAppFullscreen={shell.fullscreen.toggle}
rightDockOpen={right.view !== null}
onToggleRightDock={
isMobile ? undefined : regions.toggleRightDock
}
showTabNumbers={tabs.showNumbers}
/>
<TabStage />
</div>
</SurfaceScope>
{/* Bottom nav bar, mobile only. The desktop app keeps the rail. */}
{!shell.isElectron && (
<MobileBar
railView={left.view}
sidebarOpen={left.open}
username={shell.username}
onRailClick={regions.railClick}
onOpenTab={(type) => tabs.openSingleton(type)}
onOpenSettings={() => tabs.openSingleton("settings")}
onOpenPalette={shell.palette.open}
onLogout={() => shell.logout({ manual: true })}
/>
)}
</div>
{/* Right dock, desktop only, holds a second reference panel */}
{!isMobile && right.view && (
<DockPanel
side="right"
{...dockProps(right.view)}
open
width={right.width}
dragging={right.dragging}
onResizeStart={right.startResize}
onClose={() => right.setView(null)}
onResetWidth={right.resetWidth}
>
{renderSidebarPanels(right.view, false)}
</DockPanel>
)}
{/* Plugin panels pinned to the right edge, sized by the plugin */}
<ComponentSlot slotId="shell.rightEdge" />
</div>
</div>
);
}
-11
View File
@@ -1,11 +0,0 @@
import { BUILTIN_LAYOUT_ID, registerLayout } from "@/look/layout-registry";
import { DefaultLayout } from "./DefaultLayout";
let registered = false;
/** Core's own layout, there whatever plugins are installed. */
export function registerBuiltinLayout(): void {
if (registered) return;
registered = true;
registerLayout({ id: BUILTIN_LAYOUT_ID, component: DefaultLayout });
}
-1
View File
@@ -50,7 +50,6 @@ export function TabPortals() {
>
{renderTabContent(tab, {
shell: shell.callbacks,
settings: shell.settings,
panelTargetTab: targetTerminal,
isVisible: inPane || activeInline,
isFocusedPane,
+1 -85
View File
@@ -1,16 +1,5 @@
/* eslint-disable react-refresh/only-export-components */
import {
requestFromLegacyEvent,
requestManage,
} from "@/manage/manage-requests";
import {
LayoutDashboard,
LayoutPanelLeft,
Puzzle,
LibraryBig,
Server,
Settings,
} from "lucide-react";
import { LayoutPanelLeft, Puzzle, Server, Settings } from "lucide-react";
import { lazy, memo, Suspense } from "react";
import { useTranslation } from "react-i18next";
import type { Tab, TabType } from "@/types/ui-types";
@@ -23,27 +12,15 @@ import {
type TabTypeDef,
} from "./tab-registry";
import { getPanel, type PanelDef } from "./panel-registry";
import type { SettingsTabProps } from "@/settings/SettingsTab";
import {
markAdaptiveResourceUsed,
runAdaptiveBackgroundTask,
} from "@/lib/adaptive-resource-budget";
// Heavy tab surfaces, kept out of the AppShell critical path.
const ManageTab = lazy(() =>
import("@/manage/ManageTab").then((m) => ({ default: m.ManageTab })),
);
const SettingsTab = lazy(() =>
import("@/settings/SettingsTab").then((m) => ({ default: m.SettingsTab })),
);
const PluginsTab = lazy(() =>
import("@/plugins/PluginsTab").then((m) => ({ default: m.PluginsTab })),
);
const DashboardTab = lazy(() =>
import("@/dashboard/DashboardTab").then((m) => ({
default: m.DashboardTab,
})),
);
/** Download a likely next tab without starting a connection or mounting UI. */
export function preloadTabSurface(type: TabType): void {
@@ -104,11 +81,6 @@ function PanelTabFrame({ children }: { children: React.ReactNode }) {
export function tabIcon(type: TabType) {
switch (type) {
case "dashboard":
return <LayoutDashboard className="size-3.5" />;
case "host-manager":
return <LibraryBig className="size-3.5" />;
case "settings":
case "user-profile":
case "admin-settings":
return <Settings className="size-3.5" />;
@@ -176,29 +148,6 @@ const RegisteredTab = memo(function RegisteredTab({
);
});
const DashboardTabHost = memo(function DashboardTabHost({
shell,
isVisible,
}: {
shell: TabShellCallbacks;
isVisible: boolean;
}) {
return withTabSuspense(
<DashboardTab
onOpenSingletonTab={(type, pendingEvent) => {
// "Add host" and friends say what to open in the Manage tab.
const request = pendingEvent
? requestFromLegacyEvent(pendingEvent)
: null;
if (request) requestManage(request);
else shell.openSingletonTab(type);
}}
onOpenTab={(host, type) => shell.openTab(host, type)}
isVisible={isVisible}
/>,
);
});
/** A rail panel opened as a tab when its plugin registers no tab of its own. */
const PanelAsTab = memo(function PanelAsTab({
panel,
@@ -229,8 +178,6 @@ function noop() {}
export interface TabRenderContext {
shell: TabShellCallbacks;
/** What the Settings tab needs from the shell. */
settings?: Omit<SettingsTabProps, "section" | "sectionAt">;
/** The terminal a panel shown as a tab sends commands to. */
panelTargetTab?: Tab;
isVisible?: boolean;
@@ -247,43 +194,12 @@ export function renderTabContent(tab: Tab, context: TabRenderContext) {
} = context;
switch (tab.type) {
case "dashboard":
return <DashboardTabHost shell={shell} isVisible={isVisible} />;
case "split-screen":
return null;
case "host-manager":
return withTabSuspense(<ManageTab />);
case "plugins":
return withTabSuspense(<PluginsTab />);
case "settings":
return context.settings
? withTabSuspense(
<SettingsTab
{...context.settings}
section={
typeof tab.data?.section === "string"
? tab.data.section
: undefined
}
sectionAt={
typeof tab.data?.revealAt === "number" ||
typeof tab.data?.revealAt === "string"
? tab.data.revealAt
: undefined
}
reveal={
typeof tab.data?.reveal === "string"
? `${tab.data.reveal}@${String(tab.data.revealAt ?? "")}`
: undefined
}
/>,
)
: null;
case "user-profile":
case "admin-settings":
return null;
-259
View File
@@ -1,259 +0,0 @@
import { useConfirm } from "@/components/surface/surface-scope";
import type { Dispatch, SetStateAction } from "react";
import { useTranslation } from "react-i18next";
import { copyToClipboard } from "@/lib/clipboard";
import { deleteApiKey, type ApiKey } from "@/main-axios";
import { Button } from "@/components/button";
import { Input } from "@/components/input";
import { Copy, Network, Plus, RefreshCw, Trash2 } from "lucide-react";
import { toast } from "sonner";
import { AccordionSection } from "./AdminSettingsShared";
import type { AdminUser } from "./AdminManagementSections";
import { Select2 } from "@/components/select2";
import { DocsLink } from "@/components/docs-link";
type AdminApiKeysSectionProps = {
open: boolean;
onToggle: () => void;
apiKeys: ApiKey[];
setApiKeys: Dispatch<SetStateAction<ApiKey[]>>;
loadApiKeys: () => void;
showCreateKey: boolean;
setShowCreateKey: Dispatch<SetStateAction<boolean>>;
createdKeyToken: string | null;
setCreatedKeyToken: Dispatch<SetStateAction<string | null>>;
newKeyName: string;
setNewKeyName: Dispatch<SetStateAction<string>>;
newKeyUserId: string;
setNewKeyUserId: Dispatch<SetStateAction<string>>;
newKeyExpiry: string;
setNewKeyExpiry: Dispatch<SetStateAction<string>>;
users: AdminUser[];
handleCreateApiKey: () => void;
newKeyLoading: boolean;
};
export function AdminApiKeysSection({
open,
onToggle,
apiKeys,
setApiKeys,
loadApiKeys,
showCreateKey,
setShowCreateKey,
createdKeyToken,
setCreatedKeyToken,
newKeyName,
setNewKeyName,
newKeyUserId,
setNewKeyUserId,
newKeyExpiry,
setNewKeyExpiry,
users,
handleCreateApiKey,
newKeyLoading,
}: AdminApiKeysSectionProps) {
const { t } = useTranslation();
const confirm = useConfirm();
return (
<AccordionSection
label={t("admin.sectionApiKeys")}
icon={<Network className="size-3.5" />}
open={open}
onToggle={onToggle}
>
<div className="flex flex-col pt-2">
<div className="flex items-center justify-between py-2 border-b border-border">
<span className="text-[10px] text-muted-foreground">
{t("admin.apiKeysCount", { count: apiKeys.length })}{" "}
<DocsLink core="apiKeys">{t("admin.apiKeysDocsLink")}</DocsLink>
<br />
{t("admin.apiKeysCliHint")}{" "}
<DocsLink core="cli">{t("admin.apiKeysCliLink")}</DocsLink>
</span>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon"
className="size-6 text-muted-foreground hover:text-foreground"
onClick={loadApiKeys}
>
<RefreshCw className="size-3" />
</Button>
<Button
variant="outline"
size="sm"
className="h-6 text-[10px] border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
onClick={() => {
setShowCreateKey((o) => !o);
setCreatedKeyToken(null);
}}
>
<Plus className="size-3" />
{t("admin.createRole")}
</Button>
</div>
</div>
{showCreateKey && (
<div className="flex flex-col gap-2.5 py-3 border-b border-border">
<span className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
{t("admin.newApiKey")}
</span>
{createdKeyToken ? (
<div className="flex flex-col gap-2">
<span className="text-[10px] text-accent-brand font-semibold">
{t("admin.apiKeyCreatedWarning")}
</span>
<div className="flex items-center gap-2 bg-muted/30 border border-border px-2 py-1.5">
<span className="text-[10px] font-mono flex-1 truncate">
{createdKeyToken}
</span>
<button
onClick={() => {
copyToClipboard(createdKeyToken);
toast.info(t("admin.copiedToClipboard"));
}}
className="text-muted-foreground hover:text-accent-brand shrink-0"
>
<Copy className="size-3.5" />
</button>
</div>
<Button
variant="ghost"
size="sm"
className="text-xs self-end"
onClick={() => {
setShowCreateKey(false);
setCreatedKeyToken(null);
}}
>
{t("admin.done")}
</Button>
</div>
) : (
<>
<div className="flex flex-col gap-1">
<label className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
{t("admin.apiKeyName")}{" "}
<span className="text-accent-brand">*</span>
</label>
<Input
placeholder={t("placeholders.apiKeyName")}
value={newKeyName}
onChange={(e) => setNewKeyName(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.apiKeyUser")}{" "}
<span className="text-accent-brand">*</span>
</label>
<Select2
className="px-2 py-1.5 text-xs bg-background border border-border text-foreground outline-none"
value={newKeyUserId}
onChange={(e) => setNewKeyUserId(e.target.value)}
>
<option value="">{t("admin.apiKeySelectUser")}</option>
{users.map((u) => (
<option key={u.id} value={u.id}>
{u.username}
</option>
))}
</Select2>
</div>
<div className="flex flex-col gap-1">
<label className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
{t("admin.apiKeyExpiresAt")}
</label>
<Input
type="date"
value={newKeyExpiry}
onChange={(e) => setNewKeyExpiry(e.target.value)}
className="text-xs"
/>
</div>
<div className="flex justify-end gap-2">
<Button
variant="ghost"
size="sm"
className="text-xs"
onClick={() => setShowCreateKey(false)}
>
{t("common.cancel")}
</Button>
<Button
variant="outline"
size="sm"
className="text-xs border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
onClick={handleCreateApiKey}
disabled={newKeyLoading}
>
{newKeyLoading ? t("admin.creating") : t("admin.createKey")}
</Button>
</div>
</>
)}
</div>
)}
{apiKeys.map((key) => (
<div
key={key.id}
className="flex items-start justify-between py-2.5 border-b border-border last:border-0 gap-2"
>
<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-xs font-semibold truncate">
{key.name}
</span>
{!key.isActive && (
<span className="text-[9px] font-semibold px-1 py-px border border-destructive/40 bg-destructive/10 text-destructive">
{t("admin.revokedBadge")}
</span>
)}
</div>
<span className="text-[10px] text-muted-foreground">
{t("admin.apiKeyUser")}: {key.username}
</span>
<span className="text-[10px] font-mono text-muted-foreground truncate">
{key.tokenPrefix}…
</span>
<span className="flex gap-2.5 text-[10px] text-muted-foreground">
<span>{key.createdAt.split("T")[0]}</span>
<span>
{key.expiresAt
? key.expiresAt.split("T")[0]
: t("admin.apiKeyNoExpiry")}
</span>
</span>
</div>
<Button
variant="ghost"
size="icon"
className="size-6 text-muted-foreground hover:text-destructive shrink-0"
onClick={async () => {
const ok = await confirm({
title: t("admin.revokeKeyConfirm", { name: key.name }),
confirmLabel: t("admin.revokeKey"),
});
if (!ok) return;
try {
await deleteApiKey(key.id);
setApiKeys((prev) => prev.filter((k) => k.id !== key.id));
toast.success(
t("admin.revokeKeySuccess", { name: key.name }),
);
} catch {
toast.error(t("admin.revokeKeyFailed"));
}
}}
>
<Trash2 className="size-3" />
</Button>
</div>
))}
</div>
</AccordionSection>
);
}
-491
View File
@@ -1,491 +0,0 @@
import { useState, useEffect, useCallback, useRef } from "react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/button";
import { Input } from "@/components/input";
import {
ChevronLeft,
ChevronRight,
ClipboardList,
RefreshCw,
X,
} from "lucide-react";
import { AccordionSection } from "./AdminSettingsShared";
import {
getAuditLogs,
getAuditLogActions,
getAuditForwarding,
updateAuditForwarding,
type AuditLog,
type AuditLogFilters,
} from "@/api/audit-log-api";
import { toast } from "sonner";
import type { AdminUser } from "./AdminManagementSections";
import { Select2 } from "@/components/select2";
import { parseServerTime } from "@/lib/server-time";
const RESOURCE_TYPES = [
"user",
"host",
"credential",
"plugin",
"session",
"api_key",
"setting",
];
type AdminAuditLogSectionProps = {
open: boolean;
onToggle: () => void;
users: AdminUser[];
};
export function AdminAuditLogSection({
open,
onToggle,
users,
}: AdminAuditLogSectionProps) {
const { t } = useTranslation();
const [logs, setLogs] = useState<AuditLog[]>([]);
const [forwardUrl, setForwardUrl] = useState("");
const [forwardToken, setForwardToken] = useState("");
const [forwardHasToken, setForwardHasToken] = useState(false);
const [forwardEnvConfigured, setForwardEnvConfigured] = useState(false);
const [forwardSaving, setForwardSaving] = useState(false);
useEffect(() => {
if (!open) return;
getAuditForwarding()
.then((settings) => {
setForwardUrl(settings.url);
setForwardHasToken(settings.hasToken);
setForwardEnvConfigured(settings.envConfigured);
})
.catch(() => {});
}, [open]);
async function handleSaveForwarding() {
setForwardSaving(true);
try {
const result = await updateAuditForwarding(
forwardUrl,
forwardToken || undefined,
);
setForwardHasToken(result.hasToken || (!!result.url && forwardHasToken));
setForwardToken("");
toast.success(t("admin.auditForwardingSaved"));
} catch {
toast.error(t("admin.auditForwardingSaveError"));
} finally {
setForwardSaving(false);
}
}
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const [loading, setLoading] = useState(false);
const [expandedId, setExpandedId] = useState<number | null>(null);
const [actions, setActions] = useState<string[]>([]);
const [filterUserId, setFilterUserId] = useState("");
const [filterAction, setFilterAction] = useState("");
const [filterResourceType, setFilterResourceType] = useState("");
const [filterSuccess, setFilterSuccess] = useState<"" | "true" | "false">("");
const [filterStartDate, setFilterStartDate] = useState("");
const [filterEndDate, setFilterEndDate] = useState("");
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const fetchLogs = useCallback(
async (filters: AuditLogFilters, pg: number) => {
setLoading(true);
try {
const result = await getAuditLogs({ ...filters, page: pg, limit: 50 });
setLogs(result.logs);
setTotal(result.total);
setTotalPages(result.totalPages);
} catch {
// non-fatal
} finally {
setLoading(false);
}
},
[],
);
const buildFilters = useCallback((): AuditLogFilters => {
const f: AuditLogFilters = {};
if (filterUserId) f.userId = filterUserId;
if (filterAction) f.action = filterAction;
if (filterResourceType) f.resourceType = filterResourceType;
if (filterSuccess !== "") f.success = filterSuccess === "true";
if (filterStartDate) f.startDate = filterStartDate;
if (filterEndDate) f.endDate = filterEndDate;
return f;
}, [
filterUserId,
filterAction,
filterResourceType,
filterSuccess,
filterStartDate,
filterEndDate,
]);
useEffect(() => {
if (!open) return;
getAuditLogActions()
.then((r) => setActions(r.actions))
.catch(() => {});
fetchLogs(buildFilters(), 1);
setPage(1);
}, [open, buildFilters, fetchLogs]);
useEffect(() => {
if (!open) return;
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => {
setPage(1);
fetchLogs(buildFilters(), 1);
}, 300);
return () => {
if (debounceRef.current) clearTimeout(debounceRef.current);
};
}, [
open,
buildFilters,
fetchLogs,
filterUserId,
filterAction,
filterResourceType,
filterSuccess,
filterStartDate,
filterEndDate,
]);
function clearFilters() {
setFilterUserId("");
setFilterAction("");
setFilterResourceType("");
setFilterSuccess("");
setFilterStartDate("");
setFilterEndDate("");
}
function handlePageChange(newPage: number) {
setPage(newPage);
fetchLogs(buildFilters(), newPage);
}
function formatTimestamp(ts: string) {
try {
return parseServerTime(ts).toLocaleString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
});
} catch {
return ts;
}
}
const hasFilters =
filterUserId ||
filterAction ||
filterResourceType ||
filterSuccess !== "" ||
filterStartDate ||
filterEndDate;
return (
<AccordionSection
label={t("admin.sectionAuditLog")}
icon={<ClipboardList className="size-3.5" />}
open={open}
onToggle={onToggle}
>
<div className="flex flex-col pt-2 gap-2">
{/* SIEM forwarding */}
<div className="flex flex-col gap-1.5 pb-2 border-b border-border">
<span className="text-[9px] font-semibold uppercase tracking-widest text-muted-foreground">
{t("admin.auditForwardingTitle")}
</span>
<p className="text-[10px] text-muted-foreground">
{t("admin.auditForwardingDesc")}
{forwardEnvConfigured && (
<> {t("admin.auditForwardingEnvNotice")}</>
)}
</p>
<Input
placeholder="https://siem.example.com/ingest"
value={forwardUrl}
onChange={(e) => setForwardUrl(e.target.value)}
className="text-[10px] h-7"
/>
<Input
type="password"
placeholder={
forwardHasToken
? t("admin.auditForwardingTokenStored")
: t("admin.auditForwardingToken")
}
value={forwardToken}
onChange={(e) => setForwardToken(e.target.value)}
className="text-[10px] h-7"
/>
<Button
size="sm"
variant="outline"
className="h-7 text-[10px] self-start"
onClick={handleSaveForwarding}
disabled={forwardSaving}
>
{t("common.save")}
</Button>
</div>
{/* Filters */}
<div className="grid grid-cols-2 gap-1.5">
<div className="flex flex-col gap-0.5">
<label className="text-[9px] font-semibold uppercase tracking-widest text-muted-foreground">
{t("admin.auditLogFilterUser")}
</label>
<Select2
className="px-2 py-1 text-[10px] bg-background border border-border text-foreground outline-none"
value={filterUserId}
onChange={(e) => setFilterUserId(e.target.value)}
>
<option value="">{t("admin.auditLogFilterAll")}</option>
{(Array.isArray(users) ? users : []).map((u) => (
<option key={u.id} value={u.id}>
{u.username}
</option>
))}
</Select2>
</div>
<div className="flex flex-col gap-0.5">
<label className="text-[9px] font-semibold uppercase tracking-widest text-muted-foreground">
{t("admin.auditLogFilterAction")}
</label>
<Select2
className="px-2 py-1 text-[10px] bg-background border border-border text-foreground outline-none"
value={filterAction}
onChange={(e) => setFilterAction(e.target.value)}
>
<option value="">{t("admin.auditLogFilterAll")}</option>
{actions.map((a) => (
<option key={a} value={a}>
{a.replace(/_/g, " ")}
</option>
))}
</Select2>
</div>
<div className="flex flex-col gap-0.5">
<label className="text-[9px] font-semibold uppercase tracking-widest text-muted-foreground">
{t("admin.auditLogFilterResourceType")}
</label>
<Select2
className="px-2 py-1 text-[10px] bg-background border border-border text-foreground outline-none"
value={filterResourceType}
onChange={(e) => setFilterResourceType(e.target.value)}
>
<option value="">{t("admin.auditLogFilterAll")}</option>
{RESOURCE_TYPES.map((r) => (
<option key={r} value={r}>
{r}
</option>
))}
</Select2>
</div>
<div className="flex flex-col gap-0.5">
<label className="text-[9px] font-semibold uppercase tracking-widest text-muted-foreground">
{t("admin.auditLogFilterStatus")}
</label>
<Select2
className="px-2 py-1 text-[10px] bg-background border border-border text-foreground outline-none"
value={filterSuccess}
onChange={(e) =>
setFilterSuccess(e.target.value as "" | "true" | "false")
}
>
<option value="">{t("admin.auditLogFilterAll")}</option>
<option value="true">{t("admin.auditLogSuccess")}</option>
<option value="false">{t("admin.auditLogFailed")}</option>
</Select2>
</div>
<div className="flex flex-col gap-0.5">
<label className="text-[9px] font-semibold uppercase tracking-widest text-muted-foreground">
{t("admin.auditLogFilterFrom")}
</label>
<Input
type="datetime-local"
value={filterStartDate}
onChange={(e) => setFilterStartDate(e.target.value)}
className="text-[10px] h-7"
/>
</div>
<div className="flex flex-col gap-0.5">
<label className="text-[9px] font-semibold uppercase tracking-widest text-muted-foreground">
{t("admin.auditLogFilterTo")}
</label>
<Input
type="datetime-local"
value={filterEndDate}
onChange={(e) => setFilterEndDate(e.target.value)}
className="text-[10px] h-7"
/>
</div>
</div>
{/* Toolbar */}
<div className="flex items-center justify-between border-b border-border pb-2">
<span className="text-[10px] text-muted-foreground">
{t("admin.auditLogTotal", { total })}
</span>
<div className="flex items-center gap-1">
{hasFilters && (
<Button
variant="ghost"
size="sm"
className="h-6 text-[10px] text-muted-foreground hover:text-foreground gap-1"
onClick={clearFilters}
>
<X className="size-3" />
{t("admin.auditLogClearFilters")}
</Button>
)}
<Button
variant="ghost"
size="icon"
className="size-6 text-muted-foreground hover:text-foreground"
onClick={() => fetchLogs(buildFilters(), page)}
disabled={loading}
>
<RefreshCw
className={`size-3 ${loading ? "animate-spin" : ""}`}
/>
</Button>
</div>
</div>
{/* Log table */}
{logs.length === 0 && !loading ? (
<div className="py-6 text-center text-[10px] text-muted-foreground">
{t("admin.auditLogEmpty")}
</div>
) : (
<div className="flex flex-col">
{logs.map((log) => (
<div
key={log.id}
className="border-b border-border last:border-0 cursor-pointer hover:bg-muted/20 transition-colors"
onClick={() =>
setExpandedId(expandedId === log.id ? null : log.id)
}
>
<div className="flex items-start gap-2 py-2">
<span
className={`shrink-0 mt-0.5 w-1.5 h-1.5 rounded-full ${log.success ? "bg-green-500" : "bg-destructive"}`}
/>
<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-[10px] font-semibold text-foreground">
{(log.action || "unknown").replace(/_/g, " ")}
</span>
<span className="text-[9px] px-1 py-px border border-border text-muted-foreground">
{log.resourceType}
</span>
{log.resourceName && (
<span className="text-[10px] text-muted-foreground truncate max-w-[120px]">
{log.resourceName}
</span>
)}
</div>
<div className="flex items-center gap-2">
<span className="text-[9px] text-muted-foreground font-semibold">
{log.username}
</span>
<span className="text-[9px] text-muted-foreground">
{formatTimestamp(log.timestamp)}
</span>
</div>
</div>
<span
className={`shrink-0 text-[9px] font-semibold px-1 py-px border ${
log.success
? "border-green-500/40 text-green-500 bg-green-500/10"
: "border-destructive/40 text-destructive bg-destructive/10"
}`}
>
{log.success
? t("admin.auditLogSuccess")
: t("admin.auditLogFailed")}
</span>
</div>
{expandedId === log.id && (
<div className="ml-3.5 pb-2 flex flex-col gap-1">
{log.ipAddress && (
<span className="text-[9px] text-muted-foreground">
{t("admin.auditLogIp")}: {log.ipAddress}
</span>
)}
{log.resourceId && (
<span className="text-[9px] text-muted-foreground">
{t("admin.auditLogResourceId")}: {log.resourceId}
</span>
)}
{log.details && (
<span className="text-[9px] text-muted-foreground font-mono break-all">
{log.details}
</span>
)}
{log.errorMessage && (
<span className="text-[9px] text-destructive">
{log.errorMessage}
</span>
)}
</div>
)}
</div>
))}
</div>
)}
{/* Pagination */}
{totalPages > 1 && (
<div className="flex items-center justify-between pt-1 border-t border-border">
<Button
variant="ghost"
size="icon"
className="size-6"
disabled={page <= 1 || loading}
onClick={() => handlePageChange(page - 1)}
>
<ChevronLeft className="size-3" />
</Button>
<span className="text-[10px] text-muted-foreground">
{t("admin.auditLogPage", { page, totalPages, total })}
</span>
<Button
variant="ghost"
size="icon"
className="size-6"
disabled={page >= totalPages || loading}
onClick={() => handlePageChange(page + 1)}
>
<ChevronRight className="size-3" />
</Button>
</div>
)}
</div>
</AccordionSection>
);
}
-154
View File
@@ -1,154 +0,0 @@
import { useRef } from "react";
import { useTranslation } from "react-i18next";
import { PaintbrushIcon } from "lucide-react";
import { Button } from "@/components/button";
import { Input } from "@/components/input";
import type { BrandingSettings } from "@/api/settings-api";
import { AccordionSection } from "./AdminSettingsShared";
import {
BRANDING_LOGO_ACCEPT,
isAcceptedBrandingLogoFile,
} from "./branding-logo-file";
export function AdminBrandingSection({
open,
onToggle,
settings,
setSettings,
saving,
onSave,
onResetLogo,
}: {
open: boolean;
onToggle: () => void;
settings: BrandingSettings | null;
setSettings: (settings: BrandingSettings) => void;
saving: boolean;
onSave: () => void;
onResetLogo: () => void;
}) {
const { t } = useTranslation();
const fileInputRef = useRef<HTMLInputElement>(null);
function handleFileChange(event: React.ChangeEvent<HTMLInputElement>) {
const file = event.target.files?.[0];
event.target.value = "";
if (!file || !settings) return;
if (!isAcceptedBrandingLogoFile(file)) {
alert(t("admin.brandingLogoInvalid"));
return;
}
const reader = new FileReader();
reader.onload = () => {
if (typeof reader.result === "string") {
setSettings({ ...settings, logo: reader.result });
}
};
reader.readAsDataURL(file);
}
return (
<AccordionSection
label={t("admin.sectionBranding")}
icon={<PaintbrushIcon className="size-3.5" />}
open={open}
onToggle={onToggle}
>
{settings === null ? (
<div className="pt-3 text-xs text-muted-foreground">
{t("common.loading")}
</div>
) : (
<div className="@container flex flex-col gap-0 pt-2">
<div className="grid grid-cols-1 gap-x-4 gap-y-3 py-3 @md:grid-cols-2 border-b border-border">
<label className="flex flex-col gap-1 text-xs">
<span className="font-medium text-foreground">
{t("admin.brandingAppName")}
</span>
<Input
aria-label={t("admin.brandingAppName")}
value={settings.appName}
onChange={(event) =>
setSettings({ ...settings, appName: event.target.value })
}
className="h-8 text-xs"
/>
<span className="text-muted-foreground">
{t("admin.brandingAppNameDesc")}
</span>
</label>
<label className="flex flex-col gap-1 text-xs">
<span className="font-medium text-foreground">
{t("admin.brandingTagline")}
</span>
<Input
aria-label={t("admin.brandingTagline")}
value={settings.tagline}
onChange={(event) =>
setSettings({ ...settings, tagline: event.target.value })
}
className="h-8 text-xs"
/>
<span className="text-muted-foreground">
{t("admin.brandingTaglineDesc")}
</span>
</label>
</div>
<div className="flex flex-col gap-2 py-3 border-b border-border">
<div className="flex flex-col gap-0.5">
<span className="text-sm font-medium leading-snug">
{t("admin.brandingLogo")}
</span>
<span className="text-xs text-muted-foreground leading-snug">
{t("admin.brandingLogoDesc")}
</span>
</div>
<div className="flex items-center gap-3">
{settings.logo ? (
<img
src={settings.logo}
alt=""
className="size-10 object-contain border border-border bg-muted/40"
/>
) : (
<div className="size-10 border border-dashed border-border" />
)}
<input
ref={fileInputRef}
type="file"
accept={BRANDING_LOGO_ACCEPT}
className="hidden"
onChange={handleFileChange}
/>
<Button
size="sm"
variant="outline"
onClick={() => fileInputRef.current?.click()}
>
{t("admin.brandingLogoUpload")}
</Button>
{settings.logo && (
<Button size="sm" variant="outline" onClick={onResetLogo}>
{t("admin.brandingLogoReset")}
</Button>
)}
</div>
</div>
<div className="flex gap-2 pt-3">
<Button
size="sm"
variant="outline"
className="border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
disabled={saving}
onClick={onSave}
>
{saving ? t("common.saving") : t("common.save")}
</Button>
</div>
</div>
)}
</AccordionSection>
);
}
-99
View File
@@ -1,99 +0,0 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { X } from "lucide-react";
import { getHostTags, saveHostTags } from "@/api/host-tags-api";
export function AdminHostTags() {
const { t } = useTranslation();
const [tags, setTags] = useState<string[]>([]);
const [input, setInput] = useState("");
const [loaded, setLoaded] = useState(false);
const [saving, setSaving] = useState(false);
useEffect(() => {
let cancelled = false;
getHostTags()
.then((list) => {
if (cancelled) return;
setTags(list);
setLoaded(true);
})
.catch(() => {
if (!cancelled) toast.error(t("admin.hostTagsError"));
});
return () => {
cancelled = true;
};
}, [t]);
async function save(next: string[]) {
const previous = tags;
setTags(next);
setSaving(true);
try {
setTags(await saveHostTags(next));
window.dispatchEvent(new Event("termix:host-tags-changed"));
} catch {
setTags(previous);
toast.error(t("admin.hostTagsError"));
} finally {
setSaving(false);
}
}
function add(raw: string) {
const tag = raw.trim();
setInput("");
if (!tag || tags.includes(tag)) return;
void save([...tags, tag]);
}
return (
<div className="flex flex-col gap-1.5">
<label htmlFor="global-host-tags" className="text-xs font-medium">
{t("admin.hostTags")}
</label>
<span className="text-[10px] text-muted-foreground">
{t("admin.hostTagsDesc")}
</span>
<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">
{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"
disabled={saving}
aria-label={t("common.remove")}
onClick={() => void save(tags.filter((tg) => tg !== tag))}
className="text-muted-foreground hover:text-destructive ml-0.5"
>
<X className="size-2.5" />
</button>
</span>
))}
<input
id="global-host-tags"
className="flex-1 min-w-16 text-xs bg-transparent outline-none placeholder:text-muted-foreground/50"
placeholder={tags.length === 0 ? t("hosts.addTag") : ""}
disabled={!loaded || saving}
value={input}
onChange={(e) => setInput(e.target.value)}
onBlur={() => add(input)}
onKeyDown={(e) => {
if (e.nativeEvent.isComposing) return;
if ((e.key === " " || e.key === "Enter") && input.trim()) {
e.preventDefault();
add(input);
} else if (e.key === "Backspace" && !input && tags.length > 0) {
void save(tags.slice(0, -1));
}
}}
/>
</div>
</div>
);
}
-822
View File
@@ -1,822 +0,0 @@
import { useConfirm } from "@/components/surface/surface-scope";
import { useState, type Dispatch, type SetStateAction } from "react";
import { useTranslation } from "react-i18next";
import {
deleteRole,
deleteUser,
getPermissionsCatalog,
revokeAllUserSessions,
revokeSession,
updateRole,
} from "@/main-axios";
import type { PermissionCatalogEntry, Role } from "@/main-axios";
import { pluginKey } from "@/lib/plugin-i18n";
import { PluginIcon } from "@/lib/plugin-icon";
import { Button } from "@/components/button";
import { Input } from "@/components/input";
import { PanelSearch } from "@/components/panel-layout";
import {
Activity,
Check,
KeyRound,
Pencil,
Plus,
RefreshCw,
Settings2,
Share2,
Trash2,
Unlink,
User,
} from "lucide-react";
import { toast } from "sonner";
import { AccordionSection } from "./AdminSettingsShared";
import { roleLabel } from "@/lib/role-label";
export type AdminUser = {
id: string;
username: string;
isAdmin: boolean;
isExternal: boolean;
passwordHash?: string;
dataUnlocked?: boolean;
secondFactorEnabled?: boolean;
};
export type AdminSession = {
id: string;
userId: string;
username?: string;
deviceType: string;
deviceInfo: string;
createdAt: string;
expiresAt: string;
lastActiveAt: string;
isRevoked?: boolean;
isCurrentSession?: boolean;
};
type ApiErrorLike = {
response?: {
data?: {
error?: string;
};
};
};
function apiErrorMessage(error: unknown, fallback: string) {
return (error as ApiErrorLike).response?.data?.error || fallback;
}
type UsersSectionProps = {
open: boolean;
onToggle: () => void;
users: AdminUser[];
setUsers: Dispatch<SetStateAction<AdminUser[]>>;
loadUsers: () => void;
setCreateUserOpen: Dispatch<SetStateAction<boolean>>;
setEditUserTarget: Dispatch<SetStateAction<AdminUser | null>>;
setEditUserOpen: Dispatch<SetStateAction<boolean>>;
setLinkAccountTarget: Dispatch<
SetStateAction<{ id: string; username: string; isExternal: boolean } | null>
>;
setLinkAccountOpen: Dispatch<SetStateAction<boolean>>;
setUnlinkAccountTarget: Dispatch<
SetStateAction<{ id: string; username: string } | null>
>;
setUnlinkAccountOpen: Dispatch<SetStateAction<boolean>>;
onManageUser: (user: AdminUser) => void;
search: string;
onSearchChange: (value: string) => void;
page: number;
pageSize: number;
total: number;
onPageChange: Dispatch<SetStateAction<number>>;
};
export function AdminUsersSection({
open,
onToggle,
users,
setUsers,
loadUsers,
setCreateUserOpen,
setEditUserTarget,
setEditUserOpen,
setLinkAccountTarget,
setLinkAccountOpen,
setUnlinkAccountTarget,
setUnlinkAccountOpen,
onManageUser,
search,
onSearchChange,
page,
pageSize,
total,
onPageChange,
}: UsersSectionProps) {
const { t } = useTranslation();
const confirm = useConfirm();
const pageCount = Math.max(1, Math.ceil(total / pageSize));
const hasPrev = page > 0;
const hasNext = page + 1 < pageCount;
return (
<AccordionSection
label={t("admin.sectionUsers")}
icon={<User className="size-3.5" />}
open={open}
onToggle={onToggle}
>
<div className="flex flex-col pt-2">
<div className="flex items-center justify-between py-2 border-b border-border">
<span className="text-[10px] text-muted-foreground">
{t("admin.usersCount", { count: total })}
</span>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon"
className="size-6 text-muted-foreground hover:text-foreground"
onClick={loadUsers}
>
<RefreshCw className="size-3" />
</Button>
<Button
variant="outline"
size="sm"
className="h-6 text-[10px] border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
onClick={() => setCreateUserOpen(true)}
>
<Plus className="size-3" />
{t("admin.createUser")}
</Button>
</div>
</div>
<div className="py-2 border-b border-border">
<PanelSearch
value={search}
onChange={onSearchChange}
placeholder={t("admin.searchUsers")}
fill
/>
</div>
{users.map((user) => {
const authLabel =
user.isExternal && user.passwordHash
? t("admin.authTypeDual")
: user.isExternal
? t("admin.authTypeExternal")
: t("admin.authTypeLocal");
return (
<div
key={user.id}
className="flex items-center justify-between py-2.5 border-b border-border last:border-0"
>
<div className="flex items-center gap-2 min-w-0">
<div className="size-6 bg-muted border border-border flex items-center justify-center text-[10px] font-bold shrink-0">
{user.username[0].toUpperCase()}
</div>
<div className="flex flex-col gap-0.5 min-w-0">
<span className="text-xs font-semibold truncate max-w-[120px]">
{user.username}
</span>
<div className="flex items-center gap-1">
{user.isAdmin && (
<span className="text-[9px] font-semibold px-1 py-px border border-accent-brand/40 bg-accent-brand/10 text-accent-brand">
{t("admin.adminBadge")}
</span>
)}
<span className="text-[9px] font-semibold px-1 py-px border border-border text-muted-foreground">
{authLabel}
</span>
</div>
</div>
</div>
<div className="flex items-center gap-0.5 shrink-0">
<Button
variant="ghost"
size="icon"
className="size-6 text-muted-foreground hover:text-accent-brand"
title={t("admin.manageUserData")}
onClick={() => onManageUser(user)}
>
<Settings2 className="size-3" />
</Button>
<Button
variant="ghost"
size="icon"
className="size-6 text-muted-foreground hover:text-foreground"
onClick={() => {
setEditUserTarget(user);
setEditUserOpen(true);
}}
>
<Pencil className="size-3" />
</Button>
{user.isExternal && user.passwordHash ? (
<Button
variant="ghost"
size="icon"
className="size-6 text-muted-foreground hover:text-foreground"
title={t("admin.unlinkAccount")}
onClick={() => {
setUnlinkAccountTarget({
id: user.id,
username: user.username,
});
setUnlinkAccountOpen(true);
}}
>
<Unlink className="size-3" />
</Button>
) : (
<Button
variant="ghost"
size="icon"
className="size-6 text-muted-foreground hover:text-foreground"
title={t("admin.linkAccountTitle")}
onClick={() => {
setLinkAccountTarget({
id: user.id,
username: user.username,
isExternal: user.isExternal,
});
setLinkAccountOpen(true);
}}
>
<Share2 className="size-3" />
</Button>
)}
<Button
variant="ghost"
size="icon"
className="size-6 text-muted-foreground hover:text-destructive"
disabled={user.isAdmin}
onClick={async () => {
const ok = await confirm({
title: t("admin.deleteUserConfirm", {
username: user.username,
}),
description: t("manage.cannotBeUndone"),
});
if (!ok) return;
try {
await deleteUser(user.username);
setUsers((prev) => prev.filter((u) => u.id !== user.id));
toast.success(
t("admin.deleteUserSuccess", {
username: user.username,
}),
);
} catch (e: unknown) {
toast.error(
apiErrorMessage(e, t("admin.deleteUserFailed")),
);
}
}}
>
<Trash2 className="size-3" />
</Button>
</div>
</div>
);
})}
{users.length === 0 && (
<div className="py-4 text-center text-[10px] text-muted-foreground">
{search ? t("admin.noUsersMatch") : t("admin.noUsers")}
</div>
)}
{pageCount > 1 && (
<div className="flex items-center justify-between py-2 border-t border-border">
<Button
variant="outline"
size="sm"
className="h-6 text-[10px] rounded-none"
disabled={!hasPrev}
onClick={() => onPageChange((p) => Math.max(0, p - 1))}
>
{t("common.previous")}
</Button>
<span className="text-[10px] text-muted-foreground">
{t("admin.pageOf", { page: page + 1, total: pageCount })}
</span>
<Button
variant="outline"
size="sm"
className="h-6 text-[10px] rounded-none"
disabled={!hasNext}
onClick={() => onPageChange((p) => p + 1)}
>
{t("common.next")}
</Button>
</div>
)}
</div>
</AccordionSection>
);
}
type SessionsSectionProps = {
open: boolean;
onToggle: () => void;
sessions: AdminSession[];
setSessions: Dispatch<SetStateAction<AdminSession[]>>;
loadSessions: () => void;
};
export function AdminSessionsSection({
open,
onToggle,
sessions,
setSessions,
loadSessions,
}: SessionsSectionProps) {
const { t } = useTranslation();
const confirm = useConfirm();
return (
<AccordionSection
label={t("admin.sectionSessions")}
icon={<Activity className="size-3.5" />}
open={open}
onToggle={onToggle}
>
<div className="flex flex-col pt-2">
<div className="flex items-center justify-between py-2 border-b border-border">
<span className="text-[10px] text-muted-foreground">
{t("admin.sessionsActive", { count: sessions.length })}
</span>
<Button
variant="ghost"
size="icon"
className="size-6 text-muted-foreground hover:text-foreground"
onClick={loadSessions}
>
<RefreshCw className="size-3" />
</Button>
</div>
{sessions.map((session) => (
<div
key={session.id}
className="flex items-start justify-between py-2.5 border-b border-border last:border-0 gap-2"
>
<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-xs font-semibold">
{session.username}
</span>
{session.isCurrentSession && (
<span className="text-[9px] font-semibold px-1 py-px border border-accent-brand/40 bg-accent-brand/10 text-accent-brand">
{t("admin.youBadge")}
</span>
)}
</div>
<span className="text-[10px] text-muted-foreground truncate">
{session.deviceInfo}
</span>
<span className="text-[10px] text-muted-foreground">
{t("admin.sessionActive", { time: session.lastActiveAt })}
</span>
<span className="text-[10px] text-muted-foreground">
{t("admin.sessionExpires", { time: session.expiresAt })}
</span>
</div>
<div className="flex items-center gap-0.5 shrink-0">
<Button
variant="ghost"
size="sm"
className="text-[10px] text-muted-foreground hover:text-destructive h-6 px-1.5"
onClick={async () => {
const ok = await confirm({
title: t("admin.revokeAllSessionsConfirm", {
username: session.username ?? "",
}),
confirmLabel: t("admin.revokeAll"),
});
if (!ok) return;
try {
await revokeAllUserSessions(session.userId);
setSessions((prev) =>
prev.filter((s) => s.userId !== session.userId),
);
toast.success(t("admin.revokeAllSessionsSuccess"));
} catch {
toast.error(t("admin.revokeAllSessionsFailed"));
}
}}
>
{t("admin.revokeAll")}
</Button>
<Button
variant="ghost"
size="icon"
className="size-6 text-muted-foreground hover:text-destructive"
onClick={async () => {
const ok = await confirm({
title: t("admin.revokeSessionConfirm"),
confirmLabel: t("admin.revokeSession"),
});
if (!ok) return;
try {
await revokeSession(session.id);
setSessions((prev) =>
prev.filter((s) => s.id !== session.id),
);
} catch {
toast.error(t("admin.revokeSessionFailed"));
}
}}
>
<Trash2 className="size-3" />
</Button>
</div>
</div>
))}
</div>
</AccordionSection>
);
}
type RolesSectionProps = {
open: boolean;
onToggle: () => void;
roles: Role[];
setRoles: Dispatch<SetStateAction<Role[]>>;
showCreateRole: boolean;
setShowCreateRole: Dispatch<SetStateAction<boolean>>;
newRoleName: string;
setNewRoleName: Dispatch<SetStateAction<string>>;
newRoleDisplayName: string;
setNewRoleDisplayName: Dispatch<SetStateAction<string>>;
newRoleDescription: string;
setNewRoleDescription: Dispatch<SetStateAction<string>>;
handleCreateRole: () => void;
createRoleLoading: boolean;
};
export function AdminRolesSection({
open,
onToggle,
roles,
setRoles,
showCreateRole,
setShowCreateRole,
newRoleName,
setNewRoleName,
newRoleDisplayName,
setNewRoleDisplayName,
newRoleDescription,
setNewRoleDescription,
handleCreateRole,
createRoleLoading,
}: RolesSectionProps) {
const { t } = useTranslation();
const confirm = useConfirm();
const [catalog, setCatalog] = useState<PermissionCatalogEntry[]>([]);
const [editingRoleId, setEditingRoleId] = useState<number | null>(null);
const [editingPermissions, setEditingPermissions] = useState<Set<string>>(
new Set(),
);
const [savingPermissions, setSavingPermissions] = useState(false);
function rolePermissions(role: Role): string[] {
if (Array.isArray(role.permissions)) return role.permissions;
if (typeof role.permissions === "string") {
try {
return JSON.parse(role.permissions) as string[];
} catch {
return [];
}
}
return [];
}
async function openPermissionsEditor(role: Role) {
if (editingRoleId === role.id) {
setEditingRoleId(null);
return;
}
try {
if (catalog.length === 0) {
const res = await getPermissionsCatalog();
setCatalog(res.catalog ?? []);
}
setEditingPermissions(new Set(rolePermissions(role)));
setEditingRoleId(role.id);
} catch {
toast.error(t("admin.rolePermissions.loadError"));
}
}
function togglePermission(permission: string) {
setEditingPermissions((prev) => {
const next = new Set(prev);
if (next.has(permission)) next.delete(permission);
else next.add(permission);
return next;
});
}
async function savePermissions(role: Role) {
setSavingPermissions(true);
try {
const permissions = [...editingPermissions];
await updateRole(role.id, { permissions });
setRoles((prev) =>
prev.map((entry) =>
entry.id === role.id ? { ...entry, permissions } : entry,
),
);
setEditingRoleId(null);
toast.success(t("admin.rolePermissions.saved"));
} catch {
toast.error(t("admin.rolePermissions.saveError"));
} finally {
setSavingPermissions(false);
}
}
return (
<AccordionSection
label={t("admin.sectionRoles")}
icon={<KeyRound className="size-3.5" />}
open={open}
onToggle={onToggle}
>
<div className="flex flex-col pt-2">
<div className="flex items-center justify-between py-2 border-b border-border">
<span className="text-[10px] text-muted-foreground">
{t("admin.rolesCount", { count: roles.length })}
</span>
<Button
variant="outline"
size="sm"
className="h-6 text-[10px] border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
onClick={() => setShowCreateRole((o) => !o)}
>
<Plus className="size-3" />
{t("admin.createRole")}
</Button>
</div>
{showCreateRole && (
<div className="flex flex-col gap-2.5 py-3 border-b border-border">
<span className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
{t("admin.newRole")}
</span>
<div className="flex flex-col gap-1">
<label className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
{t("admin.roleName")}{" "}
<span className="text-accent-brand">*</span>
</label>
<Input
placeholder="developer"
value={newRoleName}
onChange={(e) => setNewRoleName(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.roleDisplayName")}{" "}
<span className="text-accent-brand">*</span>
</label>
<Input
placeholder="Developer"
value={newRoleDisplayName}
onChange={(e) => setNewRoleDisplayName(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.roleDescription")}
</label>
<textarea
rows={2}
placeholder={t("common.optional")}
value={newRoleDescription}
onChange={(e) => setNewRoleDescription(e.target.value)}
className="w-full px-2 py-1.5 text-xs bg-background border border-border text-foreground placeholder:text-muted-foreground resize-none outline-none focus:ring-1 focus:ring-ring"
/>
</div>
<div className="flex justify-end gap-2">
<Button
variant="ghost"
size="sm"
className="text-xs"
onClick={() => {
setShowCreateRole(false);
setNewRoleName("");
setNewRoleDisplayName("");
setNewRoleDescription("");
}}
>
{t("common.cancel")}
</Button>
<Button
variant="outline"
size="sm"
className="text-xs border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
onClick={handleCreateRole}
disabled={createRoleLoading}
>
{createRoleLoading
? t("admin.creating")
: t("admin.createRole")}
</Button>
</div>
</div>
)}
{roles.map((role) => {
const permissionCount = rolePermissions(role).length;
return (
<div
key={role.id}
className="flex flex-col py-2.5 border-b border-border last:border-0"
>
<div className="flex items-center justify-between">
<div className="flex flex-col gap-0.5 min-w-0">
<div className="flex items-center gap-1.5 flex-wrap">
<span className="text-xs font-semibold truncate">
{roleLabel(t, role.displayName)}
</span>
{role.isSystem ? (
<span className="text-[9px] font-semibold px-1 py-px border border-border text-muted-foreground">
{t("admin.systemBadge")}
</span>
) : (
<span className="text-[9px] font-semibold px-1 py-px border border-accent-brand/40 bg-accent-brand/10 text-accent-brand">
{t("admin.customBadge")}
</span>
)}
{permissionCount > 0 && (
<span className="text-[9px] px-1 py-px border border-border/60 text-muted-foreground">
{t("admin.rolePermissions.count", {
count: permissionCount,
})}
</span>
)}
</div>
<span className="text-[10px] font-mono text-muted-foreground">
{role.name}
</span>
</div>
{!role.isSystem && (
<div className="flex items-center gap-0.5 shrink-0">
<Button
variant="ghost"
size="icon"
className={`size-6 ${editingRoleId === role.id ? "text-accent-brand" : "text-muted-foreground hover:text-foreground"}`}
title={t("admin.rolePermissions.editAction")}
onClick={() => openPermissionsEditor(role)}
>
<KeyRound className="size-3" />
</Button>
<Button
variant="ghost"
size="icon"
className="size-6 text-muted-foreground hover:text-destructive"
onClick={async () => {
const ok = await confirm({
title: t("admin.deleteRoleConfirm", {
name: role.displayName,
}),
});
if (!ok) return;
await deleteRole(role.id);
setRoles((prev) =>
prev.filter((r) => r.id !== role.id),
);
toast.success(
t("admin.deleteRoleSuccess", {
name: role.displayName,
}),
);
}}
>
<Trash2 className="size-3" />
</Button>
</div>
)}
</div>
{editingRoleId === role.id && (
<div className="flex flex-col gap-2.5 mt-2.5 p-2.5 border border-border bg-muted/20">
{catalog.map((entry) => {
const wildcard = `${entry.group}.*`;
const wildcardOn = editingPermissions.has(wildcard);
// A plugin group with enabled === false is still granted,
// it just cannot be reached right now.
const offline = entry.enabled === false;
const titleFor = (permission: string) => {
const item = entry.items?.find(
(candidate) => candidate.permission === permission,
);
if (!item) return null;
return entry.pluginId
? t(pluginKey(entry.pluginId, item.titleKey))
: t(item.titleKey);
};
return (
<div
key={entry.group}
className={`flex flex-col gap-1 ${offline ? "opacity-50" : ""}`}
>
<button
onClick={() => togglePermission(wildcard)}
className="flex items-center gap-1.5 text-[10px] font-bold uppercase tracking-widest text-left"
>
<span
className={`size-3 border flex items-center justify-center shrink-0 transition-colors ${wildcardOn ? "border-accent-brand bg-accent-brand" : "border-border bg-background"}`}
>
{wildcardOn && (
<Check className="size-2 text-background" />
)}
</span>
{entry.pluginId && (
<PluginIcon
name={entry.icon}
className="size-3 shrink-0 text-muted-foreground"
/>
)}
<span
className={
wildcardOn
? "text-accent-brand"
: "text-muted-foreground"
}
>
{entry.label ??
(entry.labelKey
? t(entry.labelKey)
: entry.group)}
</span>
{offline && (
<span className="px-1 border border-border text-[9px] font-normal normal-case tracking-normal text-muted-foreground">
{t("admin.rolePermissions.pluginDisabled")}
</span>
)}
</button>
<div className="flex flex-col gap-0.5 pl-4">
{entry.permissions.map((permission) => {
const checked =
wildcardOn || editingPermissions.has(permission);
const title = titleFor(permission);
return (
<button
key={permission}
disabled={wildcardOn}
onClick={() => togglePermission(permission)}
className="flex items-center gap-1.5 text-[10px] text-left disabled:opacity-60"
>
<span
className={`size-3 border flex items-center justify-center shrink-0 transition-colors ${checked ? "border-accent-brand bg-accent-brand" : "border-border bg-background"}`}
>
{checked && (
<Check className="size-2 text-background" />
)}
</span>
{title && (
<span className="text-foreground">
{title}
</span>
)}
<span className="font-mono text-muted-foreground">
{permission}
</span>
</button>
);
})}
</div>
</div>
);
})}
<div className="flex justify-end gap-2 pt-1">
<Button
variant="ghost"
size="sm"
className="h-6 text-[10px]"
onClick={() => setEditingRoleId(null)}
>
{t("common.cancel")}
</Button>
<Button
variant="outline"
size="sm"
className="h-6 text-[10px] border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
disabled={savingPermissions}
onClick={() => savePermissions(role)}
>
{savingPermissions
? t("admin.rolePermissions.saving")
: t("admin.rolePermissions.save")}
</Button>
</div>
</div>
)}
</div>
);
})}
</div>
</AccordionSection>
);
}
@@ -1,77 +0,0 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { SettingRow } from "@/components/section-card";
import { useConfirm } from "@/components/surface/surface-scope";
import { getDeveloperMode, setDeveloperMode } from "@/api/plugins-api";
import { AdminToggle } from "./AdminSettingsShared";
/** Lets admins install plugins from a file. Asks before turning it on. */
export function AdminPluginDeveloperMode() {
const { t } = useTranslation();
const confirm = useConfirm();
const [enabled, setEnabled] = useState(false);
const [signedOnly, setSignedOnly] = useState(false);
const [loaded, setLoaded] = useState(false);
const [saving, setSaving] = useState(false);
useEffect(() => {
let cancelled = false;
getDeveloperMode()
.then((state) => {
if (cancelled) return;
setEnabled(state.enabled);
setSignedOnly(state.signedOnly);
setLoaded(true);
})
.catch(() => {});
return () => {
cancelled = true;
};
}, []);
const toggle = async () => {
const next = !enabled;
if (next) {
const ok = await confirm({
title: t("plugins.manager.developer.enableTitle"),
description: t("plugins.manager.developer.enableBody"),
confirmLabel: t("plugins.manager.developer.enable"),
});
if (!ok) return;
}
setSaving(true);
try {
await setDeveloperMode(next);
setEnabled(next);
} catch (error) {
toast.error(
t("plugins.manager.errors.developerMode", {
error: error instanceof Error ? error.message : String(error),
}),
);
} finally {
setSaving(false);
}
};
return (
<>
<SettingRow
label={t("plugins.manager.developer.title")}
description={t("plugins.manager.developer.hint")}
>
<AdminToggle
on={enabled}
onToggle={() => void toggle()}
disabled={!loaded || saving}
/>
</SettingRow>
{enabled && signedOnly && (
<p className="text-[10px] text-destructive">
{t("plugins.manager.developer.signedOnly")}
</p>
)}
</>
);
}
@@ -1,112 +0,0 @@
import { useCallback, useEffect, useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { ShieldOff } from "lucide-react";
import { Button } from "@/components/button";
import { pluginKey } from "@/lib/plugin-i18n";
import {
getUserSecondFactors,
resetUserSecondFactors,
type UserSecondFactorSummary,
} from "@/api/auth-methods-api";
/**
* Every second factor a user enrolled in, including ones whose plugin is off
* (which is what locks a user out), with a reset for all of them.
*/
export function AdminSecondFactorsSection({
userId,
username,
heading,
requestConfirm,
onReset,
}: {
userId: string;
username: string;
heading: ReactNode;
requestConfirm: (message: string, onConfirm: () => void) => void;
onReset: () => void;
}) {
const { t } = useTranslation();
const [factors, setFactors] = useState<UserSecondFactorSummary[]>([]);
const [working, setWorking] = useState(false);
const load = useCallback(() => {
getUserSecondFactors(userId)
.then(setFactors)
.catch(() => setFactors([]));
}, [userId]);
useEffect(() => {
load();
}, [load]);
async function reset() {
setWorking(true);
try {
await resetUserSecondFactors(userId);
toast.success(t("admin.secondFactorsResetSuccess"));
onReset();
load();
} catch {
toast.error(t("admin.secondFactorsResetFailed"));
} finally {
setWorking(false);
}
}
return (
<div className="flex flex-col gap-2">
{heading}
{factors.length === 0 ? (
<span className="text-xs text-muted-foreground">
{t("admin.secondFactorsNone")}
</span>
) : (
<div className="flex flex-col gap-1">
{factors.map((factor) => (
<div
key={`${factor.pluginId}:${factor.factorId}`}
className="flex items-center justify-between text-xs"
>
<span>
{factor.labelKey
? t(
factor.pluginId === "core"
? factor.labelKey
: pluginKey(factor.pluginId, factor.labelKey),
{ defaultValue: factor.factorId },
)
: factor.factorId}
</span>
{!factor.available && (
<span className="text-[10px] text-destructive">
{t("admin.secondFactorUnavailable")}
</span>
)}
</div>
))}
</div>
)}
{factors.length > 0 && (
<Button
variant="outline"
size="sm"
className="h-7 w-fit text-[10px] border-destructive/40 text-destructive hover:bg-destructive/10 hover:text-destructive"
disabled={working}
onClick={() =>
requestConfirm(
t("admin.resetSecondFactorsConfirm", { username }),
() => {
void reset();
},
)
}
>
<ShieldOff className="size-3" />
{t("admin.resetSecondFactors")}
</Button>
)}
</div>
);
}
-992
View File
@@ -1,992 +0,0 @@
import { useConfirm } from "@/components/surface/surface-scope";
import { useState, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { useBranding } from "@/contexts/BrandingContext";
import {
getNotificationPrivateEndpoints,
setNotificationPrivateEndpoints as setNotificationPrivateEndpointsApi,
} from "@/api/private-endpoints-api";
import {
getUserList,
getSessions,
getRoles,
getApiKeys,
createApiKey,
deleteUser,
revokeAllUserSessions,
createRole,
adminCreateUser,
makeUserAdmin,
removeAdminStatus,
getRegistrationAllowed,
updateRegistrationAllowed,
getPasswordLoginAllowed,
updatePasswordLoginAllowed,
getPasswordResetAllowed,
updatePasswordResetAllowed,
getSessionTimeout,
updateSessionTimeout,
getStatusCheckSettings,
updateStatusCheckSettings,
getLogLevel,
updateLogLevel,
getExternalAutoProvision,
updateExternalAutoProvision,
getSecondFactorAfterExternalLogin,
updateSecondFactorAfterExternalLogin,
isElectron,
getUserRoles,
} from "@/main-axios";
import {
getBranding,
updateBranding,
type BrandingSettings,
} from "@/api/settings-api";
import {
getTlsStatus,
uploadTlsCertificate,
type TlsStatus,
} from "@/api/tls-api";
import {
type ApiKey,
type CreatedApiKey,
type Role,
type UserRole,
} from "@/main-axios";
import { type AdminSection, type Host } from "@/types/ui-types";
import {
FeatureSettingsSection,
featureSectionId,
useFeatureSettings,
type FeatureSectionId,
} from "@/settings/FeatureSettingsSections";
import {
AdminRolesSection,
AdminSessionsSection,
AdminUsersSection,
type AdminSession,
type AdminUser,
} from "./AdminManagementSections";
import { toast } from "sonner";
import { getDatabaseTransferUrl } from "@/lib/database-transfer-url";
import {
AdminDatabaseSection,
AdminGeneralSettingsSection,
AdminHostDefaultsSection,
AdminSSLSection,
} from "./AdminSettingsSections";
import { AdminApiKeysSection } from "./AdminApiKeysSection";
import { AdminAuditLogSection } from "./AdminAuditLogSection";
import {
AdminCreateUserDialog,
AdminEditUserDialog,
AdminLinkAccountDialog,
AdminUnlinkAccountDialog,
} from "./AdminUserDialogs";
import { AdminUserManagePanel } from "./AdminUserManagePanel";
import { AdminBrandingSection } from "./AdminBrandingSection";
type ApiErrorLike = {
response?: {
data?: {
error?: string;
};
};
};
function apiErrorMessage(error: unknown, fallback: string) {
return (error as ApiErrorLike).response?.data?.error || fallback;
}
const USERS_PAGE_SIZE = 25;
export function AdminSettingsPanel({
onEditingChange,
onOpenHostTab,
section,
}: {
onEditingChange?: (editing: boolean) => void;
onOpenHostTab?: (host: Host) => void;
/** Shows just this section, flat, as a Settings page. */
section?: AdminSection;
} = {}) {
const { t } = useTranslation();
const confirm = useConfirm();
const [openSections, setOpenSections] = useState<
Set<AdminSection | FeatureSectionId>
>(() => new Set(["general"]));
const featureSettings = useFeatureSettings("admin");
const [manageUser, setManageUser] = useState<AdminUser | null>(null);
const [allowRegistration, setAllowRegistration] = useState(true);
const [allowPasswordLogin, setAllowPasswordLogin] = useState(true);
const [passwordLoginForced, setPasswordLoginForced] = useState(false);
const [allowPasswordReset, setAllowPasswordReset] = useState(true);
const [sessionTimeout, setSessionTimeout] = useState("24");
const [statusInterval, setStatusInterval] = useState("30");
const [logLevel, setLogLevel] = useState("info");
const [notificationPrivateEndpoints, setNotificationPrivateEndpoints] =
useState<string[]>([]);
const [brandingSettings, setBrandingSettings] =
useState<BrandingSettings | null>(null);
const [brandingSaving, setBrandingSaving] = useState(false);
const { applyBranding } = useBranding();
// External login state
const [externalAutoProvision, setExternalAutoProvision] = useState(false);
const [secondFactorAfterExternalLogin, setSecondFactorAfterExternalLogin] =
useState(false);
// Create user dialog
const [createUserOpen, setCreateUserOpen] = useState(false);
const [newUsername, setNewUsername] = useState("");
const [newPassword, setNewPassword] = useState("");
const [showNewPassword, setShowNewPassword] = useState(false);
const [createUserLoading, setCreateUserLoading] = useState(false);
// Edit user dialog
const [editUserOpen, setEditUserOpen] = useState(false);
const [editUserTarget, setEditUserTarget] = useState<AdminUser | null>(null);
const [editUserLoading, setEditUserLoading] = useState(false);
const [editUserRoles, setEditUserRoles] = useState<UserRole[]>([]);
const [editUserRolesLoading, setEditUserRolesLoading] = useState(false);
// Link account dialog
const [linkAccountOpen, setLinkAccountOpen] = useState(false);
const [linkAccountTarget, setLinkAccountTarget] = useState<{
id: string;
username: string;
isExternal: boolean;
} | null>(null);
// Unlink account dialog
const [unlinkAccountOpen, setUnlinkAccountOpen] = useState(false);
const [unlinkAccountTarget, setUnlinkAccountTarget] = useState<{
id: string;
username: string;
} | null>(null);
// Create role form
const [showCreateRole, setShowCreateRole] = useState(false);
const [newRoleName, setNewRoleName] = useState("");
const [newRoleDisplayName, setNewRoleDisplayName] = useState("");
const [newRoleDescription, setNewRoleDescription] = useState("");
const [createRoleLoading, setCreateRoleLoading] = useState(false);
// Create API key form
const [showCreateKey, setShowCreateKey] = useState(false);
const [newKeyName, setNewKeyName] = useState("");
const [newKeyUserId, setNewKeyUserId] = useState("");
const [newKeyExpiry, setNewKeyExpiry] = useState("");
const [newKeyLoading, setNewKeyLoading] = useState(false);
const [createdKeyToken, setCreatedKeyToken] = useState<string | null>(null);
// Import state
const [importFile, setImportFile] = useState<File | null>(null);
const [exportLoading, setExportLoading] = useState(false);
const [importLoading, setImportLoading] = useState(false);
const [tlsStatus, setTlsStatus] = useState<TlsStatus | null>(null);
const [manualCertDraft, setManualCertDraft] = useState("");
const [manualKeyDraft, setManualKeyDraft] = useState("");
const [manualUploading, setManualUploading] = useState(false);
const [users, setUsers] = useState<AdminUser[]>([]);
const [userSearch, setUserSearch] = useState("");
const [userPage, setUserPage] = useState(0);
const [userTotal, setUserTotal] = useState(0);
const [sessions, setSessions] = useState<AdminSession[]>([]);
const [roles, setRoles] = useState<Role[]>([]);
const [apiKeys, setApiKeys] = useState<ApiKey[]>([]);
useEffect(() => {
loadSessions();
loadRoles();
loadApiKeys();
loadGeneralSettings();
}, []);
// Debounced so typing in the search box does not fire a request per keystroke.
useEffect(() => {
const timer = setTimeout(loadUsers, userSearch ? 250 : 0);
return () => clearTimeout(timer);
}, [userSearch, userPage]);
// A new search term starts again from the first page.
useEffect(() => {
setUserPage(0);
}, [userSearch]);
// The panel stays mounted across Settings pages, so an open user view
// would otherwise replace whichever section is picked next.
useEffect(() => {
setManageUser(null);
}, [section]);
useEffect(() => {
onEditingChange?.(manageUser !== null);
return () => onEditingChange?.(false);
}, [manageUser, onEditingChange]);
useEffect(() => {
if (editUserOpen && editUserTarget) {
setEditUserRoles([]);
setEditUserRolesLoading(true);
getUserRoles(editUserTarget.id)
.then(({ roles: r }) => setEditUserRoles(r))
.catch(() => {})
.finally(() => setEditUserRolesLoading(false));
}
}, [editUserOpen, editUserTarget]);
function loadUsers() {
// Paged server-side so an install with thousands of accounts does not
// ship the whole directory to render one screen of it.
getUserList({
search: userSearch.trim() || undefined,
limit: USERS_PAGE_SIZE,
offset: userPage * USERS_PAGE_SIZE,
})
.then(({ users: u, total }) => {
setUsers(
u.map((user) => ({
id: user.userId,
username: user.username,
isAdmin: user.is_admin,
isExternal: user.is_external ?? false,
passwordHash: user.password_hash,
dataUnlocked: user.data_unlocked,
secondFactorEnabled: user.second_factor_enabled,
})),
);
setUserTotal(total ?? u.length);
})
.catch(() => {});
}
function loadSessions() {
getSessions()
.then(({ sessions: s }) => setSessions(s))
.catch(() => {});
}
function loadRoles() {
getRoles()
.then(({ roles: r }) => setRoles(r))
.catch(() => {});
}
function loadApiKeys() {
getApiKeys()
.then(({ apiKeys: k }) => setApiKeys(k))
.catch(() => {});
}
async function loadGeneralSettings() {
try {
const [
reg,
pwLogin,
pwReset,
timeout,
monitoring,
level,
oidcProv,
secondFactorExternal,
notificationEndpoints,
branding,
] = await Promise.allSettled([
getRegistrationAllowed(),
getPasswordLoginAllowed(),
getPasswordResetAllowed(),
getSessionTimeout(),
getStatusCheckSettings(),
getLogLevel(),
getExternalAutoProvision(),
getSecondFactorAfterExternalLogin(),
getNotificationPrivateEndpoints(),
getBranding(),
]);
if (reg.status === "fulfilled") setAllowRegistration(reg.value.allowed);
if (pwLogin.status === "fulfilled") {
// When forced on, the setting itself is off; show that and warn.
setPasswordLoginForced(!!pwLogin.value.forced);
setAllowPasswordLogin(
pwLogin.value.forced ? false : pwLogin.value.allowed,
);
}
if (oidcProv.status === "fulfilled")
setExternalAutoProvision(oidcProv.value.enabled);
if (secondFactorExternal.status === "fulfilled")
setSecondFactorAfterExternalLogin(secondFactorExternal.value.enabled);
if (pwReset.status === "fulfilled") setAllowPasswordReset(pwReset.value);
if (timeout.status === "fulfilled")
setSessionTimeout(String(timeout.value.timeoutHours));
if (monitoring.status === "fulfilled") {
setStatusInterval(String(monitoring.value.statusCheckInterval));
}
if (level.status === "fulfilled") setLogLevel(level.value.level);
if (notificationEndpoints.status === "fulfilled") {
setNotificationPrivateEndpoints(notificationEndpoints.value);
}
if (branding.status === "fulfilled") {
setBrandingSettings(branding.value);
}
} catch {
// non-fatal
}
getTlsStatus()
.then((s) => setTlsStatus(s))
.catch(() => {});
}
function toggle(id: AdminSection | FeatureSectionId) {
setOpenSections((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
}
async function handleToggleRegistration() {
const newVal = !allowRegistration;
setAllowRegistration(newVal);
try {
await updateRegistrationAllowed(newVal);
} catch {
setAllowRegistration(!newVal);
toast.error(t("admin.updateRegistrationFailed"));
}
}
async function handleTogglePasswordLogin() {
const newVal = !allowPasswordLogin;
setAllowPasswordLogin(newVal);
try {
await updatePasswordLoginAllowed(newVal);
} catch (e) {
setAllowPasswordLogin(!newVal);
const msg = (e as ApiErrorLike).response?.data?.error;
toast.error(msg || t("admin.updatePasswordLoginFailed"));
}
}
async function handleToggleExternalAutoProvision() {
const newVal = !externalAutoProvision;
setExternalAutoProvision(newVal);
try {
await updateExternalAutoProvision(newVal);
} catch {
setExternalAutoProvision(!newVal);
toast.error(t("admin.updateExternalAutoProvisionFailed"));
}
}
async function handleToggleSecondFactorAfterExternalLogin() {
const newVal = !secondFactorAfterExternalLogin;
setSecondFactorAfterExternalLogin(newVal);
try {
await updateSecondFactorAfterExternalLogin(newVal);
} catch {
setSecondFactorAfterExternalLogin(!newVal);
toast.error(t("admin.updateSecondFactorAfterExternalLoginFailed"));
}
}
async function handleTogglePasswordReset() {
const newVal = !allowPasswordReset;
setAllowPasswordReset(newVal);
try {
await updatePasswordResetAllowed(newVal);
} catch {
setAllowPasswordReset(!newVal);
toast.error(t("admin.updatePasswordResetFailed"));
}
}
async function handleSaveNotificationPrivateEndpoints(hosts: string[]) {
const previous = notificationPrivateEndpoints;
setNotificationPrivateEndpoints(hosts);
try {
setNotificationPrivateEndpoints(
await setNotificationPrivateEndpointsApi(hosts),
);
} catch {
setNotificationPrivateEndpoints(previous);
toast.error(t("admin.updateNotificationEndpointsFailed"));
}
}
async function handleSaveBranding() {
if (!brandingSettings) return;
setBrandingSaving(true);
try {
const saved = await updateBranding({
appName: brandingSettings.appName,
tagline: brandingSettings.tagline,
logo: brandingSettings.logo,
});
setBrandingSettings(saved);
applyBranding(saved);
toast.success(t("admin.brandingSaved"));
} catch (e) {
toast.error(apiErrorMessage(e, t("admin.brandingSaveFailed")));
} finally {
setBrandingSaving(false);
}
}
async function handleResetBrandingLogo() {
if (!brandingSettings) return;
setBrandingSaving(true);
try {
const saved = await updateBranding({ logo: null });
setBrandingSettings(saved);
applyBranding(saved);
toast.success(t("admin.brandingSaved"));
} catch (e) {
toast.error(apiErrorMessage(e, t("admin.brandingSaveFailed")));
} finally {
setBrandingSaving(false);
}
}
async function handleSaveSessionTimeout() {
const hours = parseInt(sessionTimeout, 10);
if (isNaN(hours) || hours < 1 || hours > 720) {
toast.error(t("admin.sessionTimeoutRange2"));
return;
}
try {
await updateSessionTimeout(hours);
toast.success(t("admin.sessionTimeoutSaved"));
} catch {
toast.error(t("admin.sessionTimeoutSaveFailed"));
}
}
async function handleSaveMonitoring() {
const status = parseInt(statusInterval, 10);
if (isNaN(status) || status < 5 || status > 3600) {
toast.error(t("admin.monitoringIntervalInvalid"));
return;
}
try {
await updateStatusCheckSettings({ statusCheckInterval: status });
toast.success(t("admin.monitoringSaved"));
} catch {
toast.error(t("admin.monitoringSaveFailed"));
}
}
async function handleSaveLogLevel(level: string) {
setLogLevel(level);
try {
await updateLogLevel(level);
} catch {
toast.error(t("admin.logLevelUpdateFailed"));
}
}
async function handleManualSslUpload() {
if (!manualCertDraft.trim() || !manualKeyDraft.trim()) {
toast.error(t("admin.sslManualRequiresFields"));
return;
}
setManualUploading(true);
try {
const result = await uploadTlsCertificate({
certificate: manualCertDraft,
privateKey: manualKeyDraft,
});
setTlsStatus(result);
setManualCertDraft("");
setManualKeyDraft("");
toast.success(t("admin.sslManualUploadSuccess"));
if (result.reloadMessage) {
toast.info(result.reloadMessage);
}
} catch (e) {
toast.error(apiErrorMessage(e, t("admin.sslManualUploadFailed")));
} finally {
setManualUploading(false);
}
}
async function handleCreateUser() {
if (!newUsername.trim() || !newPassword.trim()) {
toast.error(t("admin.createUserRequired"));
return;
}
if (newPassword.length < 6) {
toast.error(t("admin.createUserPasswordTooShort"));
return;
}
setCreateUserLoading(true);
try {
await adminCreateUser(newUsername.trim(), newPassword);
toast.success(t("admin.createUserSuccess", { username: newUsername }));
setCreateUserOpen(false);
setNewUsername("");
setNewPassword("");
loadUsers();
} catch (e: unknown) {
toast.error(apiErrorMessage(e, t("admin.createUserFailed")));
} finally {
setCreateUserLoading(false);
}
}
async function handleToggleAdmin(user: AdminUser) {
setEditUserLoading(true);
try {
if (user.isAdmin) {
await removeAdminStatus(user.id);
setEditUserTarget((prev) =>
prev ? { ...prev, isAdmin: false } : prev,
);
setUsers((prev) =>
prev.map((u) => (u.id === user.id ? { ...u, isAdmin: false } : u)),
);
} else {
await makeUserAdmin(user.id);
setEditUserTarget((prev) => (prev ? { ...prev, isAdmin: true } : prev));
setUsers((prev) =>
prev.map((u) => (u.id === user.id ? { ...u, isAdmin: true } : u)),
);
}
} catch {
toast.error(t("admin.updateAdminStatusFailed"));
} finally {
setEditUserLoading(false);
}
}
async function handleRevokeUserSessions(userId: string) {
try {
await revokeAllUserSessions(userId);
toast.success(t("admin.allSessionsRevoked"));
loadSessions();
} catch {
toast.error(t("admin.revokeSessionsFailed"));
}
}
async function handleDeleteEditUser() {
if (!editUserTarget) return;
const ok = await confirm({
title: t("admin.deleteUserConfirm", {
username: editUserTarget.username,
}),
description: t("manage.cannotBeUndone"),
});
if (!ok) return;
setEditUserLoading(true);
try {
await deleteUser(editUserTarget.username);
setUsers((prev) => prev.filter((u) => u.id !== editUserTarget.id));
setEditUserOpen(false);
setEditUserTarget(null);
toast.success(
t("admin.deleteUserSuccess", { username: editUserTarget.username }),
);
} catch (e: unknown) {
toast.error(apiErrorMessage(e, t("admin.deleteUserFailed")));
} finally {
setEditUserLoading(false);
}
}
async function handleCreateRole() {
if (!newRoleName.trim() || !newRoleDisplayName.trim()) {
toast.error(t("admin.createRoleRequired"));
return;
}
setCreateRoleLoading(true);
const displayName = newRoleDisplayName.trim();
try {
await createRole({
name: newRoleName.trim(),
displayName,
description: newRoleDescription.trim() || null,
});
setShowCreateRole(false);
setNewRoleName("");
setNewRoleDisplayName("");
setNewRoleDescription("");
toast.success(t("admin.createRoleSuccess", { name: displayName }));
loadRoles();
} catch (e: unknown) {
toast.error(apiErrorMessage(e, t("admin.createRoleFailed")));
} finally {
setCreateRoleLoading(false);
}
}
async function handleCreateApiKey() {
if (!newKeyName.trim()) {
toast.error(t("admin.apiKeyNameRequired"));
return;
}
if (!newKeyUserId.trim()) {
toast.error(t("admin.apiKeyUserRequired"));
return;
}
setNewKeyLoading(true);
try {
const created: CreatedApiKey = await createApiKey(
newKeyName.trim(),
newKeyUserId.trim(),
newKeyExpiry ? new Date(newKeyExpiry).toISOString() : undefined,
);
setApiKeys((prev) => [{ ...created, isActive: true }, ...prev]);
setCreatedKeyToken(created.token);
setNewKeyName("");
setNewKeyUserId("");
setNewKeyExpiry("");
toast.success(t("admin.apiKeyCreatedSuccess", { name: created.name }));
} catch (e: unknown) {
toast.error(apiErrorMessage(e, t("admin.apiKeyCreateFailed")));
} finally {
setNewKeyLoading(false);
}
}
async function handleExportDatabase() {
setExportLoading(true);
try {
const apiUrl = getDatabaseTransferUrl("export", {
electron: isElectron(),
configuredServerUrl: null,
location: window.location,
});
const response = await fetch(apiUrl, {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({}),
});
if (response.ok) {
const blob = await response.blob();
const contentDisposition = response.headers.get("content-disposition");
const filename =
contentDisposition?.match(/filename="([^"]+)"/)?.[1] ||
"termix-export.sqlite";
const url = window.URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
toast.success(t("admin.exportSuccess"));
} else {
const err = await response.json().catch(() => ({}));
toast.error(err.error || t("admin.exportFailed"));
}
} catch {
toast.error(t("admin.exportFailed"));
} finally {
setExportLoading(false);
}
}
async function handleImportDatabase() {
if (!importFile) {
toast.error(t("admin.importSelectFile"));
return;
}
setImportLoading(true);
try {
const apiUrl = getDatabaseTransferUrl("import", {
electron: isElectron(),
configuredServerUrl: null,
location: window.location,
});
const formData = new FormData();
formData.append("file", importFile);
const response = await fetch(apiUrl, {
method: "POST",
credentials: "include",
body: formData,
});
if (response.ok) {
const result = await response.json();
if (result.success) {
const s = result.summary;
const total =
(s.sshHostsImported || 0) +
(s.sshCredentialsImported || 0) +
(s.pluginItemsImported || 0) +
(s.settingsImported || 0);
toast.success(
t("admin.importCompleted", { total, skipped: s.skippedItems || 0 }),
);
setImportFile(null);
setTimeout(() => window.location.reload(), 1500);
} else {
toast.error(
t("admin.importFailed", {
error: result.summary?.errors?.join(", ") || "Unknown error",
}),
);
}
} else {
const err = await response.json().catch(() => ({}));
toast.error(err.error || t("admin.importError"));
}
} catch {
toast.error(t("admin.importError"));
} finally {
setImportLoading(false);
}
}
const isOpen = (id: AdminSection | FeatureSectionId) =>
section ? section === id : openSections.has(id);
if (manageUser) {
return (
<AdminUserManagePanel
key={manageUser.id}
user={manageUser}
roles={roles}
onBack={() => setManageUser(null)}
onOpenHostTab={onOpenHostTab}
onUserDeleted={() => {
setUsers((prev) => prev.filter((u) => u.id !== manageUser.id));
setManageUser(null);
}}
onSecondFactorsReset={() => {
setUsers((prev) =>
prev.map((u) =>
u.id === manageUser.id ? { ...u, secondFactorEnabled: false } : u,
),
);
}}
/>
);
}
return (
<div
className={
section
? "flex w-full flex-col gap-2"
: "mx-auto flex w-full max-w-5xl flex-col gap-2 p-3 flex-1 min-h-0 overflow-y-auto"
}
>
<AdminGeneralSettingsSection
open={isOpen("general")}
onToggle={() => toggle("general")}
notificationPrivateEndpoints={notificationPrivateEndpoints}
onSaveNotificationPrivateEndpoints={
handleSaveNotificationPrivateEndpoints
}
allowRegistration={allowRegistration}
handleToggleRegistration={handleToggleRegistration}
allowPasswordLogin={allowPasswordLogin}
passwordLoginForced={passwordLoginForced && !allowPasswordLogin}
handleTogglePasswordLogin={handleTogglePasswordLogin}
externalAutoProvision={externalAutoProvision}
handleToggleExternalAutoProvision={handleToggleExternalAutoProvision}
secondFactorAfterExternalLogin={secondFactorAfterExternalLogin}
handleToggleSecondFactorAfterExternalLogin={
handleToggleSecondFactorAfterExternalLogin
}
allowPasswordReset={allowPasswordReset}
handleTogglePasswordReset={handleTogglePasswordReset}
sessionTimeout={sessionTimeout}
setSessionTimeout={setSessionTimeout}
handleSaveSessionTimeout={handleSaveSessionTimeout}
statusInterval={statusInterval}
setStatusInterval={setStatusInterval}
handleSaveMonitoring={handleSaveMonitoring}
logLevel={logLevel}
handleSaveLogLevel={handleSaveLogLevel}
/>
<AdminUsersSection
open={isOpen("users")}
onToggle={() => toggle("users")}
users={users}
setUsers={setUsers}
loadUsers={loadUsers}
setCreateUserOpen={setCreateUserOpen}
setEditUserTarget={setEditUserTarget}
setEditUserOpen={setEditUserOpen}
setLinkAccountTarget={setLinkAccountTarget}
setLinkAccountOpen={setLinkAccountOpen}
setUnlinkAccountTarget={setUnlinkAccountTarget}
setUnlinkAccountOpen={setUnlinkAccountOpen}
onManageUser={setManageUser}
search={userSearch}
onSearchChange={setUserSearch}
page={userPage}
pageSize={USERS_PAGE_SIZE}
total={userTotal}
onPageChange={setUserPage}
/>
<AdminSessionsSection
open={isOpen("sessions")}
onToggle={() => toggle("sessions")}
sessions={sessions}
setSessions={setSessions}
loadSessions={loadSessions}
/>
<AdminRolesSection
open={isOpen("roles")}
onToggle={() => toggle("roles")}
roles={roles}
setRoles={setRoles}
showCreateRole={showCreateRole}
setShowCreateRole={setShowCreateRole}
newRoleName={newRoleName}
setNewRoleName={setNewRoleName}
newRoleDisplayName={newRoleDisplayName}
setNewRoleDisplayName={setNewRoleDisplayName}
newRoleDescription={newRoleDescription}
setNewRoleDescription={setNewRoleDescription}
handleCreateRole={handleCreateRole}
createRoleLoading={createRoleLoading}
/>
<AdminHostDefaultsSection
open={isOpen("host-defaults")}
onToggle={() => toggle("host-defaults")}
/>
<AdminBrandingSection
open={isOpen("branding")}
onToggle={() => toggle("branding")}
settings={brandingSettings}
setSettings={setBrandingSettings}
saving={brandingSaving}
onSave={() => void handleSaveBranding()}
onResetLogo={() => void handleResetBrandingLogo()}
/>
<AdminDatabaseSection
open={isOpen("database")}
onToggle={() => toggle("database")}
importFile={importFile}
setImportFile={setImportFile}
exportLoading={exportLoading}
importLoading={importLoading}
handleExportDatabase={handleExportDatabase}
handleImportDatabase={handleImportDatabase}
/>
<AdminSSLSection
open={isOpen("ssl")}
onToggle={() => toggle("ssl")}
status={tlsStatus}
manualCertDraft={manualCertDraft}
setManualCertDraft={setManualCertDraft}
manualKeyDraft={manualKeyDraft}
setManualKeyDraft={setManualKeyDraft}
manualUploading={manualUploading}
handleManualUpload={handleManualSslUpload}
/>
<AdminApiKeysSection
open={isOpen("api-keys")}
onToggle={() => toggle("api-keys")}
apiKeys={apiKeys}
setApiKeys={setApiKeys}
loadApiKeys={loadApiKeys}
showCreateKey={showCreateKey}
setShowCreateKey={setShowCreateKey}
createdKeyToken={createdKeyToken}
setCreatedKeyToken={setCreatedKeyToken}
newKeyName={newKeyName}
setNewKeyName={setNewKeyName}
newKeyUserId={newKeyUserId}
setNewKeyUserId={setNewKeyUserId}
newKeyExpiry={newKeyExpiry}
setNewKeyExpiry={setNewKeyExpiry}
users={users}
handleCreateApiKey={handleCreateApiKey}
newKeyLoading={newKeyLoading}
/>
<AdminAuditLogSection
open={isOpen("audit-log")}
onToggle={() => toggle("audit-log")}
users={users}
/>
{featureSettings.map((plugin) => (
<FeatureSettingsSection
key={plugin.id}
plugin={plugin}
scope="admin"
open={isOpen(featureSectionId(plugin.id))}
onToggle={() => toggle(featureSectionId(plugin.id))}
/>
))}
<AdminCreateUserDialog
open={createUserOpen}
onOpenChange={setCreateUserOpen}
newUsername={newUsername}
setNewUsername={setNewUsername}
newPassword={newPassword}
setNewPassword={setNewPassword}
showNewPassword={showNewPassword}
setShowNewPassword={setShowNewPassword}
handleCreateUser={handleCreateUser}
createUserLoading={createUserLoading}
/>
<AdminEditUserDialog
open={editUserOpen}
onOpenChange={setEditUserOpen}
editUserTarget={editUserTarget}
editUserLoading={editUserLoading}
editUserRoles={editUserRoles}
editUserRolesLoading={editUserRolesLoading}
roles={roles}
setEditUserRoles={setEditUserRoles}
handleToggleAdmin={handleToggleAdmin}
handleRevokeUserSessions={handleRevokeUserSessions}
handleDeleteEditUser={handleDeleteEditUser}
/>
<AdminLinkAccountDialog
open={linkAccountOpen}
onOpenChange={setLinkAccountOpen}
linkAccountTarget={linkAccountTarget}
setUsers={setUsers}
users={users}
/>
<AdminUnlinkAccountDialog
open={unlinkAccountOpen}
onOpenChange={setUnlinkAccountOpen}
unlinkAccountTarget={unlinkAccountTarget}
onSuccess={(userId) =>
setUsers((prev) =>
prev.map((u) =>
u.id === userId
? { ...u, isExternal: false, passwordHash: undefined }
: u,
),
)
}
/>
</div>
);
}
-530
View File
@@ -1,530 +0,0 @@
import type { Dispatch, SetStateAction } from "react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/button";
import { Input } from "@/components/input";
import { SettingRow } from "@/components/section-card";
import { Database, Lock, RefreshCw, Server, Settings } from "lucide-react";
import { AccordionSection, AdminToggle } from "./AdminSettingsShared";
import { AdminHostTags } from "./AdminHostTags";
import { AdminPluginDeveloperMode } from "./AdminPluginDeveloperMode";
import type { TlsStatus } from "@/api/tls-api";
import { DocsLink } from "@/components/docs-link";
type GeneralSettingsSectionProps = {
open: boolean;
onToggle: () => void;
notificationPrivateEndpoints: string[];
onSaveNotificationPrivateEndpoints: (hosts: string[]) => void;
allowRegistration: boolean;
handleToggleRegistration: () => void;
allowPasswordLogin: boolean;
passwordLoginForced?: boolean;
handleTogglePasswordLogin: () => void;
externalAutoProvision: boolean;
handleToggleExternalAutoProvision: () => void;
secondFactorAfterExternalLogin: boolean;
handleToggleSecondFactorAfterExternalLogin: () => void;
allowPasswordReset: boolean;
handleTogglePasswordReset: () => void;
sessionTimeout: string;
setSessionTimeout: Dispatch<SetStateAction<string>>;
handleSaveSessionTimeout: () => void;
statusInterval: string;
setStatusInterval: Dispatch<SetStateAction<string>>;
handleSaveMonitoring: () => void;
logLevel: string;
handleSaveLogLevel: (level: string) => void;
};
export function AdminGeneralSettingsSection({
open,
onToggle,
notificationPrivateEndpoints,
onSaveNotificationPrivateEndpoints,
allowRegistration,
handleToggleRegistration,
allowPasswordLogin,
passwordLoginForced,
handleTogglePasswordLogin,
externalAutoProvision,
handleToggleExternalAutoProvision,
secondFactorAfterExternalLogin,
handleToggleSecondFactorAfterExternalLogin,
allowPasswordReset,
handleTogglePasswordReset,
sessionTimeout,
setSessionTimeout,
handleSaveSessionTimeout,
statusInterval,
setStatusInterval,
handleSaveMonitoring,
logLevel,
handleSaveLogLevel,
}: GeneralSettingsSectionProps) {
const { t } = useTranslation();
return (
<AccordionSection
label={t("admin.sectionGeneral")}
icon={<Settings className="size-3.5" />}
open={open}
onToggle={onToggle}
>
<div className="flex flex-col gap-0 pt-2">
<div className="flex flex-col gap-1.5 py-2">
<span className="text-xs font-medium">
{t("admin.notificationPrivateEndpoints")}
</span>
<span className="text-[11px] leading-snug text-muted-foreground">
{t("admin.notificationPrivateEndpointsDesc")}
</span>
<Input
className="rounded-none"
defaultValue={notificationPrivateEndpoints.join(", ")}
placeholder="ntfy.internal, 192.168.1.20"
onBlur={(event) =>
onSaveNotificationPrivateEndpoints(
event.target.value
.split(",")
.map((entry) => entry.trim())
.filter(Boolean),
)
}
/>
</div>
<div className="border-t border-border pt-3 mt-2">
<SettingRow
label={t("admin.allowRegistration")}
description={t("admin.allowRegistrationDesc")}
>
<AdminToggle
on={allowRegistration}
onToggle={handleToggleRegistration}
/>
</SettingRow>
</div>
<SettingRow
label={t("admin.allowPasswordLogin")}
description={t("admin.allowPasswordLoginDesc")}
>
<AdminToggle
on={allowPasswordLogin}
onToggle={handleTogglePasswordLogin}
/>
</SettingRow>
{passwordLoginForced && (
<p className="text-[10px] text-destructive">
{t("admin.passwordLoginForced")}
</p>
)}
<SettingRow
label={t("admin.externalAutoProvision")}
description={t("admin.externalAutoProvisionDesc")}
>
<AdminToggle
on={externalAutoProvision}
onToggle={handleToggleExternalAutoProvision}
/>
</SettingRow>
<SettingRow
label={t("admin.secondFactorAfterExternalLogin")}
description={t("admin.secondFactorAfterExternalLoginDesc")}
>
<AdminToggle
on={secondFactorAfterExternalLogin}
onToggle={handleToggleSecondFactorAfterExternalLogin}
/>
</SettingRow>
<SettingRow
label={t("admin.allowPasswordReset")}
description={t("admin.allowPasswordResetDesc")}
>
<AdminToggle
on={allowPasswordReset}
onToggle={handleTogglePasswordReset}
/>
</SettingRow>
<AdminPluginDeveloperMode />
<div className="flex flex-col gap-2 pt-3 mt-2">
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("admin.sessionTimeout")}
</span>
<div className="flex items-center gap-2">
<Input
type="number"
min={1}
max={720}
value={sessionTimeout}
onChange={(e) => setSessionTimeout(e.target.value)}
className="w-20 text-sm"
/>
<span className="text-xs text-muted-foreground">
{t("admin.hours")}
</span>
<Button
variant="outline"
size="sm"
className="text-xs border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand h-7"
onClick={handleSaveSessionTimeout}
>
{t("common.save")}
</Button>
</div>
<span className="text-[10px] text-muted-foreground">
{t("admin.sessionTimeoutRange")}
</span>
</div>
<div className="flex flex-col gap-2 border-t border-border pt-3 mt-2">
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("admin.monitoringDefaults")}
</span>
<div className="flex flex-col gap-1.5">
<label className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
{t("admin.statusCheck")}
</label>
<div className="flex items-center gap-2">
<Input
type="number"
value={statusInterval}
onChange={(e) => setStatusInterval(e.target.value)}
className="w-20 text-sm"
/>
<span className="text-xs text-muted-foreground">
{t("admin.sec")}
</span>
<Button
variant="outline"
size="sm"
className="text-xs border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand h-7"
onClick={handleSaveMonitoring}
>
{t("common.save")}
</Button>
</div>
</div>
</div>
<div className="flex flex-col gap-2 border-t border-border pt-3 mt-2">
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("admin.logLevel")}
</span>
<div className="flex gap-1.5 flex-wrap">
{["debug", "info", "warn", "error"].map((l) => (
<button
key={l}
onClick={() => handleSaveLogLevel(l)}
className={`px-2 py-1 text-[10px] font-semibold border capitalize transition-colors ${logLevel === l ? "border-accent-brand/40 bg-accent-brand/10 text-accent-brand" : "border-border text-muted-foreground hover:text-foreground"}`}
>
{l}
</button>
))}
</div>
</div>
</div>
</AccordionSection>
);
}
type DatabaseSectionProps = {
open: boolean;
onToggle: () => void;
importFile: File | null;
setImportFile: Dispatch<SetStateAction<File | null>>;
exportLoading: boolean;
importLoading: boolean;
handleExportDatabase: () => void;
handleImportDatabase: () => void;
};
export function AdminDatabaseSection({
open,
onToggle,
importFile,
setImportFile,
exportLoading,
importLoading,
handleExportDatabase,
handleImportDatabase,
}: DatabaseSectionProps) {
const { t } = useTranslation();
return (
<AccordionSection
label={t("admin.sectionDatabase")}
icon={<Database className="size-3.5" />}
open={open}
onToggle={onToggle}
>
<div className="flex flex-col gap-3 pt-3">
<div className="flex flex-col gap-1.5">
<span className="text-xs font-medium">
{t("admin.exportDatabase")}
</span>
<span className="text-[10px] text-muted-foreground">
{t("admin.exportDatabaseDesc")}
</span>
<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 mt-1"
onClick={handleExportDatabase}
disabled={exportLoading}
>
{exportLoading ? t("admin.exporting") : t("admin.export")}
</Button>
</div>
<div className="flex flex-col gap-1.5 border-t border-border pt-3">
<span className="text-xs font-medium">
{t("admin.importDatabase")}
</span>
<span className="text-[10px] text-muted-foreground">
{importFile
? t("admin.importDatabaseSelected", { name: importFile.name })
: t("admin.importDatabaseDesc")}
</span>
<div className="flex items-center gap-2 mt-1">
<div className="relative">
<input
type="file"
accept=".sqlite,.db"
onChange={(e) => setImportFile(e.target.files?.[0] ?? null)}
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
/>
<Button
variant="outline"
size="sm"
className="pointer-events-none text-xs"
>
{importFile ? t("admin.changeFile") : t("admin.selectFile")}
</Button>
</div>
{importFile && (
<Button
variant="outline"
size="sm"
className="text-xs border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
onClick={handleImportDatabase}
disabled={importLoading}
>
{importLoading ? t("admin.importing") : t("admin.import")}
</Button>
)}
</div>
</div>
</div>
</AccordionSection>
);
}
type AdminHostDefaultsSectionProps = {
open: boolean;
onToggle: () => void;
};
export function AdminHostDefaultsSection({
open,
onToggle,
}: AdminHostDefaultsSectionProps) {
const { t } = useTranslation();
return (
<AccordionSection
label={t("admin.sectionHostDefaults")}
icon={<Server className="size-3.5" />}
open={open}
onToggle={onToggle}
>
<div className="flex flex-col gap-3 pt-3">
<span className="text-[10px] text-muted-foreground">
{t("hostDefaults.adminSectionDescription")}
</span>
<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={() =>
window.dispatchEvent(
new CustomEvent("termix:open-host-defaults", {
detail: { level: "admin" },
}),
)
}
>
{t("hostDefaults.editServerDefaults")}
</Button>
<div className="border-t border-border pt-3">
<AdminHostTags />
</div>
</div>
</AccordionSection>
);
}
function certState(
status: TlsStatus | null,
): "none" | "valid" | "expiring" | "expired" {
const cert = status?.certificate;
if (!cert) return "none";
const left = new Date(cert.notAfter).getTime() - Date.now();
if (left <= 0) return "expired";
if (left < 30 * 86_400_000) return "expiring";
return "valid";
}
const CERT_STATUS_STYLES: Record<ReturnType<typeof certState>, string> = {
none: "text-muted-foreground",
valid: "text-green-500",
expiring: "text-warning",
expired: "text-destructive",
};
type AdminSSLSectionProps = {
open: boolean;
onToggle: () => void;
status: TlsStatus | null;
manualCertDraft: string;
setManualCertDraft: Dispatch<SetStateAction<string>>;
manualKeyDraft: string;
setManualKeyDraft: Dispatch<SetStateAction<string>>;
manualUploading: boolean;
handleManualUpload: () => void;
};
export function AdminSSLSection({
open,
onToggle,
status,
manualCertDraft,
setManualCertDraft,
manualKeyDraft,
setManualKeyDraft,
manualUploading,
handleManualUpload,
}: AdminSSLSectionProps) {
const { t } = useTranslation();
const cert = status?.certificate ?? null;
const state = certState(status);
const expiry = cert ? new Date(cert.notAfter).toLocaleDateString() : "";
const certStatusLabel: Record<ReturnType<typeof certState>, string> = {
none: t("admin.sslCertStatusNone"),
valid: t("admin.sslCertStatusValid"),
expiring: t("admin.sslCertStatusExpiring"),
expired: t("admin.sslCertStatusExpired"),
};
return (
<AccordionSection
label={t("admin.sectionSsl")}
icon={<Lock className="size-3.5" />}
open={open}
onToggle={onToggle}
>
<div className="flex flex-col gap-3 pt-3">
<span className="text-[10px] text-muted-foreground">
{t("admin.sslDescription")}{" "}
<DocsLink core="https">{t("admin.sslDocsLink")}</DocsLink>
</span>
<div className="flex flex-col gap-0.5 p-2 border border-border bg-background/50">
<div className="flex items-center justify-between">
<span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
{t("admin.sslCertStatus")}
</span>
<span
className={`text-xs font-medium ${CERT_STATUS_STYLES[state]}`}
>
{certStatusLabel[state]}
</span>
</div>
{cert && (
<>
<span className="text-[10px] text-muted-foreground">
{t("admin.sslNames", { names: cert.names.join(", ") || "-" })}
</span>
<span className="text-[10px] text-muted-foreground">
{t("admin.sslIssuer", { issuer: cert.issuer })}
</span>
<span className="text-[10px] text-muted-foreground">
{t("admin.sslCertExpiresAt", { date: expiry })}
</span>
{cert.selfSigned && (
<span className="text-[10px] text-muted-foreground">
{t("admin.sslSelfSigned")}
</span>
)}
{status?.renewal && (
<span className="text-[10px] text-muted-foreground">
{t("admin.sslRenewedBy", {
plugin: status.renewal.pluginName,
})}
</span>
)}
</>
)}
</div>
{cert && !cert.selfSigned && !status?.renewal && (
<div className="p-2 border border-warning/40 bg-warning/10 text-[10px] text-warning">
{t("admin.sslNotRenewed", { date: expiry })}
</div>
)}
<span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest border-t border-border pt-2">
{t("admin.sslManualTitle")}
</span>
<div className="flex flex-col gap-1">
<label className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
{t("admin.sslManualCert")}
</label>
<textarea
rows={5}
value={manualCertDraft}
onChange={(e) => setManualCertDraft(e.target.value)}
placeholder={t("admin.sslManualCertPlaceholder")}
spellCheck={false}
className="w-full px-2 py-1.5 text-[10px] font-mono bg-background border border-border text-foreground placeholder:text-muted-foreground resize-none outline-none focus:ring-1 focus:ring-ring"
/>
</div>
<div className="flex flex-col gap-1">
<label className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
{t("admin.sslManualKey")}
</label>
<textarea
rows={5}
value={manualKeyDraft}
onChange={(e) => setManualKeyDraft(e.target.value)}
placeholder={t("admin.sslManualKeyPlaceholder")}
spellCheck={false}
className="w-full px-2 py-1.5 text-[10px] font-mono bg-background border border-border text-foreground placeholder:text-muted-foreground resize-none outline-none focus:ring-1 focus:ring-ring"
/>
<span className="text-[10px] text-muted-foreground">
{t("admin.sslManualDesc")}
</span>
</div>
<Button
variant="outline"
size="sm"
className="w-full text-xs border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand h-7"
onClick={handleManualUpload}
disabled={manualUploading}
>
<RefreshCw
className={`size-3 ${manualUploading ? "animate-spin" : ""}`}
/>
{manualUploading
? t("admin.sslManualUploadLoading")
: t("admin.sslManualUpload")}
</Button>
<span className="text-[10px] text-muted-foreground border-t border-border pt-2">
{t("admin.sslInfoNote")}
</span>
</div>
</AccordionSection>
);
}
-68
View File
@@ -1,68 +0,0 @@
import type React from "react";
import { ChevronDown } from "lucide-react";
import { useSettingsEmbedded } from "@/settings/settings-embed";
export function AdminToggle({
on,
onToggle,
disabled = false,
}: {
on: boolean;
onToggle: () => void;
disabled?: boolean;
}) {
return (
<button
onClick={onToggle}
disabled={disabled}
className={`relative inline-flex h-5 w-9 shrink-0 items-center border-2 transition-colors ${disabled ? "cursor-not-allowed opacity-50" : "cursor-pointer"} ${on ? "bg-accent-brand border-accent-brand" : "bg-muted border-border"}`}
>
<span
className={`pointer-events-none inline-block h-3 w-3 bg-background shadow-sm transition-transform ${on ? "translate-x-4" : "translate-x-0.5"}`}
/>
</button>
);
}
export function AccordionSection({
label,
icon,
open,
onToggle,
children,
}: {
label: string;
icon: React.ReactNode;
open: boolean;
onToggle: () => void;
children: React.ReactNode;
}) {
const embedded = useSettingsEmbedded();
if (embedded) {
if (!open) return null;
return (
<div className="flex flex-col border border-border bg-card px-3 pb-3">
{children}
</div>
);
}
return (
<div className="border border-border bg-card overflow-hidden shrink-0">
<button
onClick={onToggle}
className="flex items-center gap-2 w-full px-3 py-2.5 text-left hover:bg-muted/40 transition-colors"
>
<span className="text-muted-foreground shrink-0">{icon}</span>
<span className="text-xs font-bold uppercase tracking-widest text-foreground flex-1">
{label}
</span>
<ChevronDown
className={`size-3.5 text-muted-foreground shrink-0 transition-transform duration-150 ${open ? "rotate-180" : ""}`}
/>
</button>
{open && (
<div className="border-t border-border px-3 pb-3">{children}</div>
)}
</div>
);
}
-576
View File
@@ -1,576 +0,0 @@
import { InlineView } from "@/components/surface/surface-scope";
import { useEffect, useState, type Dispatch, type SetStateAction } from "react";
import { useTranslation } from "react-i18next";
import {
assignRoleToUser,
linkExternalToPasswordAccount,
removeRoleFromUser,
unlinkExternalFromPasswordAccount,
} from "@/main-axios";
import type { Role, UserRole } from "@/main-axios";
import { Button } from "@/components/button";
import { Input } from "@/components/input";
import { AlertCircle, Eye, EyeOff, Trash2 } from "lucide-react";
import { toast } from "sonner";
import { AdminToggle } from "./AdminSettingsShared";
import type { AdminUser } from "./AdminManagementSections";
import { roleLabel } from "@/lib/role-label";
type ApiErrorLike = {
response?: {
data?: {
error?: string;
};
};
message?: string;
};
function apiErrorMessage(error: unknown, fallback: string) {
const err = error as ApiErrorLike;
return err.response?.data?.error || err.message || fallback;
}
type CreateUserDialogProps = {
open: boolean;
onOpenChange: Dispatch<SetStateAction<boolean>>;
newUsername: string;
setNewUsername: Dispatch<SetStateAction<string>>;
newPassword: string;
setNewPassword: Dispatch<SetStateAction<string>>;
showNewPassword: boolean;
setShowNewPassword: Dispatch<SetStateAction<boolean>>;
handleCreateUser: () => void;
createUserLoading: boolean;
};
export function AdminCreateUserDialog({
open,
onOpenChange,
newUsername,
setNewUsername,
newPassword,
setNewPassword,
showNewPassword,
setShowNewPassword,
handleCreateUser,
createUserLoading,
}: CreateUserDialogProps) {
const { t } = useTranslation();
return (
<InlineView
open={open}
onOpenChange={onOpenChange}
title={t("admin.createUserTitle")}
>
<p className="text-xs text-muted-foreground">
{t("admin.createUserDesc")}
</p>
<div className="flex flex-col gap-4 mt-1">
<div className="flex flex-col gap-1.5">
<label className="text-xs font-semibold uppercase tracking-widest text-muted-foreground">
{t("admin.createUserUsername")}{" "}
<span className="text-accent-brand">*</span>
</label>
<Input
placeholder={t("admin.createUserEnterUsername")}
value={newUsername}
onChange={(e) => setNewUsername(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleCreateUser()}
/>
</div>
<div className="flex flex-col gap-1.5">
<label className="text-xs font-semibold uppercase tracking-widest text-muted-foreground">
{t("admin.createUserPassword")}{" "}
<span className="text-accent-brand">*</span>
</label>
<div className="relative">
<Input
type={showNewPassword ? "text" : "password"}
placeholder={t("placeholders.enterPassword")}
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleCreateUser()}
className="pr-9"
/>
<button
onClick={() => setShowNewPassword((o) => !o)}
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
{showNewPassword ? (
<EyeOff className="size-4" />
) : (
<Eye className="size-4" />
)}
</button>
</div>
<span className="text-xs text-muted-foreground">
{t("admin.createUserPasswordHint")}
</span>
</div>
</div>
<div className="flex justify-end gap-2 mt-2">
<Button
variant="ghost"
onClick={() => {
onOpenChange(false);
setNewUsername("");
setNewPassword("");
}}
>
{t("common.cancel")}
</Button>
<Button
variant="outline"
className="border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
onClick={handleCreateUser}
disabled={createUserLoading}
>
{createUserLoading
? t("admin.creating")
: t("admin.createUserSubmit")}
</Button>
</div>
</InlineView>
);
}
type EditUserDialogProps = {
open: boolean;
onOpenChange: Dispatch<SetStateAction<boolean>>;
editUserTarget: AdminUser | null;
editUserLoading: boolean;
editUserRoles: UserRole[];
editUserRolesLoading: boolean;
roles: Role[];
setEditUserRoles: Dispatch<SetStateAction<UserRole[]>>;
handleToggleAdmin: (user: AdminUser) => void;
handleRevokeUserSessions: (userId: string) => void;
handleDeleteEditUser: () => void;
};
export function AdminEditUserDialog({
open,
onOpenChange,
editUserTarget,
editUserLoading,
editUserRoles,
editUserRolesLoading,
roles,
setEditUserRoles,
handleToggleAdmin,
handleRevokeUserSessions,
handleDeleteEditUser,
}: EditUserDialogProps) {
const { t } = useTranslation();
return (
<InlineView
open={open}
onOpenChange={onOpenChange}
title={t("admin.editUserTitle", { username: editUserTarget?.username })}
>
<p className="text-xs text-muted-foreground">{t("admin.editUserDesc")}</p>
{editUserTarget && (
<div className="flex flex-col gap-0 mt-1 divide-y divide-border">
<div className="grid grid-cols-1 md:grid-cols-2 gap-x-6 gap-y-2 py-3">
<div className="flex flex-col gap-0.5">
<span className="text-xs text-muted-foreground uppercase tracking-widest font-semibold">
{t("admin.editUserUsername")}
</span>
<span className="text-sm font-semibold">
{editUserTarget.username}
</span>
</div>
<div className="flex flex-col gap-0.5">
<span className="text-xs text-muted-foreground uppercase tracking-widest font-semibold">
{t("admin.editUserAuthType")}
</span>
<span className="text-sm font-semibold">
{editUserTarget.isExternal && editUserTarget.passwordHash
? t("admin.authTypeDual")
: editUserTarget.isExternal
? t("admin.authTypeExternal")
: t("admin.authTypeLocal")}
</span>
</div>
<div className="flex flex-col gap-0.5">
<span className="text-xs text-muted-foreground uppercase tracking-widest font-semibold">
{t("admin.editUserAdminStatus")}
</span>
<span className="text-sm font-semibold">
{editUserTarget.isAdmin
? t("admin.adminStatusAdministrator")
: t("admin.adminStatusRegularUser")}
</span>
</div>
<div className="flex flex-col gap-0.5">
<span className="text-xs text-muted-foreground uppercase tracking-widest font-semibold">
{t("admin.editUserUserId")}
</span>
<span className="text-xs font-mono text-muted-foreground truncate">
{editUserTarget.id}
</span>
</div>
</div>
<div className="flex items-center justify-between py-3">
<div className="flex flex-col gap-0.5">
<span className="text-sm font-medium">
{t("admin.userAdminAccess")}
</span>
<span className="text-xs text-muted-foreground">
{t("admin.userAdminAccessDesc")}
</span>
</div>
<AdminToggle
on={editUserTarget.isAdmin}
onToggle={() => handleToggleAdmin(editUserTarget)}
/>
</div>
<div className="flex flex-col gap-2 py-3">
<span className="text-sm font-medium">{t("admin.userRoles")}</span>
{editUserRolesLoading ? (
<span className="text-xs text-muted-foreground">
{t("common.loading")}
</span>
) : (
<>
{editUserRoles.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{editUserRoles.map((ur) => {
const roleInfo = roles.find((r) => r.id === ur.roleId);
const isSystem = roleInfo?.isSystem ?? false;
return (
<span
key={ur.roleId}
className="inline-flex items-center gap-1 text-[10px] font-semibold px-1.5 py-0.5 border border-accent-brand/40 bg-accent-brand/10 text-accent-brand"
>
{roleLabel(t, ur.roleDisplayName)}
{!isSystem && (
<button
onClick={async () => {
try {
await removeRoleFromUser(
editUserTarget.id,
ur.roleId,
);
setEditUserRoles((prev) =>
prev.filter((r) => r.roleId !== ur.roleId),
);
} catch {
toast.error(t("admin.removeRoleFailed"));
}
}}
className="hover:text-destructive ml-0.5"
>
×
</button>
)}
</span>
);
})}
</div>
)}
{roles.filter(
(r) =>
!r.isSystem &&
!editUserRoles.some((ur) => ur.roleId === r.id),
).length > 0 && (
<div className="flex flex-col gap-1">
<span className="text-[10px] text-muted-foreground uppercase tracking-widest font-semibold">
{t("admin.addRole")}
</span>
<div className="flex flex-wrap gap-1.5">
{roles
.filter(
(r) =>
!r.isSystem &&
!editUserRoles.some((ur) => ur.roleId === r.id),
)
.map((r) => (
<button
key={r.id}
onClick={async () => {
try {
await assignRoleToUser(editUserTarget.id, r.id);
setEditUserRoles((prev) => [
...prev,
{
userId: editUserTarget.id,
roleId: r.id,
roleName: r.name,
roleDisplayName: r.displayName,
grantedBy: "",
grantedByUsername: "",
grantedAt: new Date().toISOString(),
},
]);
} catch {
toast.error(t("admin.assignRoleFailed"));
}
}}
className="inline-flex items-center gap-1 text-[10px] font-semibold px-1.5 py-0.5 border border-border text-muted-foreground hover:border-accent-brand/40 hover:text-accent-brand transition-colors"
>
+ {r.displayName}
</button>
))}
</div>
</div>
)}
{editUserRoles.length === 0 &&
roles.filter((r) => !r.isSystem).length === 0 && (
<span className="text-xs text-muted-foreground">
{t("admin.noCustomRoles")}
</span>
)}
</>
)}
</div>
<div className="flex items-center justify-between py-3">
<div className="flex flex-col gap-0.5">
<span className="text-sm font-medium">
{t("admin.revokeAllUserSessions")}
</span>
<span className="text-xs text-muted-foreground">
{t("admin.revokeAllUserSessionsDesc")}
</span>
</div>
<Button
variant="outline"
size="sm"
className="border-destructive/40 text-destructive hover:bg-destructive/10 hover:text-destructive shrink-0 ml-8"
onClick={() => handleRevokeUserSessions(editUserTarget.id)}
disabled={editUserLoading}
>
{t("admin.revoke")}
</Button>
</div>
<div className="flex flex-col gap-2 py-3">
<div className="flex items-start gap-2.5 border border-destructive/30 bg-destructive/5 px-3 py-2.5">
<AlertCircle className="size-4 text-destructive shrink-0 mt-0.5" />
<span className="text-xs text-destructive">
{t("admin.deleteUserWarning")}
</span>
</div>
<Button
variant="outline"
className="w-full border-destructive/40 text-destructive hover:bg-destructive/10 hover:text-destructive"
disabled={editUserTarget.isAdmin || editUserLoading}
onClick={handleDeleteEditUser}
>
<Trash2 className="size-3.5" />
{editUserLoading
? t("admin.deleting")
: t("admin.deleteUser", {
username: editUserTarget.username,
})}
</Button>
</div>
</div>
)}
</InlineView>
);
}
type UnlinkAccountDialogProps = {
open: boolean;
onOpenChange: Dispatch<SetStateAction<boolean>>;
unlinkAccountTarget: { id: string; username: string } | null;
onSuccess: (userId: string) => void;
};
export function AdminUnlinkAccountDialog({
open,
onOpenChange,
unlinkAccountTarget,
onSuccess,
}: UnlinkAccountDialogProps) {
const { t } = useTranslation();
const [submitting, setSubmitting] = useState(false);
const handleSubmit = async () => {
if (!unlinkAccountTarget) return;
setSubmitting(true);
try {
await unlinkExternalFromPasswordAccount(unlinkAccountTarget.id);
toast.success(t("admin.unlinkAccountSuccess"));
onSuccess(unlinkAccountTarget.id);
onOpenChange(false);
} catch (error: unknown) {
toast.error(apiErrorMessage(error, t("admin.unlinkAccountFailed")));
} finally {
setSubmitting(false);
}
};
return (
<InlineView
open={open}
onOpenChange={onOpenChange}
title={t("admin.unlinkAccountTitle")}
>
<p className="text-xs text-muted-foreground">
{t("admin.unlinkAccountDesc", {
username: unlinkAccountTarget?.username,
})}
</p>
<div className="flex items-start gap-2.5 border border-destructive/30 bg-destructive/5 px-3 py-2.5">
<AlertCircle className="size-4 text-destructive shrink-0 mt-0.5" />
<span className="text-xs text-destructive">
{t("admin.unlinkAccountWarning")}
</span>
</div>
<div className="flex justify-end gap-2 mt-2">
<Button
variant="ghost"
onClick={() => onOpenChange(false)}
disabled={submitting}
>
{t("common.cancel")}
</Button>
<Button
variant="outline"
className="border-destructive/40 text-destructive hover:bg-destructive/10 hover:text-destructive"
disabled={submitting || !unlinkAccountTarget}
onClick={handleSubmit}
>
{submitting
? t("admin.unlinkAccountInProgress")
: t("admin.unlinkAccount")}
</Button>
</div>
</InlineView>
);
}
type LinkAccountDialogProps = {
open: boolean;
onOpenChange: Dispatch<SetStateAction<boolean>>;
linkAccountTarget: {
id: string;
username: string;
isExternal: boolean;
} | null;
setUsers: Dispatch<SetStateAction<AdminUser[]>>;
users: AdminUser[];
};
export function AdminLinkAccountDialog({
open,
onOpenChange,
linkAccountTarget,
setUsers,
users,
}: LinkAccountDialogProps) {
const { t } = useTranslation();
const [otherUsername, setOtherUsername] = useState("");
const [submitting, setSubmitting] = useState(false);
useEffect(() => {
if (open) setOtherUsername("");
}, [open, linkAccountTarget]);
const isExternalInitiator = linkAccountTarget?.isExternal ?? true;
const handleSubmit = async () => {
const trimmed = otherUsername.trim();
if (!linkAccountTarget || !trimmed) return;
setSubmitting(true);
try {
if (isExternalInitiator) {
await linkExternalToPasswordAccount(linkAccountTarget.id, trimmed);
setUsers((prev) => prev.filter((u) => u.id !== linkAccountTarget.id));
} else {
const oidcUser = users.find(
(u) => u.username === trimmed && u.isExternal && !u.passwordHash,
);
if (!oidcUser) {
toast.error(t("admin.linkAccountExternalNotFound"));
return;
}
await linkExternalToPasswordAccount(
oidcUser.id,
linkAccountTarget.username,
);
setUsers((prev) => prev.filter((u) => u.id !== oidcUser.id));
}
toast.success(t("admin.linkAccountSuccess", { username: trimmed }));
setOtherUsername("");
onOpenChange(false);
} catch (error: unknown) {
toast.error(apiErrorMessage(error, t("admin.linkAccountFailed")));
} finally {
setSubmitting(false);
}
};
return (
<InlineView
open={open}
onOpenChange={onOpenChange}
title={t("admin.linkAccountTitle")}
>
<p className="text-xs text-muted-foreground">
{isExternalInitiator
? t("admin.linkAccountDesc", {
username: linkAccountTarget?.username,
})
: t("admin.linkAccountDescLocal", {
username: linkAccountTarget?.username,
})}
</p>
<div className="flex flex-col gap-4 mt-1">
<div className="flex items-start gap-2.5 border border-destructive/30 bg-destructive/5 px-3 py-2.5">
<AlertCircle className="size-4 text-destructive shrink-0 mt-0.5" />
<div className="flex flex-col gap-1 text-xs text-destructive">
<span>{t("admin.linkAccountWarningTitle")}</span>
<ul className="list-disc list-inside space-y-0.5 ml-1">
<li>{t("admin.linkAccountEffect1")}</li>
<li>{t("admin.linkAccountEffect2")}</li>
<li>{t("admin.linkAccountEffect3")}</li>
</ul>
</div>
</div>
<div className="flex flex-col gap-1.5">
<label className="text-xs font-semibold uppercase tracking-widest text-muted-foreground">
{isExternalInitiator
? t("admin.linkAccountTargetUsername")
: t("admin.linkAccountExternalUsername")}{" "}
<span className="text-accent-brand">*</span>
</label>
<Input
value={otherUsername}
onChange={(e) => setOtherUsername(e.target.value)}
placeholder={
isExternalInitiator
? t("admin.linkAccountTargetPlaceholder")
: t("admin.linkAccountExternalPlaceholder")
}
autoFocus
disabled={submitting}
/>
</div>
</div>
<div className="flex justify-end gap-2 mt-2">
<Button
variant="ghost"
onClick={() => onOpenChange(false)}
disabled={submitting}
>
{t("common.cancel")}
</Button>
<Button
variant="outline"
className="border-destructive/40 text-destructive hover:bg-destructive/10 hover:text-destructive"
disabled={submitting || !linkAccountTarget || !otherUsername.trim()}
onClick={handleSubmit}
>
{submitting
? t("admin.linkAccountInProgress")
: t("admin.linkAccounts")}
</Button>
</div>
</InlineView>
);
}
-996
View File
@@ -1,996 +0,0 @@
import { Select2 } from "@/components/select2";
import { useConfirm } from "@/components/surface/surface-scope";
import { useEffect, useState } from "react";
import { hostProtocolFlags, type HostProtocols } from "./host-protocols";
import { getUserList } from "@/main-axios";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import {
ArrowLeft,
Download,
KeyRound,
Lock,
Pencil,
Plus,
RefreshCw,
SquareTerminal,
Trash2,
TriangleAlert,
} from "lucide-react";
import { Button } from "@/components/button";
import { Input } from "@/components/input";
import { PasswordInput } from "@/components/password-input";
import {
adminGetUserHosts,
adminDeleteUserHost,
adminGetUserCredentials,
adminDeleteUserCredential,
adminResetUserPassword,
adminExportUserData,
getSessions,
revokeSession,
revokeAllUserSessions,
getApiKeys,
createApiKey,
deleteApiKey,
deleteUser,
getUserRoles,
assignRoleToUser,
removeRoleFromUser,
} from "@/main-axios";
import {
type ApiKey,
type CreatedApiKey,
type Role,
type SSHHostWithStatus,
type UserRole,
} from "@/main-axios";
import type { Host, Credential } from "@/types/ui-types";
import { CredentialEditorView } from "./CredentialEditorView";
import { AdminSecondFactorsSection } from "./AdminSecondFactorsSection";
import { HostEditor } from "./HostEditor";
import { mapCredentials, sshHostToHost } from "./HostManagerData";
import type { AdminSession, AdminUser } from "./AdminManagementSections";
import { TabStrip } from "./HostManagerTabs";
import { BackButton, PanelShell } from "@/components/panel-layout";
import { useActionSlot } from "@/hooks/use-action-slot";
import { docsUrl } from "@/lib/docs";
import { roleLabel } from "@/lib/role-label";
type ApiErrorLike = {
response?: {
data?: {
error?: string;
code?: string;
};
};
};
// Core tabs, or "plugin:<actionId>" for a tab a plugin contributes to the
// admin.userTabs slot.
type ManageTabId = string;
type EditorState =
| { kind: "host"; host: Host | null }
| { kind: "credential"; credential: Credential | null }
| null;
function apiErrorCode(error: unknown): string | undefined {
return (error as ApiErrorLike).response?.data?.code;
}
function apiErrorMessage(error: unknown, fallback: string) {
return (error as ApiErrorLike).response?.data?.error || fallback;
}
export function AdminUserManagePanel({
user,
roles,
onBack,
onOpenHostTab,
onUserDeleted,
onSecondFactorsReset,
}: {
user: AdminUser;
roles: Role[];
onBack: () => void;
onOpenHostTab?: (host: Host) => void;
onUserDeleted: () => void;
onSecondFactorsReset: () => void;
}) {
const { t } = useTranslation();
const [activeTab, setActiveTab] = useState<ManageTabId>("account");
// Who inherits the hosts and credentials when this account goes:
// the deleting admin by default, another user, or nobody.
const [successor, setSuccessor] = useState<"me" | "none" | string>("me");
const [successorOptions, setSuccessorOptions] = useState<
Array<{ id: string; username: string }>
>([]);
useEffect(() => {
getUserList()
.then((r) =>
setSuccessorOptions(
r.users
.filter((u) => u.userId !== user.id)
.map((u) => ({ id: u.userId, username: u.username })),
),
)
.catch(() => {});
}, [user.id]);
const [editor, setEditor] = useState<EditorState>(null);
const [editorProtocols, setEditorProtocols] = useState<HostProtocols>(() =>
hostProtocolFlags(null),
);
const confirm = useConfirm();
const setConfirmDialog = (
request: { message: string; onConfirm: () => void } | null,
) => {
if (!request) return;
void confirm({ title: request.message }).then((ok) => {
if (ok) void request.onConfirm();
});
};
const [hosts, setHosts] = useState<Host[]>([]);
const [credentials, setCredentials] = useState<Credential[]>([]);
const [sessions, setSessions] = useState<AdminSession[]>([]);
const [apiKeys, setApiKeys] = useState<ApiKey[]>([]);
const [userRoles, setUserRoles] = useState<UserRole[]>([]);
const [loading, setLoading] = useState(true);
// Account tab state
const [resetPassword, setResetPassword] = useState("");
const [resetLoading, setResetLoading] = useState(false);
const [exportLoading, setExportLoading] = useState(false);
const [newKeyName, setNewKeyName] = useState("");
const [newKeyLoading, setNewKeyLoading] = useState(false);
const [createdKeyToken, setCreatedKeyToken] = useState<string | null>(null);
const [deleteLoading, setDeleteLoading] = useState(false);
const dataUnlocked = user.dataUnlocked !== false;
const reloadHosts = () => {
if (!dataUnlocked) return;
adminGetUserHosts(user.id)
.then((raw) =>
setHosts(raw.map((h) => sshHostToHost(h as SSHHostWithStatus))),
)
.catch(() => {});
};
const reloadCredentials = () => {
if (!dataUnlocked) return;
adminGetUserCredentials(user.id)
.then((res) => setCredentials(mapCredentials(res)))
.catch(() => {});
};
const reloadSessions = () => {
getSessions()
.then(({ sessions: s }) =>
setSessions(s.filter((session) => session.userId === user.id)),
)
.catch(() => {});
};
useEffect(() => {
setLoading(true);
Promise.allSettled([
dataUnlocked ? adminGetUserHosts(user.id) : Promise.resolve([]),
dataUnlocked ? adminGetUserCredentials(user.id) : Promise.resolve([]),
getSessions(),
getApiKeys(),
getUserRoles(user.id),
])
.then(([h, c, sess, keys, ur]) => {
if (h.status === "fulfilled" && dataUnlocked) {
setHosts(
(h.value as SSHHostWithStatus[]).map((raw) => sshHostToHost(raw)),
);
}
if (c.status === "fulfilled" && dataUnlocked) {
setCredentials(mapCredentials(c.value));
}
if (sess.status === "fulfilled") {
setSessions(
sess.value.sessions.filter((session) => session.userId === user.id),
);
}
if (keys.status === "fulfilled") {
setApiKeys(keys.value.apiKeys.filter((k) => k.userId === user.id));
}
if (ur.status === "fulfilled") {
setUserRoles(ur.value.roles);
}
})
.finally(() => setLoading(false));
}, [user.id, dataUnlocked]);
const pluginTabs = useActionSlot("admin.userTabs").filter(
(contribution) =>
contribution.kind === "component" && contribution.component,
);
const activePluginTab = pluginTabs.find(
(contribution) => `plugin:${contribution.actionId}` === activeTab,
);
const manageTabs: { id: ManageTabId; label: string }[] = [
{ id: "account", label: t("admin.manageTabAccount") },
{ id: "hosts", label: t("admin.manageTabHosts") },
{ id: "credentials", label: t("admin.manageTabCredentials") },
...pluginTabs.map((contribution) => ({
id: `plugin:${contribution.actionId}`,
label: t(contribution.titleKey),
})),
{ id: "sessions", label: t("admin.manageTabSessions") },
{ id: "danger", label: t("admin.manageTabDanger") },
];
async function handleResetPassword(confirmDataWipe = false) {
if (resetPassword.length < 6) {
toast.error(t("admin.createUserPasswordTooShort"));
return;
}
setResetLoading(true);
try {
const result = await adminResetUserPassword(
user.id,
resetPassword,
confirmDataWipe,
);
setResetPassword("");
toast.success(
result.dataWiped
? t("admin.resetPasswordSuccessWiped")
: t("admin.resetPasswordSuccess"),
);
} catch (e) {
if (apiErrorCode(e) === "DATA_WIPE_REQUIRED") {
setConfirmDialog({
message: t("admin.resetPasswordConfirmWipe", {
username: user.username,
}),
onConfirm: () => handleResetPassword(true),
});
} else {
toast.error(apiErrorMessage(e, t("admin.resetPasswordFailed")));
}
} finally {
setResetLoading(false);
}
}
async function handleExport() {
setExportLoading(true);
try {
const data = await adminExportUserData(user.id);
const blob = new Blob([JSON.stringify(data, null, 2)], {
type: "application/json",
});
const url = window.URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `termix-user-${user.username}-export.json`;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
toast.success(t("admin.exportUserDataSuccess"));
} catch (e) {
toast.error(apiErrorMessage(e, t("admin.exportUserDataFailed")));
} finally {
setExportLoading(false);
}
}
async function handleCreateApiKey() {
if (!newKeyName.trim()) {
toast.error(t("admin.apiKeyNameRequired"));
return;
}
setNewKeyLoading(true);
try {
const created: CreatedApiKey = await createApiKey(
newKeyName.trim(),
user.id,
);
setApiKeys((prev) => [{ ...created, isActive: true }, ...prev]);
setCreatedKeyToken(created.token);
setNewKeyName("");
toast.success(t("admin.apiKeyCreatedSuccess", { name: created.name }));
} catch (e) {
toast.error(apiErrorMessage(e, t("admin.apiKeyCreateFailed")));
} finally {
setNewKeyLoading(false);
}
}
async function handleDeleteUser() {
setDeleteLoading(true);
try {
if (successor === "me") await deleteUser(user.username);
else await deleteUser(user.username, successor);
toast.success(t("admin.deleteUserSuccess", { username: user.username }));
onUserDeleted();
} catch (e) {
toast.error(apiErrorMessage(e, t("admin.deleteUserFailed")));
} finally {
setDeleteLoading(false);
}
}
const lockedNotice = (
<div className="flex items-start gap-2.5 p-3 mt-2 border border-border bg-muted/20 text-xs text-muted-foreground">
<Lock className="size-3.5 shrink-0 mt-0.5" />
<span>{t("admin.dataLockedNotice", { username: user.username })}</span>
</div>
);
const sectionHeading = (label: string) => (
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{label}
</span>
);
// Editing one of the user's hosts or credentials takes over the panel.
if (editor) {
const close = () => setEditor(null);
return (
<div className="flex flex-col flex-1 min-h-0">
<PanelShell
leading={<BackButton onClick={close} />}
title={t("admin.manageEditorBack", { username: user.username })}
docs={docsUrl("users")}
status={
editor.kind === "host"
? (editor.host?.name ?? t("manage.newHost"))
: (editor.credential?.name ?? t("manage.newCredential"))
}
scroll={false}
>
{editor.kind === "host" ? (
<HostEditor
key={editor.host ? editor.host.id : "new-host"}
host={editor.host}
onBack={close}
onSave={() => {
close();
reloadHosts();
}}
protocols={editorProtocols}
onProtocolChange={(p) =>
setEditorProtocols((prev) => ({ ...prev, ...p }))
}
hosts={hosts}
credentials={credentials}
adminTargetUserId={user.id}
/>
) : (
<CredentialEditorView
key={editor.credential ? editor.credential.id : "new-cred"}
credential={editor.credential}
existingFolders={Array.from(
new Set(
credentials
.map((c) => c.folder)
.filter((f): f is string => !!f),
),
).sort()}
onBack={close}
onSave={() => {
close();
reloadCredentials();
}}
adminTargetUserId={user.id}
/>
)}
</PanelShell>
</div>
);
}
return (
<div className="relative flex flex-col flex-1 min-h-0 overflow-hidden">
{/* Back bar */}
<div className="flex flex-col shrink-0 border-b border-border">
<button
onClick={onBack}
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("admin.backToUsers")}</span>
<span className="ml-auto flex items-center gap-1.5 min-w-0">
<span
className="font-semibold text-foreground truncate max-w-[160px]"
title={user.username}
>
{user.username}
</span>
{user.isAdmin && (
<span className="text-[9px] font-semibold px-1 py-px border border-accent-brand/40 bg-accent-brand/10 text-accent-brand">
{t("admin.adminBadge")}
</span>
)}
{!dataUnlocked && (
<span className="flex items-center gap-0.5 text-[9px] font-semibold px-1 py-px border border-border text-muted-foreground">
<Lock className="size-2.5" />
{t("admin.dataLockedBadge")}
</span>
)}
</span>
</button>
<TabStrip
tabs={manageTabs.map((tab) => ({ ...tab, icon: null }))}
activeTab={activeTab}
onTabChange={(id) => setActiveTab(id as ManageTabId)}
/>
</div>
<div className="flex-1 min-h-0 overflow-y-auto px-3 py-3 flex flex-col gap-4">
{activeTab === "account" && (
<>
{/* Password reset */}
<div className="flex flex-col gap-2">
{sectionHeading(t("admin.resetPasswordTitle"))}
{user.isExternal && !user.passwordHash ? (
<span className="text-xs text-muted-foreground">
{t("admin.resetPasswordExternalOnly")}
</span>
) : (
<div className="flex items-center gap-2">
<PasswordInput
className="h-8 text-xs pr-8 flex-1"
placeholder={t("admin.resetPasswordPlaceholder")}
value={resetPassword}
onChange={(e) => setResetPassword(e.target.value)}
/>
<Button
variant="outline"
size="sm"
className="h-8 text-xs border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand shrink-0"
disabled={resetLoading || !resetPassword}
onClick={() => handleResetPassword()}
>
{resetLoading
? t("admin.resetPasswordWorking")
: t("admin.resetPasswordBtn")}
</Button>
</div>
)}
</div>
<AdminSecondFactorsSection
userId={user.id}
username={user.username}
heading={sectionHeading(t("admin.secondFactorsSectionTitle"))}
requestConfirm={(message, onConfirm) =>
setConfirmDialog({ message, onConfirm })
}
onReset={onSecondFactorsReset}
/>
{/* Roles */}
<div className="flex flex-col gap-2">
{sectionHeading(t("admin.userRoles"))}
{userRoles.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{userRoles.map((ur) => {
const roleInfo = roles.find((r) => r.id === ur.roleId);
const isSystem = roleInfo?.isSystem ?? false;
return (
<span
key={ur.roleId}
className="inline-flex items-center gap-1 text-[10px] font-semibold px-1.5 py-0.5 border border-accent-brand/40 bg-accent-brand/10 text-accent-brand"
>
{roleLabel(t, ur.roleDisplayName)}
{!isSystem && (
<button
onClick={async () => {
try {
await removeRoleFromUser(user.id, ur.roleId);
setUserRoles((prev) =>
prev.filter((r) => r.roleId !== ur.roleId),
);
} catch {
toast.error(t("admin.removeRoleFailed"));
}
}}
className="hover:text-destructive ml-0.5"
>
×
</button>
)}
</span>
);
})}
</div>
)}
<div className="flex flex-wrap gap-1.5">
{roles
.filter(
(r) =>
!r.isSystem &&
!userRoles.some((ur) => ur.roleId === r.id),
)
.map((r) => (
<button
key={r.id}
onClick={async () => {
try {
await assignRoleToUser(user.id, r.id);
setUserRoles((prev) => [
...prev,
{
userId: user.id,
roleId: r.id,
roleName: r.name,
roleDisplayName: r.displayName,
grantedBy: "",
grantedByUsername: "",
grantedAt: new Date().toISOString(),
},
]);
} catch {
toast.error(t("admin.assignRoleFailed"));
}
}}
className="inline-flex items-center gap-1 text-[10px] font-semibold px-1.5 py-0.5 border border-border text-muted-foreground hover:border-accent-brand/40 hover:text-accent-brand transition-colors"
>
+ {r.displayName}
</button>
))}
</div>
</div>
{/* API keys */}
<div className="flex flex-col gap-2">
{sectionHeading(t("admin.manageApiKeys"))}
<div className="flex items-center gap-2">
<Input
className="h-8 text-xs flex-1"
placeholder={t("admin.apiKeyNamePlaceholder")}
value={newKeyName}
onChange={(e) => setNewKeyName(e.target.value)}
/>
<Button
variant="outline"
size="sm"
className="h-8 text-xs border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand shrink-0"
disabled={newKeyLoading || !newKeyName.trim()}
onClick={handleCreateApiKey}
>
<Plus className="size-3" />
{t("admin.createKey")}
</Button>
</div>
{createdKeyToken && (
<div className="flex flex-col gap-1 p-2 border border-accent-brand/30 bg-accent-brand/5">
<span className="text-[10px] text-muted-foreground">
{t("admin.apiKeyCopyNotice")}
</span>
<code className="text-[10px] font-mono break-all text-accent-brand">
{createdKeyToken}
</code>
</div>
)}
{apiKeys.length === 0 ? (
<span className="text-xs text-muted-foreground">
{t("admin.noApiKeysForUser")}
</span>
) : (
apiKeys.map((key) => (
<div
key={key.id}
className="flex items-center justify-between py-1.5 border-b border-border last:border-0"
>
<div className="flex flex-col gap-0.5 min-w-0">
<span className="text-xs font-semibold truncate">
{key.name}
</span>
<span className="text-[10px] font-mono text-muted-foreground">
{key.tokenPrefix}…
</span>
</div>
<Button
variant="ghost"
size="icon"
className="size-6 text-muted-foreground hover:text-destructive shrink-0"
onClick={async () => {
try {
await deleteApiKey(key.id);
setApiKeys((prev) =>
prev.filter((k) => k.id !== key.id),
);
} catch {
toast.error(t("admin.apiKeyDeleteFailed"));
}
}}
>
<Trash2 className="size-3" />
</Button>
</div>
))
)}
</div>
{/* Data export */}
<div className="flex flex-col gap-2">
{sectionHeading(t("admin.exportUserData"))}
<div className="flex items-center justify-between">
<span className="text-xs text-muted-foreground">
{t("admin.exportUserDataDesc")}
</span>
<Button
variant="outline"
size="sm"
className="h-7 text-[10px] shrink-0 ml-3"
disabled={exportLoading || !dataUnlocked}
onClick={handleExport}
>
<Download className="size-3" />
{exportLoading ? t("admin.exporting") : t("admin.export")}
</Button>
</div>
{!dataUnlocked && lockedNotice}
</div>
</>
)}
{activeTab === "hosts" &&
(!dataUnlocked ? (
lockedNotice
) : (
<div className="flex flex-col">
<div className="flex items-center justify-between py-2 border-b border-border">
<span className="text-[10px] text-muted-foreground">
{t("admin.hostsCount", { count: hosts.length })}
</span>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon"
className="size-6 text-muted-foreground hover:text-foreground"
onClick={reloadHosts}
>
<RefreshCw className="size-3" />
</Button>
<Button
variant="outline"
size="sm"
className="h-6 text-[10px] border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
onClick={() => {
setEditorProtocols(hostProtocolFlags(null));
setEditor({ kind: "host", host: null });
}}
>
<Plus className="size-3" />
{t("admin.addHostForUser")}
</Button>
</div>
</div>
{!loading && hosts.length === 0 && (
<span className="text-xs text-muted-foreground py-3">
{t("admin.noHostsForUser")}
</span>
)}
{hosts.map((host) => (
<div
key={host.id}
className="flex items-center justify-between py-2.5 border-b border-border last:border-0"
>
<div className="flex flex-col gap-0.5 min-w-0">
<span className="text-xs font-semibold truncate max-w-[180px]">
{host.name || host.ip}
</span>
<span className="text-[10px] font-mono text-muted-foreground truncate">
{host.username ? `${host.username}@` : ""}
{host.ip}:{host.port}
</span>
</div>
<div className="flex items-center gap-0.5 shrink-0">
{onOpenHostTab && (
<Button
variant="ghost"
size="icon"
className="size-6 text-muted-foreground hover:text-accent-brand"
title={t("admin.connectToHost")}
onClick={() => onOpenHostTab(host)}
>
<SquareTerminal className="size-3" />
</Button>
)}
<Button
variant="ghost"
size="icon"
className="size-6 text-muted-foreground hover:text-foreground"
onClick={() => {
setEditorProtocols(hostProtocolFlags(host));
setEditor({ kind: "host", host });
}}
>
<Pencil className="size-3" />
</Button>
<Button
variant="ghost"
size="icon"
className="size-6 text-muted-foreground hover:text-destructive"
onClick={() =>
setConfirmDialog({
message: t("admin.deleteHostConfirm", {
name: host.name || host.ip,
username: user.username,
}),
onConfirm: async () => {
try {
await adminDeleteUserHost(
user.id,
Number(host.id),
);
setHosts((prev) =>
prev.filter((h) => h.id !== host.id),
);
toast.success(t("admin.hostDeletedSuccess"));
} catch (e) {
toast.error(
apiErrorMessage(e, t("admin.hostDeleteFailed")),
);
}
},
})
}
>
<Trash2 className="size-3" />
</Button>
</div>
</div>
))}
</div>
))}
{activeTab === "credentials" &&
(!dataUnlocked ? (
lockedNotice
) : (
<div className="flex flex-col">
<div className="flex items-center justify-between py-2 border-b border-border">
<span className="text-[10px] text-muted-foreground">
{t("admin.credentialsCount", { count: credentials.length })}
</span>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon"
className="size-6 text-muted-foreground hover:text-foreground"
onClick={reloadCredentials}
>
<RefreshCw className="size-3" />
</Button>
<Button
variant="outline"
size="sm"
className="h-6 text-[10px] border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
onClick={() => {
setEditor({ kind: "credential", credential: null });
}}
>
<Plus className="size-3" />
{t("admin.addCredentialForUser")}
</Button>
</div>
</div>
{!loading && credentials.length === 0 && (
<span className="text-xs text-muted-foreground py-3">
{t("admin.noCredentialsForUser")}
</span>
)}
{credentials.map((cred) => (
<div
key={cred.id}
className="flex items-center justify-between py-2.5 border-b border-border last:border-0"
>
<div className="flex items-center gap-2 min-w-0">
<KeyRound className="size-3.5 text-muted-foreground shrink-0" />
<div className="flex flex-col gap-0.5 min-w-0">
<span className="text-xs font-semibold truncate max-w-[180px]">
{cred.name}
</span>
<span className="flex gap-2.5 text-[10px] text-muted-foreground min-w-0">
<span className="truncate">{cred.username || "-"}</span>
<span className="shrink-0">{cred.type}</span>
</span>
</div>
</div>
<div className="flex items-center gap-0.5 shrink-0">
<Button
variant="ghost"
size="icon"
className="size-6 text-muted-foreground hover:text-foreground"
onClick={() => {
setEditor({ kind: "credential", credential: cred });
}}
>
<Pencil className="size-3" />
</Button>
<Button
variant="ghost"
size="icon"
className="size-6 text-muted-foreground hover:text-destructive"
onClick={() =>
setConfirmDialog({
message: t("admin.deleteCredentialConfirm", {
name: cred.name,
username: user.username,
}),
onConfirm: async () => {
try {
await adminDeleteUserCredential(
user.id,
Number(cred.id),
);
setCredentials((prev) =>
prev.filter((c) => c.id !== cred.id),
);
toast.success(
t("admin.credentialDeletedSuccess"),
);
} catch (e) {
toast.error(
apiErrorMessage(
e,
t("admin.credentialDeleteFailed"),
),
);
}
},
})
}
>
<Trash2 className="size-3" />
</Button>
</div>
</div>
))}
</div>
))}
{activePluginTab &&
(!dataUnlocked ? (
lockedNotice
) : (
<activePluginTab.component user={user} />
))}
{activeTab === "sessions" && (
<div className="flex flex-col">
<div className="flex items-center justify-between py-2 border-b border-border">
<span className="text-[10px] text-muted-foreground">
{t("admin.sessionsActive", { count: sessions.length })}
</span>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon"
className="size-6 text-muted-foreground hover:text-foreground"
onClick={reloadSessions}
>
<RefreshCw className="size-3" />
</Button>
{sessions.length > 0 && (
<Button
variant="outline"
size="sm"
className="h-6 text-[10px] border-destructive/40 text-destructive hover:bg-destructive/10 hover:text-destructive"
onClick={async () => {
try {
await revokeAllUserSessions(user.id);
setSessions([]);
toast.success(t("admin.allSessionsRevoked"));
} catch {
toast.error(t("admin.revokeSessionsFailed"));
}
}}
>
{t("admin.revokeAll")}
</Button>
)}
</div>
</div>
{!loading && sessions.length === 0 && (
<span className="text-xs text-muted-foreground py-3">
{t("admin.noSessionsForUser")}
</span>
)}
{sessions.map((session) => (
<div
key={session.id}
className="flex items-start justify-between py-2.5 border-b border-border last:border-0 gap-2"
>
<div className="flex flex-col gap-0.5 min-w-0 flex-1">
<span className="text-[10px] text-muted-foreground truncate">
{session.deviceInfo}
</span>
<span className="text-[10px] text-muted-foreground">
{t("admin.sessionActive", { time: session.lastActiveAt })}
</span>
</div>
<Button
variant="ghost"
size="icon"
className="size-6 text-muted-foreground hover:text-destructive shrink-0"
onClick={async () => {
try {
await revokeSession(session.id);
setSessions((prev) =>
prev.filter((s) => s.id !== session.id),
);
} catch {
toast.error(t("admin.revokeSessionFailed"));
}
}}
>
<Trash2 className="size-3" />
</Button>
</div>
))}
</div>
)}
{activeTab === "danger" && (
<div className="flex flex-col gap-3">
<div className="flex items-start gap-2.5 border border-destructive/30 bg-destructive/5 px-3 py-2.5">
<TriangleAlert className="size-4 text-destructive shrink-0 mt-0.5" />
<div className="flex flex-col gap-2 flex-1">
<span className="text-xs text-muted-foreground">
{t("admin.deleteUserDangerDesc", {
username: user.username,
})}
</span>
<label className="flex flex-col gap-1 text-[10px] text-muted-foreground">
{t("admin.deleteSuccessorLabel")}
<Select2
value={successor}
onChange={(e) => setSuccessor(e.target.value)}
className="h-7 border border-border bg-background px-2 text-xs text-foreground outline-none"
>
<option value="me">{t("admin.deleteSuccessorMe")}</option>
{successorOptions.map((u) => (
<option key={u.id} value={u.id}>
{u.username}
</option>
))}
<option value="none">
{t("admin.deleteSuccessorNone")}
</option>
</Select2>
<span>{t("admin.deleteSuccessorDesc")}</span>
</label>
<Button
variant="outline"
size="sm"
className="h-7 text-[10px] border-destructive/40 text-destructive hover:bg-destructive/10 hover:text-destructive self-start"
disabled={deleteLoading || user.isAdmin}
onClick={() =>
setConfirmDialog({
message: t("admin.deleteUserConfirm", {
username: user.username,
}),
onConfirm: handleDeleteUser,
})
}
>
<Trash2 className="size-3" />
{t("admin.deleteUser", { username: user.username })}
</Button>
{user.isAdmin && (
<span className="text-[10px] text-muted-foreground">
{t("admin.deleteUserAdminBlocked")}
</span>
)}
</div>
</div>
</div>
)}
</div>
</div>
);
}
Loaded 100 of 234 files, more files were not shown because too many files have changed in this diff. Show more