diff --git a/.gitignore b/.gitignore index 0da78950..00b23169 100644 --- a/.gitignore +++ b/.gitignore @@ -60,3 +60,4 @@ deploy/scripts/licence-*-private.pem # Claude Code local config .claude/ +.worktrees diff --git a/apps/docs/docs/features/alerts.md b/apps/docs/docs/features/alerts.md index be0beb09..bc915f01 100644 --- a/apps/docs/docs/features/alerts.md +++ b/apps/docs/docs/features/alerts.md @@ -27,8 +27,8 @@ When an alert instance is created or transitions state, a **notification** is ge ## Creating Alert Rules -1. Navigate to **Alerts → Rules** -2. Click **New Rule** +1. Open a host and select **Monitoring → Alerts** +2. Click **Add Rule** 3. Configure: | Field | Description | @@ -44,6 +44,20 @@ When an alert instance is created or transitions state, a **notification** is ge 4. Click **Save** +## Global Metric Defaults + +Global alert defaults are metric threshold templates. They are not evaluated for +any host until an administrator applies them to that host. + +Administrators can apply those defaults when needed: +- On a host's **Alerts** tab, **Use Metric Defaults** replaces that host's + host-level metric threshold rules with the current global defaults. +- On **Administration → Monitoring**, **Apply to Hosts** replaces host-level + metric threshold rules across all hosts with the current global defaults. + +These actions only replace metric threshold rules. Check, certificate, Docker, +silence, and notification settings are left unchanged. + --- ## Silencing diff --git a/apps/ingest/internal/handlers/terminal_ws.go b/apps/ingest/internal/handlers/terminal_ws.go index a16df16d..1ab9479c 100644 --- a/apps/ingest/internal/handlers/terminal_ws.go +++ b/apps/ingest/internal/handlers/terminal_ws.go @@ -13,6 +13,7 @@ import ( "net" "net/http" "net/url" + "strconv" "strings" "sync" "time" @@ -51,6 +52,7 @@ type wsMessage struct { Code int32 `json:"exit_code,omitempty"` Token string `json:"token,omitempty"` Password string `json:"password,omitempty"` + Port uint32 `json:"port,omitempty"` } func (h *TerminalWSHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { @@ -84,6 +86,12 @@ func (h *TerminalWSHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { conn.Close(websocket.StatusPolicyViolation, "invalid authentication") return } + sshPort, err := terminalSSHPort(authMsg.Port) + if err != nil { + writeWS(ctx, conn, wsMessage{Type: "error", Msg: "Invalid SSH port"}) + conn.Close(websocket.StatusPolicyViolation, "invalid port") + return + } tokenSum := sha256.Sum256([]byte(authMsg.Token)) info, err := queries.ValidateAndActivateTerminalSession(ctx, h.pool, sessionID, hex.EncodeToString(tokenSum[:])) @@ -113,8 +121,8 @@ func (h *TerminalWSHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { return } - slog.Info("terminal ws: opening SSH session", "session_id", sessionID, "host_id", info.HostID, "username", info.Username) - sshClient, sshSession, stdin, stdout, err := h.openSSHSession(ctx, info.HostID, info.Host, info.Username, authMsg.Password) + slog.Info("terminal ws: opening SSH session", "session_id", sessionID, "host_id", info.HostID, "username", info.Username, "port", sshPort) + sshClient, sshSession, stdin, stdout, err := h.openSSHSession(ctx, info.HostID, info.Host, info.Username, authMsg.Password, sshPort) authMsg.Password = "" if err != nil { slog.Warn("terminal ws: SSH connection failed", "session_id", sessionID, "host_id", info.HostID, "username", info.Username, "err", err) @@ -261,7 +269,7 @@ func terminalWSAcceptOptions(trustedOrigins []string) (*websocket.AcceptOptions, }, nil } -func (h *TerminalWSHandler) openSSHSession(ctx context.Context, hostID, host, username, password string) (*ssh.Client, *ssh.Session, io.WriteCloser, io.Reader, error) { +func (h *TerminalWSHandler) openSSHSession(ctx context.Context, hostID, host, username, password, port string) (*ssh.Client, *ssh.Session, io.WriteCloser, io.Reader, error) { config := &ssh.ClientConfig{ User: username, Auth: []ssh.AuthMethod{ @@ -280,7 +288,7 @@ func (h *TerminalWSHandler) openSSHSession(ctx context.Context, hostID, host, us Timeout: 30 * time.Second, } - address := net.JoinHostPort(host, "22") + address := net.JoinHostPort(host, port) type dialResult struct { client *ssh.Client err error @@ -352,6 +360,16 @@ func terminalRemoteAddr(remoteAddr string) string { return remoteAddr } +func terminalSSHPort(port uint32) (string, error) { + if port == 0 { + return "22", nil + } + if port > 65535 { + return "", fmt.Errorf("SSH port %d is out of range", port) + } + return strconv.FormatUint(uint64(port), 10), nil +} + func isSSHAuthenticationFailure(err error) bool { var authErr *ssh.ServerAuthError return errors.As(err, &authErr) diff --git a/apps/ingest/internal/handlers/terminal_ws_security_test.go b/apps/ingest/internal/handlers/terminal_ws_security_test.go index 60717eeb..3c01f160 100644 --- a/apps/ingest/internal/handlers/terminal_ws_security_test.go +++ b/apps/ingest/internal/handlers/terminal_ws_security_test.go @@ -55,6 +55,32 @@ func TestTerminalRemoteAddrNormalisesHostPort(t *testing.T) { } } +func TestTerminalSSHPortDefaultsToTwentyTwo(t *testing.T) { + got, err := terminalSSHPort(0) + if err != nil { + t.Fatalf("terminalSSHPort(0) error = %v", err) + } + if got != "22" { + t.Fatalf("terminalSSHPort(0) = %q, want 22", got) + } +} + +func TestTerminalSSHPortAllowsCustomPort(t *testing.T) { + got, err := terminalSSHPort(2222) + if err != nil { + t.Fatalf("terminalSSHPort(2222) error = %v", err) + } + if got != "2222" { + t.Fatalf("terminalSSHPort(2222) = %q, want 2222", got) + } +} + +func TestTerminalSSHPortRejectsOutOfRangePort(t *testing.T) { + if _, err := terminalSSHPort(65536); err == nil { + t.Fatal("expected terminalSSHPort(65536) to reject the port") + } +} + func TestIsSSHAuthenticationFailure(t *testing.T) { if !isSSHAuthenticationFailure(&ssh.ServerAuthError{}) { t.Fatal("expected ssh.ServerAuthError to count as an authentication failure") diff --git a/apps/web/app/(dashboard)/hosts/[id]/alerts-tab.tsx b/apps/web/app/(dashboard)/hosts/[id]/alerts-tab.tsx index 3dd007d8..5ce2a4f3 100644 --- a/apps/web/app/(dashboard)/hosts/[id]/alerts-tab.tsx +++ b/apps/web/app/(dashboard)/hosts/[id]/alerts-tab.tsx @@ -2,8 +2,8 @@ import { useState } from 'react' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' -import { formatDistanceToNow, format } from 'date-fns' -import { Bell, Plus, Trash2, VolumeX, VolumeOff } from 'lucide-react' +import { format } from 'date-fns' +import { Bell, Plus, RotateCcw, Trash2, VolumeX, VolumeOff } from 'lucide-react' import { useForm, Controller } from 'react-hook-form' import { zodResolver } from '@hookform/resolvers/zod' import { z } from 'zod' @@ -41,6 +41,7 @@ import { createAlertRule, updateAlertRule, deleteAlertRule, + replaceHostMetricAlertsWithGlobalDefaults, getAlertInstances, getActiveSilencesForHost, createSilence, @@ -49,7 +50,7 @@ import { import { getChecksWithHistory } from '@/lib/actions/checks' import { getCertificates } from '@/lib/actions/certificates' import { getHostDockerContainers } from '@/lib/actions/docker-containers' -import type { AlertRule, AlertSeverity, AlertSilence } from '@/lib/db/schema' +import type { AlertRule, AlertSeverity } from '@/lib/db/schema' // ─── Form schema (flat — validation applied per conditionType in onSubmit) ───── @@ -159,13 +160,11 @@ const silenceFormSchema = z.object({ type SilenceFormValues = z.infer function AddSilenceDialog({ - scopeId, hostId, open, onOpenChange, onSuccess, }: { - scopeId: string hostId: string open: boolean onOpenChange: (v: boolean) => void @@ -719,6 +718,7 @@ export function AlertsTab({ scopeId, hostId }: Props) { const qc = useQueryClient() const [addDialogOpen, setAddDialogOpen] = useState(false) const [addSilenceOpen, setAddSilenceOpen] = useState(false) + const [replaceMetricDefaultsError, setReplaceMetricDefaultsError] = useState(null) const { data: allRules = [] } = useQuery({ queryKey: ['alert-rules', scopeId, hostId], @@ -757,6 +757,21 @@ export function AlertsTab({ scopeId, hostId }: Props) { onSuccess: () => qc.invalidateQueries({ queryKey: ['alert-rules', scopeId, hostId] }), }) + const replaceMetricDefaultsMutation = useMutation({ + mutationFn: async () => { + const result = await replaceHostMetricAlertsWithGlobalDefaults(hostId) + if ('error' in result) throw new Error(result.error) + return result + }, + onMutate: () => setReplaceMetricDefaultsError(null), + onSuccess: () => qc.invalidateQueries({ queryKey: ['alert-rules', scopeId, hostId] }), + onError: (error) => { + setReplaceMetricDefaultsError( + error instanceof Error ? error.message : 'Failed to replace metric alert rules', + ) + }, + }) + const deleteSilenceMutation = useMutation({ mutationFn: (silenceId: string) => deleteSilence(silenceId), onSuccess: () => qc.invalidateQueries({ queryKey: ['silences-active', scopeId, hostId] }), @@ -810,13 +825,23 @@ export function AlertsTab({ scopeId, hostId }: Props) { )} {/* Host-specific rules */} - - + +
Alert Rules Rules that apply specifically to this host
-
+
+
+ {replaceMetricDefaultsError != null && ( +

+ {replaceMetricDefaultsError} +

+ )} {hostRules.length === 0 ? (

No rules for this host yet. Add one to start alerting. @@ -881,14 +911,14 @@ export function AlertsTab({ scopeId, hostId }: Props) { - {/* Global default rules (read-only) — these also apply to this host */} + {/* Global default rules (read-only) — templates available for this host */}

- Instance-wide Default Rules + Global Metric Defaults - These rules apply to all hosts in your instance and are - evaluated in addition to the host-specific rules above.{' '} + These defaults are not evaluated for this host until you apply them with{' '} + Use Metric Defaults.{' '} Manage in Administration → Monitoring @@ -899,7 +929,7 @@ export function AlertsTab({ scopeId, hostId }: Props) { {globalDefaults.length === 0 ? (

- No instance-wide default rules configured. + No global metric defaults configured.

) : ( @@ -940,7 +970,6 @@ export function AlertsTab({ scopeId, hostId }: Props) { onSuccess={() => qc.invalidateQueries({ queryKey: ['alert-rules', scopeId, hostId] })} /> '') + const portStorageKey = getTerminalSshPortStorageKey(host.id) + const subscribePort = useCallback((onChange: () => void) => { + if (typeof window === 'undefined') return () => {} + const handler = (e: StorageEvent) => { + if (e.key === portStorageKey) onChange() + } + window.addEventListener('storage', handler) + return () => window.removeEventListener('storage', handler) + }, [portStorageKey]) + const getPortSnapshot = useCallback(() => { + if (typeof window === 'undefined') return String(DEFAULT_TERMINAL_SSH_PORT) + try { + return String(normaliseTerminalSshPort(localStorage.getItem(portStorageKey))) + } catch { + return String(DEFAULT_TERMINAL_SSH_PORT) + } + }, [portStorageKey]) + const savedPortInput = useSyncExternalStore(subscribePort, getPortSnapshot, () => String(DEFAULT_TERMINAL_SSH_PORT)) + const [typedUsername, setTypedUsername] = useState(null) + const [typedPortInput, setTypedPortInput] = useState(null) const [password, setPassword] = useState('') + const [error, setError] = useState(null) const username = typedUsername ?? savedUsername + const portInput = typedPortInput ?? savedPortInput if (accessDeniedReason) { return ( @@ -57,6 +85,12 @@ export function HostTerminalLauncher({ host, directAccess, accessDeniedReason }: } const handleOpen = () => { + const parsedPort = parseTerminalSshPort(portInput) + if (!parsedPort.ok) { + setError(parsedPort.error) + return + } + if (!directAccess && username.trim() && session?.user?.id) { try { localStorage.setItem(`terminal-username:${session.user.id}:${host.id}`, username.trim()) @@ -68,10 +102,12 @@ export function HostTerminalLauncher({ host, directAccess, accessDeniedReason }: hostId: host.id, hostname: host.displayName ?? host.hostname, username: username.trim(), + port: parsedPort.port, password, directAccess: false, }) setPassword('') + setError(null) } return ( @@ -95,10 +131,34 @@ export function HostTerminalLauncher({ host, directAccess, accessDeniedReason }: setTypedUsername(e.target.value)} + onChange={(e) => { + setTypedUsername(e.target.value) + setError(null) + }} placeholder="e.g. jsmith" /> +
+ + { + setTypedPortInput(e.target.value) + setError(null) + }} + onKeyDown={(e) => { + if (e.key === 'Enter' && username.trim() && password && portInput.trim()) handleOpen() + }} + /> +
- diff --git a/apps/web/app/(dashboard)/hosts/networks/components/host-node-terminal-dialog.tsx b/apps/web/app/(dashboard)/hosts/networks/components/host-node-terminal-dialog.tsx index 295b2f8d..6e84e910 100644 --- a/apps/web/app/(dashboard)/hosts/networks/components/host-node-terminal-dialog.tsx +++ b/apps/web/app/(dashboard)/hosts/networks/components/host-node-terminal-dialog.tsx @@ -13,6 +13,12 @@ import { import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' +import { + DEFAULT_TERMINAL_SSH_PORT, + getTerminalSshPortStorageKey, + normaliseTerminalSshPort, + parseTerminalSshPort, +} from '@/lib/terminal/ssh-port' import type { HostNodeData } from './network-flow-nodes' interface Props { @@ -38,8 +44,21 @@ function TerminalConnectForm({ } }) const [password, setPassword] = useState('') + const [portInput, setPortInput] = useState(() => { + try { + return String(normaliseTerminalSshPort(localStorage.getItem(getTerminalSshPortStorageKey(data.hostId)))) + } catch { + return String(DEFAULT_TERMINAL_SSH_PORT) + } + }) + const [error, setError] = useState(null) const handleConnect = useCallback(() => { + const parsedPort = parseTerminalSshPort(portInput) + if (!parsedPort.ok) { + setError(parsedPort.error) + return + } try { if (username.trim()) { localStorage.setItem(`terminal-username:${data.hostId}`, username.trim()) @@ -51,12 +70,14 @@ function TerminalConnectForm({ hostId: data.hostId, hostname: data.name, username: username.trim(), + port: parsedPort.port, password, directAccess: false, }) setPassword('') + setError(null) onOpenChange(false) - }, [data, username, password, openTerminal, onOpenChange]) + }, [data, username, portInput, password, openTerminal, onOpenChange]) return ( <> @@ -68,11 +89,33 @@ function TerminalConnectForm({ setUsername(e.target.value)} + onChange={(e) => { + setUsername(e.target.value) + setError(null) + }} placeholder="e.g. jsmith" autoFocus onKeyDown={(e) => { - if (e.key === 'Enter' && username.trim() && password) handleConnect() + if (e.key === 'Enter' && username.trim() && password && portInput.trim()) handleConnect() + }} + /> + +
+ + { + setPortInput(e.target.value) + setError(null) + }} + onKeyDown={(e) => { + if (e.key === 'Enter' && username.trim() && password && portInput.trim()) handleConnect() }} />
@@ -85,18 +128,26 @@ function TerminalConnectForm({ id="host-graph-password" type="password" value={password} - onChange={(e) => setPassword(e.target.value)} + onChange={(e) => { + setPassword(e.target.value) + setError(null) + }} autoComplete="current-password" onKeyDown={(e) => { - if (e.key === 'Enter' && username.trim() && password) handleConnect() + if (e.key === 'Enter' && username.trim() && password && portInput.trim()) handleConnect() }} /> + {error && ( +
+ {error} +
+ )} - @@ -108,7 +159,7 @@ function TerminalConnectForm({ export function HostNodeTerminalDialog({ data, open, onOpenChange }: Props) { return ( - + {data && ( )} diff --git a/apps/web/app/(dashboard)/settings/alerts/alerts-client.tsx b/apps/web/app/(dashboard)/settings/alerts/alerts-client.tsx index e972fd6e..2eced105 100644 --- a/apps/web/app/(dashboard)/settings/alerts/alerts-client.tsx +++ b/apps/web/app/(dashboard)/settings/alerts/alerts-client.tsx @@ -2,7 +2,7 @@ import { useState } from 'react' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' -import { Bell, Info, Plus, Trash2 } from 'lucide-react' +import { Bell, Info, Plus, RotateCcw, Trash2 } from 'lucide-react' import { useForm, Controller } from 'react-hook-form' import { zodResolver } from '@hookform/resolvers/zod' import { z } from 'zod' @@ -37,6 +37,7 @@ import { getGlobalAlertDefaults, createGlobalAlertDefault, deleteGlobalAlertDefault, + replaceAllHostMetricAlertsWithGlobalDefaults, } from '@/lib/actions/alerts' import type { AlertRule, AlertSeverity } from '@/lib/db/schema' @@ -224,6 +225,8 @@ interface GlobalAlertsClientProps { export function GlobalAlertsClient({ initialDefaults }: GlobalAlertsClientProps) { const [dialogOpen, setDialogOpen] = useState(false) + const [replaceAllMessage, setReplaceAllMessage] = useState(null) + const [replaceAllError, setReplaceAllError] = useState(null) const queryClient = useQueryClient() const { data: defaults = initialDefaults } = useQuery({ @@ -237,18 +240,40 @@ export function GlobalAlertsClient({ initialDefaults }: GlobalAlertsClientProps) onSuccess: () => queryClient.invalidateQueries({ queryKey: ['global-alert-defaults'] }), }) + const replaceAllMutation = useMutation({ + mutationFn: async () => { + const result = await replaceAllHostMetricAlertsWithGlobalDefaults() + if ('error' in result) throw new Error(result.error) + return result + }, + onMutate: () => { + setReplaceAllError(null) + setReplaceAllMessage(null) + }, + onSuccess: (result) => { + setReplaceAllMessage( + `Updated ${result.hostCount} host${result.hostCount === 1 ? '' : 's'} with ${result.createdCount} metric default rule${result.createdCount === 1 ? '' : 's'}.`, + ) + }, + onError: (error) => { + setReplaceAllError( + error instanceof Error ? error.message : 'Failed to replace host metric alert rules', + ) + }, + }) + return (

Global Alert Defaults

- These metric alert rules are automatically applied to every new host when an agent is approved. - After a host is added you can remove individual rules from the host's Alerts tab. + These metric alert rules are templates. Apply them to selected hosts when you want those hosts + to use the current defaults.

- +
@@ -259,18 +284,40 @@ export function GlobalAlertsClient({ initialDefaults }: GlobalAlertsClientProps) Check-based rules must be configured per host since they reference host-specific checks.
- +
+ + +
+ {replaceAllError != null && ( +

+ {replaceAllError} +

+ )} + {replaceAllMessage != null && ( +

+ {replaceAllMessage} +

+ )} {defaults.length === 0 ? (

No global alert defaults configured.

- Add defaults above and they'll be applied to each new host automatically. + Add defaults above, then use Apply to Hosts when you want hosts to adopt them.

) : ( @@ -314,8 +361,8 @@ export function GlobalAlertsClient({ initialDefaults }: GlobalAlertsClientProps)

- Changes to global defaults only affect newly approved hosts. Existing hosts are not modified. - To update alert rules on existing hosts, go to the host's Alerts tab. + Global defaults are not evaluated directly on hosts. Use Apply to Hosts to replace + existing host-level metric rules with these defaults.

diff --git a/apps/web/app/(dashboard)/settings/ldap/ldap-client.tsx b/apps/web/app/(dashboard)/settings/ldap/ldap-client.tsx index 5be16f57..4506dde9 100644 --- a/apps/web/app/(dashboard)/settings/ldap/ldap-client.tsx +++ b/apps/web/app/(dashboard)/settings/ldap/ldap-client.tsx @@ -35,6 +35,22 @@ import { } from '@/lib/actions/ldap' import type { LdapConfigurationSafe } from '@/lib/actions/ldap' +const AD_DEFAULTS = { + userSearchFilter: '(sAMAccountName={{username}})', + usernameAttribute: 'sAMAccountName', + emailAttribute: 'mail', + displayNameAttribute: 'displayName', +} + +const AD_PLACEHOLDERS = { + host: 'dc01.corp.example.com', + baseDn: 'DC=corp,DC=example,DC=com', + bindDn: 'CN=svc-ldap,OU=Service Accounts,DC=corp,DC=example,DC=com', + userSearchBase: 'OU=Users', + groupSearchBase: 'OU=Groups', + groupSearchFilter: '(objectClass=group)', +} + const EMPTY_FORM = { name: '', host: '', @@ -46,12 +62,12 @@ const EMPTY_FORM = { bindDn: '', bindPassword: '', userSearchBase: '', - userSearchFilter: '(uid={{username}})', + userSearchFilter: AD_DEFAULTS.userSearchFilter, groupSearchBase: '', groupSearchFilter: '', - usernameAttribute: 'uid', - emailAttribute: 'mail', - displayNameAttribute: 'cn', + usernameAttribute: AD_DEFAULTS.usernameAttribute, + emailAttribute: AD_DEFAULTS.emailAttribute, + displayNameAttribute: AD_DEFAULTS.displayNameAttribute, allowLogin: false, } @@ -275,7 +291,7 @@ export function LdapSettingsClient({ setAddForm({ ...addForm, host: e.target.value })} - placeholder="ldap.example.com" + placeholder={AD_PLACEHOLDERS.host} data-testid="ldap-settings-add-host" />
@@ -315,7 +331,7 @@ export function LdapSettingsClient({ setAddForm({ ...addForm, baseDn: e.target.value })} - placeholder="dc=example,dc=com" + placeholder={AD_PLACEHOLDERS.baseDn} data-testid="ldap-settings-add-base-dn" /> @@ -324,7 +340,7 @@ export function LdapSettingsClient({ setAddForm({ ...addForm, bindDn: e.target.value })} - placeholder="cn=admin,dc=example,dc=com" + placeholder={AD_PLACEHOLDERS.bindDn} data-testid="ldap-settings-add-bind-dn" /> @@ -342,7 +358,8 @@ export function LdapSettingsClient({ setAddForm({ ...addForm, userSearchBase: e.target.value })} - placeholder="ou=users" + placeholder={AD_PLACEHOLDERS.userSearchBase} + data-testid="ldap-settings-add-user-search-base" />
@@ -350,7 +367,55 @@ export function LdapSettingsClient({ setAddForm({ ...addForm, userSearchFilter: e.target.value })} - placeholder="(uid={{username}})" + placeholder={AD_DEFAULTS.userSearchFilter} + data-testid="ldap-settings-add-user-filter" + /> +
+
+
+ + setAddForm({ ...addForm, usernameAttribute: e.target.value })} + placeholder={AD_DEFAULTS.usernameAttribute} + data-testid="ldap-settings-add-username-attribute" + /> +
+
+ + setAddForm({ ...addForm, emailAttribute: e.target.value })} + placeholder={AD_DEFAULTS.emailAttribute} + data-testid="ldap-settings-add-email-attribute" + /> +
+
+ + setAddForm({ ...addForm, displayNameAttribute: e.target.value })} + placeholder={AD_DEFAULTS.displayNameAttribute} + data-testid="ldap-settings-add-display-name-attribute" + /> +
+
+
+ + setAddForm({ ...addForm, groupSearchBase: e.target.value })} + placeholder={AD_PLACEHOLDERS.groupSearchBase} + data-testid="ldap-settings-add-group-search-base" + /> +
+
+ + setAddForm({ ...addForm, groupSearchFilter: e.target.value })} + placeholder={AD_PLACEHOLDERS.groupSearchFilter} + data-testid="ldap-settings-add-group-filter" />
@@ -513,7 +578,7 @@ export function LdapSettingsClient({ setEditForm({ ...editForm, host: e.target.value })} - placeholder="ldap.example.com" + placeholder={AD_PLACEHOLDERS.host} data-testid="ldap-settings-edit-host" />
@@ -554,7 +619,7 @@ export function LdapSettingsClient({ setEditForm({ ...editForm, baseDn: e.target.value })} - placeholder="dc=example,dc=com" + placeholder={AD_PLACEHOLDERS.baseDn} />
@@ -562,7 +627,7 @@ export function LdapSettingsClient({ setEditForm({ ...editForm, bindDn: e.target.value })} - placeholder="cn=admin,dc=example,dc=com" + placeholder={AD_PLACEHOLDERS.bindDn} />
@@ -579,7 +644,7 @@ export function LdapSettingsClient({ setEditForm({ ...editForm, userSearchBase: e.target.value })} - placeholder="ou=users" + placeholder={AD_PLACEHOLDERS.userSearchBase} />
@@ -587,7 +652,7 @@ export function LdapSettingsClient({ setEditForm({ ...editForm, userSearchFilter: e.target.value })} - placeholder="(uid={{username}})" + placeholder={AD_DEFAULTS.userSearchFilter} data-testid="ldap-settings-edit-user-filter" />
@@ -597,6 +662,7 @@ export function LdapSettingsClient({ setEditForm({ ...editForm, usernameAttribute: e.target.value })} + placeholder={AD_DEFAULTS.usernameAttribute} />
@@ -604,6 +670,7 @@ export function LdapSettingsClient({ setEditForm({ ...editForm, emailAttribute: e.target.value })} + placeholder={AD_DEFAULTS.emailAttribute} />
@@ -611,6 +678,7 @@ export function LdapSettingsClient({ setEditForm({ ...editForm, displayNameAttribute: e.target.value })} + placeholder={AD_DEFAULTS.displayNameAttribute} />
@@ -619,7 +687,7 @@ export function LdapSettingsClient({ setEditForm({ ...editForm, groupSearchBase: e.target.value })} - placeholder="ou=groups" + placeholder={AD_PLACEHOLDERS.groupSearchBase} />
@@ -627,7 +695,7 @@ export function LdapSettingsClient({ setEditForm({ ...editForm, groupSearchFilter: e.target.value })} - placeholder="(objectClass=groupOfNames)" + placeholder={AD_PLACEHOLDERS.groupSearchFilter} />
diff --git a/apps/web/components/terminal/host-selector-dialog.tsx b/apps/web/components/terminal/host-selector-dialog.tsx index 0470d78e..b7d5f01a 100644 --- a/apps/web/components/terminal/host-selector-dialog.tsx +++ b/apps/web/components/terminal/host-selector-dialog.tsx @@ -18,6 +18,12 @@ import { Badge } from '@/components/ui/badge' import { listHosts } from '@/lib/actions/agents' import { checkTerminalAccess } from '@/lib/actions/terminal' import { useSession } from '@/lib/auth/client' +import { + DEFAULT_TERMINAL_SSH_PORT, + getTerminalSshPortStorageKey, + normaliseTerminalSshPort, + parseTerminalSshPort, +} from '@/lib/terminal/ssh-port' import { useTerminalPanel } from './terminal-panel-context' interface Props { @@ -34,6 +40,7 @@ export function HostSelectorDialog({ open, onOpenChange }: Props) { const { data: session } = useSession() const [typedUsername, setTypedUsername] = useState(null) const [password, setPassword] = useState('') + const [portInput, setPortInput] = useState(String(DEFAULT_TERMINAL_SSH_PORT)) const { data: hosts = [], isLoading } = useQuery({ queryKey: ['hosts'], @@ -101,6 +108,19 @@ export function HostSelectorDialog({ open, onOpenChange }: Props) { const username = typedUsername ?? savedUsername + const readSavedPortInput = (hostId: string) => { + try { + return String(normaliseTerminalSshPort(localStorage.getItem(getTerminalSshPortStorageKey(hostId)))) + } catch { + return String(DEFAULT_TERMINAL_SSH_PORT) + } + } + + const handleSelectHost = (hostId: string) => { + setSelectedHostId(hostId) + setPortInput(readSavedPortInput(hostId)) + } + const handleConnect = async () => { if (!selectedHost) return @@ -117,6 +137,11 @@ export function HostSelectorDialog({ open, onOpenChange }: Props) { setError('Password is required for SSH terminal access') return } + const parsedPort = parseTerminalSshPort(portInput) + if (!parsedPort.ok) { + setError(parsedPort.error) + return + } setConnecting(true) setError(null) @@ -134,6 +159,7 @@ export function HostSelectorDialog({ open, onOpenChange }: Props) { hostId: selectedHost.id, hostname: selectedHost.displayName ?? selectedHost.hostname, username: username.trim(), + port: parsedPort.port, password, directAccess: false, }) @@ -143,6 +169,7 @@ export function HostSelectorDialog({ open, onOpenChange }: Props) { setSelectedHostId(null) setTypedUsername(null) setPassword('') + setPortInput(String(DEFAULT_TERMINAL_SSH_PORT)) setError(null) setConnecting(false) onOpenChange(false) @@ -152,6 +179,7 @@ export function HostSelectorDialog({ open, onOpenChange }: Props) { setSelectedHostId(null) setTypedUsername(null) setPassword('') + setPortInput(String(DEFAULT_TERMINAL_SSH_PORT)) setError(null) } @@ -161,6 +189,7 @@ export function HostSelectorDialog({ open, onOpenChange }: Props) { setSelectedHostId(null) setTypedUsername(null) setPassword('') + setPortInput(String(DEFAULT_TERMINAL_SSH_PORT)) setError(null) } onOpenChange(nextOpen) @@ -210,7 +239,7 @@ export function HostSelectorDialog({ open, onOpenChange }: Props) {