mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-07-18 14:23:35 +00:00
release-2.5.0 (#994)
* feat(sshid) - sshid.io equivalent for termix (#919) * feat(ssh-id): database schema, migrations and field encryption Adds ssh_identities, ssh_identity_keys and ssh_identity_ca tables (public keys stored plaintext for the unauthenticated resolver; CA private key registered for per-user field encryption), with UNIQUE(user_id), an index on ssh_identity_keys(identity_id), and idempotent CREATE TABLE migrations. * feat(ssh-id): backend API — resolver, key management, CA and certificates Mounts /sshid (nginx route added). Public text/plain authorized_keys resolver (+ exact /:algo filter, HTML viewer) and CA public-key endpoint; no-store + noindex headers on every resolver response including early 404s. Authenticated management: claim/rename/delete handle, add/import/generate/enable/delete keys, and a per-user CA (create/rotate/delete) with pure-Node OpenSSH certificate issuance. Audit logging on all mutations; UNIQUE races map to a precise 409. Unit tests for key parsing and certificate signing (ssh-keygen-validated). * feat(ssh-id): frontend panel, API client and i18n SSH ID panel wired into the app rail and AppShell: claim handle, resolver URL + curl one-liner, key list, generate, paste/import, CA enable/rotate/remove with server trust command, and per-key certificate issuance. API client re-exported through main-axios.ts; all strings i18n'd. * style(ssh-id): align panel and resolver page with Termix theme - Rebuild the SSH ID sidebar panel with the theme's square components (SectionCard / SettingRow / FakeSwitch) instead of rounded ad-hoc cards; use accent-brand and destructive tokens rather than raw red/green. - Fix panel scrolling: move overflow to a block scroll container so the cards keep their natural height instead of being clipped. - Restyle the public resolver HTML page (/sshid/u/:handle) to the Termix dark theme: square corners, #18181b/#303032 palette, #f59145 accent, uppercase section labels. - Tidy copy: 'Save To Credentials' label, drop the redundant generate intro, and correct the generate tooltip (the key is stored when saving to vault). * feat: rename to Termix ID, improve UI, backend inconsistencies, and general bug fixes --------- Co-authored-by: LukeGus <bugattiguy527@gmail.com> * ci(deps): bump actions/checkout from 6 to 7 in the github-actions group (#922) Bumps the github-actions group with 1 update: [actions/checkout](https://github.com/actions/checkout). Updates `actions/checkout` from 6 to 7 - [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/v6...v7) --- updated-dependencies: - dependency-name: actions/checkout dependency-version: '7' 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-dev): bump the dev-patch-updates group with 11 updates (#923) Bumps the dev-patch-updates group with 11 updates: | Package | From | To | | --- | --- | --- | | [@codemirror/search](https://github.com/codemirror/search) | `6.7.0` | `6.7.1` | | [@codemirror/view](https://github.com/codemirror/view) | `6.43.0` | `6.43.1` | | [@tailwindcss/vite](https://github.com/tailwindlabs/tailwindcss/tree/HEAD/packages/@tailwindcss-vite) | `4.3.0` | `4.3.1` | | [@vitest/coverage-v8](https://github.com/vitest-dev/vitest/tree/HEAD/packages/coverage-v8) | `4.1.8` | `4.1.9` | | [@vitest/ui](https://github.com/vitest-dev/vitest/tree/HEAD/packages/ui) | `4.1.8` | `4.1.9` | | [eslint-plugin-react-refresh](https://github.com/ArnaudBarre/eslint-plugin-react-refresh) | `0.5.2` | `0.5.3` | | [lint-staged](https://github.com/lint-staged/lint-staged) | `17.0.7` | `17.0.8` | | [prettier](https://github.com/prettier/prettier) | `3.8.3` | `3.8.4` | | [sharp](https://github.com/lovell/sharp) | `0.35.1` | `0.35.2` | | [tailwindcss](https://github.com/tailwindlabs/tailwindcss/tree/HEAD/packages/tailwindcss) | `4.3.0` | `4.3.1` | | [vitest](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest) | `4.1.8` | `4.1.9` | Updates `@codemirror/search` from 6.7.0 to 6.7.1 - [Changelog](https://github.com/codemirror/search/blob/main/CHANGELOG.md) - [Commits](https://github.com/codemirror/search/commits) Updates `@codemirror/view` from 6.43.0 to 6.43.1 - [Changelog](https://github.com/codemirror/view/blob/main/CHANGELOG.md) - [Commits](https://github.com/codemirror/view/commits) Updates `@tailwindcss/vite` from 4.3.0 to 4.3.1 - [Release notes](https://github.com/tailwindlabs/tailwindcss/releases) - [Changelog](https://github.com/tailwindlabs/tailwindcss/blob/main/CHANGELOG.md) - [Commits](https://github.com/tailwindlabs/tailwindcss/commits/v4.3.1/packages/@tailwindcss-vite) Updates `@vitest/coverage-v8` from 4.1.8 to 4.1.9 - [Release notes](https://github.com/vitest-dev/vitest/releases) - [Changelog](https://github.com/vitest-dev/vitest/blob/main/docs/releases.md) - [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.9/packages/coverage-v8) Updates `@vitest/ui` from 4.1.8 to 4.1.9 - [Release notes](https://github.com/vitest-dev/vitest/releases) - [Changelog](https://github.com/vitest-dev/vitest/blob/main/docs/releases.md) - [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.9/packages/ui) Updates `eslint-plugin-react-refresh` from 0.5.2 to 0.5.3 - [Release notes](https://github.com/ArnaudBarre/eslint-plugin-react-refresh/releases) - [Changelog](https://github.com/ArnaudBarre/eslint-plugin-react-refresh/blob/main/CHANGELOG.md) - [Commits](https://github.com/ArnaudBarre/eslint-plugin-react-refresh/compare/v0.5.2...v0.5.3) Updates `lint-staged` from 17.0.7 to 17.0.8 - [Release notes](https://github.com/lint-staged/lint-staged/releases) - [Changelog](https://github.com/lint-staged/lint-staged/blob/main/CHANGELOG.md) - [Commits](https://github.com/lint-staged/lint-staged/compare/v17.0.7...v17.0.8) Updates `prettier` from 3.8.3 to 3.8.4 - [Release notes](https://github.com/prettier/prettier/releases) - [Changelog](https://github.com/prettier/prettier/blob/main/CHANGELOG.md) - [Commits](https://github.com/prettier/prettier/compare/3.8.3...3.8.4) Updates `sharp` from 0.35.1 to 0.35.2 - [Release notes](https://github.com/lovell/sharp/releases) - [Commits](https://github.com/lovell/sharp/compare/v0.35.1...v0.35.2) Updates `tailwindcss` from 4.3.0 to 4.3.1 - [Release notes](https://github.com/tailwindlabs/tailwindcss/releases) - [Changelog](https://github.com/tailwindlabs/tailwindcss/blob/main/CHANGELOG.md) - [Commits](https://github.com/tailwindlabs/tailwindcss/commits/v4.3.1/packages/tailwindcss) Updates `vitest` from 4.1.8 to 4.1.9 - [Release notes](https://github.com/vitest-dev/vitest/releases) - [Changelog](https://github.com/vitest-dev/vitest/blob/main/docs/releases.md) - [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.9/packages/vitest) --- updated-dependencies: - dependency-name: "@codemirror/search" dependency-version: 6.7.1 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: "@codemirror/view" dependency-version: 6.43.1 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: "@tailwindcss/vite" dependency-version: 4.3.1 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: "@vitest/coverage-v8" dependency-version: 4.1.9 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: "@vitest/ui" dependency-version: 4.1.9 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: eslint-plugin-react-refresh dependency-version: 0.5.3 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: lint-staged dependency-version: 17.0.8 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: prettier dependency-version: 3.8.4 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: sharp dependency-version: 0.35.2 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: tailwindcss dependency-version: 4.3.1 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: vitest dependency-version: 4.1.9 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * chore(deps): bump nanoid in the prod-patch-updates group (#925) Bumps the prod-patch-updates group with 1 update: [nanoid](https://github.com/ai/nanoid). Updates `nanoid` from 5.1.11 to 5.1.15 - [Release notes](https://github.com/ai/nanoid/releases) - [Changelog](https://github.com/ai/nanoid/blob/main/CHANGELOG.md) - [Commits](https://github.com/ai/nanoid/compare/5.1.11...5.1.15) --- updated-dependencies: - dependency-name: nanoid dependency-version: 5.1.15 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: prod-patch-updates ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * chore(deps): bump the major-updates group with 5 updates (#926) Bumps the major-updates group with 5 updates: | Package | From | To | | --- | --- | --- | | [js-yaml](https://github.com/nodeca/js-yaml) | `4.2.0` | `5.0.0` | | [@eslint/js](https://github.com/eslint/eslint/tree/HEAD/packages/js) | `9.39.4` | `10.0.1` | | [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) | `25.9.2` | `26.0.0` | | [concurrently](https://github.com/open-cli-tools/concurrently) | `9.2.1` | `10.0.3` | | [eslint](https://github.com/eslint/eslint) | `9.39.4` | `10.5.0` | Updates `js-yaml` from 4.2.0 to 5.0.0 - [Changelog](https://github.com/nodeca/js-yaml/blob/master/CHANGELOG.md) - [Commits](https://github.com/nodeca/js-yaml/compare/4.2.0...5.0.0) Updates `@eslint/js` from 9.39.4 to 10.0.1 - [Release notes](https://github.com/eslint/eslint/releases) - [Commits](https://github.com/eslint/eslint/commits/v10.0.1/packages/js) Updates `@types/node` from 25.9.2 to 26.0.0 - [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases) - [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node) Updates `concurrently` from 9.2.1 to 10.0.3 - [Release notes](https://github.com/open-cli-tools/concurrently/releases) - [Commits](https://github.com/open-cli-tools/concurrently/compare/v9.2.1...v10.0.3) Updates `eslint` from 9.39.4 to 10.5.0 - [Release notes](https://github.com/eslint/eslint/releases) - [Commits](https://github.com/eslint/eslint/compare/v9.39.4...v10.5.0) --- updated-dependencies: - dependency-name: js-yaml dependency-version: 5.0.0 dependency-type: direct:production update-type: version-update:semver-major dependency-group: major-updates - dependency-name: "@eslint/js" dependency-version: 10.0.1 dependency-type: direct:development update-type: version-update:semver-major dependency-group: major-updates - dependency-name: "@types/node" dependency-version: 26.0.0 dependency-type: direct:development update-type: version-update:semver-major dependency-group: major-updates - dependency-name: concurrently dependency-version: 10.0.3 dependency-type: direct:development update-type: version-update:semver-major dependency-group: major-updates - dependency-name: eslint dependency-version: 10.5.0 dependency-type: direct:development update-type: version-update:semver-major dependency-group: major-updates ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * feat(ssh): add HashiCorp Vault SSH signer authentication * fix: small fixes to vault feature to align with Termix codebase * chore: add view docs links for vault/termix id * fix: file upload fails with 400 and missing schema migrations on upgrade (#929) Two bugs introduced in v2.4.1: 1. uploadFileStream uses fileManagerApi.post() which triggers axios's transformRequest to JSON-serialize the FormData because the instance default Content-Type is application/json. Change to postForm() which sets Content-Type: multipart/form-data so the browser XHR sends the correct multipart body with boundary. 2. Two schema items added to schema.ts were not included in migrateSchema() in db/index.ts, causing 500 errors on existing installations upgrading from v2.4.0: - user_preferences.status_color_scheme (no such column) - dashboard_service_links table (no such table) Fixes #928 Co-authored-by: sash <sash@fominykh.io> * fix: support PuTTY PPK ssh keys (#930) * fix: chunk large file manager uploads (#932) * fix: route dashboard hosts by protocol (#934) * fix: resolve tunnel endpoints reliably (#935) * Fix Electron OIDC browser auth failures (#936) * Allow RDP connections without stored credentials (#937) * Sync role credential shares for OIDC users (#938) * Fix terminal link dialog layering (#940) * Confirm large files before opening editor (#942) * Confirm closing active host connections (#943) * Preserve file path case in file manager UI (#941) * fix: preserve unicode guacamole tokens (#933) * Persist VNC authentication settings (#944) * Fix Guacamole websocket base path (#946) * Promote file manager terminals to tabs (#939) * Guard Guacamole disconnect during startup (#945) * chore: increment ver * feat: bitwarden ssh agent integration * feat: serial connections support * fix: various small bug fixes * feat: open all sessions in a folder and terminal custom theme color support * feat: cross host file manager clipboard and several small bug fixes * feat: tailscale/wireguard support and added a new status state for when backend is checking status * feat: grafana like server stats history, new alert system, ntfy/webhook support * feat: new grid and widget based homepage function * feat: new donate button in dashboard * fix: alert ui incorrectly using termix css and fixed issue with alert system not loading * chore: fix ci checks (#966) * Fix dashboard service link creation (#950) * Fix jump host SOCKS5 proxy selection (#951) * Fix jump host SOCKS5 proxy selection * fix: type jump host socks proxy config * Fix tmux detection path handling (#949) * Support GUACD_URL environment config (#952) * Retry autostart tunnel host fetches (#953) * Retry autostart tunnel host fetches * chore: format tunnel route * Fix PUID html ownership in Docker entrypoint (#954) * feat: allow custom tunnel endpoints (#977) * fix: skip metrics start for non-ssh hosts (#976) * Fix Proxmox import auth fallback (#956) * Fix Proxmox import auth fallback * chore: format proxmox import auth * Fix SSH heading syntax highlighting (#955) * Fix SSH heading syntax highlighting * chore: format terminal highlighter * Initialize auth before fullscreen terminal routes (#957) * Add WebAuthn passkey authentication (#959) * chore: add Biome tooling (#965) * chore: add biome tooling * chore: support tailwind syntax in biome * Fix VNC required argument handshake (#968) * Prioritize host results in command palette search (#969) * Prevent sidebar host hover layout shift (#970) * Add terminal font zoom with mouse wheel (#971) * Add host temperature metrics card (#972) * Make file downloads reliable in desktop app (#973) * Add app rail hover expansion setting (#974) * fix: use correct translation key for nav.close (#964) * fix(tunnel): skip endpoint credential validation for direct tunnels (#963) * fix: SSH port connection bug (#975) * fix: chunked upload for files >=1.5GB to bypass browser ArrayBuffer limit (#948) * feat: support SSH agent auth across SSH features (#960) * feat: add Podman container runtime support (#958) * chore: update readme and release notes * fix: stabilize Windows app icon (#978) * Add safe host sharing export (#979) * Add external editor support for file manager (#985) * Rework SSH credential password handling (#984) * Support password fallback for SSH key credentials * Complete SSH credential password fallback * Fix runtime base path for auth callbacks (#982) * Fix TUI terminal output highlighting (#983) Co-authored-by: LukeGus <bugattiguy527@gmail.com> * Add app fullscreen mode (#993) * Fix host metrics startup polling (#986) * chore: update release notes * fix: line chart text overlap * chore: update RELEASE_NOTES.md * chore: add donation goal to readme * chore: update readme * fix: hide full-screen button in electron app * fix: failed unit tests * chore: lint, format, and bump version to 2.5.0 * chore: remove timeout from crowdin translate * chore: sync Crowdin translations for 2.5.0 --------- Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: DivByZero <mr.oplus@yahoo.fr> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: devdanetra <46488477+devdanetra@users.noreply.github.com> Co-authored-by: Aleksandr Fominykh <neoformalex@users.noreply.github.com> Co-authored-by: sash <sash@fominykh.io> Co-authored-by: ZacharyZcR <zacharyzcr1984@gmail.com>
This commit is contained in:
@@ -53,6 +53,10 @@ import {
|
||||
updateSSOProvider,
|
||||
deleteSSOProvider,
|
||||
} from "@/api/sso-provider-api";
|
||||
import {
|
||||
getMetricsHistoryRetention,
|
||||
saveMetricsHistoryRetention,
|
||||
} from "@/api/host-metrics-api";
|
||||
import type { SSOProvider } from "@/types/index";
|
||||
import type { ApiKey, CreatedApiKey, UserRole } from "@/main-axios";
|
||||
import type { AdminSection } from "@/types/ui-types";
|
||||
@@ -106,6 +110,7 @@ export function AdminSettingsPanel() {
|
||||
const [sessionTimeout, setSessionTimeout] = useState("24");
|
||||
const [statusInterval, setStatusInterval] = useState("60");
|
||||
const [metricsInterval, setMetricsInterval] = useState("30");
|
||||
const [metricsHistoryRetention, setMetricsHistoryRetention] = useState("7");
|
||||
const [guacEnabled, setGuacEnabled] = useState(false);
|
||||
const [guacUrl, setGuacUrl] = useState("guacd:4822");
|
||||
const [logLevel, setLogLevel] = useState("info");
|
||||
@@ -287,6 +292,10 @@ export function AdminSettingsPanel() {
|
||||
setStatusInterval(String(monitoring.value.statusCheckInterval));
|
||||
setMetricsInterval(String(monitoring.value.metricsInterval));
|
||||
}
|
||||
|
||||
getMetricsHistoryRetention()
|
||||
.then((days) => setMetricsHistoryRetention(String(days)))
|
||||
.catch(() => {});
|
||||
if (level.status === "fulfilled") setLogLevel(level.value.level);
|
||||
if (guac.status === "fulfilled") {
|
||||
setGuacEnabled(guac.value.enabled);
|
||||
@@ -417,15 +426,25 @@ export function AdminSettingsPanel() {
|
||||
async function handleSaveMonitoring() {
|
||||
const status = parseInt(statusInterval, 10);
|
||||
const metrics = parseInt(metricsInterval, 10);
|
||||
const retention = parseInt(metricsHistoryRetention, 10);
|
||||
if (isNaN(status) || isNaN(metrics)) {
|
||||
toast.error(t("admin.monitoringIntervalInvalid"));
|
||||
return;
|
||||
}
|
||||
if (!isNaN(retention) && (retention < 1 || retention > 90)) {
|
||||
toast.error(t("admin.metricsHistoryRetentionRange"));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await updateGlobalMonitoringSettings({
|
||||
statusCheckInterval: status,
|
||||
metricsInterval: metrics,
|
||||
});
|
||||
await Promise.all([
|
||||
updateGlobalMonitoringSettings({
|
||||
statusCheckInterval: status,
|
||||
metricsInterval: metrics,
|
||||
}),
|
||||
!isNaN(retention)
|
||||
? saveMetricsHistoryRetention(retention)
|
||||
: Promise.resolve(),
|
||||
]);
|
||||
toast.success(t("admin.monitoringSaved"));
|
||||
} catch {
|
||||
toast.error(t("admin.monitoringSaveFailed"));
|
||||
@@ -828,6 +847,8 @@ export function AdminSettingsPanel() {
|
||||
setStatusInterval={setStatusInterval}
|
||||
metricsInterval={metricsInterval}
|
||||
setMetricsInterval={setMetricsInterval}
|
||||
metricsHistoryRetention={metricsHistoryRetention}
|
||||
setMetricsHistoryRetention={setMetricsHistoryRetention}
|
||||
handleSaveMonitoring={handleSaveMonitoring}
|
||||
guacEnabled={guacEnabled}
|
||||
handleToggleGuacamole={handleToggleGuacamole}
|
||||
|
||||
@@ -49,6 +49,8 @@ type GeneralSettingsSectionProps = {
|
||||
setStatusInterval: Dispatch<SetStateAction<string>>;
|
||||
metricsInterval: string;
|
||||
setMetricsInterval: Dispatch<SetStateAction<string>>;
|
||||
metricsHistoryRetention: string;
|
||||
setMetricsHistoryRetention: Dispatch<SetStateAction<string>>;
|
||||
handleSaveMonitoring: () => void;
|
||||
guacEnabled: boolean;
|
||||
handleToggleGuacamole: () => void;
|
||||
@@ -84,6 +86,8 @@ export function AdminGeneralSettingsSection({
|
||||
setStatusInterval,
|
||||
metricsInterval,
|
||||
setMetricsInterval,
|
||||
metricsHistoryRetention,
|
||||
setMetricsHistoryRetention,
|
||||
handleSaveMonitoring,
|
||||
guacEnabled,
|
||||
handleToggleGuacamole,
|
||||
@@ -235,6 +239,35 @@ export function AdminGeneralSettingsSection({
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
|
||||
{t("admin.metricsHistoryRetention")}
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={90}
|
||||
value={metricsHistoryRetention}
|
||||
onChange={(e) => setMetricsHistoryRetention(e.target.value)}
|
||||
className="w-20 text-sm"
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t("admin.days")}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-xs border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand h-7"
|
||||
onClick={handleSaveMonitoring}
|
||||
>
|
||||
{t("common.save")}
|
||||
</Button>
|
||||
</div>
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{t("admin.metricsHistoryRetentionRange")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2 border-t border-border pt-3 mt-2">
|
||||
|
||||
@@ -0,0 +1,274 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from "@/components/dialog";
|
||||
import { Button } from "@/components/button";
|
||||
import { Input } from "@/components/input";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
createAlertRule,
|
||||
updateAlertRule,
|
||||
type AlertRule,
|
||||
type NotificationChannel,
|
||||
} from "@/api/alerts-api";
|
||||
|
||||
const TRIGGER_TYPES = [
|
||||
{ value: "host_offline", label: "Host Offline", hasThreshold: false },
|
||||
{ value: "host_online", label: "Host Online", hasThreshold: false },
|
||||
{ value: "cpu_threshold", label: "CPU Usage Threshold", hasThreshold: true },
|
||||
{
|
||||
value: "memory_threshold",
|
||||
label: "Memory Usage Threshold",
|
||||
hasThreshold: true,
|
||||
},
|
||||
{
|
||||
value: "disk_threshold",
|
||||
label: "Disk Usage Threshold",
|
||||
hasThreshold: true,
|
||||
},
|
||||
{
|
||||
value: "health_check_failure",
|
||||
label: "Health Check Failure",
|
||||
hasThreshold: false,
|
||||
},
|
||||
{ value: "user_login", label: "User SSH Login", hasThreshold: false },
|
||||
];
|
||||
|
||||
interface AlertRuleDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
rule: AlertRule | null;
|
||||
channels: NotificationChannel[];
|
||||
onSaved: () => void;
|
||||
}
|
||||
|
||||
export function AlertRuleDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
rule,
|
||||
channels,
|
||||
onSaved,
|
||||
}: AlertRuleDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const [name, setName] = useState("");
|
||||
const [triggerType, setTriggerType] = useState("host_offline");
|
||||
const [threshold, setThreshold] = useState("80");
|
||||
const [durationSeconds, setDurationSeconds] = useState("0");
|
||||
const [cooldownMinutes, setCooldownMinutes] = useState("15");
|
||||
const [selectedChannels, setSelectedChannels] = useState<number[]>([]);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const triggerDef = TRIGGER_TYPES.find((tt) => tt.value === triggerType);
|
||||
const hasThreshold = triggerDef?.hasThreshold ?? false;
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
if (rule) {
|
||||
setName(rule.name);
|
||||
setTriggerType(rule.triggerType);
|
||||
setThreshold(
|
||||
rule.thresholdValue != null ? String(rule.thresholdValue) : "80",
|
||||
);
|
||||
setDurationSeconds(
|
||||
rule.thresholdDurationSeconds != null
|
||||
? String(rule.thresholdDurationSeconds)
|
||||
: "0",
|
||||
);
|
||||
setCooldownMinutes(String(rule.cooldownMinutes));
|
||||
setSelectedChannels(rule.channelIds ?? []);
|
||||
} else {
|
||||
setName("");
|
||||
setTriggerType("host_offline");
|
||||
setThreshold("80");
|
||||
setDurationSeconds("0");
|
||||
setCooldownMinutes("15");
|
||||
setSelectedChannels([]);
|
||||
}
|
||||
}, [open, rule]);
|
||||
|
||||
function toggleChannel(id: number) {
|
||||
setSelectedChannels((prev) =>
|
||||
prev.includes(id) ? prev.filter((c) => c !== id) : [...prev, id],
|
||||
);
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (!name.trim()) {
|
||||
toast.error(t("alerts.ruleNameRequired", "Name is required"));
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
const payload = {
|
||||
name: name.trim(),
|
||||
triggerType,
|
||||
thresholdValue: hasThreshold ? Number(threshold) : null,
|
||||
thresholdDurationSeconds:
|
||||
hasThreshold && Number(durationSeconds) > 0
|
||||
? Number(durationSeconds)
|
||||
: null,
|
||||
cooldownMinutes: Number(cooldownMinutes) || 15,
|
||||
channels: selectedChannels,
|
||||
};
|
||||
if (rule) {
|
||||
await updateAlertRule(rule.id, payload);
|
||||
} else {
|
||||
await createAlertRule(payload);
|
||||
}
|
||||
onSaved();
|
||||
} catch {
|
||||
toast.error(t("alerts.ruleSaveFailed", "Failed to save rule"));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-md max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{rule
|
||||
? t("alerts.editRule", "Edit Alert Rule")
|
||||
: t("alerts.addRule", "Add Alert Rule")}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex flex-col gap-3 py-2">
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
|
||||
{t("alerts.ruleName", "Rule Name")}
|
||||
</label>
|
||||
<Input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
|
||||
{t("alerts.triggerType", "Trigger")}
|
||||
</label>
|
||||
<div className="flex flex-col gap-1">
|
||||
{TRIGGER_TYPES.map((tt) => (
|
||||
<button
|
||||
key={tt.value}
|
||||
onClick={() => setTriggerType(tt.value)}
|
||||
className={`flex items-center gap-2 px-2 py-1.5 text-xs border transition-colors text-left ${triggerType === tt.value ? "border-accent-brand/40 bg-accent-brand/10 text-accent-brand" : "border-border text-muted-foreground hover:text-foreground"}`}
|
||||
>
|
||||
<span
|
||||
className={`size-2 shrink-0 rounded-full border-2 ${triggerType === tt.value ? "border-accent-brand bg-accent-brand" : "border-muted-foreground"}`}
|
||||
/>
|
||||
{tt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{hasThreshold && (
|
||||
<>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
|
||||
{t("alerts.thresholdValue", "Threshold (%)")}
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={100}
|
||||
value={threshold}
|
||||
onChange={(e) => setThreshold(e.target.value)}
|
||||
className="w-20 text-sm"
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">%</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
|
||||
{t(
|
||||
"alerts.durationSeconds",
|
||||
"Duration (seconds, 0 = fire immediately)",
|
||||
)}
|
||||
</label>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
value={durationSeconds}
|
||||
onChange={(e) => setDurationSeconds(e.target.value)}
|
||||
className="w-24 text-sm"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
|
||||
{t("alerts.cooldownMinutes", "Cooldown (minutes)")}
|
||||
</label>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
value={cooldownMinutes}
|
||||
onChange={(e) => setCooldownMinutes(e.target.value)}
|
||||
className="w-24 text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
|
||||
{t("alerts.channels", "Notification Channels")}
|
||||
</label>
|
||||
{channels.length === 0 ? (
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{t(
|
||||
"alerts.noChannelsHint",
|
||||
"Add channels in the Channels tab first",
|
||||
)}
|
||||
</span>
|
||||
) : (
|
||||
<div className="flex flex-col gap-1">
|
||||
{channels.map((ch) => (
|
||||
<button
|
||||
key={ch.id}
|
||||
onClick={() => toggleChannel(ch.id)}
|
||||
className={`flex items-center gap-2 px-2 py-1.5 text-xs border transition-colors text-left ${selectedChannels.includes(ch.id) ? "border-accent-brand/40 bg-accent-brand/10 text-accent-brand" : "border-border text-muted-foreground hover:text-foreground"}`}
|
||||
>
|
||||
<span
|
||||
className={`size-2 shrink-0 rounded-sm border-2 ${selectedChannels.includes(ch.id) ? "border-accent-brand bg-accent-brand" : "border-muted-foreground"}`}
|
||||
/>
|
||||
{ch.name}
|
||||
<span className="ml-auto text-[9px] font-mono uppercase opacity-60">
|
||||
{ch.type}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={() => onOpenChange(false)}>
|
||||
{t("common.cancel", "Cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
className="border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
|
||||
>
|
||||
{saving
|
||||
? t("common.saving", "Saving...")
|
||||
: t("common.save", "Save")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,460 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Bell,
|
||||
BellOff,
|
||||
Check,
|
||||
CheckCheck,
|
||||
Trash2,
|
||||
Plus,
|
||||
Settings2,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/button";
|
||||
import {
|
||||
getAlertFirings,
|
||||
acknowledgeAlertFiring,
|
||||
acknowledgeAllAlertFirings,
|
||||
getAlertRules,
|
||||
getNotificationChannels,
|
||||
deleteAlertRule,
|
||||
deleteNotificationChannel,
|
||||
testNotificationChannel,
|
||||
type AlertFiring,
|
||||
type AlertRule,
|
||||
type NotificationChannel,
|
||||
} from "@/api/alerts-api";
|
||||
import { AlertRuleDialog } from "./AlertRuleDialog";
|
||||
import { NotificationChannelDialog } from "./NotificationChannelDialog";
|
||||
|
||||
type PanelTab = "firings" | "rules" | "channels";
|
||||
|
||||
const SEVERITY_CLASS: Record<string, string> = {
|
||||
info: "bg-blue-500/20 text-blue-400 border-blue-500/30",
|
||||
warning: "bg-yellow-500/20 text-yellow-400 border-yellow-500/30",
|
||||
critical: "bg-red-500/20 text-red-400 border-red-500/30",
|
||||
};
|
||||
|
||||
function timeAgo(isoStr: string): string {
|
||||
const ms = Date.now() - new Date(isoStr).getTime();
|
||||
const sec = Math.floor(ms / 1000);
|
||||
if (sec < 60) return `${sec}s ago`;
|
||||
const min = Math.floor(sec / 60);
|
||||
if (min < 60) return `${min}m ago`;
|
||||
const hr = Math.floor(min / 60);
|
||||
if (hr < 24) return `${hr}h ago`;
|
||||
return `${Math.floor(hr / 24)}d ago`;
|
||||
}
|
||||
|
||||
export function AlertsPanel() {
|
||||
const { t } = useTranslation();
|
||||
const [tab, setTab] = useState<PanelTab>("firings");
|
||||
|
||||
// Firings state
|
||||
const [firings, setFirings] = useState<AlertFiring[]>([]);
|
||||
const [firingsLoading, setFiringsLoading] = useState(true);
|
||||
const [showAcknowledged, setShowAcknowledged] = useState(false);
|
||||
|
||||
// Rules state
|
||||
const [rules, setRules] = useState<AlertRule[]>([]);
|
||||
const [rulesLoading, setRulesLoading] = useState(false);
|
||||
const [ruleDialogOpen, setRuleDialogOpen] = useState(false);
|
||||
const [editRule, setEditRule] = useState<AlertRule | null>(null);
|
||||
|
||||
// Channels state
|
||||
const [channels, setChannels] = useState<NotificationChannel[]>([]);
|
||||
const [channelsLoading, setChannelsLoading] = useState(false);
|
||||
const [channelDialogOpen, setChannelDialogOpen] = useState(false);
|
||||
const [editChannel, setEditChannel] = useState<NotificationChannel | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const loadFirings = useCallback(() => {
|
||||
setFiringsLoading(true);
|
||||
getAlertFirings({ limit: 100 })
|
||||
.then(setFirings)
|
||||
.catch(() => {})
|
||||
.finally(() => setFiringsLoading(false));
|
||||
}, []);
|
||||
|
||||
const loadRules = useCallback(() => {
|
||||
setRulesLoading(true);
|
||||
getAlertRules()
|
||||
.then(setRules)
|
||||
.catch(() => {})
|
||||
.finally(() => setRulesLoading(false));
|
||||
}, []);
|
||||
|
||||
const loadChannels = useCallback(() => {
|
||||
setChannelsLoading(true);
|
||||
getNotificationChannels()
|
||||
.then(setChannels)
|
||||
.catch(() => {})
|
||||
.finally(() => setChannelsLoading(false));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadFirings();
|
||||
}, [loadFirings]);
|
||||
|
||||
useEffect(() => {
|
||||
if (tab === "rules") loadRules();
|
||||
if (tab === "channels") loadChannels();
|
||||
}, [tab, loadRules, loadChannels]);
|
||||
|
||||
const visibleFirings = firings.filter((f) =>
|
||||
showAcknowledged ? true : !f.acknowledged,
|
||||
);
|
||||
|
||||
async function handleAck(id: number) {
|
||||
try {
|
||||
await acknowledgeAlertFiring(id);
|
||||
setFirings((prev) =>
|
||||
prev.map((f) => (f.id === id ? { ...f, acknowledged: true } : f)),
|
||||
);
|
||||
} catch {
|
||||
toast.error(t("alerts.ackFailed", "Failed to acknowledge alert"));
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAckAll() {
|
||||
try {
|
||||
await acknowledgeAllAlertFirings();
|
||||
setFirings((prev) => prev.map((f) => ({ ...f, acknowledged: true })));
|
||||
toast.success(t("alerts.allAcknowledged", "All alerts acknowledged"));
|
||||
} catch {
|
||||
toast.error(t("alerts.ackAllFailed", "Failed to acknowledge all alerts"));
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeleteRule(id: number) {
|
||||
if (!confirm(t("common.confirmDelete", "Delete this rule?"))) return;
|
||||
try {
|
||||
await deleteAlertRule(id);
|
||||
setRules((prev) => prev.filter((r) => r.id !== id));
|
||||
toast.success(t("common.deleted", "Deleted"));
|
||||
} catch {
|
||||
toast.error(t("common.deleteFailed", "Delete failed"));
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeleteChannel(id: number) {
|
||||
if (!confirm(t("common.confirmDelete", "Delete this channel?"))) return;
|
||||
try {
|
||||
await deleteNotificationChannel(id);
|
||||
setChannels((prev) => prev.filter((c) => c.id !== id));
|
||||
toast.success(t("common.deleted", "Deleted"));
|
||||
} catch {
|
||||
toast.error(t("common.deleteFailed", "Delete failed"));
|
||||
}
|
||||
}
|
||||
|
||||
async function handleTestChannel(id: number) {
|
||||
try {
|
||||
await testNotificationChannel(id);
|
||||
toast.success(t("alerts.testSent", "Test notification sent"));
|
||||
} catch {
|
||||
toast.error(t("alerts.testFailed", "Test notification failed"));
|
||||
}
|
||||
}
|
||||
|
||||
const unreadCount = firings.filter((f) => !f.acknowledged).length;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Tab strip */}
|
||||
<div className="flex shrink-0 border-b border-border">
|
||||
{(["firings", "rules", "channels"] as PanelTab[]).map((t_) => (
|
||||
<button
|
||||
key={t_}
|
||||
onClick={() => setTab(t_)}
|
||||
className={`flex-1 py-2 text-xs font-semibold uppercase tracking-widest transition-colors ${
|
||||
tab === t_
|
||||
? "border-b-2 border-accent-brand text-accent-brand"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
{t_ === "firings"
|
||||
? t("alerts.tabFirings", "Alerts")
|
||||
: t_ === "rules"
|
||||
? t("alerts.tabRules", "Rules")
|
||||
: t("alerts.tabChannels", "Channels")}
|
||||
{t_ === "firings" && unreadCount > 0 && (
|
||||
<span className="ml-1.5 rounded-full bg-destructive px-1 text-[9px] font-bold text-white">
|
||||
{unreadCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Firings tab */}
|
||||
{tab === "firings" && (
|
||||
<div className="flex flex-col flex-1 min-h-0">
|
||||
<div className="flex items-center gap-2 px-3 py-2 border-b border-border shrink-0">
|
||||
<button
|
||||
onClick={() => setShowAcknowledged((v) => !v)}
|
||||
className={`text-[10px] font-semibold px-2 py-1 border transition-colors ${showAcknowledged ? "border-accent-brand/40 bg-accent-brand/10 text-accent-brand" : "border-border text-muted-foreground hover:text-foreground"}`}
|
||||
>
|
||||
{showAcknowledged
|
||||
? t("alerts.hideAcknowledged", "Hide Acked")
|
||||
: t("alerts.showAcknowledged", "Show All")}
|
||||
</button>
|
||||
<div className="flex-1" />
|
||||
{unreadCount > 0 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 px-2 text-xs text-muted-foreground"
|
||||
onClick={handleAckAll}
|
||||
>
|
||||
<CheckCheck className="size-3 mr-1" />
|
||||
{t("alerts.ackAll", "Ack All")}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0 text-muted-foreground"
|
||||
onClick={loadFirings}
|
||||
title={t("common.refresh", "Refresh")}
|
||||
>
|
||||
<Settings2 className="size-3" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-h-0 overflow-y-auto">
|
||||
{firingsLoading && (
|
||||
<div className="flex items-center justify-center py-12 text-sm text-muted-foreground">
|
||||
{t("common.loading", "Loading...")}
|
||||
</div>
|
||||
)}
|
||||
{!firingsLoading && visibleFirings.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center py-12 gap-2 text-muted-foreground">
|
||||
<BellOff className="size-8 opacity-40" />
|
||||
<span className="text-xs">
|
||||
{t("alerts.noFirings", "No unacknowledged alerts")}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{!firingsLoading &&
|
||||
visibleFirings.map((firing) => (
|
||||
<div
|
||||
key={firing.id}
|
||||
className={`flex flex-col gap-1 px-3 py-2.5 border-b border-border transition-colors ${firing.acknowledged ? "opacity-50" : ""}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={`shrink-0 text-[9px] font-bold uppercase px-1.5 py-0.5 border ${SEVERITY_CLASS[firing.severity] ?? "bg-muted text-muted-foreground border-border"}`}
|
||||
>
|
||||
{firing.severity}
|
||||
</span>
|
||||
<span className="flex-1 truncate text-xs font-semibold">
|
||||
{firing.hostName}
|
||||
</span>
|
||||
{!firing.acknowledged && (
|
||||
<button
|
||||
onClick={() => handleAck(firing.id)}
|
||||
className="shrink-0 text-muted-foreground hover:text-foreground transition-colors"
|
||||
title={t("alerts.acknowledge", "Acknowledge")}
|
||||
>
|
||||
<Check className="size-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-[11px] text-foreground/80">
|
||||
{firing.message}
|
||||
</span>
|
||||
<div className="flex items-center gap-2 text-[10px] text-muted-foreground">
|
||||
{firing.ruleName && <span>{firing.ruleName}</span>}
|
||||
<span>{timeAgo(firing.firedAt)}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Rules tab */}
|
||||
{tab === "rules" && (
|
||||
<div className="flex flex-col flex-1 min-h-0">
|
||||
<div className="flex items-center gap-2 px-3 py-2 border-b border-border shrink-0">
|
||||
<span className="flex-1 text-xs text-muted-foreground">
|
||||
{t("alerts.rulesDesc", "Alert rules trigger notifications")}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-6 px-2 text-xs border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
|
||||
onClick={() => {
|
||||
setEditRule(null);
|
||||
setRuleDialogOpen(true);
|
||||
}}
|
||||
>
|
||||
<Plus className="size-3 mr-1" />
|
||||
{t("common.add", "Add")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-h-0 overflow-y-auto">
|
||||
{rulesLoading && (
|
||||
<div className="flex items-center justify-center py-12 text-sm text-muted-foreground">
|
||||
{t("common.loading", "Loading...")}
|
||||
</div>
|
||||
)}
|
||||
{!rulesLoading && rules.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center py-12 gap-2 text-muted-foreground">
|
||||
<Bell className="size-8 opacity-40" />
|
||||
<span className="text-xs">
|
||||
{t("alerts.noRules", "No alert rules configured")}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{!rulesLoading &&
|
||||
rules.map((rule) => (
|
||||
<div
|
||||
key={rule.id}
|
||||
className="flex items-center gap-2 px-3 py-2.5 border-b border-border"
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-xs font-semibold truncate">
|
||||
{rule.name}
|
||||
</div>
|
||||
<div className="text-[10px] text-muted-foreground">
|
||||
{rule.triggerType.replace(/_/g, " ")}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={`size-2 shrink-0 rounded-full ${rule.enabled ? "bg-green-500" : "bg-muted"}`}
|
||||
/>
|
||||
<button
|
||||
onClick={() => {
|
||||
setEditRule(rule);
|
||||
setRuleDialogOpen(true);
|
||||
}}
|
||||
className="shrink-0 text-muted-foreground hover:text-foreground"
|
||||
title={t("common.edit", "Edit")}
|
||||
>
|
||||
<Settings2 className="size-3.5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDeleteRule(rule.id)}
|
||||
className="shrink-0 text-muted-foreground hover:text-destructive"
|
||||
title={t("common.delete", "Delete")}
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Channels tab */}
|
||||
{tab === "channels" && (
|
||||
<div className="flex flex-col flex-1 min-h-0">
|
||||
<div className="flex items-center gap-2 px-3 py-2 border-b border-border shrink-0">
|
||||
<span className="flex-1 text-xs text-muted-foreground">
|
||||
{t("alerts.channelsDesc", "Where alert notifications are sent")}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-6 px-2 text-xs border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
|
||||
onClick={() => {
|
||||
setEditChannel(null);
|
||||
setChannelDialogOpen(true);
|
||||
}}
|
||||
>
|
||||
<Plus className="size-3 mr-1" />
|
||||
{t("common.add", "Add")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-h-0 overflow-y-auto">
|
||||
{channelsLoading && (
|
||||
<div className="flex items-center justify-center py-12 text-sm text-muted-foreground">
|
||||
{t("common.loading", "Loading...")}
|
||||
</div>
|
||||
)}
|
||||
{!channelsLoading && channels.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center py-12 gap-2 text-muted-foreground">
|
||||
<Bell className="size-8 opacity-40" />
|
||||
<span className="text-xs">
|
||||
{t(
|
||||
"alerts.noChannels",
|
||||
"No notification channels configured",
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{!channelsLoading &&
|
||||
channels.map((channel) => (
|
||||
<div
|
||||
key={channel.id}
|
||||
className="flex items-center gap-2 px-3 py-2.5 border-b border-border"
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-xs font-semibold truncate">
|
||||
{channel.name}
|
||||
</div>
|
||||
<div className="text-[10px] text-muted-foreground font-mono uppercase">
|
||||
{channel.type}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={`size-2 shrink-0 rounded-full ${channel.enabled ? "bg-green-500" : "bg-muted"}`}
|
||||
/>
|
||||
<button
|
||||
onClick={() => handleTestChannel(channel.id)}
|
||||
className="shrink-0 text-[10px] font-semibold text-muted-foreground hover:text-foreground border border-border px-1.5 py-0.5"
|
||||
title={t("alerts.test", "Test")}
|
||||
>
|
||||
{t("alerts.test", "Test")}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setEditChannel(channel);
|
||||
setChannelDialogOpen(true);
|
||||
}}
|
||||
className="shrink-0 text-muted-foreground hover:text-foreground"
|
||||
title={t("common.edit", "Edit")}
|
||||
>
|
||||
<Settings2 className="size-3.5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDeleteChannel(channel.id)}
|
||||
className="shrink-0 text-muted-foreground hover:text-destructive"
|
||||
title={t("common.delete", "Delete")}
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<AlertRuleDialog
|
||||
open={ruleDialogOpen}
|
||||
onOpenChange={setRuleDialogOpen}
|
||||
rule={editRule}
|
||||
channels={channels}
|
||||
onSaved={() => {
|
||||
setRuleDialogOpen(false);
|
||||
loadRules();
|
||||
}}
|
||||
/>
|
||||
|
||||
<NotificationChannelDialog
|
||||
open={channelDialogOpen}
|
||||
onOpenChange={setChannelDialogOpen}
|
||||
channel={editChannel}
|
||||
onSaved={() => {
|
||||
setChannelDialogOpen(false);
|
||||
loadChannels();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Bell,
|
||||
Clock,
|
||||
Fingerprint,
|
||||
Hammer,
|
||||
KeyRound,
|
||||
LayoutPanelLeft,
|
||||
@@ -12,24 +14,30 @@ import {
|
||||
ScrollText,
|
||||
Server,
|
||||
Settings,
|
||||
Usb,
|
||||
User,
|
||||
Zap,
|
||||
} from "lucide-react";
|
||||
import type { SplitMode, TabType, ToolsTab } from "@/types/ui-types";
|
||||
import { getAlertFirings } from "@/api/alerts-api";
|
||||
|
||||
export type RailView =
|
||||
| "hosts"
|
||||
| "credentials"
|
||||
| "termix-id"
|
||||
| "quick-connect"
|
||||
| "serial"
|
||||
| ToolsTab
|
||||
| "connections"
|
||||
| "session-logs"
|
||||
| "user-profile"
|
||||
| "admin-settings";
|
||||
| "admin-settings"
|
||||
| "alerts";
|
||||
|
||||
export type HideableRailView =
|
||||
| Exclude<RailView, "user-profile" | "admin-settings">
|
||||
| "network_graph";
|
||||
| "network_graph"
|
||||
| "homepage";
|
||||
|
||||
type RailItem =
|
||||
| {
|
||||
@@ -55,6 +63,12 @@ function buildRailButtons(
|
||||
title: t("nav.credentials"),
|
||||
},
|
||||
{ kind: "separator" },
|
||||
{
|
||||
view: "termix-id",
|
||||
icon: <Fingerprint size={16} />,
|
||||
title: t("nav.termixId"),
|
||||
},
|
||||
{ kind: "separator" },
|
||||
{
|
||||
view: "connections",
|
||||
icon: <Plug size={16} />,
|
||||
@@ -67,6 +81,12 @@ function buildRailButtons(
|
||||
title: t("nav.quickConnect"),
|
||||
},
|
||||
{ kind: "separator" },
|
||||
{
|
||||
view: "serial",
|
||||
icon: <Usb size={16} />,
|
||||
title: t("nav.serial"),
|
||||
},
|
||||
{ kind: "separator" },
|
||||
{ view: "ssh-tools", icon: <Hammer size={16} />, title: t("nav.sshTools") },
|
||||
{ kind: "separator" },
|
||||
{ view: "snippets", icon: <Play size={16} />, title: t("nav.snippets") },
|
||||
@@ -144,6 +164,27 @@ export function AppRail({
|
||||
const [pinned, setPinned] = useState(
|
||||
() => localStorage.getItem("pinAppRail") === "true",
|
||||
);
|
||||
const [expandOnHover, setExpandOnHover] = useState(
|
||||
() => localStorage.getItem("expandAppRailOnHover") !== "false",
|
||||
);
|
||||
const [unreadAlerts, setUnreadAlerts] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const poll = () => {
|
||||
getAlertFirings({ acknowledged: false, limit: 50 })
|
||||
.then((firings) => {
|
||||
if (!cancelled) setUnreadAlerts(firings.length);
|
||||
})
|
||||
.catch(() => {});
|
||||
};
|
||||
poll();
|
||||
const iv = setInterval(poll, 30000);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearInterval(iv);
|
||||
};
|
||||
}, []);
|
||||
const [hiddenTabs, setHiddenTabs] = useState<Set<string>>(() => {
|
||||
try {
|
||||
const stored = localStorage.getItem("hiddenRailTabs");
|
||||
@@ -154,10 +195,18 @@ export function AppRail({
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const handler = () =>
|
||||
const pinHandler = () =>
|
||||
setPinned(localStorage.getItem("pinAppRail") === "true");
|
||||
window.addEventListener("pinAppRailChanged", handler);
|
||||
return () => window.removeEventListener("pinAppRailChanged", handler);
|
||||
const hoverHandler = () =>
|
||||
setExpandOnHover(
|
||||
localStorage.getItem("expandAppRailOnHover") !== "false",
|
||||
);
|
||||
window.addEventListener("pinAppRailChanged", pinHandler);
|
||||
window.addEventListener("expandAppRailOnHoverChanged", hoverHandler);
|
||||
return () => {
|
||||
window.removeEventListener("pinAppRailChanged", pinHandler);
|
||||
window.removeEventListener("expandAppRailOnHoverChanged", hoverHandler);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -173,7 +222,7 @@ export function AppRail({
|
||||
return () => window.removeEventListener("hiddenRailTabsChanged", handler);
|
||||
}, []);
|
||||
|
||||
const railExpanded = pinned || hovered;
|
||||
const railExpanded = pinned || (expandOnHover && hovered);
|
||||
const railButtons = buildRailButtons(splitMode, t, hiddenTabs);
|
||||
|
||||
return (
|
||||
@@ -246,6 +295,11 @@ export function AppRail({
|
||||
|
||||
<div className="shrink-0 flex flex-col gap-1 border-t border-border pt-1 pb-1">
|
||||
{[
|
||||
{
|
||||
view: "alerts" as RailView,
|
||||
icon: <Bell size={16} />,
|
||||
title: t("nav.alerts"),
|
||||
},
|
||||
{
|
||||
view: "user-profile" as RailView,
|
||||
icon: <User size={16} />,
|
||||
@@ -272,10 +326,15 @@ export function AppRail({
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className="shrink-0 flex items-center justify-center"
|
||||
className="relative shrink-0 flex items-center justify-center"
|
||||
style={{ width: 16, height: 16 }}
|
||||
>
|
||||
{item.icon}
|
||||
{item.view === "alerts" && unreadAlerts > 0 && (
|
||||
<span className="absolute -top-1 -right-1 flex size-3 items-center justify-center rounded-full bg-destructive text-[8px] font-bold text-white leading-none">
|
||||
{unreadAlerts > 9 ? "9+" : unreadAlerts}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span
|
||||
className={`text-xs font-medium whitespace-nowrap overflow-hidden transition-opacity duration-150 ${railExpanded ? "opacity-100 delay-75" : "opacity-0"}`}
|
||||
|
||||
@@ -16,7 +16,6 @@ import {
|
||||
} from "@/main-axios";
|
||||
import type { Credential } from "@/types/ui-types";
|
||||
|
||||
type CredentialAuthType = Credential["type"];
|
||||
type CredentialWithCertificate = Credential & { certPublicKey?: string };
|
||||
|
||||
export function CredentialEditorView({
|
||||
@@ -38,7 +37,11 @@ export function CredentialEditorView({
|
||||
tags: credential?.tags ?? ([] as string[]),
|
||||
tagInput: "",
|
||||
type: credential?.type ?? "password",
|
||||
value: credential?.value ?? "",
|
||||
value: credential?.type === "key" ? (credential?.value ?? "") : "",
|
||||
password:
|
||||
credential?.type === "password"
|
||||
? (credential?.value ?? "")
|
||||
: (credential?.password ?? ""),
|
||||
publicKey: credential?.publicKey ?? "",
|
||||
passphrase: credential?.passphrase ?? "",
|
||||
certPublicKey:
|
||||
@@ -60,6 +63,12 @@ export function CredentialEditorView({
|
||||
toast.error(t("hosts.credentialNameRequired"));
|
||||
return;
|
||||
}
|
||||
const hasKey =
|
||||
credForm.value === "existing_key" || credForm.value.trim() !== "";
|
||||
if (!hasKey && !credForm.password) {
|
||||
toast.error(t("hosts.credentialAuthRequired"));
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
const data = {
|
||||
@@ -68,23 +77,20 @@ export function CredentialEditorView({
|
||||
folder: credForm.folder || null,
|
||||
description: credForm.description || null,
|
||||
tags: credForm.tags,
|
||||
authType: credForm.type,
|
||||
password: credForm.type === "password" ? credForm.value : null,
|
||||
key:
|
||||
credForm.type === "key"
|
||||
? credForm.value === "existing_key"
|
||||
? undefined
|
||||
: credForm.value || null
|
||||
: null,
|
||||
publicKey: credForm.type === "key" ? credForm.publicKey : null,
|
||||
certPublicKey:
|
||||
credForm.type === "key" ? credForm.certPublicKey || null : null,
|
||||
keyPassword:
|
||||
credForm.type === "key"
|
||||
? credForm.passphrase === "existing_key_password"
|
||||
? undefined
|
||||
: credForm.passphrase || null
|
||||
: null,
|
||||
authType: hasKey ? "key" : "password",
|
||||
password: credForm.password || null,
|
||||
key: hasKey
|
||||
? credForm.value === "existing_key"
|
||||
? undefined
|
||||
: credForm.value || null
|
||||
: null,
|
||||
publicKey: hasKey ? credForm.publicKey : null,
|
||||
certPublicKey: hasKey ? credForm.certPublicKey || null : null,
|
||||
keyPassword: hasKey
|
||||
? credForm.passphrase === "existing_key_password"
|
||||
? undefined
|
||||
: credForm.passphrase || null
|
||||
: null,
|
||||
};
|
||||
const saved = credential
|
||||
? await updateCredential(Number(credential.id), data)
|
||||
@@ -104,8 +110,6 @@ export function CredentialEditorView({
|
||||
}
|
||||
};
|
||||
|
||||
const type = credForm.type;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
{activeTab === "general" && (
|
||||
@@ -209,26 +213,6 @@ export function CredentialEditorView({
|
||||
icon={<Lock className="size-3.5" />}
|
||||
>
|
||||
<div className="flex flex-col gap-4 py-3">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("hosts.credTypeLabel")}
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
{["password", "key"].map((m) => (
|
||||
<button
|
||||
key={m}
|
||||
onClick={() =>
|
||||
setCredField("type", m as CredentialAuthType)
|
||||
}
|
||||
className={`px-3 py-1.5 text-[10px] font-bold uppercase tracking-widest border transition-colors ${type === m ? "border-accent-brand/40 bg-accent-brand/10 text-accent-brand" : "border-border text-muted-foreground hover:text-foreground"}`}
|
||||
>
|
||||
{m === "key"
|
||||
? t("hosts.sshPrivateKey")
|
||||
: t("hosts.password")}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("hosts.username")}
|
||||
@@ -239,261 +223,257 @@ export function CredentialEditorView({
|
||||
onChange={(e) => setCredField("username", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{type === "password" && (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("hosts.password")}
|
||||
</label>
|
||||
<PasswordInput
|
||||
className="h-8 text-xs pr-8"
|
||||
placeholder="••••••••"
|
||||
value={credForm.value}
|
||||
onChange={(e) => setCredField("value", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{type === "key" && (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="p-3 border border-border bg-muted/20">
|
||||
<p className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground mb-2">
|
||||
{t("hosts.generateKeyPairTitle")}
|
||||
</p>
|
||||
<p className="text-[10px] text-muted-foreground mb-2">
|
||||
{t("hosts.generateKeyPairDescription")}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{[
|
||||
{ label: "Ed25519", type: "ssh-ed25519" },
|
||||
{
|
||||
label: "ECDSA (nistp256)",
|
||||
type: "ecdsa-sha2-nistp256",
|
||||
},
|
||||
{ label: "RSA (2048)", type: "ssh-rsa", bits: 2048 },
|
||||
].map(({ label, type: keyType, bits }) => (
|
||||
<Button
|
||||
key={label}
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 text-[10px] px-2"
|
||||
disabled={generatingKey}
|
||||
onClick={async () => {
|
||||
setGeneratingKey(true);
|
||||
try {
|
||||
const result = await generateKeyPair(
|
||||
keyType as
|
||||
| "ssh-ed25519"
|
||||
| "ssh-rsa"
|
||||
| "ecdsa-sha2-nistp256",
|
||||
bits,
|
||||
credForm.passphrase === "existing_key_password"
|
||||
? undefined
|
||||
: credForm.passphrase || undefined,
|
||||
);
|
||||
if (result.success) {
|
||||
setCredField("value", result.privateKey);
|
||||
setCredField("publicKey", result.publicKey);
|
||||
toast.success(
|
||||
t("hosts.keyPairGenerated", { label }),
|
||||
);
|
||||
} else {
|
||||
toast.error(
|
||||
result.error ??
|
||||
t("hosts.failedToGenerateKeyPair"),
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
toast.error(t("hosts.failedToGenerateKeyPair"));
|
||||
} finally {
|
||||
setGeneratingKey(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{generatingKey
|
||||
? t("hosts.generatingKey")
|
||||
: t("hosts.generateLabel", { label })}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("hosts.sshPrivateKey")}
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
className="text-[10px] text-accent-brand hover:text-accent-brand/80 flex items-center gap-1"
|
||||
onClick={() => credFileInputRef.current?.click()}
|
||||
>
|
||||
<Upload className="size-3" /> {t("hosts.uploadFileBtn")}
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
ref={credFileInputRef}
|
||||
type="file"
|
||||
accept=".pem,.key,.txt"
|
||||
className="hidden"
|
||||
onChange={async (e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
const text = await file.text();
|
||||
setCredField("value", text.trim());
|
||||
e.target.value = "";
|
||||
}}
|
||||
/>
|
||||
{credForm.value === "existing_key" && (
|
||||
<div className="px-3 py-2 text-[10px] border border-accent-brand/30 bg-accent-brand/5 text-accent-brand">
|
||||
{t("hosts.keySaved")} — {t("hosts.keyReplaceNotice")}
|
||||
</div>
|
||||
)}
|
||||
<textarea
|
||||
placeholder="-----BEGIN OPENSSH PRIVATE KEY-----"
|
||||
rows={8}
|
||||
value={
|
||||
credForm.value === "existing_key" ? "" : credForm.value
|
||||
}
|
||||
onChange={(e) => setCredField("value", e.target.value)}
|
||||
className="w-full px-3 py-2 text-[10px] 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="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("hosts.keyPassphraseOptional")}
|
||||
</label>
|
||||
<PasswordInput
|
||||
className="h-8 text-xs pr-8"
|
||||
placeholder={
|
||||
credForm.passphrase === "existing_key_password"
|
||||
? t("hosts.keyPassphraseSaved")
|
||||
: "••••••••"
|
||||
}
|
||||
value={
|
||||
credForm.passphrase === "existing_key_password"
|
||||
? ""
|
||||
: credForm.passphrase
|
||||
}
|
||||
onFocus={() => {
|
||||
if (credForm.passphrase === "existing_key_password")
|
||||
setCredField("passphrase", "");
|
||||
}}
|
||||
onChange={(e) => setCredField("passphrase", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("hosts.sshPublicKeyOptional")}
|
||||
</label>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("hosts.password")} ({t("common.optional")})
|
||||
</label>
|
||||
<PasswordInput
|
||||
className="h-8 text-xs pr-8"
|
||||
placeholder="••••••••"
|
||||
value={credForm.password}
|
||||
onChange={(e) => setCredField("password", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="p-3 border border-border bg-muted/20">
|
||||
<p className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground mb-2">
|
||||
{t("hosts.generateKeyPairTitle")}
|
||||
</p>
|
||||
<p className="text-[10px] text-muted-foreground mb-2">
|
||||
{t("hosts.generateKeyPairDescription")}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{[
|
||||
{ label: "Ed25519", type: "ssh-ed25519" },
|
||||
{
|
||||
label: "ECDSA (nistp256)",
|
||||
type: "ecdsa-sha2-nistp256",
|
||||
},
|
||||
{ label: "RSA (2048)", type: "ssh-rsa", bits: 2048 },
|
||||
].map(({ label, type: keyType, bits }) => (
|
||||
<Button
|
||||
key={label}
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-6 text-[10px] px-2 border-accent-brand/40 text-accent-brand"
|
||||
disabled={
|
||||
!credForm.value ||
|
||||
credForm.value === "existing_key" ||
|
||||
generatingPublicKey
|
||||
}
|
||||
className="h-7 text-[10px] px-2"
|
||||
disabled={generatingKey}
|
||||
onClick={async () => {
|
||||
setGeneratingPublicKey(true);
|
||||
setGeneratingKey(true);
|
||||
try {
|
||||
const result = await generatePublicKeyFromPrivate(
|
||||
credForm.value,
|
||||
const result = await generateKeyPair(
|
||||
keyType as
|
||||
| "ssh-ed25519"
|
||||
| "ssh-rsa"
|
||||
| "ecdsa-sha2-nistp256",
|
||||
bits,
|
||||
credForm.passphrase === "existing_key_password"
|
||||
? undefined
|
||||
: credForm.passphrase || undefined,
|
||||
);
|
||||
if (result?.publicKey) {
|
||||
if (result.success) {
|
||||
setCredField("value", result.privateKey);
|
||||
setCredField("publicKey", result.publicKey);
|
||||
toast.success(t("hosts.publicKeyGenerated"));
|
||||
toast.success(
|
||||
t("hosts.keyPairGenerated", { label }),
|
||||
);
|
||||
} else {
|
||||
toast.error(t("hosts.failedToGeneratePublicKey"));
|
||||
toast.error(
|
||||
result.error ??
|
||||
t("hosts.failedToGenerateKeyPair"),
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
toast.error(t("hosts.failedToGeneratePublicKey"));
|
||||
toast.error(t("hosts.failedToGenerateKeyPair"));
|
||||
} finally {
|
||||
setGeneratingPublicKey(false);
|
||||
setGeneratingKey(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{generatingPublicKey
|
||||
{generatingKey
|
||||
? t("hosts.generatingKey")
|
||||
: t("hosts.generateFromPrivateKey")}
|
||||
: t("hosts.generateLabel", { label })}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-6 text-[10px] px-2"
|
||||
disabled={!credForm.publicKey}
|
||||
onClick={() => {
|
||||
copyToClipboard(credForm.publicKey ?? "");
|
||||
toast.success(t("hosts.publicKeyCopied"));
|
||||
}}
|
||||
>
|
||||
<Copy className="size-3 mr-1" /> {t("common.copy")}
|
||||
</Button>
|
||||
</div>
|
||||
<textarea
|
||||
placeholder="ssh-rsa AAAAB3Nza..."
|
||||
rows={3}
|
||||
value={credForm.publicKey}
|
||||
onChange={(e) => setCredField("publicKey", e.target.value)}
|
||||
className="w-full px-3 py-2 text-[10px] 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 p-3 border border-border bg-muted/20">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("credentials.caCertificate")}
|
||||
</label>
|
||||
{credForm.certPublicKey && (
|
||||
<button
|
||||
type="button"
|
||||
className="text-[10px] text-destructive hover:text-destructive/80"
|
||||
onClick={() => setCredField("certPublicKey", "")}
|
||||
>
|
||||
{t("credentials.clearCert")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
{t("credentials.caCertificateDescription")}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
className="text-[10px] text-accent-brand hover:text-accent-brand/80 flex items-center gap-1 self-start"
|
||||
onClick={() => certFileInputRef.current?.click()}
|
||||
>
|
||||
<Upload className="size-3" />{" "}
|
||||
{t("credentials.uploadCertFile")}
|
||||
</button>
|
||||
<input
|
||||
ref={certFileInputRef}
|
||||
type="file"
|
||||
accept=".pub,.txt"
|
||||
className="hidden"
|
||||
onChange={async (e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
const text = await file.text();
|
||||
setCredField("certPublicKey", text.trim());
|
||||
e.target.value = "";
|
||||
}}
|
||||
/>
|
||||
<textarea
|
||||
placeholder={t("credentials.pasteOrUploadCert")}
|
||||
rows={2}
|
||||
value={credForm.certPublicKey}
|
||||
onChange={(e) =>
|
||||
setCredField("certPublicKey", e.target.value)
|
||||
}
|
||||
className="w-full px-3 py-2 text-[10px] 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>
|
||||
)}
|
||||
<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.sshPrivateKey")}
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
className="text-[10px] text-accent-brand hover:text-accent-brand/80 flex items-center gap-1"
|
||||
onClick={() => credFileInputRef.current?.click()}
|
||||
>
|
||||
<Upload className="size-3" /> {t("hosts.uploadFileBtn")}
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
ref={credFileInputRef}
|
||||
type="file"
|
||||
accept=".pem,.key,.ppk,.txt"
|
||||
className="hidden"
|
||||
onChange={async (e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
const text = await file.text();
|
||||
setCredField("value", text.trim());
|
||||
e.target.value = "";
|
||||
}}
|
||||
/>
|
||||
{credForm.value === "existing_key" && (
|
||||
<div className="px-3 py-2 text-[10px] border border-accent-brand/30 bg-accent-brand/5 text-accent-brand">
|
||||
{t("hosts.keySaved")} — {t("hosts.keyReplaceNotice")}
|
||||
</div>
|
||||
)}
|
||||
<textarea
|
||||
placeholder="-----BEGIN OPENSSH PRIVATE KEY-----"
|
||||
rows={8}
|
||||
value={
|
||||
credForm.value === "existing_key" ? "" : credForm.value
|
||||
}
|
||||
onChange={(e) => setCredField("value", e.target.value)}
|
||||
className="w-full px-3 py-2 text-[10px] 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="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("hosts.keyPassphraseOptional")}
|
||||
</label>
|
||||
<PasswordInput
|
||||
className="h-8 text-xs pr-8"
|
||||
placeholder={
|
||||
credForm.passphrase === "existing_key_password"
|
||||
? t("hosts.keyPassphraseSaved")
|
||||
: "••••••••"
|
||||
}
|
||||
value={
|
||||
credForm.passphrase === "existing_key_password"
|
||||
? ""
|
||||
: credForm.passphrase
|
||||
}
|
||||
onFocus={() => {
|
||||
if (credForm.passphrase === "existing_key_password")
|
||||
setCredField("passphrase", "");
|
||||
}}
|
||||
onChange={(e) => setCredField("passphrase", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("hosts.sshPublicKeyOptional")}
|
||||
</label>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-6 text-[10px] px-2 border-accent-brand/40 text-accent-brand"
|
||||
disabled={
|
||||
!credForm.value ||
|
||||
credForm.value === "existing_key" ||
|
||||
generatingPublicKey
|
||||
}
|
||||
onClick={async () => {
|
||||
setGeneratingPublicKey(true);
|
||||
try {
|
||||
const result = await generatePublicKeyFromPrivate(
|
||||
credForm.value,
|
||||
credForm.passphrase === "existing_key_password"
|
||||
? undefined
|
||||
: credForm.passphrase || undefined,
|
||||
);
|
||||
if (result?.publicKey) {
|
||||
setCredField("publicKey", result.publicKey);
|
||||
toast.success(t("hosts.publicKeyGenerated"));
|
||||
} else {
|
||||
toast.error(t("hosts.failedToGeneratePublicKey"));
|
||||
}
|
||||
} catch {
|
||||
toast.error(t("hosts.failedToGeneratePublicKey"));
|
||||
} finally {
|
||||
setGeneratingPublicKey(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{generatingPublicKey
|
||||
? t("hosts.generatingKey")
|
||||
: t("hosts.generateFromPrivateKey")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-6 text-[10px] px-2"
|
||||
disabled={!credForm.publicKey}
|
||||
onClick={() => {
|
||||
copyToClipboard(credForm.publicKey ?? "");
|
||||
toast.success(t("hosts.publicKeyCopied"));
|
||||
}}
|
||||
>
|
||||
<Copy className="size-3 mr-1" /> {t("common.copy")}
|
||||
</Button>
|
||||
</div>
|
||||
<textarea
|
||||
placeholder="ssh-rsa AAAAB3Nza..."
|
||||
rows={3}
|
||||
value={credForm.publicKey}
|
||||
onChange={(e) => setCredField("publicKey", e.target.value)}
|
||||
className="w-full px-3 py-2 text-[10px] 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 p-3 border border-border bg-muted/20">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("credentials.caCertificate")}
|
||||
</label>
|
||||
{credForm.certPublicKey && (
|
||||
<button
|
||||
type="button"
|
||||
className="text-[10px] text-destructive hover:text-destructive/80"
|
||||
onClick={() => setCredField("certPublicKey", "")}
|
||||
>
|
||||
{t("credentials.clearCert")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
{t("credentials.caCertificateDescription")}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
className="text-[10px] text-accent-brand hover:text-accent-brand/80 flex items-center gap-1 self-start"
|
||||
onClick={() => certFileInputRef.current?.click()}
|
||||
>
|
||||
<Upload className="size-3" />{" "}
|
||||
{t("credentials.uploadCertFile")}
|
||||
</button>
|
||||
<input
|
||||
ref={certFileInputRef}
|
||||
type="file"
|
||||
accept=".pub,.txt"
|
||||
className="hidden"
|
||||
onChange={async (e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
const text = await file.text();
|
||||
setCredField("certPublicKey", text.trim());
|
||||
e.target.value = "";
|
||||
}}
|
||||
/>
|
||||
<textarea
|
||||
placeholder={t("credentials.pasteOrUploadCert")}
|
||||
rows={2}
|
||||
value={credForm.certPublicKey}
|
||||
onChange={(e) =>
|
||||
setCredField("certPublicKey", e.target.value)
|
||||
}
|
||||
className="w-full px-3 py-2 text-[10px] 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>
|
||||
</div>
|
||||
</SectionCard>
|
||||
)}
|
||||
|
||||
@@ -29,6 +29,7 @@ function CredentialItem({
|
||||
cred,
|
||||
usedByCount,
|
||||
stripeIndex,
|
||||
termixIdLinked,
|
||||
onDeploy,
|
||||
onEdit,
|
||||
onDelete,
|
||||
@@ -36,6 +37,7 @@ function CredentialItem({
|
||||
cred: Credential;
|
||||
usedByCount: number;
|
||||
stripeIndex: number;
|
||||
termixIdLinked?: boolean;
|
||||
onDeploy: () => void;
|
||||
onEdit: () => void;
|
||||
onDelete: () => void;
|
||||
@@ -60,6 +62,11 @@ function CredentialItem({
|
||||
>
|
||||
{isKey ? "KEY" : "PWD"}
|
||||
</span>
|
||||
{termixIdLinked && (
|
||||
<span className="text-[9px] px-1 py-px font-bold border leading-none shrink-0 border-accent-brand/30 text-accent-brand/70">
|
||||
ID
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Username row */}
|
||||
@@ -153,6 +160,7 @@ function CredentialFolderItem({
|
||||
stripeOffset,
|
||||
editingFolderName,
|
||||
editingFolderValue,
|
||||
termixIdLinkedIds,
|
||||
onEditingFolderNameChange,
|
||||
onEditingFolderValueChange,
|
||||
onRenameFolder,
|
||||
@@ -166,6 +174,7 @@ function CredentialFolderItem({
|
||||
stripeOffset: number;
|
||||
editingFolderName: string | null;
|
||||
editingFolderValue: string;
|
||||
termixIdLinkedIds?: Set<number>;
|
||||
onEditingFolderNameChange: (name: string | null) => void;
|
||||
onEditingFolderValueChange: (value: string) => void;
|
||||
onRenameFolder: (folder: string, newName: string) => Promise<void>;
|
||||
@@ -252,6 +261,7 @@ function CredentialFolderItem({
|
||||
cred={cred}
|
||||
usedByCount={usedByCount}
|
||||
stripeIndex={stripeOffset + 1 + i}
|
||||
termixIdLinked={termixIdLinkedIds?.has(Number(cred.id))}
|
||||
onDeploy={() => onDeploy(cred)}
|
||||
onEdit={() => onEdit(cred)}
|
||||
onDelete={() => onDelete(cred)}
|
||||
@@ -271,6 +281,7 @@ export function HostCredentialList({
|
||||
allHosts,
|
||||
editingFolderName,
|
||||
editingFolderValue,
|
||||
termixIdLinkedIds,
|
||||
onEditingFolderNameChange,
|
||||
onEditingFolderValueChange,
|
||||
onRenameFolder,
|
||||
@@ -286,6 +297,7 @@ export function HostCredentialList({
|
||||
allHosts: Host[];
|
||||
editingFolderName: string | null;
|
||||
editingFolderValue: string;
|
||||
termixIdLinkedIds?: Set<number>;
|
||||
onEditingFolderNameChange: (name: string | null) => void;
|
||||
onEditingFolderValueChange: (value: string) => void;
|
||||
onRenameFolder: (folder: string, newName: string) => Promise<void>;
|
||||
@@ -328,6 +340,9 @@ export function HostCredentialList({
|
||||
password?: string;
|
||||
}
|
||||
).password ?? ""),
|
||||
password:
|
||||
(full as CredentialWithCertificate & { password?: string })
|
||||
.password ?? "",
|
||||
passphrase: (
|
||||
full as CredentialWithCertificate & {
|
||||
hasKeyPassword?: boolean;
|
||||
@@ -364,6 +379,7 @@ export function HostCredentialList({
|
||||
stripeOffset={offset}
|
||||
editingFolderName={editingFolderName}
|
||||
editingFolderValue={editingFolderValue}
|
||||
termixIdLinkedIds={termixIdLinkedIds}
|
||||
onEditingFolderNameChange={onEditingFolderNameChange}
|
||||
onEditingFolderValueChange={onEditingFolderValueChange}
|
||||
onRenameFolder={onRenameFolder}
|
||||
|
||||
+290
-36
@@ -33,9 +33,11 @@ import {
|
||||
connectTunnel,
|
||||
disconnectTunnel,
|
||||
getUserInfo,
|
||||
getVaultProfiles,
|
||||
getHostPassword,
|
||||
} from "@/main-axios";
|
||||
import { getTailscaleDevices, getHostDefaults } from "@/api/settings-api";
|
||||
import type { Host } from "@/types/ui-types";
|
||||
import type { Host, VaultProfile } from "@/types/ui-types";
|
||||
import type { SSHHost, TunnelStatus } from "@/types";
|
||||
import { useTabsSafe } from "@/shell/TabContext";
|
||||
import {
|
||||
@@ -61,6 +63,8 @@ import {
|
||||
HostEditorVncTab,
|
||||
} from "./HostEditorGuacamoleTabs";
|
||||
import { HostStatsTab } from "./HostEditorStatsTab";
|
||||
import { VaultProfileManager } from "./VaultProfileManager";
|
||||
import { findHostByTunnelEndpoint } from "@/features/tunnel/tunnel-endpoints";
|
||||
|
||||
export function HostEditor({
|
||||
host,
|
||||
@@ -112,6 +116,14 @@ export function HostEditor({
|
||||
const [tailscaleLoading, setTailscaleLoading] = useState(false);
|
||||
const [connectingTunnel, setConnectingTunnel] = useState<number | null>(null);
|
||||
const [isOidcUser, setIsOidcUser] = useState(false);
|
||||
const [vaultProfiles, setVaultProfiles] = useState<VaultProfile[]>([]);
|
||||
const [showVaultManager, setShowVaultManager] = useState(false);
|
||||
|
||||
const reloadVaultProfiles = () => {
|
||||
getVaultProfiles()
|
||||
.then((res) => setVaultProfiles(res as unknown as VaultProfile[]))
|
||||
.catch(() => {});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
getUserInfo()
|
||||
@@ -123,6 +135,7 @@ export function HostEditor({
|
||||
getSnippets()
|
||||
.then((res) => setSnippets(mapSnippetResponse(res)))
|
||||
.catch(() => {});
|
||||
reloadVaultProfiles();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -132,6 +145,22 @@ export function HostEditor({
|
||||
.catch(() => {});
|
||||
}, [host]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!host?.id || form.vncAuthType !== "direct" || form.vncPassword) return;
|
||||
|
||||
let cancelled = false;
|
||||
getHostPassword(Number(host.id), "vncPassword").then((password) => {
|
||||
if (cancelled || !password) return;
|
||||
setForm((prev) =>
|
||||
prev.vncPassword ? prev : { ...prev, vncPassword: password },
|
||||
);
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [form.vncAuthType, form.vncPassword, host?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTab !== "tunnels") return;
|
||||
const unsub = subscribeTunnelStatuses((s) => setTunnelStatuses(s));
|
||||
@@ -252,9 +281,11 @@ export function HostEditor({
|
||||
"password",
|
||||
"key",
|
||||
"credential",
|
||||
"vault",
|
||||
"none",
|
||||
"opkssh",
|
||||
"tailscale",
|
||||
"agent",
|
||||
].map((m) => (
|
||||
<button
|
||||
key={m}
|
||||
@@ -363,7 +394,7 @@ export function HostEditor({
|
||||
</span>
|
||||
<input
|
||||
type="file"
|
||||
accept=".pem,.key,.txt"
|
||||
accept=".pem,.key,.ppk,.txt"
|
||||
className="hidden"
|
||||
onChange={async (e) => {
|
||||
const file = e.target.files?.[0];
|
||||
@@ -413,6 +444,17 @@ 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.password")} ({t("common.optional")})
|
||||
</label>
|
||||
<PasswordInput
|
||||
className="h-8 text-xs pr-8"
|
||||
placeholder="••••••••"
|
||||
value={form.password}
|
||||
onChange={(e) => setField("password", 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.keyTypeLabel")}
|
||||
@@ -441,6 +483,58 @@ export function HostEditor({
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{authMethod === "vault" && (
|
||||
<div className="flex flex-col gap-1.5 col-span-2">
|
||||
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("hosts.vaultProfile")}
|
||||
</label>
|
||||
<select
|
||||
value={form.vaultProfileId}
|
||||
onChange={(e) =>
|
||||
setField("vaultProfileId", 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.selectAVaultProfile")}
|
||||
</option>
|
||||
{vaultProfiles.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.shared ? `${p.name} (shared)` : p.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
{t("hosts.vaultProfileHint")}
|
||||
</p>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<a
|
||||
href="https://docs.termix.site/features/authentication/vault"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-[10px] text-accent-brand hover:underline"
|
||||
>
|
||||
{t("hosts.docsLink")}
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
className="text-[10px] text-accent-brand hover:text-accent-brand/80"
|
||||
onClick={() => setShowVaultManager((v) => !v)}
|
||||
>
|
||||
{t("hosts.vaultManageProfiles")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{authMethod === "vault" && showVaultManager && (
|
||||
<VaultProfileManager
|
||||
profiles={vaultProfiles}
|
||||
onChanged={reloadVaultProfiles}
|
||||
onClose={() => setShowVaultManager(false)}
|
||||
/>
|
||||
)}
|
||||
{authMethod === "credential" && (
|
||||
<>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
@@ -604,6 +698,31 @@ export function HostEditor({
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{authMethod === "agent" && (
|
||||
<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("hosts.agentLabel")}
|
||||
</span>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
{t("hosts.agentDesc")}
|
||||
</p>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("hosts.agentSocketPathLabel")}
|
||||
</label>
|
||||
<Input
|
||||
placeholder={t("hosts.agentSocketPathPlaceholder")}
|
||||
value={form.agentSocketPath}
|
||||
onChange={(e) =>
|
||||
setField("agentSocketPath", e.target.value)
|
||||
}
|
||||
/>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
{t("hosts.agentSocketPathHint")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<SettingRow
|
||||
label={t("hosts.forceKeyboardInteractiveLabel")}
|
||||
description={t("hosts.forceKeyboardInteractiveShortDesc")}
|
||||
@@ -625,28 +744,6 @@ export function HostEditor({
|
||||
onChange={(v) => setField("allowLegacyAlgorithms", v)}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
label={t("hosts.sudoPasswordAutoFillLabel")}
|
||||
description={t("hosts.sudoPasswordAutoFillDesc")}
|
||||
>
|
||||
<FakeSwitch
|
||||
checked={form.sudoPasswordAutoFill}
|
||||
onChange={(v) => setField("sudoPasswordAutoFill", v)}
|
||||
/>
|
||||
</SettingRow>
|
||||
{form.sudoPasswordAutoFill && (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("hosts.sudoPasswordLabel")}
|
||||
</label>
|
||||
<PasswordInput
|
||||
className="h-8 text-xs pr-8"
|
||||
placeholder="••••••••"
|
||||
value={form.sudoPassword}
|
||||
onChange={(e) => setField("sudoPassword", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</SectionCard>
|
||||
</>
|
||||
@@ -671,6 +768,7 @@ export function HostEditor({
|
||||
cursorBlink={form.cursorBlink}
|
||||
letterSpacing={form.letterSpacing}
|
||||
lineHeight={form.lineHeight}
|
||||
customThemeColors={form.customThemeColors ?? undefined}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
@@ -681,8 +779,14 @@ export function HostEditor({
|
||||
<select
|
||||
value={form.theme}
|
||||
onChange={(e) => {
|
||||
setField("theme", e.target.value);
|
||||
setPreviewTerminalTheme(e.target.value);
|
||||
const newTheme = e.target.value;
|
||||
setField("theme", newTheme);
|
||||
setPreviewTerminalTheme(newTheme);
|
||||
if (newTheme === "custom" && !form.customThemeColors) {
|
||||
setField("customThemeColors", {
|
||||
...TERMINAL_THEMES.termixDark.colors,
|
||||
});
|
||||
}
|
||||
}}
|
||||
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"
|
||||
>
|
||||
@@ -823,6 +927,121 @@ export function HostEditor({
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
{form.theme === "custom" && (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("hosts.customThemeColors")}
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
title={t("hosts.customThemeResetTooltip")}
|
||||
onClick={() =>
|
||||
setField("customThemeColors", {
|
||||
...TERMINAL_THEMES.termixDark.colors,
|
||||
})
|
||||
}
|
||||
className="text-[10px] text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
{t("hosts.customThemeResetTooltip")}
|
||||
</button>
|
||||
</div>
|
||||
{(
|
||||
[
|
||||
["background", "customThemeBackground"],
|
||||
["foreground", "customThemeForeground"],
|
||||
["cursor", "customThemeCursor"],
|
||||
["cursorAccent", "customThemeCursorAccent"],
|
||||
["selectionBackground", "customThemeSelection"],
|
||||
] as const
|
||||
).map(([key, labelKey]) => (
|
||||
<div
|
||||
key={key}
|
||||
className="flex items-center justify-between gap-2"
|
||||
>
|
||||
<label className="text-xs text-muted-foreground min-w-0 flex-1">
|
||||
{t(`hosts.${labelKey}`)}
|
||||
</label>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<input
|
||||
type="color"
|
||||
value={
|
||||
form.customThemeColors?.[key] ??
|
||||
TERMINAL_THEMES.termixDark.colors[key]
|
||||
}
|
||||
onChange={(e) =>
|
||||
setField("customThemeColors", {
|
||||
...(form.customThemeColors ??
|
||||
TERMINAL_THEMES.termixDark.colors),
|
||||
[key]: e.target.value,
|
||||
})
|
||||
}
|
||||
className="h-7 w-10 cursor-pointer border border-border bg-background p-0.5"
|
||||
/>
|
||||
<span className="text-[10px] font-mono text-muted-foreground w-16 tabular-nums">
|
||||
{form.customThemeColors?.[key] ??
|
||||
TERMINAL_THEMES.termixDark.colors[key]}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground mt-1">
|
||||
{t("hosts.customThemeAnsiColors")}
|
||||
</label>
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-2">
|
||||
{(
|
||||
[
|
||||
["black", "customThemeBlack"],
|
||||
["brightBlack", "customThemeBrightBlack"],
|
||||
["red", "customThemeRed"],
|
||||
["brightRed", "customThemeBrightRed"],
|
||||
["green", "customThemeGreen"],
|
||||
["brightGreen", "customThemeBrightGreen"],
|
||||
["yellow", "customThemeYellow"],
|
||||
["brightYellow", "customThemeBrightYellow"],
|
||||
["blue", "customThemeBlue"],
|
||||
["brightBlue", "customThemeBrightBlue"],
|
||||
["magenta", "customThemeMagenta"],
|
||||
["brightMagenta", "customThemeBrightMagenta"],
|
||||
["cyan", "customThemeCyan"],
|
||||
["brightCyan", "customThemeBrightCyan"],
|
||||
["white", "customThemeWhite"],
|
||||
["brightWhite", "customThemeBrightWhite"],
|
||||
] as const
|
||||
).map(([key, labelKey]) => (
|
||||
<div
|
||||
key={key}
|
||||
className="flex items-center justify-between gap-2"
|
||||
>
|
||||
<label className="text-xs text-muted-foreground min-w-0 flex-1 truncate">
|
||||
{t(`hosts.${labelKey}`)}
|
||||
</label>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<input
|
||||
type="color"
|
||||
value={
|
||||
form.customThemeColors?.[key] ??
|
||||
TERMINAL_THEMES.termixDark.colors[key]
|
||||
}
|
||||
onChange={(e) =>
|
||||
setField("customThemeColors", {
|
||||
...(form.customThemeColors ??
|
||||
TERMINAL_THEMES.termixDark.colors),
|
||||
[key]: e.target.value,
|
||||
})
|
||||
}
|
||||
className="h-7 w-10 cursor-pointer border border-border bg-background p-0.5"
|
||||
/>
|
||||
<span className="text-[10px] font-mono text-muted-foreground w-16 tabular-nums">
|
||||
{form.customThemeColors?.[key] ??
|
||||
TERMINAL_THEMES.termixDark.colors[key]}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<SettingRow
|
||||
label={t("hosts.cursorBlinking")}
|
||||
description={t("hosts.cursorBlinkingDesc")}
|
||||
@@ -1364,7 +1583,20 @@ export function HostEditor({
|
||||
</p>
|
||||
)}
|
||||
{form.serverTunnels.map((tun, i) => {
|
||||
const tunnelName = `${host?.id ?? "new"}-${i}-${tun.sourcePort}`;
|
||||
const endpointValue = (tun.endpointHost ?? "").trim();
|
||||
const selectedEndpointHost = findHostByTunnelEndpoint(
|
||||
hosts,
|
||||
endpointValue,
|
||||
);
|
||||
const directEndpoint =
|
||||
!endpointValue ||
|
||||
endpointValue === "127.0.0.1" ||
|
||||
endpointValue === "localhost";
|
||||
const endpointInputId = `server-tunnel-endpoint-${host?.id ?? "new"}-${i}`;
|
||||
const hostLabel =
|
||||
host?.name ||
|
||||
(host ? `${host.username}@${host.ip}` : "new");
|
||||
const tunnelName = `${host?.id ?? "new"}::${i}::${hostLabel}::${tun.sourcePort}::${endpointValue}::${tun.endpointPort}`;
|
||||
const tunnelStatus = tunnelStatuses[tunnelName]?.status as
|
||||
| string
|
||||
| undefined;
|
||||
@@ -1422,12 +1654,32 @@ export function HostEditor({
|
||||
sourceCredentialId: form.credentialId
|
||||
? Number(form.credentialId)
|
||||
: undefined,
|
||||
endpointIP: host.ip,
|
||||
endpointIP: directEndpoint
|
||||
? host.ip
|
||||
: (selectedEndpointHost?.ip ??
|
||||
endpointValue),
|
||||
endpointSSHPort:
|
||||
host.sshPort ?? host.port,
|
||||
endpointHost: tun.endpointHost ?? "",
|
||||
endpointUsername: form.username,
|
||||
endpointAuthMethod: form.authType,
|
||||
directEndpoint || selectedEndpointHost
|
||||
? directEndpoint
|
||||
? (host.sshPort ?? host.port)
|
||||
: (selectedEndpointHost?.sshPort ??
|
||||
selectedEndpointHost?.port ??
|
||||
22)
|
||||
: 22,
|
||||
endpointHost: endpointValue,
|
||||
endpointUsername: directEndpoint
|
||||
? form.username
|
||||
: (selectedEndpointHost?.username ??
|
||||
""),
|
||||
endpointAuthMethod:
|
||||
selectedEndpointHost?.authType ??
|
||||
"none",
|
||||
endpointCredentialId:
|
||||
selectedEndpointHost?.credentialId
|
||||
? Number(
|
||||
selectedEndpointHost.credentialId,
|
||||
)
|
||||
: undefined,
|
||||
sourcePort: tun.sourcePort,
|
||||
endpointPort: tun.endpointPort ?? 0,
|
||||
bindHost: tun.bindHost ?? "127.0.0.1",
|
||||
@@ -1506,8 +1758,10 @@ export function HostEditor({
|
||||
<label className="text-[10px] font-bold text-muted-foreground">
|
||||
{t("hosts.endpointHost")}
|
||||
</label>
|
||||
<select
|
||||
<Input
|
||||
className="h-7 text-xs border border-border bg-background px-2 outline-none focus:ring-1 focus:ring-ring"
|
||||
list={endpointInputId}
|
||||
placeholder={t("hosts.endpointHostPlaceholder")}
|
||||
value={tun.endpointHost ?? ""}
|
||||
onChange={(e) => {
|
||||
const updated = [...form.serverTunnels];
|
||||
@@ -1517,8 +1771,8 @@ export function HostEditor({
|
||||
};
|
||||
setField("serverTunnels", updated);
|
||||
}}
|
||||
>
|
||||
<option value="">{t("hosts.sameHost")}</option>
|
||||
/>
|
||||
<datalist id={endpointInputId}>
|
||||
{hosts
|
||||
.filter((h) => h.enableSsh)
|
||||
.map((h) => (
|
||||
@@ -1526,7 +1780,7 @@ export function HostEditor({
|
||||
{h.name || h.ip} ({h.ip})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</datalist>
|
||||
</div>
|
||||
)}
|
||||
{tun.mode !== "dynamic" && (
|
||||
|
||||
@@ -61,7 +61,7 @@ describe("buildHostEditorPayload auth field isolation", () => {
|
||||
expect(payload.key).toBeNull();
|
||||
});
|
||||
|
||||
it("only sends key fields when authType is key", () => {
|
||||
it("sends key fields and optional password when authType is key", () => {
|
||||
const form = {
|
||||
...createHostEditorForm(null),
|
||||
authType: "key" as const,
|
||||
@@ -75,7 +75,49 @@ describe("buildHostEditorPayload auth field isolation", () => {
|
||||
|
||||
expect(payload.key).toBe("MY KEY");
|
||||
expect(payload.keyType).toBe("ssh-ed25519");
|
||||
expect(payload.password).toBeNull();
|
||||
expect(payload.password).toBe("leftover");
|
||||
expect(payload.credentialId).toBeNull();
|
||||
});
|
||||
|
||||
it("preserves agentSocketPath in terminalConfig when authType is agent", () => {
|
||||
const form = {
|
||||
...createHostEditorForm(null),
|
||||
authType: "agent" as const,
|
||||
agentSocketPath: "/run/user/1000/gnupg/S.gpg-agent.ssh",
|
||||
};
|
||||
|
||||
const payload = buildHostEditorPayload(form, sshOnly);
|
||||
const tc = payload.terminalConfig as Record<string, unknown> | null;
|
||||
|
||||
expect(tc?.agentSocketPath).toBe("/run/user/1000/gnupg/S.gpg-agent.ssh");
|
||||
expect(payload.password).toBeNull();
|
||||
expect(payload.key).toBeNull();
|
||||
});
|
||||
|
||||
it("sets agentSocketPath to null in payload when authType is agent but path is empty", () => {
|
||||
const form = {
|
||||
...createHostEditorForm(null),
|
||||
authType: "agent" as const,
|
||||
agentSocketPath: "",
|
||||
};
|
||||
|
||||
const payload = buildHostEditorPayload(form, sshOnly);
|
||||
const tc = payload.terminalConfig as Record<string, unknown> | null;
|
||||
|
||||
expect(tc?.agentSocketPath).toBeNull();
|
||||
});
|
||||
|
||||
it("nulls out agentSocketPath when switching away from agent auth", () => {
|
||||
const form = {
|
||||
...createHostEditorForm(null),
|
||||
authType: "password" as const,
|
||||
password: "mypass",
|
||||
agentSocketPath: "/run/user/1000/gnupg/S.gpg-agent.ssh",
|
||||
};
|
||||
|
||||
const payload = buildHostEditorPayload(form, sshOnly);
|
||||
const tc = payload.terminalConfig as Record<string, unknown> | null;
|
||||
|
||||
expect(tc?.agentSocketPath).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -55,7 +55,7 @@ export function createHostEditorForm(
|
||||
rawTheme,
|
||||
)
|
||||
? "termix"
|
||||
: TERMINAL_THEMES[rawTheme]
|
||||
: rawTheme === "custom" || TERMINAL_THEMES[rawTheme]
|
||||
? rawTheme
|
||||
: "termix";
|
||||
|
||||
@@ -79,6 +79,7 @@ export function createHostEditorForm(
|
||||
credentialId:
|
||||
host?.credentialId ??
|
||||
(d?.credentialId != null ? String(d.credentialId) : ""),
|
||||
vaultProfileId: host?.vaultProfileId ?? "",
|
||||
overrideCredentialUsername: host?.overrideCredentialUsername ?? false,
|
||||
folder: host?.folder ?? "",
|
||||
tags: host?.tags ?? ([] as string[]),
|
||||
@@ -104,6 +105,7 @@ export function createHostEditorForm(
|
||||
enableFileManager: host?.enableFileManager ?? false,
|
||||
scpLegacy: host?.scpLegacy ?? false,
|
||||
enableDocker: host?.enableDocker ?? false,
|
||||
dockerConfig: host?.dockerConfig ?? { runtime: "docker" as const },
|
||||
enableTmuxMonitor: host?.enableTmuxMonitor ?? false,
|
||||
enableProxmox: host?.enableProxmox ?? false,
|
||||
proxmoxConfig: host?.proxmoxConfig ?? {
|
||||
@@ -156,9 +158,11 @@ export function createHostEditorForm(
|
||||
backgroundImage: host?.terminalConfig?.backgroundImage ?? "",
|
||||
backgroundImageOpacity:
|
||||
host?.terminalConfig?.backgroundImageOpacity ?? 0.15,
|
||||
customThemeColors: host?.terminalConfig?.customThemeColors ?? null,
|
||||
allowLegacyAlgorithms: host?.terminalConfig?.allowLegacyAlgorithms ?? true,
|
||||
linkClickBehavior: (host?.terminalConfig?.linkClickBehavior ??
|
||||
"default") as "default" | "confirm" | "direct",
|
||||
agentSocketPath: host?.terminalConfig?.agentSocketPath ?? "",
|
||||
useSSHTitle: host?.terminalConfig?.useSSHTitle ?? false,
|
||||
syntaxHighlighting: host?.terminalConfig?.syntaxHighlighting ?? true,
|
||||
syntaxHighlightingOptions: {
|
||||
@@ -226,6 +230,7 @@ export function createHostEditorForm(
|
||||
"processes",
|
||||
"ports",
|
||||
"firewall",
|
||||
"temperature",
|
||||
],
|
||||
},
|
||||
};
|
||||
@@ -243,6 +248,7 @@ export function buildHostEditorPayload(
|
||||
const usesCredential = form.authType === "credential";
|
||||
const usesKey = form.authType === "key";
|
||||
const usesPassword = form.authType === "password";
|
||||
const usesAgent = form.authType === "agent";
|
||||
|
||||
return {
|
||||
connectionType: protocols.enableSsh
|
||||
@@ -267,7 +273,7 @@ export function buildHostEditorPayload(
|
||||
pin: form.pin,
|
||||
authType: form.authType,
|
||||
useWarpgate: form.useWarpgate,
|
||||
password: usesPassword ? form.password || null : null,
|
||||
password: usesPassword || usesKey ? form.password || null : null,
|
||||
key: usesKey
|
||||
? form.key === "existing_key"
|
||||
? undefined
|
||||
@@ -281,6 +287,7 @@ export function buildHostEditorPayload(
|
||||
keyType: usesKey && form.keyType !== "auto" ? form.keyType : null,
|
||||
credentialId:
|
||||
usesCredential && form.credentialId ? Number(form.credentialId) : null,
|
||||
vaultProfileId: form.vaultProfileId ? Number(form.vaultProfileId) : null,
|
||||
overrideCredentialUsername: form.overrideCredentialUsername,
|
||||
notes: form.notes,
|
||||
macAddress: form.macAddress || null,
|
||||
@@ -292,6 +299,7 @@ export function buildHostEditorPayload(
|
||||
enableFileManager: form.enableFileManager,
|
||||
scpLegacy: form.scpLegacy,
|
||||
enableDocker: form.enableDocker,
|
||||
dockerConfig: form.enableDocker ? form.dockerConfig : null,
|
||||
enableTmuxMonitor: form.enableTmuxMonitor,
|
||||
enableProxmox: form.enableProxmox,
|
||||
proxmoxConfig: form.enableProxmox ? form.proxmoxConfig : null,
|
||||
@@ -398,7 +406,7 @@ export function buildHostEditorPayload(
|
||||
agentForwarding: form.agentForwarding,
|
||||
autoMosh: form.autoMosh,
|
||||
autoTmux: form.autoTmux,
|
||||
sudoPasswordAutoFill: form.sudoPasswordAutoFill,
|
||||
sudoPasswordAutoFill: false,
|
||||
sudoPassword: form.sudoPassword || null,
|
||||
keepaliveInterval: Number(form.keepaliveInterval),
|
||||
keepaliveCountMax: Number(form.keepaliveCountMax),
|
||||
@@ -408,11 +416,14 @@ export function buildHostEditorPayload(
|
||||
syntaxHighlightingOptions: form.syntaxHighlightingOptions,
|
||||
backgroundImage: form.backgroundImage || null,
|
||||
backgroundImageOpacity: Number(form.backgroundImageOpacity),
|
||||
customThemeColors:
|
||||
form.theme === "custom" ? form.customThemeColors : null,
|
||||
allowLegacyAlgorithms: form.allowLegacyAlgorithms,
|
||||
linkClickBehavior:
|
||||
form.linkClickBehavior !== "default"
|
||||
? form.linkClickBehavior
|
||||
: undefined,
|
||||
agentSocketPath: usesAgent ? form.agentSocketPath || null : null,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
|
||||
@@ -20,6 +20,11 @@ export function HostDockerTab({
|
||||
setField: SetHostField;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const dockerConfig = form.dockerConfig ?? { runtime: "docker" as const };
|
||||
const runtime =
|
||||
dockerConfig.runtime === "podman"
|
||||
? ("podman" as const)
|
||||
: ("docker" as const);
|
||||
|
||||
return (
|
||||
<SectionCard
|
||||
@@ -36,6 +41,30 @@ export function HostDockerTab({
|
||||
onChange={(v) => setField("enableDocker", v)}
|
||||
/>
|
||||
</SettingRow>
|
||||
{form.enableDocker && (
|
||||
<SettingRow
|
||||
label={t("hosts.containerRuntime")}
|
||||
description={t("hosts.containerRuntimeDesc")}
|
||||
>
|
||||
<select
|
||||
value={runtime}
|
||||
onChange={(e) =>
|
||||
setField("dockerConfig", {
|
||||
...dockerConfig,
|
||||
runtime: e.target.value as "docker" | "podman",
|
||||
})
|
||||
}
|
||||
className="h-7 w-44 text-xs border border-border bg-background px-2 outline-none focus:ring-1 focus:ring-ring"
|
||||
>
|
||||
<option value="docker">
|
||||
{t("hosts.containerRuntimeDocker")}
|
||||
</option>
|
||||
<option value="podman">
|
||||
{t("hosts.containerRuntimePodman")}
|
||||
</option>
|
||||
</select>
|
||||
</SettingRow>
|
||||
)}
|
||||
</div>
|
||||
</SectionCard>
|
||||
);
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
deleteCredential,
|
||||
deployCredentialToHost,
|
||||
renameCredentialFolder,
|
||||
getLinkedCredentialIds,
|
||||
} from "@/main-axios";
|
||||
|
||||
import type { Host, Credential } from "@/types/ui-types";
|
||||
@@ -128,6 +129,9 @@ export function HostManager({
|
||||
string | null
|
||||
>(null);
|
||||
const [editingCredFolderValue, setEditingCredFolderValue] = useState("");
|
||||
const [termixIdLinkedIds, setTermixIdLinkedIds] = useState<Set<number>>(
|
||||
new Set(),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
onTagsChange?.([...new Set(credentials.flatMap((c) => c.tags ?? []))]);
|
||||
@@ -171,9 +175,16 @@ export function HostManager({
|
||||
.finally(() => setCredentialsLoading(false));
|
||||
};
|
||||
|
||||
const reloadLinkedIds = () => {
|
||||
getLinkedCredentialIds()
|
||||
.then((d) => setTermixIdLinkedIds(new Set(d.credentialIds)))
|
||||
.catch(() => {});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
reloadHosts();
|
||||
reloadCredentials();
|
||||
reloadLinkedIds();
|
||||
|
||||
window.addEventListener("termix:hosts-changed", reloadHosts);
|
||||
window.addEventListener("termix:credentials-changed", reloadCredentials);
|
||||
@@ -433,6 +444,7 @@ export function HostManager({
|
||||
username: saved.username ?? "",
|
||||
type: saved.authType === "key" ? "key" : "password",
|
||||
value: saved.value,
|
||||
password: saved.password,
|
||||
publicKey: saved.publicKey,
|
||||
passphrase: saved.passphrase,
|
||||
description: saved.description,
|
||||
@@ -498,6 +510,7 @@ export function HostManager({
|
||||
allHosts={allHosts}
|
||||
editingFolderName={editingCredFolderName}
|
||||
editingFolderValue={editingCredFolderValue}
|
||||
termixIdLinkedIds={termixIdLinkedIds}
|
||||
onEditingFolderNameChange={setEditingCredFolderName}
|
||||
onEditingFolderValueChange={setEditingCredFolderValue}
|
||||
onRenameFolder={handleRenameCredentialFolder}
|
||||
|
||||
@@ -32,6 +32,7 @@ function parseJson<T>(v: unknown): T | undefined {
|
||||
|
||||
export function sshHostToHost(h: SSHHostWithStatus): Host {
|
||||
const host = h as RawSSHHost;
|
||||
const isSshHost = h.connectionType === "ssh" || !h.connectionType;
|
||||
return {
|
||||
id: String(h.id),
|
||||
name: h.name,
|
||||
@@ -52,13 +53,16 @@ export function sshHostToHost(h: SSHHostWithStatus): Host {
|
||||
keyPassword: h.keyPassword,
|
||||
keyType: h.keyType,
|
||||
credentialId: h.credentialId != null ? String(h.credentialId) : undefined,
|
||||
vaultProfileId:
|
||||
(h as { vaultProfileId?: number | string | null }).vaultProfileId != null
|
||||
? String((h as { vaultProfileId?: number | string }).vaultProfileId)
|
||||
: undefined,
|
||||
notes: h.notes,
|
||||
pin: h.pin ?? false,
|
||||
macAddress: h.macAddress,
|
||||
enableSsh: h.enableSsh != null ? h.enableSsh : h.connectionType === "ssh",
|
||||
enableSsh: h.enableSsh != null ? h.enableSsh : isSshHost,
|
||||
enableTerminal:
|
||||
h.enableTerminal ??
|
||||
(h.enableSsh != null ? h.enableSsh : h.connectionType === "ssh"),
|
||||
h.enableTerminal ?? (h.enableSsh != null ? h.enableSsh : isSshHost),
|
||||
enableSessionLogging: h.enableSessionLogging ?? true,
|
||||
enableCommandHistory: h.enableCommandHistory ?? true,
|
||||
enableTunnel: h.enableTunnel ?? false,
|
||||
@@ -82,6 +86,8 @@ export function sshHostToHost(h: SSHHostWithStatus): Host {
|
||||
domain: h.rdpDomain,
|
||||
security: h.rdpSecurity,
|
||||
ignoreCert: h.rdpIgnoreCert ?? false,
|
||||
vncAuthType: h.vncAuthType ?? (h.vncCredentialId ? "credential" : "direct"),
|
||||
vncCredentialId: h.vncCredentialId ?? null,
|
||||
vncPassword: h.vncPassword ?? "",
|
||||
vncUser: h.vncUser,
|
||||
telnetUser: h.telnetUser,
|
||||
|
||||
@@ -347,20 +347,24 @@ export function HostsPanel({
|
||||
}
|
||||
}
|
||||
|
||||
async function handleExportHosts() {
|
||||
async function handleExportHosts(share = false) {
|
||||
try {
|
||||
const result = await exportAllSSHHosts();
|
||||
const result = await exportAllSSHHosts(
|
||||
share ? { share: true } : undefined,
|
||||
);
|
||||
const data = JSON.stringify(result, null, 2);
|
||||
const blob = new Blob([data], { type: "application/json" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = "termix-hosts.json";
|
||||
a.download = share ? "termix-hosts-share.json" : "termix-hosts.json";
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
toast.success(t("hosts.hostsExported"));
|
||||
toast.success(
|
||||
t(share ? "hosts.hostsShareExported" : "hosts.hostsExported"),
|
||||
);
|
||||
} catch {
|
||||
toast.error(t("hosts.exportFailed"));
|
||||
}
|
||||
@@ -464,6 +468,10 @@ export function HostsPanel({
|
||||
const hostsArray = Array.isArray(parsed)
|
||||
? parsed
|
||||
: (parsed.hosts ?? []);
|
||||
const credentialsArray =
|
||||
!Array.isArray(parsed) && Array.isArray(parsed.credentials)
|
||||
? parsed.credentials
|
||||
: undefined;
|
||||
if (!Array.isArray(hostsArray) || hostsArray.length === 0) {
|
||||
toast.error("No hosts found in file");
|
||||
return;
|
||||
@@ -486,6 +494,7 @@ export function HostsPanel({
|
||||
const result = await bulkImportSSHHosts(
|
||||
normalized,
|
||||
importOverwriteRef.current,
|
||||
credentialsArray,
|
||||
);
|
||||
const hosts = await getSSHHosts();
|
||||
setRawHosts(hosts);
|
||||
@@ -610,12 +619,19 @@ export function HostsPanel({
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={handleExportHosts}
|
||||
onClick={() => handleExportHosts(false)}
|
||||
disabled={rawHosts.length === 0}
|
||||
>
|
||||
<Download className="size-3.5 mr-2" />
|
||||
{t("hosts.exportAll")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleExportHosts(true)}
|
||||
disabled={rawHosts.length === 0}
|
||||
>
|
||||
<Download className="size-3.5 mr-2" />
|
||||
{t("hosts.exportForSharing")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={handleDownloadSample}>
|
||||
<Download className="size-3.5 mr-2" />
|
||||
{t("hosts.downloadSample")}
|
||||
@@ -959,12 +975,19 @@ export function HostsPanel({
|
||||
{t("hosts.importSSHConfig")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={handleExportHosts}
|
||||
onClick={() => handleExportHosts(false)}
|
||||
disabled={rawHosts.length === 0}
|
||||
>
|
||||
<Download className="size-3.5 mr-2" />
|
||||
{t("hosts.exportAll")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleExportHosts(true)}
|
||||
disabled={rawHosts.length === 0}
|
||||
>
|
||||
<Download className="size-3.5 mr-2" />
|
||||
{t("hosts.exportForSharing")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={handleDownloadSample}>
|
||||
<Download className="size-3.5 mr-2" />
|
||||
{t("hosts.downloadSample")}
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from "@/components/dialog";
|
||||
import { Button } from "@/components/button";
|
||||
import { Input } from "@/components/input";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
createNotificationChannel,
|
||||
updateNotificationChannel,
|
||||
type NotificationChannel,
|
||||
} from "@/api/alerts-api";
|
||||
|
||||
interface NotificationChannelDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
channel: NotificationChannel | null;
|
||||
onSaved: () => void;
|
||||
}
|
||||
|
||||
type ChannelType = "webhook" | "ntfy";
|
||||
|
||||
interface WebhookConfig {
|
||||
url: string;
|
||||
headers?: Record<string, string>;
|
||||
}
|
||||
|
||||
interface NtfyConfig {
|
||||
url: string;
|
||||
topic: string;
|
||||
token?: string;
|
||||
}
|
||||
|
||||
export function NotificationChannelDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
channel,
|
||||
onSaved,
|
||||
}: NotificationChannelDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const [name, setName] = useState("");
|
||||
const [type, setType] = useState<ChannelType>("webhook");
|
||||
const [webhookUrl, setWebhookUrl] = useState("");
|
||||
const [ntfyUrl, setNtfyUrl] = useState("https://ntfy.sh");
|
||||
const [ntfyTopic, setNtfyTopic] = useState("");
|
||||
const [ntfyToken, setNtfyToken] = useState("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
if (channel) {
|
||||
setName(channel.name);
|
||||
setType(channel.type);
|
||||
try {
|
||||
const cfg = JSON.parse(channel.config);
|
||||
if (channel.type === "webhook") {
|
||||
setWebhookUrl((cfg as WebhookConfig).url ?? "");
|
||||
} else {
|
||||
const nc = cfg as NtfyConfig;
|
||||
setNtfyUrl(nc.url ?? "https://ntfy.sh");
|
||||
setNtfyTopic(nc.topic ?? "");
|
||||
setNtfyToken(nc.token ?? "");
|
||||
}
|
||||
} catch {
|
||||
// ignore malformed config
|
||||
}
|
||||
} else {
|
||||
setName("");
|
||||
setType("webhook");
|
||||
setWebhookUrl("");
|
||||
setNtfyUrl("https://ntfy.sh");
|
||||
setNtfyTopic("");
|
||||
setNtfyToken("");
|
||||
}
|
||||
}, [open, channel]);
|
||||
|
||||
async function handleSave() {
|
||||
if (!name.trim()) {
|
||||
toast.error(t("alerts.channelNameRequired", "Name is required"));
|
||||
return;
|
||||
}
|
||||
let config: string;
|
||||
if (type === "webhook") {
|
||||
if (!webhookUrl.trim()) {
|
||||
toast.error(t("alerts.webhookUrlRequired", "Webhook URL is required"));
|
||||
return;
|
||||
}
|
||||
config = JSON.stringify({
|
||||
url: webhookUrl.trim(),
|
||||
} satisfies WebhookConfig);
|
||||
} else {
|
||||
if (!ntfyTopic.trim()) {
|
||||
toast.error(t("alerts.ntfyTopicRequired", "Topic is required"));
|
||||
return;
|
||||
}
|
||||
const cfg: NtfyConfig = { url: ntfyUrl.trim(), topic: ntfyTopic.trim() };
|
||||
if (ntfyToken.trim()) cfg.token = ntfyToken.trim();
|
||||
config = JSON.stringify(cfg);
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
if (channel) {
|
||||
await updateNotificationChannel(channel.id, {
|
||||
name: name.trim(),
|
||||
type,
|
||||
config,
|
||||
});
|
||||
} else {
|
||||
await createNotificationChannel({ name: name.trim(), type, config });
|
||||
}
|
||||
onSaved();
|
||||
} catch {
|
||||
toast.error(t("alerts.channelSaveFailed", "Failed to save channel"));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{channel
|
||||
? t("alerts.editChannel", "Edit Channel")
|
||||
: t("alerts.addChannel", "Add Channel")}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex flex-col gap-3 py-2">
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
|
||||
{t("alerts.channelName", "Name")}
|
||||
</label>
|
||||
<Input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
|
||||
{t("alerts.channelType", "Type")}
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
{(["webhook", "ntfy"] as ChannelType[]).map((ct) => (
|
||||
<button
|
||||
key={ct}
|
||||
onClick={() => setType(ct)}
|
||||
className={`px-3 py-1 text-xs font-semibold border transition-colors ${type === ct ? "border-accent-brand/40 bg-accent-brand/10 text-accent-brand" : "border-border text-muted-foreground hover:text-foreground"}`}
|
||||
>
|
||||
{ct === "webhook" ? "Webhook" : "ntfy"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{type === "webhook" && (
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
|
||||
{t("alerts.webhookUrl", "URL")}
|
||||
</label>
|
||||
<Input
|
||||
value={webhookUrl}
|
||||
onChange={(e) => setWebhookUrl(e.target.value)}
|
||||
placeholder="https://example.com/webhook"
|
||||
className="text-sm font-mono"
|
||||
/>
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{t(
|
||||
"alerts.webhookDesc",
|
||||
"POST JSON payload to this URL on each alert firing",
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{type === "ntfy" && (
|
||||
<>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
|
||||
{t("alerts.ntfyServer", "Server URL")}
|
||||
</label>
|
||||
<Input
|
||||
value={ntfyUrl}
|
||||
onChange={(e) => setNtfyUrl(e.target.value)}
|
||||
placeholder="https://ntfy.sh"
|
||||
className="text-sm font-mono"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
|
||||
{t("alerts.ntfyTopic", "Topic")}
|
||||
</label>
|
||||
<Input
|
||||
value={ntfyTopic}
|
||||
onChange={(e) => setNtfyTopic(e.target.value)}
|
||||
placeholder="my-alerts"
|
||||
className="text-sm font-mono"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
|
||||
{t("alerts.ntfyToken", "Access Token (optional)")}
|
||||
</label>
|
||||
<Input
|
||||
type="password"
|
||||
value={ntfyToken}
|
||||
onChange={(e) => setNtfyToken(e.target.value)}
|
||||
placeholder="tk_..."
|
||||
className="text-sm font-mono"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={() => onOpenChange(false)}>
|
||||
{t("common.cancel", "Cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
className="border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
|
||||
>
|
||||
{saving
|
||||
? t("common.saving", "Saving...")
|
||||
: t("common.save", "Save")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,352 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { RefreshCw, Usb, TriangleAlert } from "lucide-react";
|
||||
import { Input } from "@/components/input";
|
||||
import { isElectron } from "@/lib/electron";
|
||||
import type { SerialConfig } from "@/types/ui-types";
|
||||
|
||||
const BAUD_RATES = [
|
||||
300, 1200, 2400, 4800, 9600, 19200, 38400, 57600, 115200, 230400, 460800,
|
||||
921600,
|
||||
];
|
||||
|
||||
interface SerialPanelProps {
|
||||
onConnect: (config: SerialConfig) => void;
|
||||
}
|
||||
|
||||
// In Chrome/Edge the Web Serial API shows its own native port picker on requestPort(),
|
||||
// so getPorts() only returns previously-granted ports. We never need the user to type
|
||||
// a path — the browser handles selection at connect time.
|
||||
const useWebSerial = !isElectron() && "serial" in navigator;
|
||||
const serialUnsupported = !isElectron() && !("serial" in navigator);
|
||||
|
||||
export function SerialPanel({ onConnect }: SerialPanelProps) {
|
||||
const { t } = useTranslation();
|
||||
const [path, setPath] = useState("");
|
||||
const [baudRate, setBaudRate] = useState(115200);
|
||||
const [dataBits, setDataBits] = useState<5 | 6 | 7 | 8>(8);
|
||||
const [stopBits, setStopBits] = useState<1 | 2>(1);
|
||||
const [parity, setParity] = useState<"none" | "even" | "odd">("none");
|
||||
// Electron only: ports discovered from the backend
|
||||
const [availablePorts, setAvailablePorts] = useState<string[]>([]);
|
||||
const [loadingPorts, setLoadingPorts] = useState(false);
|
||||
|
||||
const buildWsUrl = () => {
|
||||
const isDev =
|
||||
process.env.NODE_ENV === "development" &&
|
||||
(window.location.port === "3000" ||
|
||||
window.location.port === "5173" ||
|
||||
window.location.port === "");
|
||||
|
||||
if (
|
||||
isDev ||
|
||||
(isElectron() &&
|
||||
!(window as { configuredServerUrl?: string }).configuredServerUrl)
|
||||
) {
|
||||
const token = localStorage.getItem("jwt");
|
||||
const base = "ws://127.0.0.1:30011";
|
||||
return token ? `${base}?token=${encodeURIComponent(token)}` : base;
|
||||
}
|
||||
const configuredUrl = (window as { configuredServerUrl?: string })
|
||||
.configuredServerUrl;
|
||||
if (!configuredUrl) return null;
|
||||
const wsProtocol = configuredUrl.startsWith("https://")
|
||||
? "wss://"
|
||||
: "ws://";
|
||||
const wsHost = configuredUrl.replace(/^https?:\/\//, "").replace(/\/$/, "");
|
||||
const token = localStorage.getItem("jwt");
|
||||
const base = `${wsProtocol}${wsHost}/serial/websocket/`;
|
||||
return token ? `${base}?token=${encodeURIComponent(token)}` : base;
|
||||
};
|
||||
|
||||
const refreshPorts = useCallback(() => {
|
||||
if (!isElectron()) return;
|
||||
setLoadingPorts(true);
|
||||
const url = buildWsUrl();
|
||||
if (!url) {
|
||||
setLoadingPorts(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const ws = new WebSocket(url);
|
||||
ws.onopen = () => ws.send(JSON.stringify({ type: "list_ports" }));
|
||||
ws.onmessage = (ev) => {
|
||||
try {
|
||||
const msg = JSON.parse(ev.data as string) as {
|
||||
type: string;
|
||||
data?: { path: string }[];
|
||||
};
|
||||
if (msg.type === "ports_list" && Array.isArray(msg.data)) {
|
||||
const paths = msg.data.map((p) => p.path);
|
||||
setAvailablePorts(paths);
|
||||
if (!path && paths.length > 0) setPath(paths[0]);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
ws.close();
|
||||
setLoadingPorts(false);
|
||||
}
|
||||
};
|
||||
ws.onerror = () => setLoadingPorts(false);
|
||||
}, [path]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isElectron()) refreshPorts();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const connect = () => {
|
||||
// For Web Serial, path is irrelevant — the browser picker handles it.
|
||||
// We pass an empty string; Serial.tsx calls requestPort() itself.
|
||||
if (useWebSerial) {
|
||||
onConnect({ path: "", baudRate, dataBits, stopBits, parity });
|
||||
return;
|
||||
}
|
||||
const finalPath = path.trim();
|
||||
if (!finalPath) return;
|
||||
onConnect({ path: finalPath, baudRate, dataBits, stopBits, parity });
|
||||
};
|
||||
|
||||
// ── Not supported (Firefox, Safari, etc.) ─────────────────────────────
|
||||
|
||||
if (serialUnsupported) {
|
||||
return (
|
||||
<div className="flex flex-col flex-1 min-h-0 overflow-y-auto">
|
||||
<div className="flex flex-col gap-3 p-3">
|
||||
<div className="flex flex-col gap-2 rounded border border-destructive/30 bg-destructive/5 p-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<TriangleAlert className="size-3.5 text-destructive shrink-0" />
|
||||
<span className="text-xs font-semibold text-destructive">
|
||||
{t("serial.notSupportedTitle")}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-[11px] text-muted-foreground leading-relaxed">
|
||||
{t("serial.notSupported")}
|
||||
</p>
|
||||
<p className="text-[11px] text-muted-foreground leading-relaxed">
|
||||
{t("serial.hideHint")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Web Serial (Chrome / Edge) ─────────────────────────────────────────
|
||||
|
||||
if (useWebSerial) {
|
||||
return (
|
||||
<div className="flex flex-col flex-1 min-h-0 overflow-y-auto">
|
||||
<div className="flex flex-col gap-3 p-3">
|
||||
<p className="text-[11px] text-muted-foreground leading-relaxed">
|
||||
{t("serial.browserPickerHint")}
|
||||
</p>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
|
||||
{t("serial.baudRateLabel")}
|
||||
</label>
|
||||
<select
|
||||
value={baudRate}
|
||||
onChange={(e) => setBaudRate(Number(e.target.value))}
|
||||
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"
|
||||
>
|
||||
{BAUD_RATES.map((r) => (
|
||||
<option key={r} value={r}>
|
||||
{r}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
|
||||
{t("serial.dataBitsLabel")}
|
||||
</label>
|
||||
<select
|
||||
value={dataBits}
|
||||
onChange={(e) =>
|
||||
setDataBits(Number(e.target.value) as 5 | 6 | 7 | 8)
|
||||
}
|
||||
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"
|
||||
>
|
||||
{([5, 6, 7, 8] as const).map((b) => (
|
||||
<option key={b} value={b}>
|
||||
{b}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
|
||||
{t("serial.stopBitsLabel")}
|
||||
</label>
|
||||
<select
|
||||
value={stopBits}
|
||||
onChange={(e) => setStopBits(Number(e.target.value) as 1 | 2)}
|
||||
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={1}>1</option>
|
||||
<option value={2}>2</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
|
||||
{t("serial.parityLabel")}
|
||||
</label>
|
||||
<select
|
||||
value={parity}
|
||||
onChange={(e) =>
|
||||
setParity(e.target.value as "none" | "even" | "odd")
|
||||
}
|
||||
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="none">{t("serial.parityNone")}</option>
|
||||
<option value="even">{t("serial.parityEven")}</option>
|
||||
<option value="odd">{t("serial.parityOdd")}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={connect}
|
||||
className="flex items-center justify-center gap-1.5 h-7 w-full border border-accent-brand/40 bg-accent-brand/10 text-accent-brand text-xs font-semibold hover:bg-accent-brand/20 transition-colors mt-1"
|
||||
>
|
||||
<Usb className="size-3.5" />
|
||||
{t("serial.connect")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Electron (backend serialport) ─────────────────────────────────────
|
||||
|
||||
return (
|
||||
<div className="flex flex-col flex-1 min-h-0 overflow-y-auto">
|
||||
<div className="flex flex-col gap-3 p-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
|
||||
{t("serial.portLabel")}
|
||||
</label>
|
||||
<button
|
||||
onClick={refreshPorts}
|
||||
className="text-muted-foreground hover:text-foreground transition-colors"
|
||||
title={t("serial.refreshPorts")}
|
||||
>
|
||||
<RefreshCw
|
||||
className={`size-3 ${loadingPorts ? "animate-spin" : ""}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
{availablePorts.length > 0 && (
|
||||
<select
|
||||
value={path}
|
||||
onChange={(e) => setPath(e.target.value)}
|
||||
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("serial.portPlaceholder")}</option>
|
||||
{availablePorts.map((p) => (
|
||||
<option key={p} value={p}>
|
||||
{p}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
<Input
|
||||
placeholder={t("serial.portPlaceholder")}
|
||||
value={path}
|
||||
onChange={(e) => setPath(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") connect();
|
||||
}}
|
||||
className="h-7 text-xs font-mono"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
|
||||
{t("serial.baudRateLabel")}
|
||||
</label>
|
||||
<select
|
||||
value={baudRate}
|
||||
onChange={(e) => setBaudRate(Number(e.target.value))}
|
||||
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"
|
||||
>
|
||||
{BAUD_RATES.map((r) => (
|
||||
<option key={r} value={r}>
|
||||
{r}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
|
||||
{t("serial.dataBitsLabel")}
|
||||
</label>
|
||||
<select
|
||||
value={dataBits}
|
||||
onChange={(e) =>
|
||||
setDataBits(Number(e.target.value) as 5 | 6 | 7 | 8)
|
||||
}
|
||||
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"
|
||||
>
|
||||
{([5, 6, 7, 8] as const).map((b) => (
|
||||
<option key={b} value={b}>
|
||||
{b}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
|
||||
{t("serial.stopBitsLabel")}
|
||||
</label>
|
||||
<select
|
||||
value={stopBits}
|
||||
onChange={(e) => setStopBits(Number(e.target.value) as 1 | 2)}
|
||||
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={1}>1</option>
|
||||
<option value={2}>2</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
|
||||
{t("serial.parityLabel")}
|
||||
</label>
|
||||
<select
|
||||
value={parity}
|
||||
onChange={(e) =>
|
||||
setParity(e.target.value as "none" | "even" | "odd")
|
||||
}
|
||||
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="none">{t("serial.parityNone")}</option>
|
||||
<option value="even">{t("serial.parityEven")}</option>
|
||||
<option value="odd">{t("serial.parityOdd")}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={connect}
|
||||
disabled={!path.trim()}
|
||||
className="flex items-center justify-center gap-1.5 h-7 w-full border border-accent-brand/40 bg-accent-brand/10 text-accent-brand text-xs font-semibold hover:bg-accent-brand/20 transition-colors disabled:opacity-40 disabled:cursor-not-allowed mt-1"
|
||||
>
|
||||
<Usb className="size-3.5" />
|
||||
{t("serial.connect")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -55,12 +55,14 @@ import {
|
||||
import type { Host, HostFolder, TabType } from "@/types/ui-types";
|
||||
import type { SSHHostData } from "@/types/index";
|
||||
import { FolderIconEl } from "@/components/folder-style";
|
||||
import { resolveHostTabType } from "@/lib/host-connection-tabs";
|
||||
import { copyToClipboard } from "@/lib/clipboard";
|
||||
import { FolderMetadataDialog } from "./FolderMetadataDialog";
|
||||
import {
|
||||
useStatusColorScheme,
|
||||
getStatusClasses,
|
||||
} from "@/hooks/use-status-color-scheme";
|
||||
import { useServerStatus } from "@/lib/ServerStatusContext";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
@@ -303,6 +305,11 @@ export function HostItem({
|
||||
() => localStorage.getItem("compactHostView") === "true",
|
||||
);
|
||||
const statusScheme = useStatusColorScheme();
|
||||
const { initialLoadComplete, getStatus } = useServerStatus();
|
||||
const statusCheckOn = statusCheckEnabled(host);
|
||||
const statusLoading = !initialLoadComplete && statusCheckOn;
|
||||
const liveStatus = statusCheckOn ? getStatus(Number(host.id)) : null;
|
||||
const isOnline = liveStatus != null ? liveStatus === "online" : host.online;
|
||||
const isTouchOnly =
|
||||
typeof window !== "undefined" && window.matchMedia("(hover: none)").matches;
|
||||
const shouldUseClickTray = trayOnClick || isTouchOnly;
|
||||
@@ -413,7 +420,7 @@ export function HostItem({
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={`w-[3px] shrink-0 transition-colors ${getStatusClasses(host.online, statusScheme, "stripe")}`}
|
||||
className={`w-[3px] shrink-0 transition-colors ${getStatusClasses(isOnline, statusScheme, "stripe", statusLoading)}`}
|
||||
/>
|
||||
<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">
|
||||
@@ -428,11 +435,11 @@ export function HostItem({
|
||||
<Tooltip>
|
||||
<TooltipTrigger className="flex items-center">
|
||||
<span
|
||||
className={`size-1.5 rounded-full shrink-0 ${getStatusClasses(host.online, statusScheme, "dot")}`}
|
||||
className={`size-1.5 rounded-full shrink-0 ${getStatusClasses(isOnline, statusScheme, "dot", statusLoading)}`}
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">
|
||||
{buildStatusTooltip(host, host.online)}
|
||||
{buildStatusTooltip(host, isOnline)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
@@ -916,7 +923,7 @@ export function HostItem({
|
||||
>
|
||||
{/* Status stripe */}
|
||||
<div
|
||||
className={`w-[3px] shrink-0 transition-colors ${getStatusClasses(host.online, statusScheme, "stripe")}`}
|
||||
className={`w-[3px] shrink-0 transition-colors ${getStatusClasses(isOnline, statusScheme, "stripe", statusLoading)}`}
|
||||
/>
|
||||
|
||||
<div className="flex flex-col flex-1 min-w-0 px-2.5 pt-2 pb-1.5 gap-1">
|
||||
@@ -933,11 +940,11 @@ export function HostItem({
|
||||
<Tooltip>
|
||||
<TooltipTrigger className="flex items-center">
|
||||
<span
|
||||
className={`size-1.5 rounded-full shrink-0 ${getStatusClasses(host.online, statusScheme, "dot")}`}
|
||||
className={`size-1.5 rounded-full shrink-0 ${getStatusClasses(isOnline, statusScheme, "dot", statusLoading)}`}
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">
|
||||
{buildStatusTooltip(host, host.online)}
|
||||
{buildStatusTooltip(host, isOnline)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
@@ -1074,7 +1081,7 @@ export function HostItem({
|
||||
<div
|
||||
className={`overflow-hidden transition-all duration-150 ease-out max-h-0 opacity-0 ${!shouldUseClickTray ? "group-hover:max-h-[300px] group-hover:opacity-100" : ""} ${selectionMode ? "!max-h-0 !opacity-0" : ""} ${(isMenuOpen || (shouldUseClickTray && isTrayOpen)) && !selectionMode ? "!max-h-[300px] !opacity-100" : ""}`}
|
||||
>
|
||||
{host.online &&
|
||||
{isOnline &&
|
||||
((host.cpu != null && host.cpu > 0) ||
|
||||
(host.ram != null && host.ram > 0)) && (
|
||||
<div className="flex items-center gap-3 pl-3">
|
||||
@@ -1471,6 +1478,7 @@ export function FolderItem({
|
||||
onTrayOpenChange,
|
||||
onManageFolder,
|
||||
onDeleteFolder,
|
||||
onOpenAllSessions,
|
||||
onMoveHostsToFolder,
|
||||
draggedHostIds,
|
||||
onDragHostStart,
|
||||
@@ -1497,13 +1505,20 @@ export function FolderItem({
|
||||
onTrayOpenChange: (hostId: string | null) => void;
|
||||
onManageFolder: (folder: HostFolder) => void;
|
||||
onDeleteFolder: (folder: HostFolder) => void;
|
||||
onOpenAllSessions: (folder: HostFolder) => void;
|
||||
onMoveHostsToFolder: (hostIds: string[], targetPath: string) => void;
|
||||
draggedHostIds: string[] | null;
|
||||
onDragHostStart: (hostId: string) => void;
|
||||
onDragEnd: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { total, online } = folderHostCount(folder);
|
||||
const { getStatus, initialLoadComplete } = useServerStatus();
|
||||
const { total } = folderHostCount(folder);
|
||||
const online = initialLoadComplete
|
||||
? collectAllHosts(folder.children).filter(
|
||||
(h) => statusCheckEnabled(h) && getStatus(Number(h.id)) === "online",
|
||||
).length
|
||||
: folderHostCount(folder).online;
|
||||
const [dragOver, setDragOver] = useState(false);
|
||||
|
||||
if (query && !folderHasMatch(folder, query)) return null;
|
||||
@@ -1561,6 +1576,16 @@ export function FolderItem({
|
||||
</span>
|
||||
{!isGroup && (
|
||||
<span className="flex items-center gap-1.5 ml-1 opacity-0 group-hover/folder:opacity-100 transition-opacity">
|
||||
<span
|
||||
title={t("hosts.openAllSessions")}
|
||||
className="text-muted-foreground/50 hover:text-foreground"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onOpenAllSessions(folder);
|
||||
}}
|
||||
>
|
||||
<FolderOpen className="size-2.5" />
|
||||
</span>
|
||||
<span
|
||||
title={t("hosts.editFolder")}
|
||||
className="text-muted-foreground/50 hover:text-foreground"
|
||||
@@ -1613,6 +1638,7 @@ export function FolderItem({
|
||||
onTrayOpenChange={onTrayOpenChange}
|
||||
onManageFolder={onManageFolder}
|
||||
onDeleteFolder={onDeleteFolder}
|
||||
onOpenAllSessions={onOpenAllSessions}
|
||||
onMoveHostsToFolder={onMoveHostsToFolder}
|
||||
draggedHostIds={draggedHostIds}
|
||||
onDragHostStart={onDragHostStart}
|
||||
@@ -1732,6 +1758,14 @@ export function SidebarTree({
|
||||
setFolderDialog({ mode: "edit", folder });
|
||||
}
|
||||
|
||||
function handleOpenAllSessions(folder: HostFolder) {
|
||||
const hosts = collectAllHosts(folder.children);
|
||||
for (const host of hosts) {
|
||||
const type = resolveHostTabType(host);
|
||||
onOpenTab(host, type);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSaveFolderMetadata(value: {
|
||||
name: string;
|
||||
color: string;
|
||||
@@ -1888,6 +1922,8 @@ export function SidebarTree({
|
||||
rdpDomain: host.domain ?? null,
|
||||
rdpSecurity: host.security ?? null,
|
||||
rdpIgnoreCert: host.ignoreCert ?? false,
|
||||
vncAuthType: host.vncAuthType ?? null,
|
||||
vncCredentialId: host.vncCredentialId ?? null,
|
||||
vncPassword: host.vncPassword ?? null,
|
||||
vncUser: host.vncUser ?? null,
|
||||
telnetUser: host.telnetUser ?? null,
|
||||
@@ -2007,6 +2043,7 @@ export function SidebarTree({
|
||||
onTrayOpenChange={setOpenTrayHostId}
|
||||
onManageFolder={handleManageFolder}
|
||||
onDeleteFolder={handleDeleteFolder}
|
||||
onOpenAllSessions={handleOpenAllSessions}
|
||||
onMoveHostsToFolder={handleMoveHostsToFolder}
|
||||
draggedHostIds={draggedHostIds}
|
||||
onDragHostStart={handleDragHostStart}
|
||||
|
||||
@@ -1199,6 +1199,101 @@ function ExecutionResultDialog({
|
||||
);
|
||||
}
|
||||
|
||||
function VirtualFolderGroup({
|
||||
folderName,
|
||||
snippets: folderSnippets,
|
||||
selectedTabIds,
|
||||
terminalTabs,
|
||||
activeTabId,
|
||||
onDelete,
|
||||
onEdit,
|
||||
onShare,
|
||||
onConfirmRun,
|
||||
onDirectExecute,
|
||||
draggedSnippet,
|
||||
dropTarget,
|
||||
onDragStart,
|
||||
onDragEnd,
|
||||
onDragOver,
|
||||
onDrop,
|
||||
availableHosts,
|
||||
t,
|
||||
defaultOpen,
|
||||
}: {
|
||||
folderName: string;
|
||||
snippets: 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;
|
||||
draggedSnippet: Snippet | null;
|
||||
dropTarget: { id: number; position: "above" | "below" } | null;
|
||||
onDragStart: (snippet: Snippet) => void;
|
||||
onDragEnd: () => void;
|
||||
onDragOver: (e: React.DragEvent, id: number) => void;
|
||||
onDrop: (folder: string | null) => void;
|
||||
availableHosts: SSHHost[];
|
||||
t: (key: string, opts?: Record<string, unknown>) => string;
|
||||
defaultOpen: boolean;
|
||||
}) {
|
||||
const [open, setOpen] = useState(defaultOpen);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<button
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className="flex items-center gap-1.5 w-full text-left"
|
||||
>
|
||||
<ChevronDown
|
||||
className={`size-3 text-muted-foreground shrink-0 transition-transform ${open ? "" : "-rotate-90"}`}
|
||||
/>
|
||||
<Folder className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
<span className="text-xs font-semibold flex-1 truncate text-muted-foreground">
|
||||
{folderName}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground shrink-0">
|
||||
{folderSnippets.length}
|
||||
</span>
|
||||
</button>
|
||||
{open && (
|
||||
<div
|
||||
className="flex flex-col gap-2 ml-1"
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
onDrop={() => onDrop(folderName)}
|
||||
>
|
||||
{folderSnippets.map((snippet) => (
|
||||
<SnippetCard
|
||||
key={snippet.id}
|
||||
snippet={snippet}
|
||||
selectedTabIds={selectedTabIds}
|
||||
terminalTabs={terminalTabs}
|
||||
activeTabId={activeTabId}
|
||||
onDelete={onDelete}
|
||||
onEdit={onEdit}
|
||||
onShare={onShare}
|
||||
onConfirmRun={onConfirmRun}
|
||||
onDirectExecute={onDirectExecute}
|
||||
onDragStart={() => onDragStart(snippet)}
|
||||
onDragEnd={onDragEnd}
|
||||
onDragOver={(e) => onDragOver(e, snippet.id)}
|
||||
dropIndicator={
|
||||
dropTarget?.id === snippet.id ? dropTarget.position : null
|
||||
}
|
||||
isDragging={draggedSnippet?.id === snippet.id}
|
||||
availableHosts={availableHosts}
|
||||
t={t}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SnippetsPanel({
|
||||
terminalTabs,
|
||||
activeTabId,
|
||||
@@ -1612,6 +1707,15 @@ export function SnippetsPanel({
|
||||
|
||||
const uncategorizedSnippets = filtered.filter((s) => s.folder === null);
|
||||
|
||||
const ownedFolderNames = new Set(folders.map((f) => f.name));
|
||||
const virtualFolderNames = Array.from(
|
||||
new Set(
|
||||
filtered
|
||||
.filter((s) => s.folder !== null && !ownedFolderNames.has(s.folder!))
|
||||
.map((s) => s.folder!),
|
||||
),
|
||||
).sort();
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col gap-3 p-3">
|
||||
@@ -1901,6 +2005,36 @@ export function SnippetsPanel({
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{virtualFolderNames.map((folderName) => {
|
||||
const folderSnippets = filtered.filter(
|
||||
(s) => s.folder === folderName,
|
||||
);
|
||||
if (folderSnippets.length === 0) return null;
|
||||
return (
|
||||
<VirtualFolderGroup
|
||||
key={`virtual-${folderName}`}
|
||||
folderName={folderName}
|
||||
snippets={folderSnippets}
|
||||
selectedTabIds={selectedTabIds}
|
||||
terminalTabs={terminalTabs}
|
||||
activeTabId={activeTabId}
|
||||
onDelete={handleDeleteSnippet}
|
||||
onEdit={handleEditSnippet}
|
||||
onShare={setShareSnippet}
|
||||
onConfirmRun={handleConfirmRun}
|
||||
onDirectExecute={handleDirectExecute}
|
||||
draggedSnippet={draggedSnippet}
|
||||
dropTarget={dropTarget}
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
onDragOver={handleDragOver}
|
||||
onDrop={handleDrop}
|
||||
availableHosts={availableHosts}
|
||||
t={t}
|
||||
defaultOpen={!getFoldersCollapsed()}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -2,9 +2,10 @@ import { useState, useRef, useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@/components/button";
|
||||
import { Separator } from "@/components/separator";
|
||||
import { Terminal } from "lucide-react";
|
||||
import { KeyRound, Terminal } from "lucide-react";
|
||||
import type { Tab } from "@/types/ui-types";
|
||||
import { getCookie, setCookie } from "@/main-axios";
|
||||
import { getCookie, getHostPassword, setCookie } from "@/main-axios";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export function SshToolsPanel({
|
||||
terminalTabs,
|
||||
@@ -59,6 +60,41 @@ export function SshToolsPanel({
|
||||
}
|
||||
}
|
||||
|
||||
async function fillPassword() {
|
||||
let filled = 0;
|
||||
let missing = 0;
|
||||
|
||||
for (const tabId of selectedTabIds) {
|
||||
const tab = terminalTabs.find((t) => t.id === tabId);
|
||||
const hostId = tab?.host?.id ? Number(tab.host.id) : null;
|
||||
const ref = tab?.terminalRef?.current;
|
||||
if (!hostId || !ref?.sendInput) {
|
||||
missing++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const password = await getHostPassword(hostId, "password");
|
||||
if (!password) {
|
||||
missing++;
|
||||
continue;
|
||||
}
|
||||
|
||||
ref.sendInput(password + "\r");
|
||||
filled++;
|
||||
}
|
||||
|
||||
if (filled > 0) {
|
||||
toast.success(
|
||||
t("newUi.sidebar.sshTools.fillPasswordSuccess", { count: filled }),
|
||||
);
|
||||
}
|
||||
if (missing > 0) {
|
||||
toast.error(
|
||||
t("newUi.sidebar.sshTools.fillPasswordMissing", { count: missing }),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function broadcastArrow(normalSeq: string, appSeq: string) {
|
||||
for (const tabId of selectedTabIds) {
|
||||
const tab = terminalTabs.find((t) => t.id === tabId);
|
||||
@@ -250,6 +286,20 @@ export function SshToolsPanel({
|
||||
className="w-full px-2.5 py-2 text-xs bg-background border border-accent-brand/40 text-foreground placeholder:text-muted-foreground/40 outline-none focus:border-accent-brand/70 caret-transparent"
|
||||
/>
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={selectedTabIds.size === 0}
|
||||
className="w-full"
|
||||
onClick={() => {
|
||||
void fillPassword();
|
||||
}}
|
||||
>
|
||||
<KeyRound className="size-3.5 mr-2" />
|
||||
{selectedTabIds.size === 0
|
||||
? t("newUi.sidebar.sshTools.selectTerminalsAbove")
|
||||
: `${t("newUi.sidebar.sshTools.fillPassword")} (${selectedTabIds.size})`}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
@@ -0,0 +1,933 @@
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Check,
|
||||
Copy,
|
||||
Fingerprint,
|
||||
KeyRound,
|
||||
Loader2,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
ScrollText,
|
||||
ShieldCheck,
|
||||
Sparkles,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/button";
|
||||
import { Input } from "@/components/input";
|
||||
import { Textarea } from "@/components/textarea";
|
||||
import { SectionCard, SettingRow, FakeSwitch } from "@/components/section-card";
|
||||
import {
|
||||
getMyTermixId,
|
||||
checkTermixIdHandle,
|
||||
createTermixId,
|
||||
deleteTermixId,
|
||||
addTermixIdKey,
|
||||
generateTermixIdKey,
|
||||
setTermixIdKeyEnabled,
|
||||
deleteTermixIdKey,
|
||||
getCredentials,
|
||||
getMyCa,
|
||||
createCa,
|
||||
rotateCa,
|
||||
deleteCa,
|
||||
issueCertificate,
|
||||
getLinkedCredentialIds,
|
||||
type TermixIdentity,
|
||||
type TermixIdentityKey,
|
||||
type TermixIdCa,
|
||||
} from "@/main-axios";
|
||||
|
||||
const accentBtn =
|
||||
"border-accent-brand/40 text-accent-brand hover:bg-accent-brand/20 hover:text-accent-brand";
|
||||
|
||||
function resolverUrl(handle: string): string {
|
||||
const origin =
|
||||
typeof window !== "undefined"
|
||||
? window.location.origin
|
||||
: "https://your-termix";
|
||||
return `${origin}/termix-id/u/${handle}`;
|
||||
}
|
||||
|
||||
function CopyRow({ label, value }: { label: string; value: string }) {
|
||||
const { t } = useTranslation();
|
||||
const [copied, setCopied] = useState(false);
|
||||
async function copy() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(value);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
} catch {
|
||||
toast.error(t("termixId.copyFailed"));
|
||||
}
|
||||
}
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5 py-2.5 border-b border-border last:border-0">
|
||||
<span className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
|
||||
{label}
|
||||
</span>
|
||||
<div className="flex items-stretch gap-2">
|
||||
<code className="flex-1 min-w-0 text-[11px] font-mono bg-muted/30 border border-border px-2 py-1.5 overflow-x-auto whitespace-nowrap text-muted-foreground">
|
||||
{value}
|
||||
</code>
|
||||
<button
|
||||
onClick={copy}
|
||||
className="shrink-0 w-7 flex items-center justify-center border border-border bg-background hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
{copied ? (
|
||||
<Check className="size-3.5 text-accent-brand" />
|
||||
) : (
|
||||
<Copy className="size-3.5" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function TermixIdPanel() {
|
||||
const { t } = useTranslation();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [identity, setIdentity] = useState<TermixIdentity | null>(null);
|
||||
const [keys, setKeys] = useState<TermixIdentityKey[]>([]);
|
||||
const [ca, setCa] = useState<TermixIdCa | null>(null);
|
||||
const [linkedCredentialIds, setLinkedCredentialIds] = useState<Set<number>>(
|
||||
new Set(),
|
||||
);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
const [data, caData, linkedData] = await Promise.all([
|
||||
getMyTermixId(),
|
||||
getMyCa().catch(() => ({ ca: null })),
|
||||
getLinkedCredentialIds().catch(() => ({ credentialIds: [] })),
|
||||
]);
|
||||
setIdentity(data.identity);
|
||||
setKeys(data.keys);
|
||||
setCa(data.identity ? caData.ca : null);
|
||||
setLinkedCredentialIds(new Set(linkedData.credentialIds));
|
||||
} catch {
|
||||
toast.error(t("termixId.loadFailed"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [t]);
|
||||
|
||||
useEffect(() => {
|
||||
refresh();
|
||||
}, [refresh]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-40 text-muted-foreground">
|
||||
<Loader2 className="animate-spin size-4" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 min-h-0 overflow-y-auto scrollbar-none">
|
||||
<div className="flex flex-col gap-3 p-3">
|
||||
{!identity ? (
|
||||
<ClaimHandle onCreated={refresh} />
|
||||
) : (
|
||||
<>
|
||||
<IdentityCard identity={identity} onChanged={refresh} />
|
||||
<AddKey
|
||||
handle={identity.handle}
|
||||
linkedCredentialIds={linkedCredentialIds}
|
||||
onAdded={refresh}
|
||||
/>
|
||||
<KeyList
|
||||
keys={keys}
|
||||
handle={identity.handle}
|
||||
caEnabled={!!ca}
|
||||
onChanged={refresh}
|
||||
/>
|
||||
<CaCard handle={identity.handle} ca={ca} onChanged={refresh} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ClaimHandle({ onCreated }: { onCreated: () => void }) {
|
||||
const { t } = useTranslation();
|
||||
const [handle, setHandle] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [checking, setChecking] = useState(false);
|
||||
const [status, setStatus] = useState<
|
||||
"idle" | "available" | "taken" | "invalid"
|
||||
>("idle");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const h = handle.trim().toLowerCase();
|
||||
if (!h) {
|
||||
setStatus("idle");
|
||||
return;
|
||||
}
|
||||
// Guard against an out-of-order response: if the input changed (effect
|
||||
// cleanup ran) before this request resolved, ignore its result.
|
||||
let cancelled = false;
|
||||
setChecking(true);
|
||||
const timer = setTimeout(async () => {
|
||||
try {
|
||||
const res = await checkTermixIdHandle(h);
|
||||
if (cancelled) return;
|
||||
setStatus(
|
||||
!res.valid ? "invalid" : res.available ? "available" : "taken",
|
||||
);
|
||||
} catch {
|
||||
if (!cancelled) setStatus("idle");
|
||||
} finally {
|
||||
if (!cancelled) setChecking(false);
|
||||
}
|
||||
}, 350);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}, [handle]);
|
||||
|
||||
async function submit() {
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await createTermixId(
|
||||
handle.trim().toLowerCase(),
|
||||
description.trim() || undefined,
|
||||
);
|
||||
toast.success(t("termixId.created"));
|
||||
onCreated();
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : t("termixId.createFailed"));
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<SectionCard
|
||||
title={t("termixId.title")}
|
||||
icon={<Fingerprint className="size-3.5" />}
|
||||
action={
|
||||
<a
|
||||
href="https://docs.termix.site/features/authentication/termix-id"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-[10px] text-accent-brand hover:underline"
|
||||
>
|
||||
{t("hosts.docsLink")}
|
||||
</a>
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col gap-3 py-3">
|
||||
<p className="text-xs text-muted-foreground leading-snug">
|
||||
{t("termixId.claimIntro")}
|
||||
</p>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<span className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
|
||||
{t("termixId.handleLabel")}
|
||||
</span>
|
||||
<div className="relative flex items-center">
|
||||
<span className="absolute left-2 text-muted-foreground text-xs pointer-events-none select-none">
|
||||
@
|
||||
</span>
|
||||
<Input
|
||||
value={handle}
|
||||
placeholder={t("termixId.handlePlaceholder")}
|
||||
autoCapitalize="none"
|
||||
spellCheck={false}
|
||||
className="h-8 text-xs pl-5"
|
||||
onChange={(e) => setHandle(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-[11px] h-4 leading-4">
|
||||
{checking ? (
|
||||
<span className="text-muted-foreground">
|
||||
{t("termixId.checking")}
|
||||
</span>
|
||||
) : status === "available" ? (
|
||||
<span className="text-accent-brand">
|
||||
{t("termixId.available")}
|
||||
</span>
|
||||
) : status === "taken" ? (
|
||||
<span className="text-destructive">{t("termixId.taken")}</span>
|
||||
) : status === "invalid" ? (
|
||||
<span className="text-destructive">
|
||||
{t("termixId.invalidHandle")}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<span className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
|
||||
{t("termixId.descriptionLabel")}
|
||||
</span>
|
||||
<Input
|
||||
value={description}
|
||||
placeholder={t("termixId.descriptionPlaceholder")}
|
||||
className="h-8 text-xs"
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className={`self-start ${accentBtn}`}
|
||||
disabled={status !== "available" || submitting}
|
||||
onClick={submit}
|
||||
>
|
||||
{submitting ? (
|
||||
<Loader2 className="animate-spin size-3.5" />
|
||||
) : (
|
||||
t("termixId.create")
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</SectionCard>
|
||||
);
|
||||
}
|
||||
|
||||
function IdentityCard({
|
||||
identity,
|
||||
onChanged,
|
||||
}: {
|
||||
identity: TermixIdentity;
|
||||
onChanged: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [confirming, setConfirming] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const url = resolverUrl(identity.handle);
|
||||
const curl = `curl -fsSL ${url} >> ~/.ssh/authorized_keys`;
|
||||
|
||||
async function remove() {
|
||||
setBusy(true);
|
||||
try {
|
||||
await deleteTermixId();
|
||||
toast.success(t("termixId.deleted"));
|
||||
onChanged();
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : t("termixId.deleteFailed"));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
setConfirming(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<SectionCard
|
||||
title={t("termixId.title")}
|
||||
icon={<Fingerprint className="size-3.5" />}
|
||||
action={
|
||||
confirming ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="border-destructive/40 text-destructive hover:bg-destructive/10 hover:text-destructive"
|
||||
onClick={remove}
|
||||
disabled={busy}
|
||||
>
|
||||
{busy ? (
|
||||
<Loader2 className="animate-spin size-3.5" />
|
||||
) : (
|
||||
t("common.confirm")
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setConfirming(false)}
|
||||
>
|
||||
{t("nav.cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-1">
|
||||
<a
|
||||
href="https://docs.termix.site/features/authentication/termix-id"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-[10px] text-accent-brand hover:underline px-1"
|
||||
>
|
||||
{t("hosts.docsLink")}
|
||||
</a>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={() => setConfirming(true)}
|
||||
className="hover:text-destructive"
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
>
|
||||
{confirming && (
|
||||
<div className="py-2 border-b border-border">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("termixId.deleteConfirm")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-2 py-2.5 border-b border-border">
|
||||
<span className="text-sm font-semibold text-accent-brand">
|
||||
@{identity.handle}
|
||||
</span>
|
||||
</div>
|
||||
<CopyRow label={t("termixId.resolverUrlLabel")} value={url} />
|
||||
<CopyRow label={t("termixId.provisionLabel")} value={curl} />
|
||||
</SectionCard>
|
||||
);
|
||||
}
|
||||
|
||||
interface CredentialOption {
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
function downloadText(filename: string, text: string) {
|
||||
// octet-stream so the browser/OS keeps the given extension instead of
|
||||
// appending .txt to a text/plain download.
|
||||
const blob = new Blob([text], { type: "application/octet-stream" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||
}
|
||||
|
||||
function CredentialImportRow({
|
||||
credentials,
|
||||
linkedCredentialIds,
|
||||
submitting,
|
||||
onImport,
|
||||
}: {
|
||||
credentials: CredentialOption[];
|
||||
linkedCredentialIds: Set<number>;
|
||||
submitting: boolean;
|
||||
onImport: (id: number) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [selected, setSelected] = useState<string>("");
|
||||
const selectedId = selected ? Number(selected) : null;
|
||||
const alreadyLinked =
|
||||
selectedId !== null && linkedCredentialIds.has(selectedId);
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<select
|
||||
value={selected}
|
||||
onChange={(e) => setSelected(e.target.value)}
|
||||
disabled={submitting}
|
||||
className="flex-1 h-8 min-w-0 border border-border bg-background px-2 text-xs outline-none focus:ring-1 focus:ring-ring disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<option value="" disabled>
|
||||
{t("termixId.selectCredential")}
|
||||
</option>
|
||||
{credentials.map((c) => (
|
||||
<option key={c.id} value={String(c.id)}>
|
||||
{linkedCredentialIds.has(c.id) ? `✓ ${c.name}` : c.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="default"
|
||||
className={`h-8 shrink-0 ${alreadyLinked ? "border-accent-brand/20 text-accent-brand/50" : accentBtn}`}
|
||||
disabled={!selectedId || submitting || alreadyLinked}
|
||||
onClick={() => selectedId !== null && onImport(selectedId)}
|
||||
>
|
||||
{alreadyLinked ? <Check className="size-3.5" /> : t("termixId.import")}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AddKey({
|
||||
handle,
|
||||
linkedCredentialIds,
|
||||
onAdded,
|
||||
}: {
|
||||
handle: string;
|
||||
linkedCredentialIds: Set<number>;
|
||||
onAdded: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [publicKey, setPublicKey] = useState("");
|
||||
const [label, setLabel] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [generating, setGenerating] = useState(false);
|
||||
const [saveToVault, setSaveToVault] = useState(true);
|
||||
const [credentials, setCredentials] = useState<CredentialOption[]>([]);
|
||||
|
||||
const loadCredentials = useCallback(async () => {
|
||||
try {
|
||||
const data = await getCredentials();
|
||||
const list = Array.isArray(data) ? data : [];
|
||||
setCredentials(
|
||||
list
|
||||
.filter((c) => c.authType === "key")
|
||||
.map((c) => ({ id: Number(c.id), name: String(c.name) })),
|
||||
);
|
||||
} catch {
|
||||
// non-fatal — manual paste still works
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadCredentials();
|
||||
}, [loadCredentials]);
|
||||
|
||||
async function addManual() {
|
||||
if (!publicKey.trim()) return;
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await addTermixIdKey({
|
||||
publicKey: publicKey.trim(),
|
||||
label: label.trim() || undefined,
|
||||
});
|
||||
toast.success(t("termixId.keyPublished"));
|
||||
setPublicKey("");
|
||||
setLabel("");
|
||||
onAdded();
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : t("termixId.addKeyFailed"));
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function generate() {
|
||||
setGenerating(true);
|
||||
try {
|
||||
const result = await generateTermixIdKey("ed25519", saveToVault);
|
||||
downloadText(`termix-${handle}-ed25519.key`, result.privateKey);
|
||||
toast.success(
|
||||
saveToVault
|
||||
? t("termixId.generatedSaved")
|
||||
: t("termixId.generatedOnly"),
|
||||
);
|
||||
if (saveToVault) loadCredentials();
|
||||
onAdded();
|
||||
} catch (e) {
|
||||
toast.error(
|
||||
e instanceof Error ? e.message : t("termixId.generateFailed"),
|
||||
);
|
||||
} finally {
|
||||
setGenerating(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function importCredential(id: number) {
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await addTermixIdKey({ credentialId: id });
|
||||
toast.success(t("termixId.imported"));
|
||||
onAdded();
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : t("termixId.importFailed"));
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<SectionCard
|
||||
title={t("termixId.publishTitle")}
|
||||
icon={<Plus className="size-3.5" />}
|
||||
action={
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className={accentBtn}
|
||||
onClick={generate}
|
||||
disabled={generating}
|
||||
title={t("termixId.generateTooltip")}
|
||||
>
|
||||
{generating ? (
|
||||
<Loader2 className="animate-spin size-3.5" />
|
||||
) : (
|
||||
<>
|
||||
<Sparkles className="size-3.5" />
|
||||
{t("termixId.generate")}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col gap-3 py-3">
|
||||
<SettingRow label={t("termixId.saveToVault")}>
|
||||
<FakeSwitch checked={saveToVault} onChange={setSaveToVault} />
|
||||
</SettingRow>
|
||||
|
||||
<Textarea
|
||||
value={publicKey}
|
||||
onChange={(e) => setPublicKey(e.target.value)}
|
||||
placeholder={t("termixId.keyPlaceholder")}
|
||||
rows={3}
|
||||
className="rounded-none font-mono text-[11px]"
|
||||
spellCheck={false}
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
value={label}
|
||||
onChange={(e) => setLabel(e.target.value)}
|
||||
placeholder={t("termixId.labelPlaceholder")}
|
||||
className="h-8 text-xs flex-1"
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="default"
|
||||
className={`h-8 ${accentBtn}`}
|
||||
onClick={addManual}
|
||||
disabled={!publicKey.trim() || submitting}
|
||||
>
|
||||
{submitting ? (
|
||||
<Loader2 className="animate-spin size-3.5" />
|
||||
) : (
|
||||
t("termixId.add")
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{credentials.length > 0 && (
|
||||
<div className="flex flex-col gap-1.5 border-t border-border pt-3">
|
||||
<span className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
|
||||
{t("termixId.importFromCredential")}
|
||||
</span>
|
||||
<CredentialImportRow
|
||||
credentials={credentials}
|
||||
linkedCredentialIds={linkedCredentialIds}
|
||||
submitting={submitting}
|
||||
onImport={importCredential}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</SectionCard>
|
||||
);
|
||||
}
|
||||
|
||||
function KeyList({
|
||||
keys,
|
||||
handle,
|
||||
caEnabled,
|
||||
onChanged,
|
||||
}: {
|
||||
keys: TermixIdentityKey[];
|
||||
handle: string;
|
||||
caEnabled: boolean;
|
||||
onChanged: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [issuingId, setIssuingId] = useState<number | null>(null);
|
||||
|
||||
async function toggle(k: TermixIdentityKey) {
|
||||
try {
|
||||
await setTermixIdKeyEnabled(k.id, !k.enabled);
|
||||
onChanged();
|
||||
} catch (e) {
|
||||
toast.error(
|
||||
e instanceof Error ? e.message : t("termixId.updateKeyFailed"),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(k: TermixIdentityKey) {
|
||||
try {
|
||||
await deleteTermixIdKey(k.id);
|
||||
toast.success(t("termixId.keyRemoved"));
|
||||
onChanged();
|
||||
} catch (e) {
|
||||
toast.error(
|
||||
e instanceof Error ? e.message : t("termixId.removeKeyFailed"),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function issueCert(k: TermixIdentityKey) {
|
||||
setIssuingId(k.id);
|
||||
try {
|
||||
const res = await issueCertificate(k.id);
|
||||
downloadText(`termix-${handle}-${k.id}-cert.pub`, res.certificate + "\n");
|
||||
toast.success(t("termixId.certIssued"));
|
||||
} catch (e) {
|
||||
toast.error(
|
||||
e instanceof Error ? e.message : t("termixId.certIssueFailed"),
|
||||
);
|
||||
} finally {
|
||||
setIssuingId(null);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col border border-border bg-card">
|
||||
<div className="flex items-center gap-2 px-4 py-2.5 border-b border-border shrink-0">
|
||||
<span className="text-muted-foreground">
|
||||
<KeyRound className="size-3.5" />
|
||||
</span>
|
||||
<span className="text-xs font-semibold uppercase tracking-widest text-muted-foreground flex-1">
|
||||
{t("termixId.keysTitle")}
|
||||
</span>
|
||||
</div>
|
||||
{keys.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground px-4 py-3">
|
||||
{t("termixId.noKeys")}
|
||||
</p>
|
||||
) : (
|
||||
keys.map((k) => {
|
||||
const canCert = caEnabled && k.algorithm.toUpperCase() === "ED25519";
|
||||
return (
|
||||
<div
|
||||
key={k.id}
|
||||
className="flex flex-col px-3 py-2 gap-1 border-b border-border last:border-0"
|
||||
>
|
||||
{/* row 1: label + badges */}
|
||||
<div className="flex items-center gap-1.5 min-w-0">
|
||||
<span className="text-[10px] font-bold uppercase tracking-wide text-accent-brand border border-accent-brand/40 px-1 py-px shrink-0 leading-none">
|
||||
{k.algorithm}
|
||||
</span>
|
||||
{k.credentialId && (
|
||||
<span className="text-[9px] font-semibold uppercase tracking-widest text-accent-brand/60 border border-accent-brand/20 px-1 py-px shrink-0 leading-none">
|
||||
{t("termixId.fromVault")}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-xs font-medium truncate min-w-0">
|
||||
{k.label || k.comment || k.keyType}
|
||||
</span>
|
||||
</div>
|
||||
{/* row 2: public key */}
|
||||
<code className="text-[10px] font-mono text-muted-foreground truncate">
|
||||
{k.publicKey}
|
||||
</code>
|
||||
{/* row 3: actions */}
|
||||
<div className="flex items-center gap-1 pt-0.5">
|
||||
<FakeSwitch checked={k.enabled} onChange={() => toggle(k)} />
|
||||
{canCert && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon-sm"
|
||||
onClick={() => issueCert(k)}
|
||||
disabled={issuingId === k.id}
|
||||
title={t("termixId.issueCertTooltip")}
|
||||
>
|
||||
{issuingId === k.id ? (
|
||||
<Loader2 className="animate-spin size-3.5" />
|
||||
) : (
|
||||
<ScrollText className="size-3.5" />
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={() => remove(k)}
|
||||
className="hover:text-destructive"
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CaCard({
|
||||
handle,
|
||||
ca,
|
||||
onChanged,
|
||||
}: {
|
||||
handle: string;
|
||||
ca: TermixIdCa | null;
|
||||
onChanged: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [confirmingRotate, setConfirmingRotate] = useState(false);
|
||||
const [confirmingDelete, setConfirmingDelete] = useState(false);
|
||||
|
||||
const caUrl =
|
||||
typeof window !== "undefined"
|
||||
? `${window.location.origin}/termix-id/u/${handle}/ca`
|
||||
: `/termix-id/u/${handle}/ca`;
|
||||
const trustCmd = `curl -fsSL ${caUrl} | sudo tee /etc/ssh/${handle}-ca.pub && echo "TrustedUserCAKeys /etc/ssh/${handle}-ca.pub" | sudo tee -a /etc/ssh/sshd_config && sudo systemctl reload sshd`;
|
||||
|
||||
async function enable() {
|
||||
setBusy(true);
|
||||
try {
|
||||
await createCa();
|
||||
toast.success(t("termixId.caEnabled"));
|
||||
onChanged();
|
||||
} catch (e) {
|
||||
toast.error(
|
||||
e instanceof Error ? e.message : t("termixId.caCreateFailed"),
|
||||
);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function rotate() {
|
||||
setBusy(true);
|
||||
try {
|
||||
await rotateCa();
|
||||
toast.success(t("termixId.caRotated"));
|
||||
onChanged();
|
||||
} catch (e) {
|
||||
toast.error(
|
||||
e instanceof Error ? e.message : t("termixId.caRotateFailed"),
|
||||
);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
setConfirmingRotate(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function remove() {
|
||||
setBusy(true);
|
||||
try {
|
||||
await deleteCa();
|
||||
toast.success(t("termixId.caDeleted"));
|
||||
onChanged();
|
||||
} catch (e) {
|
||||
toast.error(
|
||||
e instanceof Error ? e.message : t("termixId.caDeleteFailed"),
|
||||
);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
setConfirmingDelete(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<SectionCard
|
||||
title={t("termixId.caTitle")}
|
||||
icon={<ShieldCheck className="size-3.5" />}
|
||||
action={
|
||||
!ca ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className={accentBtn}
|
||||
onClick={enable}
|
||||
disabled={busy}
|
||||
>
|
||||
{busy ? (
|
||||
<Loader2 className="animate-spin size-3.5" />
|
||||
) : (
|
||||
t("termixId.caEnable")
|
||||
)}
|
||||
</Button>
|
||||
) : confirmingRotate ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="border-destructive/40 text-destructive hover:bg-destructive/10 hover:text-destructive"
|
||||
onClick={rotate}
|
||||
disabled={busy}
|
||||
>
|
||||
{busy ? (
|
||||
<Loader2 className="animate-spin size-3.5" />
|
||||
) : (
|
||||
t("termixId.caRotate")
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setConfirmingRotate(false)}
|
||||
>
|
||||
{t("nav.cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
) : confirmingDelete ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="border-destructive/40 text-destructive hover:bg-destructive/10 hover:text-destructive"
|
||||
onClick={remove}
|
||||
disabled={busy}
|
||||
>
|
||||
{busy ? (
|
||||
<Loader2 className="animate-spin size-3.5" />
|
||||
) : (
|
||||
t("termixId.caDelete")
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setConfirmingDelete(false)}
|
||||
>
|
||||
{t("nav.cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setConfirmingRotate(true)}
|
||||
disabled={busy}
|
||||
>
|
||||
<RefreshCw className="size-3.5" />
|
||||
{t("termixId.caRotate")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={() => setConfirmingDelete(true)}
|
||||
disabled={busy}
|
||||
className="hover:text-destructive"
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
>
|
||||
<div className="py-2.5 border-b border-border">
|
||||
<p className="text-xs text-muted-foreground leading-snug">
|
||||
{t("termixId.caIntro")}
|
||||
</p>
|
||||
</div>
|
||||
{confirmingRotate && (
|
||||
<div className="py-2.5 border-b border-border">
|
||||
<p className="text-xs text-destructive/80 leading-snug">
|
||||
{t("termixId.caRotateConfirm")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{confirmingDelete && (
|
||||
<div className="py-2.5 border-b border-border">
|
||||
<p className="text-xs text-destructive/80 leading-snug">
|
||||
{t("termixId.caDeleteConfirm")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{ca && (
|
||||
<>
|
||||
<CopyRow label={t("termixId.caTrustLabel")} value={trustCmd} />
|
||||
<CopyRow
|
||||
label={t("termixId.caPublicKeyLabel")}
|
||||
value={ca.publicKey}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</SectionCard>
|
||||
);
|
||||
}
|
||||
@@ -17,6 +17,13 @@ import {
|
||||
saveUserPreferences,
|
||||
getUserPreferences,
|
||||
} from "@/main-axios";
|
||||
import {
|
||||
deleteWebAuthnCredential,
|
||||
listWebAuthnCredentials,
|
||||
registerWebAuthnCredential,
|
||||
type WebAuthnCredentialSummary,
|
||||
type WebAuthnUserVerification,
|
||||
} from "@/api/webauthn-api";
|
||||
import type { UserRole } from "@/main-axios";
|
||||
import type React from "react";
|
||||
import { isElectron } from "@/lib/electron";
|
||||
@@ -55,6 +62,7 @@ import {
|
||||
ShieldCheck,
|
||||
Trash2,
|
||||
Type,
|
||||
Usb,
|
||||
User,
|
||||
X,
|
||||
Zap,
|
||||
@@ -429,6 +437,7 @@ export function UserProfilePanel({
|
||||
hostTrayOnClick?: boolean | null;
|
||||
compactHostView?: boolean | null;
|
||||
pinAppRail?: boolean | null;
|
||||
expandAppRailOnHover?: boolean | null;
|
||||
foldersCollapsed?: boolean | null;
|
||||
confirmSnippetExecution?: boolean | null;
|
||||
disableUpdateCheck?: boolean | null;
|
||||
@@ -478,6 +487,11 @@ export function UserProfilePanel({
|
||||
const [totpLoading, setTotpLoading] = useState(false);
|
||||
const [showDisableTotp, setShowDisableTotp] = useState(false);
|
||||
const [disableTotpInput, setDisableTotpInput] = useState("");
|
||||
const [passkeys, setPasskeys] = useState<WebAuthnCredentialSummary[]>([]);
|
||||
const [passkeyLoading, setPasskeyLoading] = useState(false);
|
||||
const [passkeyName, setPasskeyName] = useState("");
|
||||
const [passkeyUserVerification, setPasskeyUserVerification] =
|
||||
useState<WebAuthnUserVerification>("preferred");
|
||||
|
||||
// Delete account
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||
@@ -535,6 +549,9 @@ export function UserProfilePanel({
|
||||
const [pinAppRail, setPinAppRail] = useState(
|
||||
() => localStorage.getItem("pinAppRail") === "true",
|
||||
);
|
||||
const [expandAppRailOnHover, setExpandAppRailOnHover] = useState(
|
||||
() => localStorage.getItem("expandAppRailOnHover") !== "false",
|
||||
);
|
||||
const [foldersCollapsed, setFoldersCollapsed] = useState(
|
||||
() => localStorage.getItem("defaultSnippetFoldersCollapsed") !== "false",
|
||||
);
|
||||
@@ -589,6 +606,9 @@ export function UserProfilePanel({
|
||||
getApiKeys()
|
||||
.then(({ apiKeys: keys }) => setApiKeys(keys))
|
||||
.catch(() => {});
|
||||
listWebAuthnCredentials()
|
||||
.then(({ credentials }) => setPasskeys(credentials ?? []))
|
||||
.catch(() => {});
|
||||
getVersionInfo()
|
||||
.then((info) => {
|
||||
setVersion(info.localVersion);
|
||||
@@ -615,6 +635,7 @@ export function UserProfilePanel({
|
||||
"hostTrayOnClick",
|
||||
"compactHostView",
|
||||
"pinAppRail",
|
||||
"expandAppRailOnHover",
|
||||
"defaultSnippetFoldersCollapsed",
|
||||
"confirmSnippetExecution",
|
||||
"disableUpdateCheck",
|
||||
@@ -682,6 +703,15 @@ export function UserProfilePanel({
|
||||
if (prefs.pinAppRail != null) {
|
||||
setPinAppRail(prefs.pinAppRail);
|
||||
localStorage.setItem("pinAppRail", String(prefs.pinAppRail));
|
||||
window.dispatchEvent(new Event("pinAppRailChanged"));
|
||||
}
|
||||
if (prefs.expandAppRailOnHover != null) {
|
||||
setExpandAppRailOnHover(prefs.expandAppRailOnHover);
|
||||
localStorage.setItem(
|
||||
"expandAppRailOnHover",
|
||||
String(prefs.expandAppRailOnHover),
|
||||
);
|
||||
window.dispatchEvent(new Event("expandAppRailOnHoverChanged"));
|
||||
}
|
||||
if (prefs.foldersCollapsed != null) {
|
||||
setFoldersCollapsed(prefs.foldersCollapsed);
|
||||
@@ -758,6 +788,10 @@ export function UserProfilePanel({
|
||||
window.dispatchEvent(new CustomEvent("compactHostViewChanged"));
|
||||
setPinAppRail(false);
|
||||
localStorage.setItem("pinAppRail", "false");
|
||||
window.dispatchEvent(new Event("pinAppRailChanged"));
|
||||
setExpandAppRailOnHover(true);
|
||||
localStorage.setItem("expandAppRailOnHover", "true");
|
||||
window.dispatchEvent(new Event("expandAppRailOnHoverChanged"));
|
||||
setFoldersCollapsed(true);
|
||||
localStorage.removeItem("defaultSnippetFoldersCollapsed");
|
||||
setConfirmSnippetExecution(false);
|
||||
@@ -784,6 +818,7 @@ export function UserProfilePanel({
|
||||
hostTrayOnClick: false,
|
||||
compactHostView: false,
|
||||
pinAppRail: false,
|
||||
expandAppRailOnHover: true,
|
||||
foldersCollapsed: true,
|
||||
confirmSnippetExecution: false,
|
||||
disableUpdateCheck: false,
|
||||
@@ -863,6 +898,16 @@ export function UserProfilePanel({
|
||||
const restoredPinRail = restore("pinAppRail", "false") === "true";
|
||||
setPinAppRail(restoredPinRail);
|
||||
localStorage.setItem("pinAppRail", String(restoredPinRail));
|
||||
window.dispatchEvent(new Event("pinAppRailChanged"));
|
||||
|
||||
const restoredExpandRailOnHover =
|
||||
restore("expandAppRailOnHover", "true") !== "false";
|
||||
setExpandAppRailOnHover(restoredExpandRailOnHover);
|
||||
localStorage.setItem(
|
||||
"expandAppRailOnHover",
|
||||
String(restoredExpandRailOnHover),
|
||||
);
|
||||
window.dispatchEvent(new Event("expandAppRailOnHoverChanged"));
|
||||
|
||||
const restoredFolders =
|
||||
restore("defaultSnippetFoldersCollapsed", null) !== "false";
|
||||
@@ -1006,6 +1051,41 @@ export function UserProfilePanel({
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRegisterPasskey() {
|
||||
setPasskeyLoading(true);
|
||||
try {
|
||||
await registerWebAuthnCredential(
|
||||
passkeyName || "Passkey",
|
||||
passkeyUserVerification,
|
||||
);
|
||||
const { credentials } = await listWebAuthnCredentials();
|
||||
setPasskeys(credentials ?? []);
|
||||
setPasskeyName("");
|
||||
toast.success(t("newUi.sidebar.userProfile.passkeyAdded"));
|
||||
} catch (e: unknown) {
|
||||
toast.error(
|
||||
apiErrorMessage(e, t("newUi.sidebar.userProfile.passkeyAddFailed")),
|
||||
);
|
||||
} finally {
|
||||
setPasskeyLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeletePasskey(credentialId: string) {
|
||||
setPasskeyLoading(true);
|
||||
try {
|
||||
await deleteWebAuthnCredential(credentialId);
|
||||
setPasskeys((prev) => prev.filter((item) => item.id !== credentialId));
|
||||
toast.success(t("newUi.sidebar.userProfile.passkeyDeleted"));
|
||||
} catch (e: unknown) {
|
||||
toast.error(
|
||||
apiErrorMessage(e, t("newUi.sidebar.userProfile.passkeyDeleteFailed")),
|
||||
);
|
||||
} finally {
|
||||
setPasskeyLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
function downloadBackupCodes() {
|
||||
const blob = new Blob([totpBackupCodes.join("\n")], { type: "text/plain" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
@@ -1546,6 +1626,25 @@ export function UserProfilePanel({
|
||||
}}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
label={t("newUi.sidebar.userProfile.expandAppRailOnHover")}
|
||||
description={t(
|
||||
"newUi.sidebar.userProfile.expandAppRailOnHoverDesc",
|
||||
)}
|
||||
>
|
||||
<FakeSwitch
|
||||
checked={expandAppRailOnHover}
|
||||
onChange={(v) => {
|
||||
setExpandAppRailOnHover(v);
|
||||
localStorage.setItem("expandAppRailOnHover", v.toString());
|
||||
window.dispatchEvent(
|
||||
new Event("expandAppRailOnHoverChanged"),
|
||||
);
|
||||
if (storageMode === "cloud")
|
||||
saveToCloud({ expandAppRailOnHover: v });
|
||||
}}
|
||||
/>
|
||||
</SettingRow>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1 border-t border-border pt-3">
|
||||
@@ -1577,6 +1676,11 @@ export function UserProfilePanel({
|
||||
icon: <Zap size={12} />,
|
||||
label: t("nav.quickConnect"),
|
||||
},
|
||||
{
|
||||
id: "serial",
|
||||
icon: <Usb size={12} />,
|
||||
label: t("nav.serial"),
|
||||
},
|
||||
{
|
||||
id: "ssh-tools",
|
||||
icon: <Hammer size={12} />,
|
||||
@@ -1933,6 +2037,93 @@ export function UserProfilePanel({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3 border-t border-border pt-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="text-xs font-medium">
|
||||
{t("newUi.sidebar.userProfile.passkeys")}
|
||||
</span>
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{t("newUi.sidebar.userProfile.passkeysDesc")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-[1fr_auto] gap-2">
|
||||
<Input
|
||||
placeholder={t("newUi.sidebar.userProfile.passkeyName")}
|
||||
value={passkeyName}
|
||||
onChange={(e) => setPasskeyName(e.target.value)}
|
||||
className="h-8 text-xs"
|
||||
disabled={passkeyLoading}
|
||||
/>
|
||||
<select
|
||||
value={passkeyUserVerification}
|
||||
onChange={(e) =>
|
||||
setPasskeyUserVerification(
|
||||
e.target.value as WebAuthnUserVerification,
|
||||
)
|
||||
}
|
||||
className="h-8 w-28 text-xs border border-border bg-background px-2 outline-none focus:ring-1 focus:ring-ring"
|
||||
disabled={passkeyLoading}
|
||||
>
|
||||
<option value="preferred">
|
||||
{t("newUi.sidebar.userProfile.passkeyUvPreferred")}
|
||||
</option>
|
||||
<option value="required">
|
||||
{t("newUi.sidebar.userProfile.passkeyUvRequired")}
|
||||
</option>
|
||||
<option value="discouraged">
|
||||
{t("newUi.sidebar.userProfile.passkeyUvDiscouraged")}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 text-[10px] border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
|
||||
onClick={handleRegisterPasskey}
|
||||
disabled={passkeyLoading}
|
||||
>
|
||||
<KeyRound className="size-3" />
|
||||
{t("newUi.sidebar.userProfile.addPasskey")}
|
||||
</Button>
|
||||
|
||||
<div className="flex flex-col divide-y divide-border border border-border">
|
||||
{passkeys.length === 0 ? (
|
||||
<div className="py-4 text-center text-[10px] text-muted-foreground">
|
||||
{t("newUi.sidebar.userProfile.noPasskeys")}
|
||||
</div>
|
||||
) : (
|
||||
passkeys.map((passkey) => (
|
||||
<div
|
||||
key={passkey.id}
|
||||
className="flex items-center justify-between gap-2 px-2 py-2"
|
||||
>
|
||||
<div className="min-w-0 flex flex-col">
|
||||
<span className="text-xs font-medium truncate">
|
||||
{passkey.name}
|
||||
</span>
|
||||
<span className="text-[10px] text-muted-foreground truncate">
|
||||
{passkey.deviceType || "unknown"}
|
||||
{passkey.backedUp ? " / synced" : ""}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-6 text-muted-foreground hover:text-destructive"
|
||||
onClick={() => handleDeletePasskey(passkey.id)}
|
||||
disabled={passkeyLoading}
|
||||
>
|
||||
<Trash2 className="size-3" />
|
||||
</Button>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{canChangePasword && (
|
||||
<PasswordChangeSection
|
||||
showPassword={showPassword}
|
||||
|
||||
@@ -0,0 +1,276 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Pencil, Plus, Trash2, X } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/button";
|
||||
import { Input } from "@/components/input";
|
||||
import {
|
||||
createVaultProfile,
|
||||
updateVaultProfile,
|
||||
deleteVaultProfile,
|
||||
type VaultProfilePayload,
|
||||
} from "@/main-axios";
|
||||
import type { VaultProfile } from "@/types/ui-types";
|
||||
|
||||
type FormState = VaultProfilePayload & { id?: string };
|
||||
|
||||
const emptyForm: FormState = {
|
||||
name: "",
|
||||
vaultAddr: "",
|
||||
vaultNamespace: "",
|
||||
oidcMount: "oidc",
|
||||
oidcRole: "",
|
||||
sshMount: "ssh-client-signer",
|
||||
sshRole: "",
|
||||
validPrincipals: "",
|
||||
keyType: "ssh-ed25519",
|
||||
shared: false,
|
||||
};
|
||||
|
||||
function toForm(p: VaultProfile): FormState {
|
||||
return {
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
vaultAddr: p.vaultAddr,
|
||||
vaultNamespace: p.vaultNamespace ?? "",
|
||||
oidcMount: p.oidcMount ?? "oidc",
|
||||
oidcRole: p.oidcRole ?? "",
|
||||
sshMount: p.sshMount ?? "ssh-client-signer",
|
||||
sshRole: p.sshRole,
|
||||
validPrincipals: p.validPrincipals ?? "",
|
||||
keyType: p.keyType ?? "ssh-ed25519",
|
||||
shared: p.shared,
|
||||
};
|
||||
}
|
||||
|
||||
export function VaultProfileManager({
|
||||
profiles,
|
||||
onChanged,
|
||||
onClose,
|
||||
}: {
|
||||
profiles: VaultProfile[];
|
||||
onChanged: () => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [form, setForm] = useState<FormState | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const setField = <K extends keyof FormState>(k: K, v: FormState[K]) =>
|
||||
setForm((p) => (p ? { ...p, [k]: v } : p));
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!form) return;
|
||||
if (!form.name.trim() || !form.vaultAddr.trim() || !form.sshRole.trim()) {
|
||||
toast.error(t("hosts.vaultProfileValidationError"));
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
const payload: VaultProfilePayload = {
|
||||
name: form.name.trim(),
|
||||
vaultAddr: form.vaultAddr.trim(),
|
||||
vaultNamespace: form.vaultNamespace?.trim() || null,
|
||||
oidcMount: form.oidcMount?.trim() || null,
|
||||
oidcRole: form.oidcRole?.trim() || null,
|
||||
sshMount: form.sshMount?.trim() || null,
|
||||
sshRole: form.sshRole.trim(),
|
||||
validPrincipals: form.validPrincipals?.trim() || null,
|
||||
keyType: form.keyType?.trim() || null,
|
||||
shared: !!form.shared,
|
||||
};
|
||||
if (form.id) {
|
||||
await updateVaultProfile(Number(form.id), payload);
|
||||
toast.success(t("hosts.vaultProfileSaved"));
|
||||
} else {
|
||||
await createVaultProfile(payload);
|
||||
toast.success(t("hosts.vaultProfileCreated"));
|
||||
}
|
||||
setForm(null);
|
||||
onChanged();
|
||||
} catch (e) {
|
||||
toast.error(
|
||||
e instanceof Error ? e.message : t("hosts.vaultProfileSaveFailed"),
|
||||
);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (p: VaultProfile) => {
|
||||
try {
|
||||
await deleteVaultProfile(Number(p.id));
|
||||
toast.success(t("hosts.vaultProfileDeleted"));
|
||||
onChanged();
|
||||
} catch (e) {
|
||||
toast.error(
|
||||
e instanceof Error ? e.message : t("hosts.vaultProfileDeleteFailed"),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const field = (label: string, key: keyof FormState, placeholder?: string) => (
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-[9px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{label}
|
||||
</label>
|
||||
<Input
|
||||
className="h-8 text-xs"
|
||||
placeholder={placeholder}
|
||||
value={(form?.[key] as string) ?? ""}
|
||||
onChange={(e) => setField(key, e.target.value as FormState[typeof key])}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3 col-span-2 border border-border bg-muted/20 p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("hosts.vaultManageProfiles")}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{!form && (
|
||||
<>
|
||||
<div className="flex flex-col divide-y divide-border/50">
|
||||
{profiles.length === 0 && (
|
||||
<span className="text-[11px] text-muted-foreground py-1">
|
||||
{t("hosts.vaultNoProfiles")}
|
||||
</span>
|
||||
)}
|
||||
{profiles.map((p) => (
|
||||
<div
|
||||
key={p.id}
|
||||
className="flex items-center justify-between py-1.5"
|
||||
>
|
||||
<div className="flex flex-col min-w-0">
|
||||
<span className="text-xs text-foreground truncate">
|
||||
{p.name}
|
||||
{p.shared && (
|
||||
<span className="ml-1 text-[9px] text-accent-brand">
|
||||
{t("hosts.vaultSharedBadge")}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="text-[10px] text-muted-foreground truncate">
|
||||
{p.vaultAddr}
|
||||
</span>
|
||||
</div>
|
||||
{p.owned && (
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
title="Edit"
|
||||
onClick={() => setForm(toForm(p))}
|
||||
className="size-7 flex items-center justify-center rounded text-muted-foreground/60 hover:text-foreground hover:bg-muted-foreground/10"
|
||||
>
|
||||
<Pencil className="size-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
title="Delete"
|
||||
onClick={() => handleDelete(p)}
|
||||
className="size-7 flex items-center justify-center rounded text-muted-foreground/50 hover:text-destructive hover:bg-destructive/10"
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 text-[10px] self-start"
|
||||
onClick={() => setForm({ ...emptyForm })}
|
||||
>
|
||||
<Plus className="size-3 mr-1" /> {t("hosts.vaultNewProfile")}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{form && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
|
||||
{field(t("hosts.friendlyNameLabel"), "name", "Production Vault")}
|
||||
{field(
|
||||
t("hosts.vaultAddrLabel"),
|
||||
"vaultAddr",
|
||||
"https://vault:8200",
|
||||
)}
|
||||
{field(t("hosts.vaultNamespaceLabel"), "vaultNamespace", "admin")}
|
||||
{field(t("hosts.vaultOidcMountLabel"), "oidcMount", "oidc")}
|
||||
{field(t("hosts.vaultOidcRoleLabel"), "oidcRole", "default")}
|
||||
{field(
|
||||
t("hosts.vaultSshMountLabel"),
|
||||
"sshMount",
|
||||
"ssh-client-signer",
|
||||
)}
|
||||
{field(t("hosts.vaultSshRoleLabel"), "sshRole", "my-role")}
|
||||
{field(
|
||||
t("hosts.vaultValidPrincipalsLabel"),
|
||||
"validPrincipals",
|
||||
"root,deploy",
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-[9px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("hosts.vaultKeyTypeLabel")}
|
||||
</label>
|
||||
<select
|
||||
value={form.keyType ?? "ssh-ed25519"}
|
||||
onChange={(e) => setField("keyType", e.target.value)}
|
||||
className="flex h-8 w-full border border-border bg-background px-3 py-1 text-xs outline-none focus:ring-1 focus:ring-ring"
|
||||
>
|
||||
<option value="ssh-ed25519">Ed25519</option>
|
||||
<option value="ecdsa-sha2-nistp256">ECDSA (nistp256)</option>
|
||||
<option value="ssh-rsa">RSA (4096)</option>
|
||||
</select>
|
||||
</div>
|
||||
<label className="flex items-center gap-2 text-xs text-foreground">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!!form.shared}
|
||||
onChange={(e) => setField("shared", e.target.checked)}
|
||||
/>
|
||||
{t("hosts.vaultSharedLabel")}
|
||||
</label>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setForm(null)}
|
||||
disabled={saving}
|
||||
>
|
||||
{t("hosts.cancelBtn")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="border-accent-brand/40 text-accent-brand"
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
>
|
||||
{form.id
|
||||
? t("hosts.vaultSaveProfile")
|
||||
: t("hosts.vaultCreateProfile")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user