mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-07-18 14:23:35 +00:00
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:
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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} ·{" "}
|
||||
</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>
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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~",
|
||||
|
||||
@@ -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")}
|
||||
|
||||
Reference in New Issue
Block a user