Notes and Expiry fields add #453

Merged
Medvedinca merged 6 commits from future-notes-and-expiry into dev-1.10.0 2025-12-20 03:11:28 +00:00
9 changed files with 140 additions and 33 deletions

View File

@@ -210,6 +210,8 @@ async function initializeCompleteDatabase(): Promise<void> {
stats_config TEXT,
docker_config TEXT,
terminal_config TEXT,
notes TEXT,
expiration_date TEXT,
use_socks5 INTEGER,
socks5_host TEXT,
socks5_port INTEGER,
@@ -575,6 +577,9 @@ const migrateSchema = () => {
);
addColumnIfNotExists("ssh_data", "docker_config", "TEXT");
addColumnIfNotExists("ssh_data", "notes", "TEXT");
addColumnIfNotExists("ssh_data", "expiration_date", "TEXT");
// SOCKS5 Proxy columns
addColumnIfNotExists("ssh_data", "use_socks5", "INTEGER");
addColumnIfNotExists("ssh_data", "socks5_host", "TEXT");

View File

@@ -93,6 +93,8 @@ export const sshData = sqliteTable("ssh_data", {
statsConfig: text("stats_config"),
terminalConfig: text("terminal_config"),
quickActions: text("quick_actions"),
notes: text("notes"),
expirationDate: text("expiration_date"),
useSocks5: integer("use_socks5", { mode: "boolean" }),
socks5Host: text("socks5_host"),

View File

@@ -14,7 +14,17 @@ import {
hostAccess,
userRoles,
} from "../db/schema.js";
import { eq, and, desc, isNotNull, or, isNull, gte, sql, inArray } from "drizzle-orm";
import {
eq,
and,
desc,
isNotNull,
or,
isNull,
gte,
sql,
inArray,
} from "drizzle-orm";
import type { Request, Response } from "express";
import multer from "multer";
import { sshLogger } from "../../utils/logger.js";
@@ -245,12 +255,15 @@ router.post(
statsConfig,
terminalConfig,
forceKeyboardInteractive,
notes,
expirationDate,
useSocks5,
socks5Host,
socks5Port,
socks5Username,
socks5Password,
socks5ProxyChain,
overrideCredentialUsername,
} = hostData;
console.log("POST /db/ssh - Received SOCKS5 data:", {
@@ -285,6 +298,7 @@ router.post(
username,
authType: effectiveAuthType,
credentialId: credentialId || null,
overrideCredentialUsername: overrideCredentialUsername ? 1 : 0,
pin: pin ? 1 : 0,
enableTerminal: enableTerminal ? 1 : 0,
enableTunnel: enableTunnel ? 1 : 0,
@@ -301,12 +315,16 @@ router.post(
statsConfig: statsConfig ? JSON.stringify(statsConfig) : null,
terminalConfig: terminalConfig ? JSON.stringify(terminalConfig) : null,
forceKeyboardInteractive: forceKeyboardInteractive ? "true" : "false",
notes: notes || null,
expirationDate: expirationDate || null,
useSocks5: useSocks5 ? 1 : 0,
socks5Host: socks5Host || null,
socks5Port: socks5Port || null,
socks5Username: socks5Username || null,
socks5Password: socks5Password || null,
socks5ProxyChain: socks5ProxyChain ? JSON.stringify(socks5ProxyChain) : null,
socks5ProxyChain: socks5ProxyChain
? JSON.stringify(socks5ProxyChain)
: null,
};
if (effectiveAuthType === "password") {
@@ -489,13 +507,20 @@ router.put(
statsConfig,
terminalConfig,
forceKeyboardInteractive,
notes,
expirationDate,
useSocks5,
socks5Host,
socks5Port,
socks5Username,
socks5Password,
socks5ProxyChain,
overrideCredentialUsername,
} = hostData;
// Temporary logging to debug notes and expirationDate
console.log("DEBUG - Update host data:", { notes, expirationDate });
if (
!isNonEmptyString(userId) ||
!isNonEmptyString(ip) ||
@@ -523,6 +548,7 @@ router.put(
username,
authType: effectiveAuthType,
credentialId: credentialId || null,
overrideCredentialUsername: overrideCredentialUsername ? 1 : 0,
pin: pin ? 1 : 0,
enableTerminal: enableTerminal ? 1 : 0,
enableTunnel: enableTunnel ? 1 : 0,
@@ -539,12 +565,16 @@ router.put(
statsConfig: statsConfig ? JSON.stringify(statsConfig) : null,
terminalConfig: terminalConfig ? JSON.stringify(terminalConfig) : null,
forceKeyboardInteractive: forceKeyboardInteractive ? "true" : "false",
notes: notes || null,
expirationDate: expirationDate || null,
useSocks5: useSocks5 ? 1 : 0,
socks5Host: socks5Host || null,
socks5Port: socks5Port || null,
socks5Username: socks5Username || null,
socks5Password: socks5Password || null,
socks5ProxyChain: socks5ProxyChain ? JSON.stringify(socks5ProxyChain) : null,
socks5ProxyChain: socks5ProxyChain
? JSON.stringify(socks5ProxyChain)
: null,
};
if (effectiveAuthType === "password") {
@@ -742,6 +772,8 @@ router.get(
credentialId: sshData.credentialId,
overrideCredentialUsername: sshData.overrideCredentialUsername,
quickActions: sshData.quickActions,
notes: sshData.notes,
expirationDate: sshData.expirationDate,
// Shared access info
isShared: sql<boolean>`${hostAccess.id} IS NOT NULL`,
@@ -755,12 +787,11 @@ router.get(
eq(hostAccess.hostId, sshData.id),
or(
eq(hostAccess.userId, userId),
roleIds.length > 0 ? inArray(hostAccess.roleId, roleIds) : sql`false`,
),
or(
isNull(hostAccess.expiresAt),
gte(hostAccess.expiresAt, now),
roleIds.length > 0
? inArray(hostAccess.roleId, roleIds)
: sql`false`,
),
or(isNull(hostAccess.expiresAt), gte(hostAccess.expiresAt, now)),
),
)
.where(
@@ -769,10 +800,7 @@ router.get(
and(
// Shared to user directly (not expired)
eq(hostAccess.userId, userId),
or(
isNull(hostAccess.expiresAt),
gte(hostAccess.expiresAt, now),
),
or(isNull(hostAccess.expiresAt), gte(hostAccess.expiresAt, now)),
),
roleIds.length > 0
? and(

View File

@@ -9,7 +9,7 @@ const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
return (
<textarea
className={cn(
"flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
"flex min-h-[80px] w-full rounded-md border border-input bg-transparent dark:bg-input/30 px-3 py-2 text-sm shadow-xs transition-[color,box-shadow] duration-200 outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 disabled:pointer-events-none",
className,
)}
ref={ref}

View File

@@ -670,6 +670,8 @@
"folder": "Folder",
"tags": "Tags",
"pin": "Pin",
"notes": "Notes",
"expirationDate": "Expiration Date",
"passwordRequired": "Password is required when using password authentication",
"sshKeyRequired": "SSH Private Key is required when using key authentication",
"keyTypeRequired": "Key Type is required when using key authentication",
@@ -1615,6 +1617,8 @@
"folder": "folder",
"password": "password",
"keyPassword": "key password",
"notes": "Add notes about this host...",
"expirationDate": "Select expiration date",
"pastePrivateKey": "Paste your private key here...",
"pastePublicKey": "Paste your public key here...",
"credentialName": "My SSH Server",

View File

@@ -665,6 +665,8 @@
"folder": "Папка",
"tags": "Теги",
"pin": "Закрепить",
"notes": "Заметки",
"expirationDate": "Дата истечения",
"passwordRequired": "Пароль требуется при использовании аутентификации по паролю",
"sshKeyRequired": "Приватный SSH-ключ требуется при использовании аутентификации по ключу",
"keyTypeRequired": "Тип ключа требуется при использовании аутентификации по ключу",
@@ -915,7 +917,7 @@
"noProxyNodes": "Узлы прокси не настроены. Нажмите 'Добавить узел прокси' чтобы добавить.",
"proxyNode": "Узел прокси",
"proxyType": "Тип прокси",
"advancedAuthSettings": "Расширенные настройки аутентификации"
"advancedAuthSettings": "Расширенные настройки аутентификации",
"advancedAuthSettings": "Расширенные настройки аутентификации",
"addQuickAction": "Добавить Quick Action",
"allHostsInFolderDeleted": "{{count}} хостов успешно удалены из папки \"{{folder}}\"",
@@ -1609,6 +1611,8 @@
"folder": "папка",
"password": "пароль",
"keyPassword": "пароль ключа",
"notes": "Добавьте заметки об этом хосте...",
"expirationDate": "Выберите дату истечения",
"pastePrivateKey": "Вставьте ваш приватный ключ здесь...",
"pastePublicKey": "Вставьте ваш публичный ключ здесь...",
"credentialName": "Мой SSH-сервер",
@@ -1833,4 +1837,4 @@
"close": "Закрыть",
"hostManager": "Менеджер хостов"
}
}
}

View File

@@ -47,6 +47,8 @@ export interface SSHHost {
quickActions?: QuickAction[];
statsConfig?: string | Record<string, unknown>;
terminalConfig?: TerminalConfig;
notes?: string;
expirationDate?: string;
useSocks5?: boolean;
socks5Host?: string;
@@ -107,6 +109,8 @@ export interface SSHHostData {
quickActions?: QuickActionData[];
statsConfig?: string | Record<string, unknown>;
terminalConfig?: TerminalConfig;
notes?: string;
expirationDate?: string;
// SOCKS5 Proxy configuration
useSocks5?: boolean;

View File

@@ -14,6 +14,7 @@ import {
} from "@/components/ui/form.tsx";
import { Input } from "@/components/ui/input.tsx";
import { PasswordInput } from "@/components/ui/password-input.tsx";
import { Textarea } from "@/components/ui/textarea.tsx";
import { ScrollArea } from "@/components/ui/scroll-area.tsx";
import { Separator } from "@/components/ui/separator.tsx";
import {
@@ -534,6 +535,8 @@ export function HostManagerEditor({
}),
)
.default([]),
notes: z.string().optional(),
expirationDate: z.string().optional(),
useSocks5: z.boolean().optional(),
socks5Host: z.string().optional(),
socks5Port: z.coerce.number().min(1).max(65535).optional(),
@@ -639,6 +642,8 @@ export function HostManagerEditor({
statsConfig: DEFAULT_STATS_CONFIG,
terminalConfig: DEFAULT_TERMINAL_CONFIG,
forceKeyboardInteractive: false,
notes: "",
expirationDate: "",
useSocks5: false,
socks5Host: "",
socks5Port: 1080,
@@ -741,6 +746,8 @@ export function HostManagerEditor({
: [],
},
forceKeyboardInteractive: Boolean(cleanedHost.forceKeyboardInteractive),
notes: cleanedHost.notes || "",
expirationDate: cleanedHost.expirationDate || "",
useSocks5: Boolean(cleanedHost.useSocks5),
socks5Host: cleanedHost.socks5Host || "",
socks5Port: cleanedHost.socks5Port || 1080,
@@ -862,15 +869,6 @@ export function HostManagerEditor({
const submitData: Partial<SSHHost> = {
...data,
};
if (proxyMode === "single") {
submitData.socks5ProxyChain = [];
} else if (proxyMode === "chain") {
submitData.socks5Host = "";
submitData.socks5Port = 1080;
submitData.socks5Username = "";
submitData.socks5Password = "";
}
if (data.authType !== "credential") {
submitData.credentialId = undefined;
@@ -1390,6 +1388,47 @@ export function HostManagerEditor({
</FormItem>
)}
/>
<FormField
control={form.control}
name="expirationDate"
render={({ field }) => (
<FormItem className="col-span-10">
<FormLabel>{t("hosts.expirationDate")}</FormLabel>
<FormControl>
<Input
type="date"
placeholder={t("placeholders.expirationDate")}
value={field.value || ""}
onChange={field.onChange}
onBlur={field.onBlur}
name={field.name}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="notes"
render={({ field }) => (
<FormItem className="col-span-26">
<FormLabel>{t("hosts.notes")}</FormLabel>
<FormControl>
<Textarea
placeholder={t("placeholders.notes")}
className="resize-none"
rows={3}
value={field.value || ""}
onChange={field.onChange}
onBlur={field.onBlur}
name={field.name}
/>
</FormControl>
</FormItem>
)}
/>
</div>
<FormLabel className="mb-3 mt-3 font-bold">
{t("hosts.authentication")}

View File

@@ -643,7 +643,7 @@ function initializeApiInstances() {
// RBAC API (port 30001)
rbacApi = createApiInstance(getApiUrl("", 30001), "RBAC");
// Docker Management API (port 30007)
dockerApi = createApiInstance(getApiUrl("/docker", 30007), "DOCKER");
}
@@ -931,6 +931,8 @@ export async function createSSHHost(hostData: SSHHostData): Promise<SSHHost> {
: null,
terminalConfig: hostData.terminalConfig || null,
forceKeyboardInteractive: Boolean(hostData.forceKeyboardInteractive),
notes: hostData.notes || "",
expirationDate: hostData.expirationDate || "",
useSocks5: Boolean(hostData.useSocks5),
socks5Host: hostData.socks5Host || null,
socks5Port: hostData.socks5Port || null,
@@ -1009,6 +1011,8 @@ export async function updateSSHHost(
: null,
terminalConfig: hostData.terminalConfig || null,
forceKeyboardInteractive: Boolean(hostData.forceKeyboardInteractive),
notes: hostData.notes || "",
expirationDate: hostData.expirationDate || "",
useSocks5: Boolean(hostData.useSocks5),
socks5Host: hostData.socks5Host || null,
socks5Port: hostData.socks5Port || null,
@@ -3240,7 +3244,9 @@ export async function updateRole(
}
}
export async function deleteRole(roleId: number): Promise<{ success: boolean }> {
export async function deleteRole(
roleId: number,
): Promise<{ success: boolean }> {
try {
const response = await rbacApi.delete(`/rbac/roles/${roleId}`);
return response.data;
@@ -3250,7 +3256,9 @@ export async function deleteRole(roleId: number): Promise<{ success: boolean }>
}
// User-Role Management
export async function getUserRoles(userId: string): Promise<{ roles: UserRole[] }> {
export async function getUserRoles(
userId: string,
): Promise<{ roles: UserRole[] }> {
try {
const response = await rbacApi.get(`/rbac/users/${userId}/roles`);
return response.data;
@@ -3264,7 +3272,9 @@ export async function assignRoleToUser(
roleId: number,
): Promise<{ success: boolean }> {
try {
const response = await rbacApi.post(`/rbac/users/${userId}/roles`, { roleId });
const response = await rbacApi.post(`/rbac/users/${userId}/roles`, {
roleId,
});
return response.data;
} catch (error) {
throw handleApiError(error, "assign role to user");
@@ -3276,7 +3286,9 @@ export async function removeRoleFromUser(
roleId: number,
): Promise<{ success: boolean }> {
try {
const response = await rbacApi.delete(`/rbac/users/${userId}/roles/${roleId}`);
const response = await rbacApi.delete(
`/rbac/users/${userId}/roles/${roleId}`,
);
return response.data;
} catch (error) {
throw handleApiError(error, "remove role from user");
@@ -3295,14 +3307,19 @@ export async function shareHost(
},
): Promise<{ success: boolean }> {
try {
const response = await rbacApi.post(`/rbac/host/${hostId}/share`, shareData);
const response = await rbacApi.post(
`/rbac/host/${hostId}/share`,
shareData,
);
return response.data;
} catch (error) {
throw handleApiError(error, "share host");
}
}
export async function getHostAccess(hostId: number): Promise<{ accessList: AccessRecord[] }> {
export async function getHostAccess(
hostId: number,
): Promise<{ accessList: AccessRecord[] }> {
try {
const response = await rbacApi.get(`/rbac/host/${hostId}/access`);
return response.data;
@@ -3316,11 +3333,15 @@ export async function revokeHostAccess(
accessId: number,
): Promise<{ success: boolean }> {
try {
const response = await rbacApi.delete(`/rbac/host/${hostId}/access/${accessId}`);
const response = await rbacApi.delete(
`/rbac/host/${hostId}/access/${accessId}`,
);
return response.data;
} catch (error) {
throw handleApiError(error, "revoke host access");
}
}
// ============================================================================
// DOCKER MANAGEMENT API
// ============================================================================