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:
Luke Gustafson
2026-06-29 13:28:26 -05:00
committed by GitHub
parent fd27a366d0
commit 9de904dd4c
348 changed files with 125184 additions and 76537 deletions
+25 -4
View File
@@ -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}
+33
View File
@@ -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">
+274
View File
@@ -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>
);
}
+460
View File
@@ -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>
);
}
+66 -7
View File
@@ -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"}`}
+254 -274
View File
@@ -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>
)}
+16
View File
@@ -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
View File
@@ -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" && (
+44 -2
View File
@@ -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();
});
});
+14 -3
View File
@@ -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,
};
+29
View File
@@ -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>
);
+13
View File
@@ -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}
+9 -3
View File
@@ -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,
+29 -6
View File
@@ -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>
);
}
+352
View File
@@ -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>
);
}
+45 -8
View File
@@ -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}
+134
View File
@@ -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>
+52 -2
View File
@@ -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 />
+933
View File
@@ -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>
);
}
+191
View File
@@ -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}
+276
View File
@@ -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>
);
}