release-2.4.1 (#921)

* chore(deps-dev): bump the dev-minor-updates group across 1 directory with 12 updates (#910)

Bumps the dev-minor-updates group with 12 updates in the / directory:

| Package | From | To |
| --- | --- | --- |
| [@radix-ui/react-select](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/select) | `2.2.6` | `2.3.1` |
| [@radix-ui/react-slider](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/slider) | `1.3.6` | `1.4.1` |
| [@radix-ui/react-slot](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/slot) | `1.2.5` | `1.3.0` |
| [@radix-ui/react-switch](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/switch) | `1.2.6` | `1.3.1` |
| [cytoscape](https://github.com/cytoscape/cytoscape.js) | `3.33.4` | `3.34.0` |
| [electron](https://github.com/electron/electron) | `42.2.0` | `42.4.1` |
| [electron-builder](https://github.com/electron-userland/electron-builder/tree/HEAD/packages/electron-builder) | `26.8.1` | `26.15.3` |
| [lucide-react](https://github.com/lucide-icons/lucide/tree/HEAD/packages/lucide-react) | `1.16.0` | `1.20.0` |
| [radix-ui](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/radix-ui) | `1.4.3` | `1.6.0` |
| [react-hook-form](https://github.com/react-hook-form/react-hook-form) | `7.76.1` | `7.79.0` |
| [sharp](https://github.com/lovell/sharp) | `0.34.5` | `0.35.1` |
| [typescript-eslint](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/typescript-eslint) | `8.60.0` | `8.61.1` |



Updates `@radix-ui/react-select` from 2.2.6 to 2.3.1
- [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/select/CHANGELOG.md)
- [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/select)

Updates `@radix-ui/react-slider` from 1.3.6 to 1.4.1
- [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/slider/CHANGELOG.md)
- [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/slider)

Updates `@radix-ui/react-slot` from 1.2.5 to 1.3.0
- [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/slot/CHANGELOG.md)
- [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/slot)

Updates `@radix-ui/react-switch` from 1.2.6 to 1.3.1
- [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/switch/CHANGELOG.md)
- [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/switch)

Updates `cytoscape` from 3.33.4 to 3.34.0
- [Release notes](https://github.com/cytoscape/cytoscape.js/releases)
- [Commits](https://github.com/cytoscape/cytoscape.js/compare/v3.33.4...v3.34.0)

Updates `electron` from 42.2.0 to 42.4.1
- [Release notes](https://github.com/electron/electron/releases)
- [Commits](https://github.com/electron/electron/compare/v42.2.0...v42.4.1)

Updates `electron-builder` from 26.8.1 to 26.15.3
- [Release notes](https://github.com/electron-userland/electron-builder/releases)
- [Changelog](https://github.com/electron-userland/electron-builder/blob/master/packages/electron-builder/CHANGELOG.md)
- [Commits](https://github.com/electron-userland/electron-builder/commits/electron-builder@26.15.3/packages/electron-builder)

Updates `lucide-react` from 1.16.0 to 1.20.0
- [Release notes](https://github.com/lucide-icons/lucide/releases)
- [Commits](https://github.com/lucide-icons/lucide/commits/1.20.0/packages/lucide-react)

Updates `radix-ui` from 1.4.3 to 1.6.0
- [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/radix-ui/CHANGELOG.md)
- [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/radix-ui)

Updates `react-hook-form` from 7.76.1 to 7.79.0
- [Release notes](https://github.com/react-hook-form/react-hook-form/releases)
- [Changelog](https://github.com/react-hook-form/react-hook-form/blob/master/CHANGELOG.md)
- [Commits](https://github.com/react-hook-form/react-hook-form/compare/v7.76.1...v7.79.0)

Updates `sharp` from 0.34.5 to 0.35.1
- [Release notes](https://github.com/lovell/sharp/releases)
- [Commits](https://github.com/lovell/sharp/compare/v0.34.5...v0.35.1)

Updates `typescript-eslint` from 8.60.0 to 8.61.1
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/typescript-eslint/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.61.1/packages/typescript-eslint)

---
updated-dependencies:
- dependency-name: "@radix-ui/react-select"
  dependency-version: 2.3.1
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: dev-minor-updates
- dependency-name: "@radix-ui/react-slider"
  dependency-version: 1.4.1
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: dev-minor-updates
- dependency-name: "@radix-ui/react-slot"
  dependency-version: 1.3.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: dev-minor-updates
- dependency-name: "@radix-ui/react-switch"
  dependency-version: 1.3.1
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: dev-minor-updates
- dependency-name: cytoscape
  dependency-version: 3.34.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: dev-minor-updates
- dependency-name: electron
  dependency-version: 42.4.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: dev-minor-updates
- dependency-name: electron-builder
  dependency-version: 26.15.3
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: dev-minor-updates
- dependency-name: lucide-react
  dependency-version: 1.18.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: dev-minor-updates
- dependency-name: radix-ui
  dependency-version: 1.6.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: dev-minor-updates
- dependency-name: react-hook-form
  dependency-version: 7.79.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: dev-minor-updates
- dependency-name: sharp
  dependency-version: 0.35.1
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: dev-minor-updates
- dependency-name: typescript-eslint
  dependency-version: 8.61.1
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: dev-minor-updates
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* chore(deps): bump node in /docker in the docker-major-updates group (#914)

Bumps the docker-major-updates group in /docker with 1 update: node.


Updates `node` from 24-slim to 26-slim

---
updated-dependencies:
- dependency-name: node
  dependency-version: 26-slim
  dependency-type: direct:production
  dependency-group: docker-major-updates
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* ci(deps): bump the github-actions group with 2 updates (#915)

Bumps the github-actions group with 2 updates: [actions/checkout](https://github.com/actions/checkout) and [actions/setup-node](https://github.com/actions/setup-node).


Updates `actions/checkout` from 5 to 6
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v5...v6)

Updates `actions/setup-node` from 4 to 6
- [Release notes](https://github.com/actions/setup-node/releases)
- [Commits](https://github.com/actions/setup-node/compare/v4...v6)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions
- dependency-name: actions/setup-node
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* chore(deps): bump the prod-minor-updates group across 1 directory with 5 updates (#916)

Bumps the prod-minor-updates group with 5 updates in the / directory:

| Package | From | To |
| --- | --- | --- |
| [axios](https://github.com/axios/axios) | `1.17.0` | `1.18.0` |
| [better-sqlite3](https://github.com/WiseLibs/better-sqlite3) | `12.10.0` | `12.11.1` |
| [body-parser](https://github.com/expressjs/body-parser) | `2.2.2` | `2.3.0` |
| [multer](https://github.com/expressjs/multer) | `2.1.1` | `2.2.0` |
| [undici](https://github.com/nodejs/undici) | `8.4.0` | `8.5.0` |



Updates `axios` from 1.17.0 to 1.18.0
- [Release notes](https://github.com/axios/axios/releases)
- [Changelog](https://github.com/axios/axios/blob/v1.x/CHANGELOG.md)
- [Commits](https://github.com/axios/axios/compare/v1.17.0...v1.18.0)

Updates `better-sqlite3` from 12.10.0 to 12.11.1
- [Release notes](https://github.com/WiseLibs/better-sqlite3/releases)
- [Commits](https://github.com/WiseLibs/better-sqlite3/compare/v12.10.0...v12.11.1)

Updates `body-parser` from 2.2.2 to 2.3.0
- [Release notes](https://github.com/expressjs/body-parser/releases)
- [Changelog](https://github.com/expressjs/body-parser/blob/master/HISTORY.md)
- [Commits](https://github.com/expressjs/body-parser/compare/v2.2.2...v2.3.0)

Updates `multer` from 2.1.1 to 2.2.0
- [Release notes](https://github.com/expressjs/multer/releases)
- [Changelog](https://github.com/expressjs/multer/blob/main/CHANGELOG.md)
- [Commits](https://github.com/expressjs/multer/compare/v2.1.1...v2.2.0)

Updates `undici` from 8.4.0 to 8.5.0
- [Release notes](https://github.com/nodejs/undici/releases)
- [Commits](https://github.com/nodejs/undici/compare/v8.4.0...v8.5.0)

---
updated-dependencies:
- dependency-name: axios
  dependency-version: 1.18.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: prod-minor-updates
- dependency-name: better-sqlite3
  dependency-version: 12.11.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: prod-minor-updates
- dependency-name: body-parser
  dependency-version: 2.3.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: prod-minor-updates
- dependency-name: multer
  dependency-version: 2.2.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: prod-minor-updates
- dependency-name: undici
  dependency-version: 8.5.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: prod-minor-updates
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* feat: add unlink button for dual auth

* fix: general bug fixes

* fix: general qol/small feature additions

* fix: 100mb max upload size

* fix: terminal syntax not handling carriage returns or shell prompt lines properly

* feat: continued general improvements

* fix: terminal outputting success right after folder path

* chore: update release notes

* feat: continued fixes and improvements

* chore: lint, format, and bump version to 2.4.1

* chore: sync Crowdin translations for 2.4.1

* fix: rebase dev branch onto main before Crowdin download

* fix: use merge instead of rebase to sync main before Crowdin

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
This commit is contained in:
Luke Gustafson
2026-06-21 15:55:41 -05:00
committed by GitHub
parent 8072b4ede9
commit d1a8dcf3c6
165 changed files with 87244 additions and 60766 deletions
+25 -1
View File
@@ -17,6 +17,7 @@ import {
RefreshCw,
Share2,
Trash2,
Unlink,
User,
} from "lucide-react";
import { toast } from "sonner";
@@ -68,6 +69,10 @@ type UsersSectionProps = {
SetStateAction<{ id: string; username: string; isOidc: boolean } | null>
>;
setLinkAccountOpen: Dispatch<SetStateAction<boolean>>;
setUnlinkAccountTarget: Dispatch<
SetStateAction<{ id: string; username: string } | null>
>;
setUnlinkAccountOpen: Dispatch<SetStateAction<boolean>>;
};
export function AdminUsersSection({
@@ -81,6 +86,8 @@ export function AdminUsersSection({
setEditUserOpen,
setLinkAccountTarget,
setLinkAccountOpen,
setUnlinkAccountTarget,
setUnlinkAccountOpen,
}: UsersSectionProps) {
const { t } = useTranslation();
@@ -160,11 +167,28 @@ export function AdminUsersSection({
>
<Pencil className="size-3" />
</Button>
{!(user.isOidc && user.passwordHash) && (
{user.isOidc && 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,
+159 -4
View File
@@ -9,7 +9,7 @@ import {
deleteUser,
revokeAllUserSessions,
createRole,
registerUser,
adminCreateUser,
makeUserAdmin,
removeAdminStatus,
getRegistrationAllowed,
@@ -28,6 +28,8 @@ import {
updateGuacamoleSettings,
getOidcAutoProvision,
updateOidcAutoProvision,
getOidcSilentLoginDefault,
updateOidcSilentLoginDefault,
getCommandHistoryEnabled,
updateCommandHistoryEnabled,
isElectron,
@@ -36,7 +38,16 @@ import {
import {
getTailscaleSettings,
updateTailscaleSettings,
getHostDefaults,
updateHostDefaults,
type HostDefaults,
} from "@/api/settings-api";
import {
getAcmeSslSettings,
updateAcmeSslSettings,
requestAcmeCertificate,
type AcmeSettings,
} from "@/api/acme-ssl-api";
import {
getAdminSSOProviders,
updateSSOProvider,
@@ -58,7 +69,9 @@ import { getBasePath } from "@/lib/base-path";
import {
AdminDatabaseSection,
AdminGeneralSettingsSection,
AdminHostDefaultsSection,
AdminSSOSection,
AdminSSLSection,
} from "./AdminSettingsSections";
import { SSOProviderDialog } from "./SSOProviderDialog";
import { AdminApiKeysSection } from "./AdminApiKeysSection";
@@ -67,6 +80,7 @@ import {
AdminCreateUserDialog,
AdminEditUserDialog,
AdminLinkAccountDialog,
AdminUnlinkAccountDialog,
} from "./AdminUserDialogs";
type ApiErrorLike = {
@@ -97,9 +111,11 @@ export function AdminSettingsPanel() {
const [logLevel, setLogLevel] = useState("info");
const [tailscaleApiKey, setTailscaleApiKey] = useState("");
const [commandHistoryEnabled, setCommandHistoryEnabled] = useState(true);
const [hostDefaults, setHostDefaults] = useState<HostDefaults>({});
// SSO / auto-provision state
const [oidcAutoProvision, setOidcAutoProvision] = useState(false);
const [oidcSilentLoginDefault, setOidcSilentLoginDefault] = useState(false);
const [ssoProviders, setSsoProviders] = useState<SSOProvider[]>([]);
const [ssoDialogOpen, setSsoDialogOpen] = useState(false);
const [ssoDialogProvider, setSsoDialogProvider] =
@@ -127,6 +143,13 @@ export function AdminSettingsPanel() {
isOidc: 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("");
@@ -147,6 +170,22 @@ export function AdminSettingsPanel() {
const [exportLoading, setExportLoading] = useState(false);
const [importLoading, setImportLoading] = useState(false);
// ACME SSL state
const defaultAcmeSettings: AcmeSettings = {
enabled: false,
domain: "",
email: "",
challengeType: "http-webroot",
cloudflareToken: "",
lastIssuedAt: null,
certStatus: "none",
certExpiresAt: null,
};
const [acmeSettings, setAcmeSettings] =
useState<AcmeSettings>(defaultAcmeSettings);
const [cloudflareTokenDraft, setCloudflareTokenDraft] = useState("");
const [acmeRequesting, setAcmeRequesting] = useState(false);
const [users, setUsers] = useState<AdminUser[]>([]);
const [sessions, setSessions] = useState<AdminSession[]>([]);
const [roles, setRoles] = useState<Role[]>([]);
@@ -217,6 +256,7 @@ export function AdminSettingsPanel() {
level,
guac,
oidcProv,
oidcSilent,
tailscale,
cmdHistory,
] = await Promise.allSettled([
@@ -228,6 +268,7 @@ export function AdminSettingsPanel() {
getLogLevel(),
getGuacamoleSettings(),
getOidcAutoProvision(),
getOidcSilentLoginDefault(),
getTailscaleSettings(),
getCommandHistoryEnabled(),
]);
@@ -237,6 +278,8 @@ export function AdminSettingsPanel() {
setAllowPasswordLogin(pwLogin.value.allowed);
if (oidcProv.status === "fulfilled")
setOidcAutoProvision(oidcProv.value.enabled);
if (oidcSilent.status === "fulfilled")
setOidcSilentLoginDefault(oidcSilent.value.enabled);
if (pwReset.status === "fulfilled") setAllowPasswordReset(pwReset.value);
if (timeout.status === "fulfilled")
setSessionTimeout(String(timeout.value.timeoutHours));
@@ -258,6 +301,14 @@ export function AdminSettingsPanel() {
} catch {
// non-fatal
}
getHostDefaults()
.then((d) => setHostDefaults(d))
.catch(() => {});
getAcmeSslSettings()
.then((s) => setAcmeSettings(s))
.catch(() => {});
}
async function loadSSOProviders() {
@@ -273,6 +324,15 @@ export function AdminSettingsPanel() {
setOpenSection((prev) => (prev === id ? null : id));
}
async function handleSaveHostDefaults() {
try {
await updateHostDefaults(hostDefaults);
toast.success(t("admin.hostDefaultsSaved"));
} catch {
toast.error(t("admin.hostDefaultsSaveFailed"));
}
}
async function handleToggleRegistration() {
const newVal = !allowRegistration;
setAllowRegistration(newVal);
@@ -289,9 +349,10 @@ export function AdminSettingsPanel() {
setAllowPasswordLogin(newVal);
try {
await updatePasswordLoginAllowed(newVal);
} catch {
} catch (e) {
setAllowPasswordLogin(!newVal);
toast.error(t("admin.updatePasswordLoginFailed"));
const msg = (e as ApiErrorLike).response?.data?.error;
toast.error(msg || t("admin.updatePasswordLoginFailed"));
}
}
@@ -306,6 +367,17 @@ export function AdminSettingsPanel() {
}
}
async function handleToggleOidcSilentLoginDefault() {
const newVal = !oidcSilentLoginDefault;
setOidcSilentLoginDefault(newVal);
try {
await updateOidcSilentLoginDefault(newVal);
} catch {
setOidcSilentLoginDefault(!newVal);
toast.error(t("admin.updateOidcSilentLoginDefaultFailed"));
}
}
async function handleTogglePasswordReset() {
const newVal = !allowPasswordReset;
setAllowPasswordReset(newVal);
@@ -435,6 +507,50 @@ export function AdminSettingsPanel() {
}
}
async function handleSaveAcmeSettings() {
try {
const payload: Parameters<typeof updateAcmeSslSettings>[0] = {
enabled: acmeSettings.enabled,
domain: acmeSettings.domain,
email: acmeSettings.email,
challengeType: acmeSettings.challengeType,
...(cloudflareTokenDraft && { cloudflareToken: cloudflareTokenDraft }),
};
const updated = await updateAcmeSslSettings(payload);
setAcmeSettings(updated);
setCloudflareTokenDraft("");
toast.success(t("admin.sslSaved"));
} catch {
toast.error(t("admin.sslSaveFailed"));
}
}
async function handleRequestAcmeCertificate() {
if (!acmeSettings.domain || !acmeSettings.email) {
toast.error(t("admin.sslRequiresDomain"));
return;
}
setAcmeRequesting(true);
try {
if (cloudflareTokenDraft) {
await updateAcmeSslSettings({
domain: acmeSettings.domain,
email: acmeSettings.email,
challengeType: acmeSettings.challengeType,
cloudflareToken: cloudflareTokenDraft,
});
setCloudflareTokenDraft("");
}
const result = await requestAcmeCertificate();
setAcmeSettings(result);
toast.success(t("admin.sslRequestCertSuccess"));
} catch (e) {
toast.error(apiErrorMessage(e, t("admin.sslRequestCertFailed")));
} finally {
setAcmeRequesting(false);
}
}
function handleProviderSaved(saved: SSOProvider) {
setSsoProviders((prev) => {
const idx = prev.findIndex((p) => p.id === saved.id);
@@ -458,7 +574,7 @@ export function AdminSettingsPanel() {
}
setCreateUserLoading(true);
try {
await registerUser(newUsername.trim(), newPassword);
await adminCreateUser(newUsername.trim(), newPassword);
toast.success(t("admin.createUserSuccess", { username: newUsername }));
setCreateUserOpen(false);
setNewUsername("");
@@ -699,6 +815,8 @@ export function AdminSettingsPanel() {
handleTogglePasswordLogin={handleTogglePasswordLogin}
oidcAutoProvision={oidcAutoProvision}
handleToggleOidcAutoProvision={handleToggleOidcAutoProvision}
oidcSilentLoginDefault={oidcSilentLoginDefault}
handleToggleOidcSilentLoginDefault={handleToggleOidcSilentLoginDefault}
allowPasswordReset={allowPasswordReset}
handleTogglePasswordReset={handleTogglePasswordReset}
commandHistoryEnabled={commandHistoryEnabled}
@@ -751,6 +869,8 @@ export function AdminSettingsPanel() {
setEditUserOpen={setEditUserOpen}
setLinkAccountTarget={setLinkAccountTarget}
setLinkAccountOpen={setLinkAccountOpen}
setUnlinkAccountTarget={setUnlinkAccountTarget}
setUnlinkAccountOpen={setUnlinkAccountOpen}
/>
<AdminSessionsSection
@@ -778,6 +898,14 @@ export function AdminSettingsPanel() {
createRoleLoading={createRoleLoading}
/>
<AdminHostDefaultsSection
open={openSection === "host-defaults"}
onToggle={() => toggle("host-defaults")}
defaults={hostDefaults}
setDefaults={setHostDefaults}
handleSaveDefaults={handleSaveHostDefaults}
/>
<AdminDatabaseSection
open={openSection === "database"}
onToggle={() => toggle("database")}
@@ -789,6 +917,18 @@ export function AdminSettingsPanel() {
handleImportDatabase={handleImportDatabase}
/>
<AdminSSLSection
open={openSection === "ssl"}
onToggle={() => toggle("ssl")}
settings={acmeSettings}
setSettings={setAcmeSettings}
cloudflareTokenDraft={cloudflareTokenDraft}
setCloudflareTokenDraft={setCloudflareTokenDraft}
requesting={acmeRequesting}
handleSave={handleSaveAcmeSettings}
handleRequest={handleRequestAcmeCertificate}
/>
<AdminApiKeysSection
open={openSection === "api-keys"}
onToggle={() => toggle("api-keys")}
@@ -850,6 +990,21 @@ export function AdminSettingsPanel() {
setUsers={setUsers}
users={users}
/>
<AdminUnlinkAccountDialog
open={unlinkAccountOpen}
onOpenChange={setUnlinkAccountOpen}
unlinkAccountTarget={unlinkAccountTarget}
onSuccess={(userId) =>
setUsers((prev) =>
prev.map((u) =>
u.id === userId
? { ...u, isOidc: false, passwordHash: undefined }
: u,
),
)
}
/>
</div>
);
}
+410
View File
@@ -2,18 +2,30 @@ import type { Dispatch, SetStateAction } from "react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/button";
import { Input } from "@/components/input";
import { PasswordInput } from "@/components/password-input";
import { SettingRow } from "@/components/section-card";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/select";
import {
Database,
Lock,
Pencil,
Plus,
RefreshCw,
Server,
Settings,
Shield,
Trash2,
} from "lucide-react";
import { AccordionSection, AdminToggle } from "./AdminSettingsShared";
import type { SSOProvider, SSOProviderType } from "@/types/index";
import type { HostDefaults } from "@/api/settings-api";
import type { AcmeSettings, AcmeChallengeType } from "@/api/acme-ssl-api";
type GeneralSettingsSectionProps = {
open: boolean;
@@ -24,6 +36,8 @@ type GeneralSettingsSectionProps = {
handleTogglePasswordLogin: () => void;
oidcAutoProvision: boolean;
handleToggleOidcAutoProvision: () => void;
oidcSilentLoginDefault: boolean;
handleToggleOidcSilentLoginDefault: () => void;
allowPasswordReset: boolean;
handleTogglePasswordReset: () => void;
commandHistoryEnabled: boolean;
@@ -57,6 +71,8 @@ export function AdminGeneralSettingsSection({
handleTogglePasswordLogin,
oidcAutoProvision,
handleToggleOidcAutoProvision,
oidcSilentLoginDefault,
handleToggleOidcSilentLoginDefault,
allowPasswordReset,
handleTogglePasswordReset,
commandHistoryEnabled,
@@ -117,6 +133,15 @@ export function AdminGeneralSettingsSection({
onToggle={handleToggleOidcAutoProvision}
/>
</SettingRow>
<SettingRow
label={t("admin.oidcSilentLoginDefault")}
description={t("admin.oidcSilentLoginDefaultDesc")}
>
<AdminToggle
on={oidcSilentLoginDefault}
onToggle={handleToggleOidcSilentLoginDefault}
/>
</SettingRow>
<SettingRow
label={t("admin.allowPasswordReset")}
description={t("admin.allowPasswordResetDesc")}
@@ -774,3 +799,388 @@ export function AdminSSOSection({
</AccordionSection>
);
}
type AdminHostDefaultsSectionProps = {
open: boolean;
onToggle: () => void;
defaults: HostDefaults;
setDefaults: Dispatch<SetStateAction<HostDefaults>>;
handleSaveDefaults: () => void;
};
export function AdminHostDefaultsSection({
open,
onToggle,
defaults,
setDefaults,
handleSaveDefaults,
}: AdminHostDefaultsSectionProps) {
const { t } = useTranslation();
return (
<AccordionSection
label={t("admin.sectionHostDefaults")}
icon={<Server className="size-3.5" />}
open={open}
onToggle={onToggle}
>
<div className="flex flex-col gap-4 pt-3">
<span className="text-[10px] text-muted-foreground">
{t("admin.hostDefaultsDesc")}
</span>
<div className="flex flex-col gap-2">
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("admin.hostDefaultsSocks5")}
</span>
<SettingRow
label={t("admin.hostDefaultsUseSocks5")}
description={t("admin.hostDefaultsUseSocks5Desc")}
>
<AdminToggle
on={defaults.useSocks5 ?? false}
onToggle={() =>
setDefaults((p) => ({ ...p, useSocks5: !p.useSocks5 }))
}
/>
</SettingRow>
{defaults.useSocks5 && (
<div className="flex flex-col gap-3 ml-4">
<div className="flex flex-col gap-1">
<label className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
{t("admin.hostDefaultsSocks5Host")}
</label>
<div className="flex gap-2">
<Input
value={defaults.socks5Host ?? ""}
onChange={(e) =>
setDefaults((p) => ({ ...p, socks5Host: e.target.value }))
}
placeholder="127.0.0.1"
className="text-xs"
/>
<Input
type="number"
value={defaults.socks5Port ?? 1080}
onChange={(e) =>
setDefaults((p) => ({
...p,
socks5Port: Number(e.target.value),
}))
}
placeholder="1080"
className="text-xs w-24 [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
/>
</div>
</div>
<div className="flex flex-col gap-1">
<label className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
{t("admin.hostDefaultsSocks5Username")}
</label>
<Input
value={defaults.socks5Username ?? ""}
onChange={(e) =>
setDefaults((p) => ({
...p,
socks5Username: e.target.value,
}))
}
className="text-xs"
/>
</div>
<div className="flex flex-col gap-1">
<label className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
{t("admin.hostDefaultsSocks5Password")}
</label>
<PasswordInput
value={defaults.socks5Password ?? ""}
onChange={(e) =>
setDefaults((p) => ({
...p,
socks5Password: e.target.value,
}))
}
className="h-8 text-xs pr-8"
/>
</div>
</div>
)}
</div>
<div className="flex flex-col gap-2 border-t border-border pt-3">
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("admin.hostDefaultsMetrics")}
</span>
<SettingRow
label={t("admin.hostDefaultsMetricsEnabled")}
description={t("admin.hostDefaultsMetricsEnabledDesc")}
>
<AdminToggle
on={defaults.metricsEnabled ?? true}
onToggle={() =>
setDefaults((p) => ({
...p,
metricsEnabled: !(p.metricsEnabled ?? true),
}))
}
/>
</SettingRow>
<SettingRow
label={t("admin.hostDefaultsStatusCheckEnabled")}
description={t("admin.hostDefaultsStatusCheckEnabledDesc")}
>
<AdminToggle
on={defaults.statusCheckEnabled ?? true}
onToggle={() =>
setDefaults((p) => ({
...p,
statusCheckEnabled: !(p.statusCheckEnabled ?? true),
}))
}
/>
</SettingRow>
</div>
<div className="flex flex-col gap-2 border-t border-border pt-3">
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("admin.hostDefaultsTerminal")}
</span>
<SettingRow
label={t("admin.hostDefaultsSessionLogging")}
description={t("admin.hostDefaultsSessionLoggingDesc")}
>
<AdminToggle
on={defaults.enableSessionLogging ?? true}
onToggle={() =>
setDefaults((p) => ({
...p,
enableSessionLogging: !(p.enableSessionLogging ?? true),
}))
}
/>
</SettingRow>
<SettingRow
label={t("admin.hostDefaultsCommandHistory")}
description={t("admin.hostDefaultsCommandHistoryDesc")}
>
<AdminToggle
on={defaults.enableCommandHistory ?? true}
onToggle={() =>
setDefaults((p) => ({
...p,
enableCommandHistory: !(p.enableCommandHistory ?? true),
}))
}
/>
</SettingRow>
</div>
<Button
variant="outline"
size="sm"
className="self-start text-xs border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand h-7"
onClick={handleSaveDefaults}
>
{t("common.save")}
</Button>
</div>
</AccordionSection>
);
}
const CERT_STATUS_STYLES: Record<AcmeSettings["certStatus"], string> = {
none: "text-muted-foreground",
valid: "text-green-500",
expiring: "text-yellow-500",
expired: "text-destructive",
};
type AdminSSLSectionProps = {
open: boolean;
onToggle: () => void;
settings: AcmeSettings;
setSettings: Dispatch<SetStateAction<AcmeSettings>>;
cloudflareTokenDraft: string;
setCloudflareTokenDraft: Dispatch<SetStateAction<string>>;
requesting: boolean;
handleSave: () => void;
handleRequest: () => void;
};
export function AdminSSLSection({
open,
onToggle,
settings,
setSettings,
cloudflareTokenDraft,
setCloudflareTokenDraft,
requesting,
handleSave,
handleRequest,
}: AdminSSLSectionProps) {
const { t } = useTranslation();
const certStatusLabel: Record<AcmeSettings["certStatus"], 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")}{" "}
<a
href="https://docs.termix.site/features/networking/ssl"
target="_blank"
rel="noreferrer"
className="text-accent-brand hover:underline"
>
{t("admin.sslDocsLink")}
</a>
</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[settings.certStatus]}`}
>
{certStatusLabel[settings.certStatus]}
</span>
</div>
{settings.certExpiresAt && (
<span className="text-[10px] text-muted-foreground">
{t("admin.sslCertExpiresAt", {
date: new Date(settings.certExpiresAt).toLocaleDateString(),
})}
</span>
)}
{settings.lastIssuedAt && (
<span className="text-[10px] text-muted-foreground">
{t("admin.sslLastIssued", {
date: new Date(settings.lastIssuedAt).toLocaleString(),
})}
</span>
)}
</div>
<div className="flex flex-col gap-1">
<label className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
{t("admin.sslDomain")}
</label>
<Input
value={settings.domain}
onChange={(e) =>
setSettings((p) => ({ ...p, domain: e.target.value }))
}
placeholder={t("admin.sslDomainPlaceholder")}
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.sslEmail")}
</label>
<Input
value={settings.email}
onChange={(e) =>
setSettings((p) => ({ ...p, email: e.target.value }))
}
placeholder={t("admin.sslEmailPlaceholder")}
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.sslChallengeType")}
</label>
<Select
value={settings.challengeType}
onValueChange={(v) =>
setSettings((p) => ({
...p,
challengeType: v as AcmeChallengeType,
}))
}
>
<SelectTrigger size="sm" className="w-full text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="http-webroot" className="text-xs">
HTTP (webroot)
</SelectItem>
<SelectItem value="dns-cloudflare" className="text-xs">
DNS (Cloudflare)
</SelectItem>
</SelectContent>
</Select>
<span className="text-[10px] text-muted-foreground">
{t("admin.sslChallengeTypeDesc")}
</span>
</div>
{settings.challengeType === "dns-cloudflare" && (
<div className="flex flex-col gap-1">
<label className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
{t("admin.sslCloudflareToken")}
</label>
<PasswordInput
value={cloudflareTokenDraft}
onChange={(e) => setCloudflareTokenDraft(e.target.value)}
placeholder={
settings.cloudflareToken ||
t("admin.sslCloudflareTokenPlaceholder")
}
className="text-xs h-8 pr-8"
/>
<span className="text-[10px] text-muted-foreground">
{t("admin.sslCloudflareTokenDesc")}
</span>
</div>
)}
<span className="text-[10px] text-muted-foreground border-t border-border pt-2">
{t("admin.sslInfoNote")}
</span>
<div className="flex flex-col gap-1.5">
<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={handleSave}
>
{t("admin.sslSave")}
</Button>
<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={handleRequest}
disabled={requesting}
>
<RefreshCw
className={`size-3 ${requesting ? "animate-spin" : ""}`}
/>
{requesting
? t("admin.sslRequestCertLoading")
: t("admin.sslRequestCert")}
</Button>
</div>
</div>
</AccordionSection>
);
}
+75
View File
@@ -4,6 +4,7 @@ import {
assignRoleToUser,
linkOIDCToPasswordAccount,
removeRoleFromUser,
unlinkOIDCFromPasswordAccount,
} from "@/main-axios";
import type { Role, UserRole } from "@/main-axios";
import { Button } from "@/components/button";
@@ -391,6 +392,80 @@ export function AdminEditUserDialog({
);
}
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 unlinkOIDCFromPasswordAccount(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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="w-[calc(100vw-2rem)] sm:max-w-lg">
<DialogHeader>
<DialogTitle className="text-lg font-bold">
{t("admin.unlinkAccountTitle")}
</DialogTitle>
<DialogDescription className="text-xs text-muted-foreground">
{t("admin.unlinkAccountDesc", {
username: unlinkAccountTarget?.username,
})}
</DialogDescription>
</DialogHeader>
<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>
</DialogContent>
</Dialog>
);
}
type LinkAccountDialogProps = {
open: boolean;
onOpenChange: Dispatch<SetStateAction<boolean>>;
+41 -50
View File
@@ -5,6 +5,7 @@ import {
Hammer,
KeyRound,
LayoutPanelLeft,
LogOut,
Network,
Play,
Plug,
@@ -14,12 +15,6 @@ import {
User,
Zap,
} from "lucide-react";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/dropdown-menu";
import type { SplitMode, TabType, ToolsTab } from "@/types/ui-types";
export type RailView =
@@ -131,8 +126,6 @@ export function AppRail({
splitMode,
username,
isAdmin,
profileDropdownOpen,
onProfileDropdownChange,
onRailClick,
onOpenTab,
onLogout,
@@ -142,8 +135,6 @@ export function AppRail({
splitMode: SplitMode;
username: string;
isAdmin: boolean;
profileDropdownOpen: boolean;
onProfileDropdownChange: (open: boolean) => void;
onRailClick: (view: RailView) => void;
onOpenTab?: (type: TabType) => void;
onLogout: () => void;
@@ -182,7 +173,7 @@ export function AppRail({
return () => window.removeEventListener("hiddenRailTabsChanged", handler);
}, []);
const railExpanded = pinned || hovered || profileDropdownOpen;
const railExpanded = pinned || hovered;
const railButtons = buildRailButtons(splitMode, t, hiddenTabs);
return (
@@ -293,50 +284,50 @@ export function AppRail({
</span>
</button>
))}
<div className="mx-2 my-1 border-t border-border" />
<button
onClick={onLogout}
style={btnStyle}
className={`${btnBase} text-muted-foreground hover:text-destructive hover:bg-destructive/10`}
>
<span
className="shrink-0 flex items-center justify-center"
style={{ width: 16, height: 16 }}
>
<LogOut size={16} />
</span>
<span
className={`text-xs font-medium whitespace-nowrap overflow-hidden transition-opacity duration-150 ${railExpanded ? "opacity-100 delay-75" : "opacity-0"}`}
>
{t("common.logout")}
</span>
</button>
</div>
<div className="shrink-0 border-t border-border">
<DropdownMenu
open={profileDropdownOpen}
onOpenChange={onProfileDropdownChange}
<button
className="flex items-center gap-2.5 w-full h-10 text-muted-foreground hover:text-foreground hover:bg-muted/60 transition-colors"
style={{ padding: "0 6px" }}
>
<DropdownMenuTrigger asChild>
<button
className="flex items-center gap-2.5 w-full h-10 text-muted-foreground hover:text-foreground hover:bg-muted/60 transition-colors"
style={{ padding: "0 6px" }}
>
<div
className="rounded-full bg-accent-brand/20 border border-accent-brand/30 flex items-center justify-center font-bold text-accent-brand shrink-0"
style={{ width: 24, height: 24, fontSize: 11 }}
>
{username.charAt(0).toUpperCase() || "U"}
</div>
<div
className={`flex flex-col items-start overflow-hidden transition-opacity duration-150 ${
railExpanded ? "opacity-100 delay-75" : "opacity-0"
}`}
>
<span className="text-xs font-semibold leading-tight whitespace-nowrap">
{username || "User"}
</span>
<span className="text-[10px] text-muted-foreground leading-tight whitespace-nowrap">
{isAdmin ? t("nav.roleAdministrator") : t("nav.roleUser")}
</span>
</div>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent
side="right"
align="end"
sideOffset={1}
className="!w-auto min-w-max [clip-path:inset(-4px_-4px_-4px_0px)]"
<div
className="rounded-full bg-accent-brand/20 border border-accent-brand/30 flex items-center justify-center font-bold text-accent-brand shrink-0"
style={{ width: 24, height: 24, fontSize: 11 }}
>
<DropdownMenuItem variant="destructive" onClick={onLogout}>
<KeyRound size={14} />
Logout
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{username.charAt(0).toUpperCase() || "U"}
</div>
<div
className={`flex flex-col items-start overflow-hidden transition-opacity duration-150 ${
railExpanded ? "opacity-100 delay-75" : "opacity-0"
}`}
>
<span className="text-xs font-semibold leading-tight whitespace-nowrap">
{username || "User"}
</span>
<span className="text-[10px] text-muted-foreground leading-tight whitespace-nowrap">
{isAdmin ? t("nav.roleAdministrator") : t("nav.roleUser")}
</span>
</div>
</button>
</div>
</div>
);
+233 -44
View File
@@ -1,6 +1,6 @@
import { useState, useEffect, useRef, useCallback } from "react";
import { useTranslation } from "react-i18next";
import { ExternalLink, Plug, Search, X } from "lucide-react";
import { ExternalLink, Plug, Search, X, Pencil, Check } from "lucide-react";
import {
getActiveSessions,
deleteOpenTab,
@@ -62,36 +62,65 @@ function ConnectionRow({
isLive,
tabType,
name,
hostName,
subLabel,
icon,
onSwitch,
onClose,
switchTitle,
faded,
onRename,
isDragging,
}: {
isActive?: boolean;
isLive: boolean;
tabType: string;
name: string;
hostName?: string;
subLabel: string;
icon: React.ReactNode;
onSwitch?: () => void;
onClose: () => void;
switchTitle?: string;
faded?: boolean;
onRename?: (newLabel: string) => void;
isDragging?: boolean;
}) {
const { t } = useTranslation();
const [editing, setEditing] = useState(false);
const [editValue, setEditValue] = useState(name);
function startEdit(e: React.MouseEvent) {
e.stopPropagation();
setEditValue(name);
setEditing(true);
}
function commitEdit() {
const trimmed = editValue.trim();
if (trimmed && trimmed !== name && onRename) {
onRename(trimmed);
}
setEditing(false);
}
function handleKeyDown(e: React.KeyboardEvent) {
if (e.key === "Enter") commitEdit();
if (e.key === "Escape") setEditing(false);
}
return (
<div
role={onSwitch ? "button" : undefined}
tabIndex={onSwitch ? 0 : undefined}
onClick={onSwitch}
onKeyDown={(e) => e.key === "Enter" && onSwitch?.()}
role={onSwitch && !editing ? "button" : undefined}
tabIndex={onSwitch && !editing ? 0 : undefined}
onClick={!editing ? onSwitch : undefined}
onKeyDown={(e) => !editing && e.key === "Enter" && onSwitch?.()}
className={`group flex items-center gap-2.5 px-3 py-2.5 border-b border-border/40 transition-colors last:border-b-0 ${
faded ? "opacity-60" : ""
} ${
} ${isDragging ? "opacity-30" : ""} ${
isActive
? "bg-accent-brand/8 cursor-pointer border-l-2 border-l-accent-brand"
: onSwitch
: onSwitch && !editing
? "hover:bg-muted/40 cursor-pointer"
: ""
}`}
@@ -113,13 +142,26 @@ function ConnectionRow({
isLive ? "bg-green-500" : "bg-muted-foreground/30"
}`}
/>
<span
className={`text-xs font-semibold truncate flex-1 ${
isActive ? "text-accent-brand" : "text-foreground"
}`}
>
{name}
</span>
{editing ? (
<input
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
onBlur={commitEdit}
onKeyDown={handleKeyDown}
onClick={(e) => e.stopPropagation()}
onPointerDown={(e) => e.stopPropagation()}
className="text-xs font-semibold flex-1 min-w-0 bg-transparent border-b border-accent-brand outline-none text-foreground"
autoFocus
/>
) : (
<span
className={`text-xs font-semibold truncate flex-1 ${
isActive ? "text-accent-brand" : "text-foreground"
}`}
>
{name}
</span>
)}
<Badge
variant="outline"
className="text-[9px] px-1 py-0 h-4 font-mono shrink-0 text-muted-foreground/60 border-border/60"
@@ -128,13 +170,46 @@ function ConnectionRow({
</Badge>
</div>
<span className="text-[10px] text-muted-foreground/60 truncate pl-3">
{hostName && hostName !== name ? (
<span className="text-muted-foreground/50">
{hostName} &middot;{" "}
</span>
) : null}
{subLabel}
</span>
</div>
<TooltipProvider>
<div className="shrink-0 flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
{switchTitle && onSwitch && (
{editing ? (
<button
onClick={(e) => {
e.stopPropagation();
commitEdit();
}}
className="size-6 flex items-center justify-center text-accent-brand hover:bg-muted/60 rounded transition-colors"
>
<Check className="size-3" />
</button>
) : (
onRename && (
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={startEdit}
onPointerDown={(e) => e.stopPropagation()}
className="size-6 flex items-center justify-center text-muted-foreground/50 hover:text-foreground hover:bg-muted/60 rounded transition-colors"
>
<Pencil className="size-3" />
</button>
</TooltipTrigger>
<TooltipContent side="left">
{t("connections.rename")}
</TooltipContent>
</Tooltip>
)
)}
{switchTitle && onSwitch && !editing && (
<Tooltip>
<TooltipTrigger asChild>
<button
@@ -150,15 +225,17 @@ function ConnectionRow({
<TooltipContent side="left">{switchTitle}</TooltipContent>
</Tooltip>
)}
<button
onClick={(e) => {
e.stopPropagation();
onClose();
}}
className="size-6 flex items-center justify-center text-muted-foreground/50 hover:text-destructive hover:bg-destructive/10 rounded transition-colors"
>
<X className="size-3" />
</button>
{!editing && (
<button
onClick={(e) => {
e.stopPropagation();
onClose();
}}
className="size-6 flex items-center justify-center text-muted-foreground/50 hover:text-destructive hover:bg-destructive/10 rounded transition-colors"
>
<X className="size-3" />
</button>
)}
</div>
</TooltipProvider>
</div>
@@ -187,6 +264,8 @@ export function ConnectionsPanel({
onCloseTab,
onReopenTab,
onForgetBackground,
onRenameTab,
onReorderTabs,
}: {
tabs: Tab[];
activeTabId: string;
@@ -199,6 +278,8 @@ export function ConnectionsPanel({
restoredSessionId: string | null,
) => void;
onForgetBackground: (recordId: string) => void;
onRenameTab?: (tabId: string, newLabel: string) => void;
onReorderTabs?: (tabs: Tab[]) => void;
}) {
const { t } = useTranslation();
const [now, setNow] = useState(Date.now());
@@ -206,11 +287,17 @@ export function ConnectionsPanel({
const [search, setSearch] = useState("");
const pollTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
// Drag-to-reorder state
const [dragTabId, setDragTabId] = useState<string | null>(null);
const [dragOverTabId, setDragOverTabId] = useState<string | null>(null);
const rowEls = useRef<Map<string, HTMLDivElement>>(new Map());
const dragStartY = useRef<number>(0);
const didDragRef = useRef(false);
const openTabs = tabs.filter((tab) =>
CONNECTION_TAB_TYPES.includes(tab.type),
);
// Filter background records to only those not already open in the tab bar
const openInstanceIds = new Set(
tabs.map((t) => t.instanceId).filter(Boolean),
);
@@ -220,9 +307,13 @@ export function ConnectionsPanel({
const q = search.trim().toLowerCase();
const filteredOpenTabs = q
? openTabs.filter((tab) =>
(tab.host?.name ?? tab.label).toLowerCase().includes(q),
)
? openTabs.filter((tab) => {
const displayName = tab.customLabel ?? tab.host?.name ?? tab.label;
return (
displayName.toLowerCase().includes(q) ||
(tab.host?.name ?? "").toLowerCase().includes(q)
);
})
: openTabs;
const filteredBackgroundTabs = q
? backgroundTabs.filter((r) => {
@@ -257,6 +348,69 @@ export function ConnectionsPanel({
};
}, [refresh]);
// Global pointer listeners for drag reorder
useEffect(() => {
if (!dragTabId) return;
function onPointerMove(e: PointerEvent) {
if (Math.abs(e.clientY - dragStartY.current) > 4)
didDragRef.current = true;
if (!didDragRef.current) return;
// Find which row the pointer is over
let overTabId: string | null = null;
rowEls.current.forEach((el, id) => {
if (id === dragTabId) return;
const rect = el.getBoundingClientRect();
if (e.clientY >= rect.top && e.clientY <= rect.bottom) {
overTabId = id;
}
});
setDragOverTabId(overTabId);
}
function onPointerUp(e: PointerEvent) {
if (didDragRef.current && dragTabId && onReorderTabs) {
// Find drop target
let targetTabId: string | null = null;
rowEls.current.forEach((el, id) => {
if (id === dragTabId) return;
const rect = el.getBoundingClientRect();
if (e.clientY >= rect.top && e.clientY <= rect.bottom) {
targetTabId = id;
}
});
if (targetTabId) {
const fromIdx = openTabs.findIndex((t) => t.id === dragTabId);
const toIdx = openTabs.findIndex((t) => t.id === targetTabId);
if (fromIdx !== -1 && toIdx !== -1 && fromIdx !== toIdx) {
const reordered = [...openTabs];
reordered.splice(toIdx, 0, reordered.splice(fromIdx, 1)[0]);
const connectionSet = new Set(CONNECTION_TAB_TYPES as string[]);
const nonConnectionTabs = tabs.filter(
(t) => !connectionSet.has(t.type),
);
onReorderTabs([...nonConnectionTabs, ...reordered]);
}
}
}
setDragTabId(null);
setDragOverTabId(null);
setTimeout(() => {
didDragRef.current = false;
}, 0);
}
window.addEventListener("pointermove", onPointerMove);
window.addEventListener("pointerup", onPointerUp);
return () => {
window.removeEventListener("pointermove", onPointerMove);
window.removeEventListener("pointerup", onPointerUp);
};
}, [dragTabId, openTabs, tabs, onReorderTabs]);
const sessionByInstanceId = new Map(
activeSessions.map((s) => [s.tabInstanceId, s]),
);
@@ -322,24 +476,59 @@ export function ConnectionsPanel({
? formatDuration(now - liveSession.createdAt)
: formatDuration(now - tab.openedAt);
const displayName = tab.customLabel ?? tab.host?.name ?? tab.label;
const hostName = tab.host?.name;
const isDraggingThis = dragTabId === tab.id;
const isDropTarget = dragOverTabId === tab.id && !isDraggingThis;
return (
<ConnectionRow
<div
key={tab.id}
isActive={isActive}
isLive={isLive}
tabType={tab.type}
name={tab.host?.name ?? tab.label}
subLabel={
isLive && tab.type === "terminal"
? t("connections.connectedFor", { duration })
: isLive
? t("connections.connected")
: t("connections.disconnected")
}
icon={tabIcon(tab.type)}
onSwitch={() => onSwitchToTab(tab.id)}
onClose={() => onCloseTab(tab.id)}
/>
ref={(el) => {
if (el) rowEls.current.set(tab.id, el);
else rowEls.current.delete(tab.id);
}}
onPointerDown={(e) => {
if (e.button !== 0) return;
dragStartY.current = e.clientY;
didDragRef.current = false;
setDragTabId(tab.id);
}}
className={`relative ${isDropTarget ? "border-t-2 border-accent-brand" : ""}`}
style={{
cursor: dragTabId
? isDraggingThis
? "grabbing"
: "default"
: "grab",
}}
>
<ConnectionRow
isActive={isActive}
isLive={isLive}
tabType={tab.type}
name={displayName}
hostName={tab.customLabel ? hostName : undefined}
subLabel={
isLive && tab.type === "terminal"
? t("connections.connectedFor", { duration })
: isLive
? t("connections.connected")
: t("connections.disconnected")
}
icon={tabIcon(tab.type)}
onSwitch={() => {
if (!didDragRef.current) onSwitchToTab(tab.id);
}}
onClose={() => onCloseTab(tab.id)}
onRename={
onRenameTab
? (newLabel) => onRenameTab(tab.id, newLabel)
: undefined
}
isDragging={isDraggingThis}
/>
</div>
);
})}
</div>
+8 -3
View File
@@ -56,6 +56,10 @@ export function CredentialEditorView({
const [saving, setSaving] = useState(false);
const handleSave = async () => {
if (!credForm.name.trim()) {
toast.error(t("hosts.credentialNameRequired"));
return;
}
setSaving(true);
try {
const data = {
@@ -92,8 +96,9 @@ export function CredentialEditorView({
);
window.dispatchEvent(new CustomEvent("termix:credentials-changed"));
onSave(saved);
} catch {
toast.error(t("hosts.failedToSaveCredential"));
} catch (err) {
const msg = err instanceof Error ? err.message : null;
toast.error(msg || t("hosts.failedToSaveCredential"));
} finally {
setSaving(false);
}
@@ -327,7 +332,7 @@ export function CredentialEditorView({
<input
ref={credFileInputRef}
type="file"
accept=".pem,.key,.txt,.ppk"
accept=".pem,.key,.txt"
className="hidden"
onChange={async (e) => {
const file = e.target.files?.[0];
+131 -2
View File
@@ -32,8 +32,9 @@ import {
subscribeTunnelStatuses,
connectTunnel,
disconnectTunnel,
getUserInfo,
} from "@/main-axios";
import { getTailscaleDevices } from "@/api/settings-api";
import { getTailscaleDevices, getHostDefaults } from "@/api/settings-api";
import type { Host } from "@/types/ui-types";
import type { SSHHost, TunnelStatus } from "@/types";
import { useTabsSafe } from "@/shell/TabContext";
@@ -110,6 +111,13 @@ export function HostEditor({
const [tailscaleHasApiKey, setTailscaleHasApiKey] = useState(false);
const [tailscaleLoading, setTailscaleLoading] = useState(false);
const [connectingTunnel, setConnectingTunnel] = useState<number | null>(null);
const [isOidcUser, setIsOidcUser] = useState(false);
useEffect(() => {
getUserInfo()
.then((info) => setIsOidcUser(info.is_oidc))
.catch(() => {});
}, []);
useEffect(() => {
getSnippets()
@@ -117,6 +125,13 @@ export function HostEditor({
.catch(() => {});
}, []);
useEffect(() => {
if (host) return;
getHostDefaults()
.then((d) => setForm(createHostEditorForm(null, d)))
.catch(() => {});
}, [host]);
useEffect(() => {
if (activeTab !== "tunnels") return;
const unsub = subscribeTunnelStatuses((s) => setTunnelStatuses(s));
@@ -266,8 +281,19 @@ export function HostEditor({
!!selectedCredential?.username &&
!form.overrideCredentialUsername
}
onFocus={() => {
if (form.username === "root") setField("username", "");
}}
onBlur={() => {
if (form.username === "") setField("username", "root");
}}
onChange={(e) => setField("username", e.target.value)}
/>
{isOidcUser && (
<p className="text-[10px] text-muted-foreground/60">
{t("hosts.oidcUsernameHint")}
</p>
)}
</div>
{authMethod === "password" && (
<div className="flex flex-col gap-1.5">
@@ -337,7 +363,7 @@ export function HostEditor({
</span>
<input
type="file"
accept=".pem,.key,.txt,.ppk"
accept=".pem,.key,.txt"
className="hidden"
onChange={async (e) => {
const file = e.target.files?.[0];
@@ -566,6 +592,18 @@ export function HostEditor({
)}
</div>
)}
{authMethod === "warpgate" && (
<div className="flex flex-col gap-2 border-t border-border pt-3">
<div className="flex items-center justify-between">
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.warpgateLabel")}
</span>
</div>
<p className="text-[10px] text-muted-foreground">
{t("hosts.warpgateDesc")}
</p>
</div>
)}
<SettingRow
label={t("hosts.forceKeyboardInteractiveLabel")}
description={t("hosts.forceKeyboardInteractiveShortDesc")}
@@ -575,6 +613,18 @@ export function HostEditor({
onChange={(v) => setField("forceKeyboardInteractive", v)}
/>
</SettingRow>
<SettingRow
label={t("hosts.allowLegacyAlgorithmsLabel")}
badge={
form.allowLegacyAlgorithms ? t("hosts.insecure") : undefined
}
description={t("hosts.allowLegacyAlgorithmsDesc")}
>
<FakeSwitch
checked={form.allowLegacyAlgorithms}
onChange={(v) => setField("allowLegacyAlgorithms", v)}
/>
</SettingRow>
<SettingRow
label={t("hosts.sudoPasswordAutoFillLabel")}
description={t("hosts.sudoPasswordAutoFillDesc")}
@@ -856,6 +906,44 @@ export function HostEditor({
))}
</div>
)}
<div className="flex flex-col gap-1.5">
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.backgroundImageLabel")}
</label>
<p className="text-[10px] text-muted-foreground">
{t("hosts.backgroundImageDesc")}
</p>
<input
type="url"
value={form.backgroundImage}
onChange={(e) =>
setField("backgroundImage", e.target.value)
}
placeholder="https://example.com/image.jpg"
className="flex h-9 w-full border border-border bg-background px-3 py-1 text-xs outline-none focus:ring-1 focus:ring-ring font-mono"
/>
</div>
{form.backgroundImage && (
<div className="flex flex-col gap-1.5">
<div className="flex items-center justify-between">
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.backgroundImageOpacityLabel")}
</label>
<span className="text-[10px] text-muted-foreground tabular-nums">
{Math.round(form.backgroundImageOpacity * 100)}%
</span>
</div>
<Slider
min={0.05}
max={1}
step={0.05}
value={[form.backgroundImageOpacity]}
onValueChange={([v]) =>
setField("backgroundImageOpacity", v)
}
/>
</div>
)}
</div>
</SectionCard>
@@ -891,6 +979,15 @@ export function HostEditor({
onChange={(v) => setField("agentForwarding", v)}
/>
</SettingRow>
<SettingRow
label={t("hosts.useSSHTitleLabel")}
description={t("hosts.useSSHTitleDesc")}
>
<FakeSwitch
checked={form.useSSHTitle}
onChange={(v) => setField("useSSHTitle", v)}
/>
</SettingRow>
<SettingRow
label={t("hosts.enableAutoMosh")}
description={t("hosts.enableAutoMoshDesc")}
@@ -963,6 +1060,34 @@ export function HostEditor({
onChange={(v) => setField("enableCommandHistory", v)}
/>
</SettingRow>
<div className="flex flex-col gap-1.5">
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.linkClickBehaviorLabel")}
</label>
<select
value={form.linkClickBehavior}
onChange={(e) =>
setField(
"linkClickBehavior",
e.target.value as "default" | "confirm" | "direct",
)
}
className="flex h-9 w-full border border-border bg-background px-3 py-1 text-xs outline-none focus:ring-1 focus:ring-ring"
>
<option value="default">
{t("hosts.linkClickBehaviorDefault")}
</option>
<option value="confirm">
{t("hosts.linkClickBehaviorConfirm")}
</option>
<option value="direct">
{t("hosts.linkClickBehaviorDirect")}
</option>
</select>
<p className="text-[10px] text-muted-foreground">
{t("hosts.linkClickBehaviorDesc")}
</p>
</div>
<div className="flex flex-col gap-3">
<div className="flex items-center justify-between">
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
@@ -1540,6 +1665,7 @@ export function HostEditor({
setField={setField}
setGuacField={setGuacField}
host={host}
credentials={credentials}
/>
)}
@@ -1549,6 +1675,7 @@ export function HostEditor({
setField={setField}
setGuacField={setGuacField}
host={host}
credentials={credentials}
/>
)}
@@ -1557,6 +1684,8 @@ export function HostEditor({
form={form}
setField={setField}
setGuacField={setGuacField}
host={host}
credentials={credentials}
/>
)}
</div>
+113 -26
View File
@@ -1,6 +1,7 @@
import { TERMINAL_THEMES } from "@/lib/terminal-themes";
import type { Host } from "@/types/ui-types";
import type { SSHHostData } from "@/types";
import type { HostDefaults } from "@/api/settings-api";
type HostSocks5ProxyNode = NonNullable<Host["socks5ProxyChain"]>[number];
@@ -42,8 +43,12 @@ export function mapSnippetResponse(
}));
}
export function createHostEditorForm(host: Host | null) {
const rawTheme = host?.terminalConfig?.theme;
export function createHostEditorForm(
host: Host | null,
defaults?: HostDefaults,
) {
const d = host ? undefined : defaults;
const rawTheme = host?.terminalConfig?.theme ?? d?.theme;
const normalizedTheme =
!rawTheme ||
["Termix Dark", "Termix Light", "termixDark", "termixLight"].includes(
@@ -57,12 +62,13 @@ export function createHostEditorForm(host: Host | null) {
return {
name: host?.name ?? "",
ip: host?.ip ?? "",
username: host?.username ?? "",
username: host?.username ?? (host ? "" : "root"),
sshPort: host?.sshPort ?? host?.port ?? 22,
rdpPort: host?.rdpPort ?? 3389,
vncPort: host?.vncPort ?? 5900,
telnetPort: host?.telnetPort ?? 23,
authType: host?.authType ?? "password",
useWarpgate: host?.useWarpgate ?? false,
password: host?.password ?? "",
key: host?.key ?? (host?.hasKey ? "existing_key" : ""),
keyPassword: host?.hasKeyPassword
@@ -70,7 +76,9 @@ export function createHostEditorForm(host: Host | null) {
: (host?.keyPassword ?? ""),
keyType: host?.keyType ?? "auto",
keySubTab: "paste" as "paste" | "upload",
credentialId: host?.credentialId ?? "",
credentialId:
host?.credentialId ??
(d?.credentialId != null ? String(d.credentialId) : ""),
overrideCredentialUsername: host?.overrideCredentialUsername ?? false,
folder: host?.folder ?? "",
tags: host?.tags ?? ([] as string[]),
@@ -78,24 +86,29 @@ export function createHostEditorForm(host: Host | null) {
notes: host?.notes ?? "",
pin: host?.pin ?? false,
macAddress: host?.macAddress ?? "",
useSocks5: host?.useSocks5 ?? false,
socks5Host: host?.socks5Host ?? "",
socks5Port: host?.socks5Port ?? 1080,
socks5Username: host?.socks5Username ?? "",
socks5Password: host?.socks5Password ?? "",
wolBroadcastAddress: host?.wolBroadcastAddress ?? "",
useSocks5: host?.useSocks5 ?? d?.useSocks5 ?? false,
socks5Host: host?.socks5Host ?? d?.socks5Host ?? "",
socks5Port: host?.socks5Port ?? d?.socks5Port ?? 1080,
socks5Username: host?.socks5Username ?? d?.socks5Username ?? "",
socks5Password: host?.socks5Password ?? d?.socks5Password ?? "",
socks5ProxyMode: ((host?.socks5ProxyChain ?? []).length > 0
? "chain"
: "single") as "single" | "chain",
socks5ProxyChain: (host?.socks5ProxyChain ?? []) as HostSocks5ProxyNode[],
enableTerminal: host?.enableTerminal ?? true,
enableSessionLogging: host?.enableSessionLogging ?? true,
enableCommandHistory: host?.enableCommandHistory ?? true,
enableSessionLogging:
host?.enableSessionLogging ?? d?.enableSessionLogging ?? true,
enableCommandHistory:
host?.enableCommandHistory ?? d?.enableCommandHistory ?? true,
enableFileManager: host?.enableFileManager ?? false,
scpLegacy: host?.scpLegacy ?? false,
enableDocker: host?.enableDocker ?? false,
enableTmuxMonitor: host?.enableTmuxMonitor ?? false,
enableProxmox: host?.enableProxmox ?? false,
proxmoxConfig: host?.proxmoxConfig ?? {
defaultCredentialId: null as number | null,
defaultAuthType: "password" as string,
windowsPatterns: "win, windows",
dockerPatterns: "docker",
preferredPrefixes: "10., 192.168.",
@@ -103,15 +116,16 @@ export function createHostEditorForm(host: Host | null) {
enableTunnel: host?.enableTunnel ?? false,
defaultPath: host?.defaultPath ?? "/",
forceKeyboardInteractive: host?.forceKeyboardInteractive ?? false,
fontSize: host?.terminalConfig?.fontSize ?? 14,
fontSize: host?.terminalConfig?.fontSize ?? d?.fontSize ?? 14,
fontFamily:
host?.terminalConfig?.fontFamily ?? "Caskaydia Cove Nerd Font Mono",
host?.terminalConfig?.fontFamily ??
d?.fontFamily ??
"Caskaydia Cove Nerd Font Mono",
theme: normalizedTheme,
cursorStyle: (host?.terminalConfig?.cursorStyle ?? "bar") as
| "block"
| "underline"
| "bar",
cursorBlink: host?.terminalConfig?.cursorBlink ?? true,
cursorStyle: (host?.terminalConfig?.cursorStyle ??
d?.cursorStyle ??
"bar") as "block" | "underline" | "bar",
cursorBlink: host?.terminalConfig?.cursorBlink ?? d?.cursorBlink ?? true,
scrollback: host?.terminalConfig?.scrollback ?? 10000,
letterSpacing: host?.terminalConfig?.letterSpacing ?? 0,
lineHeight: host?.terminalConfig?.lineHeight ?? 1.0,
@@ -139,6 +153,13 @@ export function createHostEditorForm(host: Host | null) {
sudoPassword: host?.terminalConfig?.sudoPassword ?? "",
keepaliveInterval: host?.terminalConfig?.keepaliveInterval ?? 60,
keepaliveCountMax: host?.terminalConfig?.keepaliveCountMax ?? 5,
backgroundImage: host?.terminalConfig?.backgroundImage ?? "",
backgroundImageOpacity:
host?.terminalConfig?.backgroundImageOpacity ?? 0.15,
allowLegacyAlgorithms: host?.terminalConfig?.allowLegacyAlgorithms ?? true,
linkClickBehavior: (host?.terminalConfig?.linkClickBehavior ??
"default") as "default" | "confirm" | "direct",
useSSHTitle: host?.terminalConfig?.useSSHTitle ?? false,
syntaxHighlighting: host?.terminalConfig?.syntaxHighlighting ?? true,
syntaxHighlightingOptions: {
logLevels:
@@ -161,21 +182,37 @@ export function createHostEditorForm(host: Host | null) {
([] as { port: number; protocol: "tcp" | "udp"; delay: number }[]),
quickActions:
host?.quickActions ?? ([] as { name: string; snippetId: string }[]),
rdpCredentialId: host?.rdpCredentialId ?? "",
rdpUser: host?.rdpUser ?? "",
rdpPassword: host?.rdpPassword ?? "",
domain: host?.domain ?? "",
security: host?.security ?? "",
ignoreCert: host?.ignoreCert ?? false,
vncCredentialId: host?.vncCredentialId ?? "",
vncPassword: host?.vncPassword ?? "",
vncUser: host?.vncUser ?? "",
telnetUser: host?.telnetUser ?? "",
telnetPassword: host?.telnetPassword ?? "",
telnetCredentialId:
host?.telnetCredentialId != null ? String(host.telnetCredentialId) : "",
rdpAuthType: (host?.rdpAuthType ??
(host?.rdpCredentialId ? "credential" : "direct")) as
| "direct"
| "credential",
vncAuthType: (host?.vncAuthType ??
(host?.vncCredentialId ? "credential" : "direct")) as
| "direct"
| "credential",
telnetAuthType: (host?.telnetAuthType ??
(host?.telnetCredentialId ? "credential" : "direct")) as
| "direct"
| "credential",
guacamoleConfig: host?.guacamoleConfig ?? {},
statsConfig: host?.statsConfig ?? {
statusCheckEnabled: true,
statusCheckEnabled: d?.statusCheckEnabled ?? true,
statusCheckInterval: 60,
useGlobalStatusInterval: true,
metricsEnabled: true,
metricsEnabled: d?.metricsEnabled ?? true,
metricsInterval: 30,
useGlobalMetricsInterval: true,
enabledWidgets: [
@@ -229,6 +266,7 @@ export function buildHostEditorPayload(
tags: form.tags,
pin: form.pin,
authType: form.authType,
useWarpgate: form.useWarpgate,
password: usesPassword ? form.password || null : null,
key: usesKey
? form.key === "existing_key"
@@ -246,11 +284,13 @@ export function buildHostEditorPayload(
overrideCredentialUsername: form.overrideCredentialUsername,
notes: form.notes,
macAddress: form.macAddress || null,
wolBroadcastAddress: form.wolBroadcastAddress || null,
enableTerminal: form.enableTerminal,
enableSessionLogging: form.enableSessionLogging,
enableCommandHistory: form.enableCommandHistory,
enableTunnel: form.enableTunnel,
enableFileManager: form.enableFileManager,
scpLegacy: form.scpLegacy,
enableDocker: form.enableDocker,
enableTmuxMonitor: form.enableTmuxMonitor,
enableProxmox: form.enableProxmox,
@@ -276,15 +316,54 @@ export function buildHostEditorPayload(
vncPort: Number(form.vncPort),
telnetPort: Number(form.telnetPort),
forceKeyboardInteractive: form.forceKeyboardInteractive,
rdpUser: form.rdpUser || null,
rdpPassword: form.rdpPassword || null,
rdpAuthType: protocols.enableRdp ? form.rdpAuthType : null,
rdpCredentialId:
protocols.enableRdp &&
form.rdpAuthType === "credential" &&
form.rdpCredentialId
? Number(form.rdpCredentialId)
: null,
rdpUser:
protocols.enableRdp && form.rdpAuthType === "direct"
? form.rdpUser || null
: null,
rdpPassword:
protocols.enableRdp && form.rdpAuthType === "direct"
? form.rdpPassword || null
: null,
rdpDomain: form.domain || null,
rdpSecurity: form.security || null,
rdpIgnoreCert: form.ignoreCert,
vncPassword: form.vncPassword || null,
vncUser: form.vncUser || null,
telnetUser: form.telnetUser || null,
telnetPassword: form.telnetPassword || null,
vncAuthType: protocols.enableVnc ? form.vncAuthType : null,
vncCredentialId:
protocols.enableVnc &&
form.vncAuthType === "credential" &&
form.vncCredentialId
? Number(form.vncCredentialId)
: null,
vncPassword:
protocols.enableVnc && form.vncAuthType === "direct"
? form.vncPassword || null
: null,
vncUser:
protocols.enableVnc && form.vncAuthType === "direct"
? form.vncUser || null
: null,
telnetAuthType: protocols.enableTelnet ? form.telnetAuthType : null,
telnetCredentialId:
protocols.enableTelnet &&
form.telnetAuthType === "credential" &&
form.telnetCredentialId
? Number(form.telnetCredentialId)
: null,
telnetUser:
protocols.enableTelnet && form.telnetAuthType === "direct"
? form.telnetUser || null
: null,
telnetPassword:
protocols.enableTelnet && form.telnetAuthType === "direct"
? form.telnetPassword || null
: null,
jumpHosts: form.jumpHosts,
portKnockSequence: form.portKnockSequence,
tunnelConnections: form.serverTunnels,
@@ -324,8 +403,16 @@ export function buildHostEditorPayload(
keepaliveInterval: Number(form.keepaliveInterval),
keepaliveCountMax: Number(form.keepaliveCountMax),
environmentVariables: form.environmentVariables,
useSSHTitle: form.useSSHTitle,
syntaxHighlighting: form.syntaxHighlighting,
syntaxHighlightingOptions: form.syntaxHighlightingOptions,
backgroundImage: form.backgroundImage || null,
backgroundImageOpacity: Number(form.backgroundImageOpacity),
allowLegacyAlgorithms: form.allowLegacyAlgorithms,
linkClickBehavior:
form.linkClickBehavior !== "default"
? form.linkClickBehavior
: undefined,
}
: null,
};
+33
View File
@@ -72,6 +72,7 @@ export function HostProxmoxTab({
const cfg = form.proxmoxConfig ?? {
defaultCredentialId: null,
defaultAuthType: "password",
windowsPatterns: "win, windows",
dockerPatterns: "docker",
preferredPrefixes: "10., 192.168.",
@@ -106,6 +107,29 @@ export function HostProxmoxTab({
</SettingRow>
{form.enableProxmox && (
<>
<SettingRow
label={t("hosts.proxmoxDefaultAuthType")}
description={t("hosts.proxmoxDefaultAuthTypeDesc")}
>
<select
value={cfg.defaultAuthType ?? "password"}
onChange={(e) =>
setField("proxmoxConfig", {
...cfg,
defaultAuthType: e.target.value,
})
}
className="h-7 w-44 text-xs border border-border bg-background px-2 outline-none focus:ring-1 focus:ring-ring"
>
<option value="password">{t("hosts.authTypePassword")}</option>
<option value="key">{t("hosts.authTypeKey")}</option>
<option value="credential">
{t("hosts.authTypeCredential")}
</option>
<option value="opkssh">{t("hosts.authTypeOpkssh")}</option>
<option value="none">{t("hosts.authTypeNone")}</option>
</select>
</SettingRow>
<SettingRow
label={t("hosts.proxmoxDefaultCredential")}
description={t("hosts.proxmoxDefaultCredentialDesc")}
@@ -209,6 +233,15 @@ export function HostFilesTab({
onChange={(v) => setField("enableFileManager", v)}
/>
</SettingRow>
<SettingRow
label={t("hosts.scpLegacyLabel")}
description={t("hosts.scpLegacyDesc")}
>
<FakeSwitch
checked={form.scpLegacy}
onChange={(v) => setField("scpLegacy", v)}
/>
</SettingRow>
<div className="flex flex-col gap-1.5">
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.defaultPathLabel")}
+29 -2
View File
@@ -72,12 +72,22 @@ export function HostEditorGeneralTab({
// Folders come from two sources: paths referenced by existing hosts, and
// standalone folder records (including empty ones just created).
// Intermediate ancestor paths are expanded so "A" appears even when only
// "A / B" is directly stored on a host.
const folderPaths = React.useMemo(() => {
const set = new Set<string>();
const addWithAncestors = (path: string) => {
const parts = path.split(" / ");
let accumulated = "";
for (const part of parts) {
accumulated = accumulated ? `${accumulated} / ${part}` : part;
set.add(accumulated);
}
};
for (const h of hosts) {
if (h.folder) set.add(h.folder);
if (h.folder) addWithAncestors(h.folder);
}
for (const path of folderMeta.keys()) set.add(path);
for (const path of folderMeta.keys()) addWithAncestors(path);
return [...set];
}, [hosts, folderMeta]);
@@ -199,6 +209,23 @@ export function HostEditorGeneralTab({
/>
</div>
)}
{protocols.enableSsh && form.macAddress && (
<div className="flex flex-col gap-1.5">
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.wolBroadcastAddress")}
</label>
<Input
placeholder="192.168.1.255"
value={form.wolBroadcastAddress}
onChange={(e) =>
setField("wolBroadcastAddress", e.target.value)
}
/>
<p className="text-[10px] text-muted-foreground/60">
{t("hosts.wolBroadcastAddressDesc")}
</p>
</div>
)}
</div>
</div>
</SectionCard>
+506 -45
View File
@@ -14,6 +14,7 @@ import {
Shield,
Terminal,
Zap,
Cpu,
} from "lucide-react";
import type { HostEditorForm } from "./HostEditorData";
@@ -42,11 +43,13 @@ export function HostEditorRdpTab({
setField,
setGuacField,
host,
credentials,
}: {
form: HostEditorForm;
setField: HostEditorSetField;
setGuacField: GuacFieldSetter;
host?: Host | null;
credentials?: { id: string; name: string; username: string }[];
}) {
const { t } = useTranslation();
@@ -78,41 +81,181 @@ export function HostEditorRdpTab({
</div>
</SectionCard>
<SectionCard
title={t("hosts.guac.authentication")}
icon={<Shield className="size-3.5" />}
title={t("hosts.statusChecksLabel")}
icon={<Activity className="size-3.5" />}
>
<div className="flex flex-col gap-0 py-1">
<SettingRow
label={t("hosts.enableStatusChecks")}
description={t("hosts.enableStatusChecksDesc")}
>
<FakeSwitch
checked={form.statsConfig.statusCheckEnabled}
onChange={(v) =>
setField("statsConfig", {
...form.statsConfig,
statusCheckEnabled: v,
})
}
/>
</SettingRow>
{form.statsConfig.statusCheckEnabled && (
<SettingRow
label={t("hosts.useGlobalInterval")}
description={t("hosts.useGlobalIntervalDesc")}
>
<FakeSwitch
checked={form.statsConfig.useGlobalStatusInterval}
onChange={(v) =>
setField("statsConfig", {
...form.statsConfig,
useGlobalStatusInterval: v,
})
}
/>
</SettingRow>
)}
{form.statsConfig.statusCheckEnabled &&
!form.statsConfig.useGlobalStatusInterval && (
<SettingRow
label={t("hosts.checkIntervalS")}
description={t("hosts.checkIntervalDesc")}
>
<Input
type="number"
value={form.statsConfig.statusCheckInterval}
onChange={(e) =>
setField("statsConfig", {
...form.statsConfig,
statusCheckInterval: Number(e.target.value),
})
}
className="w-20 h-7 text-xs text-right [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
/>
</SettingRow>
)}
</div>
</SectionCard>
<SectionCard
title={t("hosts.guac.guacdProxy")}
icon={<Cpu className="size-3.5" />}
>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 py-3">
<div className="flex flex-col gap-1.5">
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.guac.username")}
{t("hosts.guac.guacdHostname")}
</label>
<Input
placeholder="Administrator"
value={form.rdpUser}
onChange={(e) => setField("rdpUser", e.target.value)}
placeholder={t("hosts.guac.guacdHostnamePlaceholder")}
value={(form.guacamoleConfig["guacd-hostname"] as string) ?? ""}
onChange={(e) => setGuacField("guacd-hostname", e.target.value)}
/>
</div>
<div className="flex flex-col gap-1.5">
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.guac.password")}
</label>
<PasswordInput
className="h-8 text-xs pr-8"
placeholder="••••••••"
value={form.rdpPassword}
onChange={(e) => setField("rdpPassword", e.target.value)}
/>
</div>
<div className="flex flex-col gap-1.5">
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.guac.domain")}
{t("hosts.guac.guacdPort")}
</label>
<Input
placeholder="WORKGROUP"
value={form.domain}
onChange={(e) => setField("domain", e.target.value)}
type="number"
placeholder="4822"
value={(form.guacamoleConfig["guacd-port"] as string) ?? ""}
onChange={(e) => setGuacField("guacd-port", e.target.value)}
className="[appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
/>
</div>
<p className="col-span-full text-[10px] text-muted-foreground -mt-2">
{t("hosts.guac.guacdProxyDesc")}
</p>
</div>
</SectionCard>
<SectionCard
title={t("hosts.guac.authentication")}
icon={<Shield className="size-3.5" />}
>
<div className="flex flex-col gap-4 py-3">
{credentials && credentials.length > 0 && (
<div className="flex flex-col gap-1.5">
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.guac.authMethod")}
</label>
<div className="flex gap-2">
{(["direct", "credential"] as const).map((m) => (
<button
key={m}
type="button"
onClick={() => setField("rdpAuthType", m)}
className={`px-3 py-1.5 text-[10px] font-bold uppercase tracking-widest border transition-colors ${
form.rdpAuthType === m
? "border-accent-brand/40 bg-accent-brand/10 text-accent-brand"
: "border-border text-muted-foreground hover:text-foreground"
}`}
>
{t(
`hosts.guac.authType${m.charAt(0).toUpperCase() + m.slice(1)}`,
)}
</button>
))}
</div>
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{form.rdpAuthType === "credential" &&
credentials &&
credentials.length > 0 ? (
<div className="flex flex-col gap-1.5 col-span-full">
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.guac.storedCredential")}
</label>
<select
value={form.rdpCredentialId}
onChange={(e) => setField("rdpCredentialId", e.target.value)}
className="flex h-9 w-full border border-border bg-background px-3 py-1 text-xs outline-none focus:ring-1 focus:ring-ring"
>
<option value="">{t("hosts.guac.selectCredential")}</option>
{credentials.map((c) => (
<option key={c.id} value={c.id}>
{c.username ? `${c.name} (${c.username})` : c.name}
</option>
))}
</select>
</div>
) : (
<>
<div className="flex flex-col gap-1.5">
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.guac.username")}
</label>
<Input
placeholder="Administrator"
value={form.rdpUser}
onChange={(e) => setField("rdpUser", e.target.value)}
/>
</div>
<div className="flex flex-col gap-1.5">
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.guac.password")}
</label>
<PasswordInput
className="h-8 text-xs pr-8"
placeholder="••••••••"
value={form.rdpPassword}
onChange={(e) => setField("rdpPassword", e.target.value)}
/>
</div>
</>
)}
<div className="flex flex-col gap-1.5">
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.guac.domain")}
</label>
<Input
placeholder="WORKGROUP"
value={form.domain}
onChange={(e) => setField("domain", e.target.value)}
/>
</div>
</div>
</div>
</SectionCard>
@@ -147,6 +290,23 @@ export function HostEditorRdpTab({
onChange={(v) => setField("ignoreCert", v)}
/>
</SettingRow>
<div className="flex flex-col gap-1.5">
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.guac.loadBalanceInfo")}
</label>
<Input
placeholder="tsv://MS Terminal Services Plugin.1.CollectionName"
value={
(form.guacamoleConfig["load-balance-info"] as string) ?? ""
}
onChange={(e) =>
setGuacField("load-balance-info", e.target.value)
}
/>
<p className="text-[10px] text-muted-foreground">
{t("hosts.guac.loadBalanceInfoDesc")}
</p>
</div>
</div>
</SectionCard>
@@ -810,11 +970,13 @@ export function HostEditorVncTab({
setField,
setGuacField,
host,
credentials,
}: {
form: HostEditorForm;
setField: HostEditorSetField;
setGuacField: GuacFieldSetter;
host?: Host | null;
credentials?: { id: string; name: string; username: string }[];
}) {
const { t } = useTranslation();
@@ -846,31 +1008,169 @@ export function HostEditorVncTab({
</div>
</SectionCard>
<SectionCard
title={t("hosts.guac.authentication")}
icon={<Shield className="size-3.5" />}
title={t("hosts.statusChecksLabel")}
icon={<Activity className="size-3.5" />}
>
<div className="flex flex-col gap-0 py-1">
<SettingRow
label={t("hosts.enableStatusChecks")}
description={t("hosts.enableStatusChecksDesc")}
>
<FakeSwitch
checked={form.statsConfig.statusCheckEnabled}
onChange={(v) =>
setField("statsConfig", {
...form.statsConfig,
statusCheckEnabled: v,
})
}
/>
</SettingRow>
{form.statsConfig.statusCheckEnabled && (
<SettingRow
label={t("hosts.useGlobalInterval")}
description={t("hosts.useGlobalIntervalDesc")}
>
<FakeSwitch
checked={form.statsConfig.useGlobalStatusInterval}
onChange={(v) =>
setField("statsConfig", {
...form.statsConfig,
useGlobalStatusInterval: v,
})
}
/>
</SettingRow>
)}
{form.statsConfig.statusCheckEnabled &&
!form.statsConfig.useGlobalStatusInterval && (
<SettingRow
label={t("hosts.checkIntervalS")}
description={t("hosts.checkIntervalDesc")}
>
<Input
type="number"
value={form.statsConfig.statusCheckInterval}
onChange={(e) =>
setField("statsConfig", {
...form.statsConfig,
statusCheckInterval: Number(e.target.value),
})
}
className="w-20 h-7 text-xs text-right [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
/>
</SettingRow>
)}
</div>
</SectionCard>
<SectionCard
title={t("hosts.guac.guacdProxy")}
icon={<Cpu className="size-3.5" />}
>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 py-3">
<div className="flex flex-col gap-1.5">
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.guac.vncPassword")}
{t("hosts.guac.guacdHostname")}
</label>
<PasswordInput
className="h-8 text-xs pr-8"
placeholder="••••••••"
value={form.vncPassword}
onChange={(e) => setField("vncPassword", e.target.value)}
<Input
placeholder={t("hosts.guac.guacdHostnamePlaceholder")}
value={(form.guacamoleConfig["guacd-hostname"] as string) ?? ""}
onChange={(e) => setGuacField("guacd-hostname", e.target.value)}
/>
</div>
<div className="flex flex-col gap-1.5">
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.guac.vncUsernameOptional")}
{t("hosts.guac.guacdPort")}
</label>
<Input
placeholder={t("hosts.guac.vncLeaveBlank")}
value={form.vncUser}
onChange={(e) => setField("vncUser", e.target.value)}
type="number"
placeholder="4822"
value={(form.guacamoleConfig["guacd-port"] as string) ?? ""}
onChange={(e) => setGuacField("guacd-port", e.target.value)}
className="[appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
/>
</div>
<p className="col-span-full text-[10px] text-muted-foreground -mt-2">
{t("hosts.guac.guacdProxyDesc")}
</p>
</div>
</SectionCard>
<SectionCard
title={t("hosts.guac.authentication")}
icon={<Shield className="size-3.5" />}
>
<div className="flex flex-col gap-4 py-3">
{credentials && credentials.length > 0 && (
<div className="flex flex-col gap-1.5">
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.guac.authMethod")}
</label>
<div className="flex gap-2">
{(["direct", "credential"] as const).map((m) => (
<button
key={m}
type="button"
onClick={() => setField("vncAuthType", m)}
className={`px-3 py-1.5 text-[10px] font-bold uppercase tracking-widest border transition-colors ${
form.vncAuthType === m
? "border-accent-brand/40 bg-accent-brand/10 text-accent-brand"
: "border-border text-muted-foreground hover:text-foreground"
}`}
>
{t(
`hosts.guac.authType${m.charAt(0).toUpperCase() + m.slice(1)}`,
)}
</button>
))}
</div>
</div>
)}
{form.vncAuthType === "credential" &&
credentials &&
credentials.length > 0 ? (
<div className="flex flex-col gap-1.5">
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.guac.storedCredential")}
</label>
<select
value={form.vncCredentialId}
onChange={(e) => setField("vncCredentialId", e.target.value)}
className="flex h-9 w-full border border-border bg-background px-3 py-1 text-xs outline-none focus:ring-1 focus:ring-ring"
>
<option value="">{t("hosts.guac.selectCredential")}</option>
{credentials.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</select>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="flex flex-col gap-1.5">
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.guac.vncPassword")}
</label>
<PasswordInput
className="h-8 text-xs pr-8"
placeholder="••••••••"
value={form.vncPassword}
onChange={(e) => setField("vncPassword", e.target.value)}
/>
</div>
<div className="flex flex-col gap-1.5">
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.guac.vncUsernameOptional")}
</label>
<Input
placeholder={t("hosts.guac.vncLeaveBlank")}
value={form.vncUser}
onChange={(e) => setField("vncUser", e.target.value)}
/>
</div>
</div>
)}
</div>
</SectionCard>
@@ -981,6 +1281,28 @@ export function HostEditorVncTab({
<option value="remote">Remote</option>
</select>
</div>
<div className="flex flex-col gap-1.5">
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.guac.serverLayout")}
</label>
<select
className="flex h-9 w-full border border-border bg-background px-3 py-1 text-xs outline-none focus:ring-1 focus:ring-ring"
value={form.guacamoleConfig["server-layout"] ?? "auto"}
onChange={(e) => setGuacField("server-layout", e.target.value)}
>
<option value="auto">Auto</option>
<option>en-us-qwerty</option>
<option>en-gb-qwerty</option>
<option>de-de-qwertz</option>
<option>fr-fr-azerty</option>
<option>it-it-qwerty</option>
<option>sv-se-qwerty</option>
<option>ja-jp-qwerty</option>
<option>pt-br-qwerty</option>
<option>es-es-qwerty</option>
<option>failsafe</option>
</select>
</div>
<SettingRow
label={t("hosts.guac.swapRedBlue")}
description={t("hosts.guac.swapRedBlueDesc")}
@@ -1193,11 +1515,13 @@ export function HostEditorTelnetTab({
form,
setField,
setGuacField,
credentials,
}: {
form: HostEditorForm;
setField: HostEditorSetField;
setGuacField: GuacFieldSetter;
host?: Host | null;
credentials?: { id: string; name: string; username: string }[];
}) {
const { t } = useTranslation();
@@ -1229,31 +1553,168 @@ export function HostEditorTelnetTab({
</div>
</SectionCard>
<SectionCard
title={t("hosts.guac.authentication")}
icon={<Shield className="size-3.5" />}
title={t("hosts.statusChecksLabel")}
icon={<Activity className="size-3.5" />}
>
<div className="flex flex-col gap-0 py-1">
<SettingRow
label={t("hosts.enableStatusChecks")}
description={t("hosts.enableStatusChecksDesc")}
>
<FakeSwitch
checked={form.statsConfig.statusCheckEnabled}
onChange={(v) =>
setField("statsConfig", {
...form.statsConfig,
statusCheckEnabled: v,
})
}
/>
</SettingRow>
{form.statsConfig.statusCheckEnabled && (
<SettingRow
label={t("hosts.useGlobalInterval")}
description={t("hosts.useGlobalIntervalDesc")}
>
<FakeSwitch
checked={form.statsConfig.useGlobalStatusInterval}
onChange={(v) =>
setField("statsConfig", {
...form.statsConfig,
useGlobalStatusInterval: v,
})
}
/>
</SettingRow>
)}
{form.statsConfig.statusCheckEnabled &&
!form.statsConfig.useGlobalStatusInterval && (
<SettingRow
label={t("hosts.checkIntervalS")}
description={t("hosts.checkIntervalDesc")}
>
<Input
type="number"
value={form.statsConfig.statusCheckInterval}
onChange={(e) =>
setField("statsConfig", {
...form.statsConfig,
statusCheckInterval: Number(e.target.value),
})
}
className="w-20 h-7 text-xs text-right [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
/>
</SettingRow>
)}
</div>
</SectionCard>
<SectionCard
title={t("hosts.guac.guacdProxy")}
icon={<Cpu className="size-3.5" />}
>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 py-3">
<div className="flex flex-col gap-1.5">
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.guac.username")}
{t("hosts.guac.guacdHostname")}
</label>
<Input
placeholder="admin"
value={form.telnetUser}
onChange={(e) => setField("telnetUser", e.target.value)}
placeholder={t("hosts.guac.guacdHostnamePlaceholder")}
value={(form.guacamoleConfig["guacd-hostname"] as string) ?? ""}
onChange={(e) => setGuacField("guacd-hostname", e.target.value)}
/>
</div>
<div className="flex flex-col gap-1.5">
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.guac.password")}
{t("hosts.guac.guacdPort")}
</label>
<PasswordInput
className="h-8 text-xs pr-8"
placeholder="••••••••"
value={form.telnetPassword}
onChange={(e) => setField("telnetPassword", e.target.value)}
<Input
type="number"
placeholder="4822"
value={(form.guacamoleConfig["guacd-port"] as string) ?? ""}
onChange={(e) => setGuacField("guacd-port", e.target.value)}
className="[appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
/>
</div>
<p className="col-span-full text-[10px] text-muted-foreground -mt-2">
{t("hosts.guac.guacdProxyDesc")}
</p>
</div>
</SectionCard>
<SectionCard
title={t("hosts.guac.authentication")}
icon={<Shield className="size-3.5" />}
>
<div className="flex flex-col gap-4 py-3">
{credentials && credentials.length > 0 && (
<div className="flex flex-col gap-1.5">
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.guac.authMethod")}
</label>
<div className="flex gap-2">
{(["direct", "credential"] as const).map((m) => (
<button
key={m}
type="button"
onClick={() => setField("telnetAuthType", m)}
className={`px-3 py-1.5 text-[10px] font-bold uppercase tracking-widest border transition-colors ${
form.telnetAuthType === m
? "border-accent-brand/40 bg-accent-brand/10 text-accent-brand"
: "border-border text-muted-foreground hover:text-foreground"
}`}
>
{t(
`hosts.guac.authType${m.charAt(0).toUpperCase() + m.slice(1)}`,
)}
</button>
))}
</div>
</div>
)}
{form.telnetAuthType === "credential" &&
credentials &&
credentials.length > 0 ? (
<div className="flex flex-col gap-1.5">
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.guac.storedCredential")}
</label>
<select
value={form.telnetCredentialId}
onChange={(e) => setField("telnetCredentialId", e.target.value)}
className="flex h-9 w-full border border-border bg-background px-3 py-1 text-xs outline-none focus:ring-1 focus:ring-ring"
>
<option value="">{t("hosts.guac.selectCredential")}</option>
{credentials.map((c) => (
<option key={c.id} value={c.id}>
{c.username ? `${c.name} (${c.username})` : c.name}
</option>
))}
</select>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="flex flex-col gap-1.5">
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.guac.username")}
</label>
<Input
placeholder="admin"
value={form.telnetUser}
onChange={(e) => setField("telnetUser", e.target.value)}
/>
</div>
<div className="flex flex-col gap-1.5">
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.guac.password")}
</label>
<PasswordInput
className="h-8 text-xs pr-8"
placeholder="••••••••"
value={form.telnetPassword}
onChange={(e) => setField("telnetPassword", e.target.value)}
/>
</div>
</div>
)}
</div>
</SectionCard>
+32 -28
View File
@@ -50,20 +50,22 @@ export function HostStatsTab({
}
/>
</SettingRow>
<SettingRow
label={t("hosts.useGlobalInterval")}
description={t("hosts.useGlobalIntervalDesc")}
>
<FakeSwitch
checked={form.statsConfig.useGlobalStatusInterval}
onChange={(v) =>
setField("statsConfig", {
...form.statsConfig,
useGlobalStatusInterval: v,
})
}
/>
</SettingRow>
{form.statsConfig.statusCheckEnabled && (
<SettingRow
label={t("hosts.useGlobalInterval")}
description={t("hosts.useGlobalIntervalDesc")}
>
<FakeSwitch
checked={form.statsConfig.useGlobalStatusInterval}
onChange={(v) =>
setField("statsConfig", {
...form.statsConfig,
useGlobalStatusInterval: v,
})
}
/>
</SettingRow>
)}
{form.statsConfig.statusCheckEnabled &&
!form.statsConfig.useGlobalStatusInterval && (
<SettingRow
@@ -104,20 +106,22 @@ export function HostStatsTab({
}
/>
</SettingRow>
<SettingRow
label={t("hosts.useGlobalMetrics")}
description={t("hosts.useGlobalMetricsDesc")}
>
<FakeSwitch
checked={form.statsConfig.useGlobalMetricsInterval}
onChange={(v) =>
setField("statsConfig", {
...form.statsConfig,
useGlobalMetricsInterval: v,
})
}
/>
</SettingRow>
{form.statsConfig.metricsEnabled && (
<SettingRow
label={t("hosts.useGlobalMetrics")}
description={t("hosts.useGlobalMetricsDesc")}
>
<FakeSwitch
checked={form.statsConfig.useGlobalMetricsInterval}
onChange={(v) =>
setField("statsConfig", {
...form.statsConfig,
useGlobalMetricsInterval: v,
})
}
/>
</SettingRow>
)}
{form.statsConfig.metricsEnabled &&
!form.statsConfig.useGlobalMetricsInterval && (
<SettingRow
+1 -1
View File
@@ -62,7 +62,7 @@ export function sshHostToHost(h: SSHHostWithStatus): Host {
enableSessionLogging: h.enableSessionLogging ?? true,
enableCommandHistory: h.enableCommandHistory ?? true,
enableTunnel: h.enableTunnel ?? false,
enableFileManager: h.enableFileManager ?? false,
enableFileManager: h.enableFileManager ?? true,
enableDocker: h.enableDocker ?? false,
enableProxmox: h.enableProxmox ?? false,
enableTmuxMonitor: h.enableTmuxMonitor ?? false,
+2 -1
View File
@@ -90,7 +90,8 @@ export function HostShareModal({
if (!open) return null;
const hasCredential = !!host?.credentialId;
const hasCredential =
!!host?.credentialId || !!host?.rdpCredentialId || !!host?.vncCredentialId;
return (
<div className="absolute inset-0 z-20 flex flex-col bg-sidebar">
+61
View File
@@ -39,6 +39,7 @@ import {
import {
getSSHHosts,
bulkImportSSHHosts,
importSSHConfigHosts,
exportAllSSHHosts,
} from "@/main-axios";
import type { SSHHostWithStatus } from "@/main-axios";
@@ -248,6 +249,9 @@ export function HostsPanel({
const [proxmoxDefaultCredentialId, setProxmoxDefaultCredentialId] = useState<
number | null
>(null);
const [proxmoxDefaultAuthType, setProxmoxDefaultAuthType] = useState<
string | undefined
>(undefined);
const [proxmoxDefaultUsername, setProxmoxDefaultUsername] = useState<
string | undefined
>(undefined);
@@ -267,6 +271,7 @@ export function HostsPanel({
});
const filterActive = Object.values(filterState).some((arr) => arr.length > 0);
const fileInputRef = useRef<HTMLInputElement>(null);
const sshConfigInputRef = useRef<HTMLInputElement>(null);
const importOverwriteRef = useRef(false);
const allTags = [...new Set(rawHosts.flatMap((h) => h.tags ?? []))];
@@ -501,6 +506,42 @@ export function HostsPanel({
}}
/>
<input
ref={sshConfigInputRef}
type="file"
accept=".conf,.config,*"
className="hidden"
onChange={async (e) => {
const file = e.target.files?.[0];
if (!file) return;
e.target.value = "";
try {
const text = await file.text();
const result = await importSSHConfigHosts(
text,
importOverwriteRef.current,
);
const hosts = await getSSHHosts();
setRawHosts(hosts);
window.dispatchEvent(new CustomEvent("termix:hosts-changed"));
const msg = [
result.success ? `${result.success} imported` : null,
result.updated ? `${result.updated} updated` : null,
result.failed ? `${result.failed} failed` : null,
]
.filter(Boolean)
.join(", ");
toast.success(`${t("hosts.importSSHConfig")}: ${msg}`);
} catch (err: unknown) {
toast.error(
err instanceof Error
? err.message
: "Failed to import SSH config",
);
}
}}
/>
<div className="flex flex-wrap items-center gap-1">
<Button
variant="ghost"
@@ -544,6 +585,15 @@ export function HostsPanel({
<Upload className="size-3.5 mr-2" />
{t("hosts.importOverwrite")}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
importOverwriteRef.current = false;
sshConfigInputRef.current?.click();
}}
>
<Upload className="size-3.5 mr-2" />
{t("hosts.importSSHConfig")}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
setProxmoxHostId(undefined);
@@ -899,6 +949,15 @@ export function HostsPanel({
<Upload className="size-3.5 mr-2" />
{t("hosts.importOverwrite")}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
importOverwriteRef.current = false;
sshConfigInputRef.current?.click();
}}
>
<Upload className="size-3.5 mr-2" />
{t("hosts.importSSHConfig")}
</DropdownMenuItem>
<DropdownMenuItem
onClick={handleExportHosts}
disabled={rawHosts.length === 0}
@@ -948,6 +1007,7 @@ export function HostsPanel({
const cfg = host.proxmoxConfig;
setProxmoxHostId(Number(host.id));
setProxmoxDefaultCredentialId(cfg?.defaultCredentialId ?? null);
setProxmoxDefaultAuthType(cfg?.defaultAuthType ?? undefined);
setProxmoxDefaultUsername(undefined);
setProxmoxDialogOpen(true);
}}
@@ -980,6 +1040,7 @@ export function HostsPanel({
onHostsChanged={setRawHosts}
preselectedHostId={proxmoxHostId}
defaultCredentialId={proxmoxDefaultCredentialId}
defaultAuthType={proxmoxDefaultAuthType}
defaultUsername={proxmoxDefaultUsername}
/>
</div>
+40 -8
View File
@@ -1,8 +1,10 @@
import { useState } from "react";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Eye, EyeOff, FolderSearch, Terminal } from "lucide-react";
import { Input } from "@/components/input";
import type { Host } from "@/types/ui-types";
import { getCredentials } from "@/api/credentials-api";
import { mapCredentials } from "./HostManagerData";
interface QuickConnectPanelProps {
onConnect: (host: Host, type: "terminal" | "files") => void;
@@ -12,13 +14,23 @@ export function QuickConnectPanel({ onConnect }: QuickConnectPanelProps) {
const { t } = useTranslation();
const [host, setHost] = useState("");
const [port, setPort] = useState("22");
const [username, setUsername] = useState("");
const [username, setUsername] = useState("root");
const [authType, setAuthType] = useState<"password" | "key" | "credential">(
"password",
);
const [password, setPassword] = useState("");
const [privateKey, setPrivateKey] = useState("");
const [showPassword, setShowPassword] = useState(false);
const [credentialId, setCredentialId] = useState("");
const [credentials, setCredentials] = useState<
{ id: string; name: string; username: string }[]
>([]);
useEffect(() => {
getCredentials()
.then((res) => setCredentials(mapCredentials(res)))
.catch(() => {});
}, []);
const connect = (type: "terminal" | "files") => {
if (!host || !username) return;
@@ -31,6 +43,7 @@ export function QuickConnectPanel({ onConnect }: QuickConnectPanelProps) {
authType,
password: authType === "password" ? password : undefined,
key: authType === "key" ? privateKey : undefined,
credentialId: authType === "credential" ? credentialId : undefined,
folder: "",
online: false,
cpu: null,
@@ -94,6 +107,12 @@ export function QuickConnectPanel({ onConnect }: QuickConnectPanelProps) {
<Input
placeholder={t("newUi.sidebar.quickConnect.usernamePlaceholder")}
value={username}
onFocus={() => {
if (username === "root") setUsername("");
}}
onBlur={() => {
if (username === "") setUsername("root");
}}
onChange={(e) => setUsername(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") connect("terminal");
@@ -172,12 +191,25 @@ export function QuickConnectPanel({ onConnect }: QuickConnectPanelProps) {
<label className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
{t("newUi.sidebar.quickConnect.credentialLabel")}
</label>
<Input
placeholder={t(
"newUi.sidebar.quickConnect.credentialPlaceholder",
)}
className="h-7 text-xs"
/>
<select
value={credentialId}
onChange={(e) => {
const newId = e.target.value;
setCredentialId(newId);
const cred = credentials.find((c) => c.id === newId);
if (cred?.username) setUsername(cred.username);
}}
className="flex h-7 w-full border border-border bg-background px-2.5 py-1 text-xs outline-none focus:ring-1 focus:ring-ring"
>
<option value="">
{t("newUi.sidebar.quickConnect.credentialPlaceholder")}
</option>
{credentials.map((c) => (
<option key={c.id} value={c.id}>
{c.username ? `${c.name} (${c.username})` : c.name}
</option>
))}
</select>
</div>
)}
<div className="flex flex-col gap-1.5 pt-1">
+54 -11
View File
@@ -51,6 +51,7 @@ function emptyOidc(): OIDCProviderConfig {
allowed_users: "",
admin_group: "",
group_claim: "",
ca_cert: "",
};
}
@@ -84,6 +85,7 @@ type OIDCFields = {
allowed_users: string;
admin_group: string;
group_claim: string;
ca_cert: string;
};
type LDAPFields = {
@@ -175,6 +177,7 @@ export function SSOProviderDialog({
allowed_users: (config.allowed_users as string) ?? d.allowed_users,
admin_group: (config.admin_group as string) ?? d.admin_group,
group_claim: (config.group_claim as string) ?? "",
ca_cert: (config.ca_cert as string) ?? "",
});
}
} else {
@@ -218,19 +221,29 @@ export function SSOProviderDialog({
allowedUsers: ldap.allowedUsers || undefined,
};
}
const providerDefaults =
type === "google"
? googleDefaults
: type === "github"
? githubDefaults
: null;
return {
client_id: oidc.client_id,
client_secret: oidc.client_secret,
issuer_url: oidc.issuer_url,
authorization_url: oidc.authorization_url,
token_url: oidc.token_url,
userinfo_url: oidc.userinfo_url || undefined,
identifier_path: oidc.identifier_path,
name_path: oidc.name_path,
scopes: oidc.scopes,
issuer_url: oidc.issuer_url || providerDefaults?.issuer_url || "",
authorization_url:
oidc.authorization_url || providerDefaults?.authorization_url || "",
token_url: oidc.token_url || providerDefaults?.token_url || "",
userinfo_url:
oidc.userinfo_url || providerDefaults?.userinfo_url || undefined,
identifier_path:
oidc.identifier_path || providerDefaults?.identifier_path || "sub",
name_path: oidc.name_path || providerDefaults?.name_path || "name",
scopes: oidc.scopes || providerDefaults?.scopes || "openid email profile",
allowed_users: oidc.allowed_users || undefined,
admin_group: oidc.admin_group || undefined,
group_claim: oidc.group_claim || undefined,
ca_cert: oidc.ca_cert || undefined,
};
}
@@ -395,8 +408,24 @@ function OIDCConfigFields({
simplified: boolean;
t: (key: string) => string;
}) {
const githubAuthUrl = "https://github.com/login/oauth/authorize";
const googleAuthUrl = "https://accounts.google.com/o/oauth2/v2/auth";
const githubDefaults = {
authorization_url: "https://github.com/login/oauth/authorize",
token_url: "https://github.com/login/oauth/access_token",
issuer_url: "https://token.actions.githubusercontent.com",
userinfo_url: "https://api.github.com/user",
scopes: "read:user user:email",
identifier_path: "id",
name_path: "name",
};
const googleDefaults = {
authorization_url: "https://accounts.google.com/o/oauth2/v2/auth",
token_url: "https://oauth2.googleapis.com/token",
issuer_url: "https://accounts.google.com",
userinfo_url: "https://openidconnect.googleapis.com/v1/userinfo",
scopes: "openid email profile",
identifier_path: "sub",
name_path: "name",
};
const docsHref = simplified
? "https://docs.termix.site/features/authentication/github-google"
: "https://docs.termix.site/features/authentication/oidc";
@@ -434,8 +463,8 @@ function OIDCConfigFields({
<div className="flex flex-col gap-1">
<span className="text-[10px] text-muted-foreground">
{type === "github"
? `Authorization URL: ${githubAuthUrl}`
: `Authorization URL: ${googleAuthUrl}`}
? `Authorization URL: ${githubDefaults.authorization_url}`
: `Authorization URL: ${googleDefaults.authorization_url}`}
</span>
</div>
) : (
@@ -531,6 +560,20 @@ function OIDCConfigFields({
className="text-xs"
/>
</Field>
<Field label={t("admin.oidcCaCert")}>
<span className="text-[10px] text-muted-foreground">
{t("admin.oidcCaCertDesc")}
</span>
<textarea
value={oidc.ca_cert}
onChange={(e) => setOidcField("ca_cert", e.target.value)}
placeholder={
"-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----"
}
rows={4}
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 font-mono"
/>
</Field>
</div>
);
}
+5 -4
View File
@@ -1,5 +1,6 @@
import { useState, useEffect, useCallback } from "react";
import { useTranslation } from "react-i18next";
import { copyToClipboard } from "@/lib/clipboard";
import { Input } from "@/components/input";
import {
Tooltip,
@@ -247,12 +248,12 @@ export function SessionLogsPanel() {
const handleCopy = async () => {
if (!viewContent) return;
try {
await navigator.clipboard.writeText(viewContent);
const ok = await copyToClipboard(viewContent);
if (ok) {
setCopied(true);
setTimeout(() => setCopied(false), 1500);
} catch {
toast.error("Failed to copy");
} else {
toast.error(t("common.copyFailed"));
}
};
+584 -9
View File
@@ -57,11 +57,37 @@ import type { SSHHostData } from "@/types/index";
import { FolderIconEl } from "@/components/folder-style";
import { copyToClipboard } from "@/lib/clipboard";
import { FolderMetadataDialog } from "./FolderMetadataDialog";
import {
useStatusColorScheme,
getStatusClasses,
} from "@/hooks/use-status-color-scheme";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/tooltip";
export function isFolder(item: Host | HostFolder): item is HostFolder {
return "children" in item;
}
function statusCheckEnabled(host: Host): boolean {
return host.statsConfig?.statusCheckEnabled !== false;
}
function buildStatusTooltip(host: Host, online: boolean): string {
const statusLabel = online ? "Online" : "Offline";
if (!statusCheckEnabled(host)) return "Monitoring disabled";
const protocols: string[] = [];
if (host.enableSsh) protocols.push("SSH");
if (host.enableRdp) protocols.push("RDP");
if (host.enableVnc) protocols.push("VNC");
if (host.enableTelnet) protocols.push("Telnet");
if (protocols.length === 0) return statusLabel;
return `${protocols.join(", ")}: ${statusLabel}`;
}
function getSshActions(
host: Host,
): { type: TabType; icon: typeof Terminal; label: string }[] {
@@ -273,6 +299,10 @@ export function HostItem({
const v = localStorage.getItem("showHostTags");
return v !== null ? v === "true" : true;
});
const [compactHostView, setCompactHostView] = useState(
() => localStorage.getItem("compactHostView") === "true",
);
const statusScheme = useStatusColorScheme();
const isTouchOnly =
typeof window !== "undefined" && window.matchMedia("(hover: none)").matches;
const shouldUseClickTray = trayOnClick || isTouchOnly;
@@ -322,8 +352,521 @@ export function HostItem({
};
}, []);
useEffect(() => {
const handler = () =>
setCompactHostView(localStorage.getItem("compactHostView") === "true");
window.addEventListener("storage", handler);
window.addEventListener("compactHostViewChanged", handler);
return () => {
window.removeEventListener("storage", handler);
window.removeEventListener("compactHostViewChanged", handler);
};
}, []);
if (query && !hostMatchesQuery(host, query)) return null;
if (compactHostView) {
return (
<div
draggable={!selectionMode && !isTouchOnly}
onDragStart={(e) => {
e.dataTransfer.effectAllowed = "move";
onDragStart?.();
}}
onDragEnd={() => onDragEnd?.()}
className={`group relative flex items-stretch cursor-pointer select-none transition-colors hover:bg-muted/40 ${
selected
? "bg-accent-brand/5"
: stripeIndex % 2 === 1
? "bg-muted/20"
: ""
} ${isMenuOpen ? "bg-muted/40" : ""}`}
onClick={(e) => {
if (selectionMode) {
onToggleSelect?.();
return;
}
if (isTouchOnly) {
e.stopPropagation();
const actionCount = getSshActions(host).length;
const otherProtocols = [
host.enableRdp,
host.enableVnc,
host.enableTelnet,
].filter(Boolean).length;
if (actionCount + otherProtocols <= 1) {
if (host.enableSsh) onOpenTab("terminal");
else if (host.enableRdp) onOpenTab("rdp");
else if (host.enableVnc) onOpenTab("vnc");
else if (host.enableTelnet) onOpenTab("telnet");
else onOpenTab("terminal");
} else {
onTrayOpenChange?.(!isTrayOpen);
}
return;
}
if (host.enableSsh) onOpenTab("terminal");
else if (host.enableRdp) onOpenTab("rdp");
else if (host.enableVnc) onOpenTab("vnc");
else if (host.enableTelnet) onOpenTab("telnet");
else onOpenTab("terminal");
}}
>
<div
className={`w-[3px] shrink-0 transition-colors ${getStatusClasses(host.online, statusScheme, "stripe")}`}
/>
<div className="flex flex-col min-w-0 flex-1">
<div className="flex items-center gap-1.5 min-w-0 px-2.5 py-1">
{selectionMode && (
<div
className={`size-3.5 border-2 flex items-center justify-center shrink-0 transition-colors ${selected ? "border-accent-brand bg-accent-brand" : "border-border bg-background"}`}
>
{selected && <Check className="size-2 text-background" />}
</div>
)}
<TooltipProvider delayDuration={300}>
<Tooltip>
<TooltipTrigger className="flex items-center">
<span
className={`size-1.5 rounded-full shrink-0 ${getStatusClasses(host.online, statusScheme, "dot")}`}
/>
</TooltipTrigger>
<TooltipContent side="right">
{buildStatusTooltip(host, host.online)}
</TooltipContent>
</Tooltip>
</TooltipProvider>
<span className="text-[13px] font-medium truncate text-foreground leading-none">
{host.name}
</span>
{!selectionMode && shouldUseClickTray && (
<button
title={
isTrayOpen
? t("hosts.collapseActions")
: t("hosts.expandActions")
}
onClick={(e) => {
e.stopPropagation();
onTrayOpenChange?.(!isTrayOpen);
}}
className="ml-auto flex items-center justify-center size-5 rounded text-muted-foreground/30 hover:text-muted-foreground hover:bg-muted-foreground/10 transition-colors shrink-0"
>
<ChevronRight
className={`size-3 transition-transform duration-150 ${isTrayOpen ? "rotate-90" : ""}`}
/>
</button>
)}
{!selectionMode && !shouldUseClickTray && (
<span className="text-[11px] text-muted-foreground/45 truncate leading-none ml-auto shrink-0 group-hover:hidden">
{host.ip}
</span>
)}
{selectionMode && (
<span className="text-[11px] text-muted-foreground/45 truncate leading-none ml-auto shrink-0">
{host.ip}
</span>
)}
</div>
{/* Click-tray mode: always-visible action buttons */}
{shouldUseClickTray && !selectionMode && (
<div
className={`overflow-hidden transition-all duration-150 ease-out ${isTrayOpen || isMenuOpen ? "max-h-[200px] opacity-100" : "max-h-0 opacity-0"}`}
>
<div className="flex items-center flex-wrap gap-1 px-2 pb-1">
{getSshActions(host).map(({ type, icon: Icon, label }) => (
<button
key={type}
title={label}
onClick={(e) => {
e.stopPropagation();
onOpenTab(type);
}}
className="flex items-center justify-center size-7 rounded text-muted-foreground/50 hover:text-foreground hover:bg-muted-foreground/10 transition-colors"
>
<Icon className="size-3.5" />
</button>
))}
{host.enableSsh &&
(host.enableRdp || host.enableVnc || host.enableTelnet) &&
getSshActions(host).length > 0 && (
<div className="w-px h-3.5 bg-border/60 mx-0.5 shrink-0" />
)}
{host.enableRdp && (
<button
title="RDP"
onClick={(e) => {
e.stopPropagation();
onOpenTab("rdp");
}}
className="flex items-center justify-center size-7 rounded text-muted-foreground/50 hover:text-foreground hover:bg-muted-foreground/10 transition-colors"
>
<Monitor className="size-3.5" />
</button>
)}
{host.enableVnc && (
<button
title="VNC"
onClick={(e) => {
e.stopPropagation();
onOpenTab("vnc");
}}
className="flex items-center justify-center size-7 rounded text-muted-foreground/50 hover:text-foreground hover:bg-muted-foreground/10 transition-colors"
>
<MousePointerClick className="size-3.5" />
</button>
)}
{host.enableTelnet && (
<button
title="Telnet"
onClick={(e) => {
e.stopPropagation();
onOpenTab("telnet");
}}
className="flex items-center justify-center size-7 rounded text-muted-foreground/50 hover:text-foreground hover:bg-muted-foreground/10 transition-colors"
>
<MessagesSquare className="size-3.5" />
</button>
)}
{host.macAddress && (
<button
title={t("hosts.wakeOnLanAction")}
onClick={async (e) => {
e.stopPropagation();
try {
await wakeOnLan(host.id);
toast.success(
t("hosts.wakeOnLanSuccess", { name: host.name }),
);
} catch {
toast.error(t("hosts.wakeOnLanError"));
}
}}
className="flex items-center justify-center size-7 rounded text-muted-foreground/50 hover:text-foreground hover:bg-muted-foreground/10 transition-colors"
>
<Zap className="size-3.5" />
</button>
)}
<div className="w-px h-3.5 bg-border/60 mx-0.5 shrink-0" />
{showPasswordCopy && (
<button
title={t("nav.copyPassword")}
onClick={(e) => handleCopyPassword(e, "password")}
className="flex items-center justify-center size-7 rounded text-muted-foreground/50 hover:text-foreground hover:bg-muted-foreground/10 transition-colors"
>
<Key className="size-3.5" />
</button>
)}
{showSudoPasswordCopy && (
<button
title={t("nav.copySudoPassword")}
onClick={(e) => handleCopyPassword(e, "sudoPassword")}
className="flex items-center justify-center size-7 rounded text-muted-foreground/50 hover:text-foreground hover:bg-muted-foreground/10 transition-colors"
>
<KeyRound className="size-3.5" />
</button>
)}
{onEditHost && (
<button
title="Edit Host"
onClick={(e) => {
e.stopPropagation();
onEditHost();
}}
className="flex items-center justify-center size-7 rounded text-muted-foreground/50 hover:text-foreground hover:bg-muted-foreground/10 transition-colors"
>
<Pencil className="size-3.5" />
</button>
)}
{onShareHost && (
<button
title={t("hosts.shareHost")}
onClick={(e) => {
e.stopPropagation();
onShareHost();
}}
className="flex items-center justify-center size-7 rounded text-muted-foreground/50 hover:text-foreground hover:bg-muted-foreground/10 transition-colors"
>
<Share2 className="size-3.5" />
</button>
)}
{host.enableProxmox && onProxmoxDiscover && (
<button
title={t("hosts.proxmoxDiscoverAction")}
onClick={(e) => {
e.stopPropagation();
onProxmoxDiscover();
}}
className="flex items-center justify-center size-7 rounded text-muted-foreground/50 hover:text-foreground hover:bg-muted-foreground/10 transition-colors"
>
<Boxes className="size-3.5" />
</button>
)}
<DropdownMenu open={isMenuOpen} onOpenChange={onMenuOpenChange}>
<DropdownMenuTrigger asChild>
<button
title="More options"
onClick={(e) => e.stopPropagation()}
className="flex items-center justify-center size-7 rounded text-muted-foreground/50 hover:text-foreground hover:bg-muted-foreground/10 transition-colors"
>
<MoreHorizontal className="size-3.5" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="text-xs">
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
writeClipboardText(`${host.username}@${host.ip}`);
toast.success(t("hosts.copiedToClipboard"));
}}
>
<Copy className="size-3.5 mr-2" />
{t("hosts.copyAddress")}
</DropdownMenuItem>
{showPasswordCopy && (
<DropdownMenuItem
onClick={(e) => handleCopyPassword(e, "password")}
>
<Key className="size-3.5 mr-2" />
{t("nav.copyPassword")}
</DropdownMenuItem>
)}
{showSudoPasswordCopy && (
<DropdownMenuItem
onClick={(e) => handleCopyPassword(e, "sudoPassword")}
>
<KeyRound className="size-3.5 mr-2" />
{t("nav.copySudoPassword")}
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
onDuplicate();
}}
>
<CopyPlus className="size-3.5 mr-2" />
{t("hosts.cloneHostAction")}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onClick={(e) => {
e.stopPropagation();
onDelete();
}}
>
<Trash2 className="size-3.5 mr-2" />
{t("common.delete")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
)}
{/* Hover tray (non-click-tray mode) */}
{!shouldUseClickTray && !selectionMode && (
<div className="max-h-0 opacity-0 overflow-hidden transition-all duration-150 ease-out group-hover:max-h-[200px] group-hover:opacity-100">
<div className="flex items-center flex-wrap gap-1 px-2 pb-1">
{getSshActions(host).map(({ type, icon: Icon, label }) => (
<button
key={type}
title={label}
onClick={(e) => {
e.stopPropagation();
onOpenTab(type);
}}
className="flex items-center justify-center size-7 rounded text-muted-foreground/50 hover:text-foreground hover:bg-muted-foreground/10 transition-colors"
>
<Icon className="size-3.5" />
</button>
))}
{host.enableSsh &&
(host.enableRdp || host.enableVnc || host.enableTelnet) &&
getSshActions(host).length > 0 && (
<div className="w-px h-3.5 bg-border/60 mx-0.5 shrink-0" />
)}
{host.enableRdp && (
<button
title="RDP"
onClick={(e) => {
e.stopPropagation();
onOpenTab("rdp");
}}
className="flex items-center justify-center size-7 rounded text-muted-foreground/50 hover:text-foreground hover:bg-muted-foreground/10 transition-colors"
>
<Monitor className="size-3.5" />
</button>
)}
{host.enableVnc && (
<button
title="VNC"
onClick={(e) => {
e.stopPropagation();
onOpenTab("vnc");
}}
className="flex items-center justify-center size-7 rounded text-muted-foreground/50 hover:text-foreground hover:bg-muted-foreground/10 transition-colors"
>
<MousePointerClick className="size-3.5" />
</button>
)}
{host.enableTelnet && (
<button
title="Telnet"
onClick={(e) => {
e.stopPropagation();
onOpenTab("telnet");
}}
className="flex items-center justify-center size-7 rounded text-muted-foreground/50 hover:text-foreground hover:bg-muted-foreground/10 transition-colors"
>
<MessagesSquare className="size-3.5" />
</button>
)}
{host.macAddress && (
<button
title={t("hosts.wakeOnLanAction")}
onClick={async (e) => {
e.stopPropagation();
try {
await wakeOnLan(host.id);
toast.success(
t("hosts.wakeOnLanSuccess", { name: host.name }),
);
} catch {
toast.error(t("hosts.wakeOnLanError"));
}
}}
className="flex items-center justify-center size-7 rounded text-muted-foreground/50 hover:text-foreground hover:bg-muted-foreground/10 transition-colors"
>
<Zap className="size-3.5" />
</button>
)}
<div className="w-px h-3.5 bg-border/60 mx-0.5 shrink-0" />
{showPasswordCopy && (
<button
title={t("nav.copyPassword")}
onClick={(e) => handleCopyPassword(e, "password")}
className="flex items-center justify-center size-7 rounded text-muted-foreground/50 hover:text-foreground hover:bg-muted-foreground/10 transition-colors"
>
<Key className="size-3.5" />
</button>
)}
{showSudoPasswordCopy && (
<button
title={t("nav.copySudoPassword")}
onClick={(e) => handleCopyPassword(e, "sudoPassword")}
className="flex items-center justify-center size-7 rounded text-muted-foreground/50 hover:text-foreground hover:bg-muted-foreground/10 transition-colors"
>
<KeyRound className="size-3.5" />
</button>
)}
{onEditHost && (
<button
title="Edit Host"
onClick={(e) => {
e.stopPropagation();
onEditHost();
}}
className="flex items-center justify-center size-7 rounded text-muted-foreground/50 hover:text-foreground hover:bg-muted-foreground/10 transition-colors"
>
<Pencil className="size-3.5" />
</button>
)}
{onShareHost && (
<button
title={t("hosts.shareHost")}
onClick={(e) => {
e.stopPropagation();
onShareHost();
}}
className="flex items-center justify-center size-7 rounded text-muted-foreground/50 hover:text-foreground hover:bg-muted-foreground/10 transition-colors"
>
<Share2 className="size-3.5" />
</button>
)}
{host.enableProxmox && onProxmoxDiscover && (
<button
title={t("hosts.proxmoxDiscoverAction")}
onClick={(e) => {
e.stopPropagation();
onProxmoxDiscover();
}}
className="flex items-center justify-center size-7 rounded text-muted-foreground/50 hover:text-foreground hover:bg-muted-foreground/10 transition-colors"
>
<Boxes className="size-3.5" />
</button>
)}
<DropdownMenu open={isMenuOpen} onOpenChange={onMenuOpenChange}>
<DropdownMenuTrigger asChild>
<button
title="More options"
onClick={(e) => e.stopPropagation()}
className="flex items-center justify-center size-7 rounded text-muted-foreground/50 hover:text-foreground hover:bg-muted-foreground/10 transition-colors"
>
<MoreHorizontal className="size-3.5" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="text-xs">
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
writeClipboardText(`${host.username}@${host.ip}`);
toast.success(t("hosts.copiedToClipboard"));
}}
>
<Copy className="size-3.5 mr-2" />
{t("hosts.copyAddress")}
</DropdownMenuItem>
{showPasswordCopy && (
<DropdownMenuItem
onClick={(e) => handleCopyPassword(e, "password")}
>
<Key className="size-3.5 mr-2" />
{t("nav.copyPassword")}
</DropdownMenuItem>
)}
{showSudoPasswordCopy && (
<DropdownMenuItem
onClick={(e) => handleCopyPassword(e, "sudoPassword")}
>
<KeyRound className="size-3.5 mr-2" />
{t("nav.copySudoPassword")}
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
onDuplicate();
}}
>
<CopyPlus className="size-3.5 mr-2" />
{t("hosts.cloneHostAction")}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onClick={(e) => {
e.stopPropagation();
onDelete();
}}
>
<Trash2 className="size-3.5 mr-2" />
{t("common.delete")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
)}
</div>
</div>
);
}
return (
<div
draggable={!selectionMode && !isTouchOnly}
@@ -373,7 +916,7 @@ export function HostItem({
>
{/* Status stripe */}
<div
className={`w-[3px] shrink-0 transition-colors ${host.online ? "bg-accent-brand" : "bg-transparent"}`}
className={`w-[3px] shrink-0 transition-colors ${getStatusClasses(host.online, statusScheme, "stripe")}`}
/>
<div className="flex flex-col flex-1 min-w-0 px-2.5 pt-2 pb-1.5 gap-1">
@@ -386,9 +929,18 @@ export function HostItem({
{selected && <Check className="size-2 text-background" />}
</div>
)}
<span
className={`size-1.5 rounded-full shrink-0 ${host.online ? "bg-accent-brand" : "bg-muted-foreground/25"}`}
/>
<TooltipProvider delayDuration={300}>
<Tooltip>
<TooltipTrigger className="flex items-center">
<span
className={`size-1.5 rounded-full shrink-0 ${getStatusClasses(host.online, statusScheme, "dot")}`}
/>
</TooltipTrigger>
<TooltipContent side="right">
{buildStatusTooltip(host, host.online)}
</TooltipContent>
</Tooltip>
</TooltipProvider>
<span className="text-[13px] font-medium truncate text-foreground leading-none">
{host.name}
</span>
@@ -1309,11 +1861,14 @@ export function SidebarTree({
pin: host.pin ?? false,
notes: host.notes,
macAddress: host.macAddress,
authType: host.authType,
password: host.password ?? null,
key: host.key ?? null,
keyPassword: host.keyPassword ?? null,
keyType: host.keyType ?? null,
// Key material is never sent to the frontend, so a cloned key-auth
// host would have authType "key" with no key — unusable. Reset to
// password so the clone is in a connectable (editable) state.
authType: host.authType === "key" ? "password" : host.authType,
password: host.authType === "key" ? null : (host.password ?? null),
key: null,
keyPassword: null,
keyType: null,
credentialId: host.credentialId ? Number(host.credentialId) : null,
overrideCredentialUsername: host.overrideCredentialUsername ?? false,
enableSsh: host.enableSsh,
@@ -1654,6 +2209,26 @@ export function SidebarTree({
))}
</DropdownMenuContent>
</DropdownMenu>
<button
className="text-[10px] text-muted-foreground hover:text-foreground px-1.5 py-1 hover:bg-muted rounded transition-colors flex items-center gap-1 disabled:opacity-40"
disabled={selectedHostIds.size === 0}
onClick={() => {
const selectedHosts = allHosts.filter((h) =>
selectedHostIds.has(String(h.id)),
);
for (const host of selectedHosts) {
if (host.enableSsh) onOpenTab(host, "terminal");
else if (host.enableRdp) onOpenTab(host, "rdp");
else if (host.enableVnc) onOpenTab(host, "vnc");
else if (host.enableTelnet) onOpenTab(host, "telnet");
}
setSelectedHostIds(new Set());
onToggleSelectionMode();
}}
>
<Terminal className="size-3" />
{t("hosts.connectSelected")}
</button>
<button
className="text-[10px] text-destructive hover:text-destructive px-1.5 py-1 hover:bg-destructive/10 rounded transition-colors disabled:opacity-40"
disabled={selectedHostIds.size === 0}
+518 -19
View File
@@ -19,6 +19,14 @@ import {
getRoles,
reorderSnippets,
} from "@/main-axios";
import {
exportSnippets,
importSnippets,
type SnippetExportData,
executeSnippet as apiExecuteSnippet,
} from "@/api/snippets-api";
import { getSSHHosts } from "@/api/ssh-host-management-api";
import type { SSHHost } from "@/types/index";
import { Button } from "@/components/button";
import { Input } from "@/components/input";
import { Separator } from "@/components/separator";
@@ -35,6 +43,7 @@ import {
Copy,
Cpu,
Database,
Download,
Folder,
Globe,
GripVertical,
@@ -48,9 +57,11 @@ import {
Share2,
Terminal,
Trash2,
Upload,
UserPlus,
X,
MoreHorizontal,
Zap,
} from "lucide-react";
import {
DropdownMenu,
@@ -121,18 +132,23 @@ function SnippetFormDialog({
folders,
snippet,
onSave,
availableHosts,
}: {
open: boolean;
onOpenChange: (v: boolean) => void;
folders: SnippetFolder[];
snippet: Snippet | null;
onSave: (data: Omit<Snippet, "id" | "order">, id?: number) => void;
availableHosts: SSHHost[];
}) {
const { t } = useTranslation();
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [folder, setFolder] = useState<string | null>(null);
const [content, setContent] = useState("");
const [selectedHostIds, setSelectedHostIds] = useState<Set<number>>(
new Set(),
);
useEffect(() => {
if (open) {
@@ -140,9 +156,22 @@ function SnippetFormDialog({
setDescription(snippet?.description ?? "");
setFolder(snippet?.folder ?? null);
setContent(snippet?.content ?? "");
setSelectedHostIds(new Set(snippet?.hostIds ?? []));
}
}, [open, snippet]);
function toggleHost(id: number) {
setSelectedHostIds((prev) => {
const next = new Set(prev);
if (next.has(id)) {
next.delete(id);
} else {
next.add(id);
}
return next;
});
}
function handleSave() {
if (!name.trim() || !content.trim()) return;
onSave(
@@ -151,6 +180,8 @@ function SnippetFormDialog({
description: description.trim() || undefined,
content: content.trim(),
folder,
hostIds:
selectedHostIds.size > 0 ? Array.from(selectedHostIds) : undefined,
},
snippet?.id,
);
@@ -234,6 +265,67 @@ function SnippetFormDialog({
className="w-full h-36 px-3 py-2 text-xs bg-background border border-border text-foreground placeholder:text-muted-foreground resize-none outline-none focus:ring-1 focus:ring-ring font-mono"
/>
</div>
<div className="flex flex-col gap-1.5">
<label className="flex items-center gap-2 text-xs font-semibold text-muted-foreground">
<Server className="size-3.5" />
{t("newUi.sidebar.snippets.targetHostsLabel")}{" "}
<span className="font-normal">
({t("newUi.sidebar.snippets.optional")})
</span>
</label>
<p className="text-xs text-muted-foreground/70">
{t("newUi.sidebar.snippets.targetHostsHint")}
</p>
{availableHosts.length === 0 ? (
<span className="text-xs text-muted-foreground/50">
{t("newUi.sidebar.snippets.noHostsAvailable")}
</span>
) : (
<div className="flex flex-col gap-1 max-h-32 overflow-y-auto border border-border p-1.5">
{availableHosts.map((host) => {
const selected = selectedHostIds.has(host.id);
return (
<button
key={host.id}
type="button"
onClick={() => toggleHost(host.id)}
className={`flex items-center gap-2 px-2 py-1.5 text-left transition-colors ${
selected
? "bg-accent-brand/10 text-accent-brand"
: "text-muted-foreground hover:text-foreground"
}`}
>
<div
className={`size-3 border-2 flex items-center justify-center shrink-0 transition-colors ${
selected
? "border-accent-brand bg-accent-brand"
: "border-border/60"
}`}
>
{selected && <div className="size-1.5 bg-background" />}
</div>
<Server className="size-3 shrink-0 opacity-60" />
<span className="text-xs font-medium truncate flex-1">
{host.name || host.ip}
</span>
<span className="text-xs text-muted-foreground/60 shrink-0">
{host.ip}
</span>
</button>
);
})}
</div>
)}
{selectedHostIds.size > 0 && (
<button
type="button"
onClick={() => setSelectedHostIds(new Set())}
className="text-xs text-muted-foreground hover:text-foreground self-start"
>
{t("newUi.sidebar.snippets.clearTargetHosts")}
</button>
)}
</div>
</div>
<div className="flex items-center justify-end gap-2 mt-2">
<Button variant="ghost" onClick={() => onOpenChange(false)}>
@@ -503,10 +595,36 @@ type AccessRecord = {
};
type SnippetPayload = Omit<Snippet, "id" | "order">;
type RawSnippet = SnippetPayload & {
type RawSnippet = Omit<SnippetPayload, "hostIds"> & {
id: number;
order?: number | null;
hostFilter?: string | null;
};
function parseHostFilter(raw: string | null | undefined): number[] | undefined {
if (!raw) return undefined;
try {
const parsed = JSON.parse(raw);
if (Array.isArray(parsed) && parsed.every((x) => typeof x === "number")) {
return parsed.length > 0 ? parsed : undefined;
}
} catch {
// ignore malformed values
}
return undefined;
}
function mapRawSnippet(s: RawSnippet): Snippet {
return {
id: s.id,
name: s.name,
description: s.description,
content: s.content,
folder: s.folder ?? null,
order: s.order ?? 0,
hostIds: parseHostFilter(s.hostFilter),
};
}
type RawSnippetFolder = {
id: number;
name: string;
@@ -694,32 +812,44 @@ function SnippetCard({
snippet,
selectedTabIds,
terminalTabs,
activeTabId,
onDelete,
onEdit,
onShare,
onConfirmRun,
onDirectExecute,
onDragStart,
onDragEnd,
onDragOver,
dropIndicator,
isDragging,
availableHosts,
t,
}: {
snippet: Snippet;
selectedTabIds: Set<string>;
terminalTabs: Tab[];
activeTabId: string;
onDelete: (id: number) => void;
onEdit: (snippet: Snippet) => void;
onShare: (snippet: Snippet) => void;
onConfirmRun: (snippet: Snippet, execute: () => void) => void;
onDirectExecute: (snippet: Snippet) => void;
onDragStart: () => void;
onDragEnd: () => void;
onDragOver: (e: React.DragEvent) => void;
dropIndicator: "above" | "below" | null;
isDragging: boolean;
availableHosts: SSHHost[];
t: (key: string, opts?: Record<string, unknown>) => string;
}) {
const hasTargetHosts = (snippet.hostIds?.length ?? 0) > 0;
function executeRun() {
if (hasTargetHosts) {
onDirectExecute(snippet);
return;
}
const targets = terminalTabs.filter((tab) => selectedTabIds.has(tab.id));
if (targets.length > 0) {
targets.forEach((tab) => {
@@ -732,7 +862,9 @@ function SnippetCard({
}),
);
} else if (terminalTabs.length > 0) {
terminalTabs[0].terminalRef?.current?.sendInput?.(snippet.content + "\r");
const activeTab =
terminalTabs.find((tab) => tab.id === activeTabId) ?? terminalTabs[0];
activeTab.terminalRef?.current?.sendInput?.(snippet.content + "\r");
toast.success(
t("newUi.sidebar.snippets.runSuccess", {
name: snippet.name,
@@ -758,6 +890,10 @@ function SnippetCard({
);
}
const targetHosts = hasTargetHosts
? availableHosts.filter((h) => snippet.hostIds!.includes(h.id))
: [];
return (
<div className="relative" onDragOver={onDragOver}>
{dropIndicator === "above" && (
@@ -774,7 +910,7 @@ function SnippetCard({
>
<div className="flex items-start gap-2">
<GripVertical className="size-3.5 mt-0.5 shrink-0 text-muted-foreground/30 group-hover/card:text-muted-foreground/60 cursor-grab active:cursor-grabbing transition-colors" />
<div className="flex flex-col min-w-0">
<div className="flex flex-col min-w-0 flex-1">
<span className="text-xs font-semibold">{snippet.name}</span>
{snippet.description && (
<span className="text-xs text-muted-foreground">
@@ -782,19 +918,44 @@ function SnippetCard({
</span>
)}
</div>
{hasTargetHosts && (
<Zap
className="size-3 shrink-0 mt-0.5 text-accent-brand/70"
title={t("newUi.sidebar.snippets.hasTargetHosts")}
/>
)}
</div>
<span className="text-xs text-muted-foreground font-mono px-1">
{snippet.content}
</span>
{targetHosts.length > 0 && (
<div className="flex flex-wrap gap-1 px-1">
{targetHosts.map((host) => (
<span
key={host.id}
className="inline-flex items-center gap-1 text-[10px] px-1.5 py-0.5 bg-accent-brand/10 text-accent-brand border border-accent-brand/20"
>
<Server className="size-2.5" />
{host.name || host.ip}
</span>
))}
</div>
)}
<div className="flex items-center gap-1">
<Button
variant="outline"
size="sm"
className="flex-1 text-xs h-7 gap-1.5"
className={`flex-1 text-xs h-7 gap-1.5 ${hasTargetHosts ? "border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand" : ""}`}
onClick={handleRun}
>
<Play className="size-3" />
{t("newUi.sidebar.snippets.run")}
{hasTargetHosts ? (
<Zap className="size-3" />
) : (
<Play className="size-3" />
)}
{hasTargetHosts
? t("newUi.sidebar.snippets.runOnTargets")
: t("newUi.sidebar.snippets.run")}
</Button>
<Button
variant="ghost"
@@ -837,6 +998,207 @@ function SnippetCard({
);
}
function ImportSnippetsDialog({
open,
onOpenChange,
onImportDone,
}: {
open: boolean;
onOpenChange: (v: boolean) => void;
onImportDone: () => void;
}) {
const { t } = useTranslation();
const [file, setFile] = useState<File | null>(null);
const [overwrite, setOverwrite] = useState(false);
const [importing, setImporting] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (!open) {
setFile(null);
setOverwrite(false);
}
}, [open]);
function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
setFile(e.target.files?.[0] ?? null);
}
function handleDrop(e: React.DragEvent) {
e.preventDefault();
const dropped = e.dataTransfer.files?.[0];
if (dropped?.name.endsWith(".json")) setFile(dropped);
}
async function handleImport() {
if (!file) return;
setImporting(true);
try {
const text = await file.text();
let parsed: SnippetExportData;
try {
parsed = JSON.parse(text);
} catch {
toast.error(t("newUi.sidebar.snippets.importInvalidFile"));
return;
}
if (
typeof parsed !== "object" ||
parsed === null ||
(!Array.isArray(parsed.snippets) && !Array.isArray(parsed.folders))
) {
toast.error(t("newUi.sidebar.snippets.importInvalidFile"));
return;
}
const result = await importSnippets(parsed, overwrite);
toast.success(
t("newUi.sidebar.snippets.importSuccess", {
snippets: result.snippetsImported,
updated: result.snippetsUpdated,
skipped: result.snippetsSkipped,
folders: result.foldersImported,
}),
);
onImportDone();
onOpenChange(false);
} catch {
toast.error(t("newUi.sidebar.snippets.importFailed"));
} finally {
setImporting(false);
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="text-lg font-bold">
{t("newUi.sidebar.snippets.importTitle")}
</DialogTitle>
<DialogDescription className="text-xs text-muted-foreground">
{t("newUi.sidebar.snippets.importDescription")}
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-4 mt-1">
<div
className="flex flex-col items-center justify-center gap-2 border-2 border-dashed border-border px-4 py-8 cursor-pointer hover:border-accent-brand/50 transition-colors"
onClick={() => fileInputRef.current?.click()}
onDrop={handleDrop}
onDragOver={(e) => e.preventDefault()}
>
<Upload className="size-6 text-muted-foreground" />
<span className="text-xs text-muted-foreground text-center">
{file
? t("newUi.sidebar.snippets.importSelectedFile", {
name: file.name,
})
: t("newUi.sidebar.snippets.importDropOrClick")}
</span>
<input
ref={fileInputRef}
type="file"
accept=".json"
className="hidden"
onChange={handleFileChange}
/>
</div>
<label className="flex items-center gap-2 text-xs cursor-pointer select-none">
<input
type="checkbox"
checked={overwrite}
onChange={(e) => setOverwrite(e.target.checked)}
className="size-3.5"
/>
{t("newUi.sidebar.snippets.importOverwrite")}
</label>
</div>
<div className="flex items-center justify-end gap-2 mt-2">
<Button variant="ghost" onClick={() => onOpenChange(false)}>
{t("newUi.sidebar.snippets.cancel")}
</Button>
<Button
variant="outline"
className="border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
onClick={handleImport}
disabled={!file || importing}
>
{t("newUi.sidebar.snippets.importStartBtn")}
</Button>
</div>
</DialogContent>
</Dialog>
);
}
interface ExecutionResult {
hostLabel: string;
success: boolean;
output: string;
error?: string;
}
function ExecutionResultDialog({
open,
onOpenChange,
snippetName,
results,
}: {
open: boolean;
onOpenChange: (v: boolean) => void;
snippetName: string;
results: ExecutionResult[];
}) {
const { t } = useTranslation();
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-xl">
<DialogHeader>
<DialogTitle className="text-lg font-bold">
{t("newUi.sidebar.snippets.executionResultTitle", {
name: snippetName,
})}
</DialogTitle>
<DialogDescription className="text-xs text-muted-foreground">
{t("newUi.sidebar.snippets.executionResultDescription")}
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-3 max-h-80 overflow-y-auto">
{results.map((r, i) => (
<div
key={i}
className="flex flex-col gap-1 border border-border p-2.5"
>
<div className="flex items-center gap-2">
<Server className="size-3 shrink-0 text-muted-foreground" />
<span className="text-xs font-semibold">{r.hostLabel}</span>
<span
className={`ml-auto text-[10px] px-1.5 py-0.5 ${r.success ? "bg-green-500/10 text-green-500" : "bg-destructive/10 text-destructive"}`}
>
{r.success
? t("newUi.sidebar.snippets.executionSuccess")
: t("newUi.sidebar.snippets.executionFailed")}
</span>
</div>
{(r.output || r.error) && (
<pre className="text-xs bg-muted/30 p-2 overflow-x-auto whitespace-pre-wrap font-mono text-muted-foreground max-h-40">
{r.error ? r.error : r.output}
</pre>
)}
</div>
))}
</div>
<div className="flex justify-end mt-2">
<Button variant="ghost" onClick={() => onOpenChange(false)}>
{t("newUi.sidebar.snippets.close")}
</Button>
</div>
</DialogContent>
</Dialog>
);
}
export function SnippetsPanel({
terminalTabs,
activeTabId,
@@ -856,6 +1218,13 @@ export function SnippetsPanel({
const [editFolder, setEditFolder] = useState<SnippetFolder | null>(null);
const [editFolderOpen, setEditFolderOpen] = useState(false);
const [shareSnippet, setShareSnippet] = useState<Snippet | null>(null);
const [importOpen, setImportOpen] = useState(false);
const [availableHosts, setAvailableHosts] = useState<SSHHost[]>([]);
const [executionResultOpen, setExecutionResultOpen] = useState(false);
const [executionResults, setExecutionResults] = useState<ExecutionResult[]>(
[],
);
const [executionSnippetName, setExecutionSnippetName] = useState("");
const [selectedTabIds, setSelectedTabIds] = useState<Set<string>>(
() =>
new Set(
@@ -984,15 +1353,7 @@ export function SnippetsPanel({
getSnippets()
.then((data) => {
const arr: RawSnippet[] = Array.isArray(data) ? data : [];
const mapped: Snippet[] = arr.map((s) => ({
id: s.id,
name: s.name,
description: s.description,
content: s.content,
folder: s.folder ?? null,
order: s.order ?? 0,
}));
updateSnippets(mapped);
updateSnippets(arr.map(mapRawSnippet));
})
.catch(() => {});
@@ -1009,6 +1370,10 @@ export function SnippetsPanel({
setFolders(mapped);
})
.catch(() => {});
getSSHHosts()
.then((hosts) => setAvailableHosts(hosts))
.catch(() => {});
}, []);
function toggleTab(id: string) {
@@ -1027,21 +1392,28 @@ export function SnippetsPanel({
data: Omit<Snippet, "id" | "order">,
id?: number,
) {
const { hostIds, ...rest } = data;
const payload = {
...rest,
hostFilter: hostIds && hostIds.length > 0 ? hostIds : null,
};
try {
if (id !== undefined) {
await apiUpdateSnippet(id, data);
await apiUpdateSnippet(id, payload);
updateSnippets((prev) =>
prev.map((s) => (s.id === id ? { ...s, ...data } : s)),
);
toast.success(t("newUi.sidebar.snippets.updateSuccess"));
} else {
const created = await apiCreateSnippet(data);
const created = await apiCreateSnippet(payload);
updateSnippets((prev) => [
...prev,
{
...data,
id: created.id ?? Math.max(0, ...prev.map((x) => x.id)) + 1,
order: created.order ?? prev.length,
id:
(created.id as number) ??
Math.max(0, ...prev.map((x) => x.id)) + 1,
order: (created.order as number) ?? prev.length,
},
]);
toast.success(t("newUi.sidebar.snippets.createSuccess"));
@@ -1125,6 +1497,47 @@ export function SnippetsPanel({
}
}
async function handleExport() {
try {
const data = await exportSnippets();
const blob = new Blob([JSON.stringify(data, null, 2)], {
type: "application/json",
});
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "snippets-export.json";
a.click();
URL.revokeObjectURL(url);
toast.success(t("newUi.sidebar.snippets.exportSuccess"));
} catch {
toast.error(t("newUi.sidebar.snippets.exportFailed"));
}
}
function reloadData() {
const collapsed = getFoldersCollapsed();
getSnippets()
.then((data) => {
const arr: RawSnippet[] = Array.isArray(data) ? data : [];
updateSnippets(arr.map(mapRawSnippet));
})
.catch(() => {});
getSnippetFolders()
.then((data) => {
const arr: RawSnippetFolder[] = Array.isArray(data) ? data : [];
const mapped: SnippetFolder[] = arr.map((f) => ({
id: f.id,
name: f.name,
color: f.color ?? FOLDER_COLORS[0],
icon: (f.icon as FolderIconId) ?? "folder",
open: !collapsed,
}));
setFolders(mapped);
})
.catch(() => {});
}
async function handleDeleteSnippet(id: number) {
try {
await apiDeleteSnippet(id);
@@ -1139,6 +1552,56 @@ export function SnippetsPanel({
setSnippetFormOpen(true);
}
async function handleDirectExecute(snippet: Snippet) {
const hostIds = snippet.hostIds ?? [];
if (hostIds.length === 0) return;
setExecutionSnippetName(snippet.name);
setExecutionResults([]);
setExecutionResultOpen(true);
const results: ExecutionResult[] = await Promise.all(
hostIds.map(async (hostId) => {
const host = availableHosts.find((h) => h.id === hostId);
const hostLabel = host ? host.name || host.ip : String(hostId);
try {
const result = await apiExecuteSnippet(snippet.id, hostId);
return {
hostLabel,
success: result.success,
output: result.output,
error: result.error,
};
} catch (err) {
return {
hostLabel,
success: false,
output: "",
error: err instanceof Error ? err.message : String(err),
};
}
}),
);
setExecutionResults(results);
const allOk = results.every((r) => r.success);
if (allOk) {
toast.success(
t("newUi.sidebar.snippets.directRunSuccess", {
name: snippet.name,
count: results.length,
}),
);
} else {
toast.error(
t("newUi.sidebar.snippets.directRunPartialFail", {
name: snippet.name,
}),
);
}
}
const filtered = snippetSearch
? snippets.filter(
(s) =>
@@ -1265,6 +1728,24 @@ export function SnippetsPanel({
{t("newUi.sidebar.snippets.newFolder")}
</Button>
</div>
<div className="flex gap-2 min-w-0">
<Button
variant="ghost"
className="flex-1 text-xs min-w-0 overflow-hidden text-muted-foreground hover:text-foreground"
onClick={handleExport}
>
<Download className="size-3.5 shrink-0" />
{t("newUi.sidebar.snippets.exportBtn")}
</Button>
<Button
variant="ghost"
className="flex-1 text-xs min-w-0 overflow-hidden text-muted-foreground hover:text-foreground"
onClick={() => setImportOpen(true)}
>
<Upload className="size-3.5 shrink-0" />
{t("newUi.sidebar.snippets.importBtn")}
</Button>
</div>
<div className="flex flex-col gap-4">
{(!snippetSearch || uncategorizedSnippets.length > 0) && (
<div className="flex flex-col gap-2">
@@ -1295,10 +1776,12 @@ export function SnippetsPanel({
snippet={snippet}
selectedTabIds={selectedTabIds}
terminalTabs={terminalTabs}
activeTabId={activeTabId}
onDelete={handleDeleteSnippet}
onEdit={handleEditSnippet}
onShare={setShareSnippet}
onConfirmRun={handleConfirmRun}
onDirectExecute={handleDirectExecute}
onDragStart={() => handleDragStart(snippet)}
onDragEnd={handleDragEnd}
onDragOver={(e) => handleDragOver(e, snippet.id)}
@@ -1308,6 +1791,7 @@ export function SnippetsPanel({
: null
}
isDragging={draggedSnippet?.id === snippet.id}
availableHosts={availableHosts}
t={t}
/>
))}
@@ -1388,10 +1872,12 @@ export function SnippetsPanel({
snippet={snippet}
selectedTabIds={selectedTabIds}
terminalTabs={terminalTabs}
activeTabId={activeTabId}
onDelete={handleDeleteSnippet}
onEdit={handleEditSnippet}
onShare={setShareSnippet}
onConfirmRun={handleConfirmRun}
onDirectExecute={handleDirectExecute}
onDragStart={() => handleDragStart(snippet)}
onDragEnd={handleDragEnd}
onDragOver={(e) => handleDragOver(e, snippet.id)}
@@ -1401,6 +1887,7 @@ export function SnippetsPanel({
: null
}
isDragging={draggedSnippet?.id === snippet.id}
availableHosts={availableHosts}
t={t}
/>
))}
@@ -1426,6 +1913,7 @@ export function SnippetsPanel({
folders={folders}
snippet={editingSnippet}
onSave={handleSaveSnippet}
availableHosts={availableHosts}
/>
<CreateFolderDialog
open={createFolderOpen}
@@ -1445,6 +1933,17 @@ export function SnippetsPanel({
snippet={shareSnippet}
onClose={() => setShareSnippet(null)}
/>
<ImportSnippetsDialog
open={importOpen}
onOpenChange={setImportOpen}
onImportDone={reloadData}
/>
<ExecutionResultDialog
open={executionResultOpen}
onOpenChange={setExecutionResultOpen}
snippetName={executionSnippetName}
results={executionResults}
/>
</>
);
}
+51
View File
@@ -347,6 +347,57 @@ export function SplitScreenPanel({
</Button>
</>
)}
<Separator />
<div className="flex flex-col gap-2">
<span className="text-xs font-bold uppercase tracking-widest text-muted-foreground">
{t("newUi.sidebar.splitScreen.hotkeysTitle")}
</span>
<div className="flex flex-col gap-1">
{[
{
keys: ["Ctrl", "Shift", "\\"],
label: t("newUi.sidebar.splitScreen.hotkeysSplitRight"),
},
{
keys: ["Ctrl", "Shift", "-"],
label: t("newUi.sidebar.splitScreen.hotkeysSplitBelow"),
},
{
keys: ["Alt", "↑↓←→"],
label: t("newUi.sidebar.splitScreen.hotkeysNavigatePane"),
},
{
keys: ["Ctrl", "Shift", "]"],
label: t("newUi.sidebar.splitScreen.hotkeysNextTab"),
},
{
keys: ["Ctrl", "Shift", "["],
label: t("newUi.sidebar.splitScreen.hotkeysPrevTab"),
},
].map(({ keys, label }) => (
<div
key={label}
className="flex items-center justify-between gap-2"
>
<span className="text-xs text-muted-foreground/70 flex-1 min-w-0 truncate">
{label}
</span>
<div className="flex items-center gap-0.5 shrink-0">
{keys.map((k, i) => (
<kbd
key={i}
className="inline-flex items-center px-1 py-0.5 text-[10px] font-mono bg-muted border border-border/60 text-muted-foreground leading-none"
>
{k}
</kbd>
))}
</div>
</div>
))}
</div>
</div>
</div>
);
}
+27 -4
View File
@@ -59,6 +59,16 @@ export function SshToolsPanel({
}
}
function broadcastArrow(normalSeq: string, appSeq: string) {
for (const tabId of selectedTabIds) {
const tab = terminalTabs.find((t) => t.id === tabId);
const ref = tab?.terminalRef?.current;
if (!ref) continue;
const appMode = ref.getApplicationCursorKeysMode?.() ?? false;
ref.sendInput?.(appMode ? appSeq : normalSeq);
}
}
function handleKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
e.preventDefault();
e.stopPropagation();
@@ -86,16 +96,29 @@ export function SshToolsPanel({
}
}
if (key === "ArrowUp") {
broadcastArrow("\x1B[A", "\x1BOA");
return;
}
if (key === "ArrowDown") {
broadcastArrow("\x1B[B", "\x1BOB");
return;
}
if (key === "ArrowRight") {
broadcastArrow("\x1B[C", "\x1BOC");
return;
}
if (key === "ArrowLeft") {
broadcastArrow("\x1B[D", "\x1BOD");
return;
}
const specialMap: Record<string, string> = {
Enter: "\r",
Backspace: "\x7F",
Delete: "\x1B[3~",
Tab: "\t",
Escape: "\x1B",
ArrowUp: "\x1B[A",
ArrowDown: "\x1B[B",
ArrowRight: "\x1B[C",
ArrowLeft: "\x1B[D",
Home: "\x1B[H",
End: "\x1B[F",
PageUp: "\x1B[5~",
+132
View File
@@ -413,11 +413,13 @@ function PasswordChangeSection({
export function UserProfilePanel({
username,
onLogout,
onChangeServer,
userPrefs,
onPrefsChange,
}: {
username?: string;
onLogout?: () => void;
onChangeServer?: () => void;
userPrefs?: {
reopenTabsOnLogin: boolean;
storageMode?: string | null;
@@ -425,12 +427,14 @@ export function UserProfilePanel({
commandPaletteEnabled?: boolean | null;
showHostTags?: boolean | null;
hostTrayOnClick?: boolean | null;
compactHostView?: boolean | null;
pinAppRail?: boolean | null;
foldersCollapsed?: boolean | null;
confirmSnippetExecution?: boolean | null;
disableUpdateCheck?: boolean | null;
confirmTabClose?: boolean | null;
hiddenRailTabs?: string | null;
statusColorScheme?: string | null;
};
onPrefsChange?: (prefs: { reopenTabsOnLogin: boolean }) => void;
}) {
@@ -508,6 +512,9 @@ export function UserProfilePanel({
const [commandAutocomplete, setCommandAutocomplete] = useState(
() => localStorage.getItem("commandAutocomplete") === "true",
);
const [terminalLinkClickBehavior, setTerminalLinkClickBehavior] = useState(
() => localStorage.getItem("terminalLinkClickBehavior") ?? "confirm",
);
const [commandPaletteEnabled, setCommandPaletteEnabled] = useState(() => {
const v = localStorage.getItem("commandPaletteShortcutEnabled");
return v !== null ? v === "true" : true;
@@ -519,6 +526,12 @@ export function UserProfilePanel({
const [hostTrayOnClick, setHostTrayOnClick] = useState(
() => localStorage.getItem("hostTrayOnClick") === "true",
);
const [compactHostView, setCompactHostView] = useState(
() => localStorage.getItem("compactHostView") === "true",
);
const [statusColorScheme, setStatusColorScheme] = useState(
() => localStorage.getItem("statusColorScheme") ?? "accent",
);
const [pinAppRail, setPinAppRail] = useState(
() => localStorage.getItem("pinAppRail") === "true",
);
@@ -600,12 +613,14 @@ export function UserProfilePanel({
"commandPaletteShortcutEnabled",
"showHostTags",
"hostTrayOnClick",
"compactHostView",
"pinAppRail",
"defaultSnippetFoldersCollapsed",
"confirmSnippetExecution",
"disableUpdateCheck",
"confirmTabClose",
"hiddenRailTabs",
"statusColorScheme",
];
const snap: Record<string, string | null> = { __theme: theme };
for (const key of SNAPSHOT_KEYS) snap[key] = localStorage.getItem(key);
@@ -656,6 +671,14 @@ export function UserProfilePanel({
String(prefs.hostTrayOnClick),
);
}
if (prefs.compactHostView != null) {
setCompactHostView(prefs.compactHostView);
localStorage.setItem(
"compactHostView",
String(prefs.compactHostView),
);
window.dispatchEvent(new CustomEvent("compactHostViewChanged"));
}
if (prefs.pinAppRail != null) {
setPinAppRail(prefs.pinAppRail);
localStorage.setItem("pinAppRail", String(prefs.pinAppRail));
@@ -694,6 +717,11 @@ export function UserProfilePanel({
localStorage.setItem("hiddenRailTabs", prefs.hiddenRailTabs);
window.dispatchEvent(new CustomEvent("hiddenRailTabsChanged"));
}
if (prefs.statusColorScheme != null) {
setStatusColorScheme(prefs.statusColorScheme);
localStorage.setItem("statusColorScheme", prefs.statusColorScheme);
window.dispatchEvent(new CustomEvent("statusColorSchemeChanged"));
}
} catch {
// leave UI as-is on error
}
@@ -725,6 +753,9 @@ export function UserProfilePanel({
window.dispatchEvent(new CustomEvent("showHostTagsChanged"));
setHostTrayOnClick(false);
localStorage.setItem("hostTrayOnClick", "false");
setCompactHostView(false);
localStorage.setItem("compactHostView", "false");
window.dispatchEvent(new CustomEvent("compactHostViewChanged"));
setPinAppRail(false);
localStorage.setItem("pinAppRail", "false");
setFoldersCollapsed(true);
@@ -738,6 +769,9 @@ export function UserProfilePanel({
setHiddenRailTabs(new Set());
localStorage.removeItem("hiddenRailTabs");
window.dispatchEvent(new CustomEvent("hiddenRailTabsChanged"));
setStatusColorScheme("accent");
localStorage.setItem("statusColorScheme", "accent");
window.dispatchEvent(new CustomEvent("statusColorSchemeChanged"));
if (storageMode === "cloud") {
saveToCloud({
theme: "system",
@@ -748,12 +782,14 @@ export function UserProfilePanel({
commandPaletteEnabled: true,
showHostTags: true,
hostTrayOnClick: false,
compactHostView: false,
pinAppRail: false,
foldersCollapsed: true,
confirmSnippetExecution: false,
disableUpdateCheck: false,
confirmTabClose: false,
hiddenRailTabs: "[]",
statusColorScheme: "accent",
});
}
localSnapshot.current = {};
@@ -818,6 +854,12 @@ export function UserProfilePanel({
setHostTrayOnClick(restoredTrayOnClick);
localStorage.setItem("hostTrayOnClick", String(restoredTrayOnClick));
const restoredCompactHostView =
restore("compactHostView", "false") === "true";
setCompactHostView(restoredCompactHostView);
localStorage.setItem("compactHostView", String(restoredCompactHostView));
window.dispatchEvent(new CustomEvent("compactHostViewChanged"));
const restoredPinRail = restore("pinAppRail", "false") === "true";
setPinAppRail(restoredPinRail);
localStorage.setItem("pinAppRail", String(restoredPinRail));
@@ -866,6 +908,13 @@ export function UserProfilePanel({
localStorage.setItem("hiddenRailTabs", restoredHiddenRaw);
}
window.dispatchEvent(new CustomEvent("hiddenRailTabsChanged"));
const restoredStatusScheme =
restore("statusColorScheme", "accent") ?? "accent";
setStatusColorScheme(restoredStatusScheme);
localStorage.setItem("statusColorScheme", restoredStatusScheme);
window.dispatchEvent(new CustomEvent("statusColorSchemeChanged"));
localStorage.removeItem("termix-local-snapshot");
localSnapshot.current = {};
}
@@ -1126,6 +1175,30 @@ export function UserProfilePanel({
</div>
</div>
{isElectron() && onChangeServer && (
<div className="border-t border-border pt-3 mt-3">
<div className="flex items-center justify-between">
<div className="flex flex-col gap-0.5">
<span className="text-xs font-medium">
{t("serverConfig.changeServer")}
</span>
<span className="text-[10px] text-muted-foreground">
{t("newUi.sidebar.userProfile.changeServerDescription")}
</span>
</div>
<Button
variant="outline"
size="sm"
className="shrink-0 ml-3 text-[10px] h-7"
onClick={onChangeServer}
>
<Server className="size-3" />
{t("serverConfig.changeServer")}
</Button>
</div>
</div>
)}
<div className="border-t border-border pt-3 mt-3">
<div className="flex items-center justify-between">
<div className="flex flex-col gap-0.5">
@@ -1315,6 +1388,34 @@ export function UserProfilePanel({
}}
/>
</SettingRow>
<div className="flex flex-col gap-1.5 py-3 border-b border-border">
<div className="flex flex-col gap-0.5">
<span className="text-sm font-medium leading-snug">
{t("newUi.sidebar.userProfile.terminalLinkBehavior")}
</span>
<span className="text-xs text-muted-foreground leading-snug">
{t("newUi.sidebar.userProfile.terminalLinkBehaviorDesc")}
</span>
</div>
<select
value={terminalLinkClickBehavior}
onChange={(e) => {
setTerminalLinkClickBehavior(e.target.value);
localStorage.setItem(
"terminalLinkClickBehavior",
e.target.value,
);
}}
className="h-7 border border-border bg-background px-2 text-xs outline-none focus:ring-1 focus:ring-ring"
>
<option value="confirm">
{t("hosts.linkClickBehaviorConfirm")}
</option>
<option value="direct">
{t("hosts.linkClickBehaviorDirect")}
</option>
</select>
</div>
<SettingRow
label={t("newUi.sidebar.userProfile.commandPalette")}
description={t("newUi.sidebar.userProfile.commandPaletteDesc")}
@@ -1400,6 +1501,37 @@ export function UserProfilePanel({
}}
/>
</SettingRow>
<SettingRow
label={t("newUi.sidebar.userProfile.compactHostView")}
description={t("newUi.sidebar.userProfile.compactHostViewDesc")}
>
<FakeSwitch
checked={compactHostView}
onChange={(v) => {
setCompactHostView(v);
localStorage.setItem("compactHostView", v.toString());
window.dispatchEvent(new Event("compactHostViewChanged"));
if (storageMode === "cloud")
saveToCloud({ compactHostView: v });
}}
/>
</SettingRow>
<SettingRow
label={t("newUi.sidebar.userProfile.statusColors")}
description={t("newUi.sidebar.userProfile.statusColorsDesc")}
>
<FakeSwitch
checked={statusColorScheme === "status"}
onChange={(v) => {
const scheme = v ? "status" : "accent";
setStatusColorScheme(scheme);
localStorage.setItem("statusColorScheme", scheme);
window.dispatchEvent(new Event("statusColorSchemeChanged"));
if (storageMode === "cloud")
saveToCloud({ statusColorScheme: scheme });
}}
/>
</SettingRow>
<SettingRow
label={t("newUi.sidebar.userProfile.pinAppRail")}
description={t("newUi.sidebar.userProfile.pinAppRailDesc")}