diff --git a/src/backend/ssh/docker.ts b/src/backend/ssh/docker.ts
index 7bc5d57e..c995a824 100644
--- a/src/backend/ssh/docker.ts
+++ b/src/backend/ssh/docker.ts
@@ -2970,7 +2970,7 @@ app.get("/docker/containers/:sessionId/:containerId/logs", async (req, res) => {
session.activeOperations++;
try {
- let command = `docker logs ${containerId}`;
+ let command = `docker logs ${containerId} 2>&1`;
if (tail && tail > 0) {
command += ` --tail ${Math.floor(tail)}`;
diff --git a/src/ui/types/connection-log.ts b/src/types/connection-log.ts
similarity index 100%
rename from src/ui/types/connection-log.ts
rename to src/types/connection-log.ts
diff --git a/src/ui/types/electron.d.ts b/src/types/electron.d.ts
similarity index 100%
rename from src/ui/types/electron.d.ts
rename to src/types/electron.d.ts
diff --git a/src/ui/types/guacamole-common-js.d.ts b/src/types/guacamole-common-js.d.ts
similarity index 100%
rename from src/ui/types/guacamole-common-js.d.ts
rename to src/types/guacamole-common-js.d.ts
diff --git a/src/ui/types/index.ts b/src/types/index.ts
similarity index 100%
rename from src/ui/types/index.ts
rename to src/types/index.ts
diff --git a/src/ui/types/stats-widgets.ts b/src/types/stats-widgets.ts
similarity index 100%
rename from src/ui/types/stats-widgets.ts
rename to src/types/stats-widgets.ts
diff --git a/src/ui/types/ui-types.ts b/src/types/ui-types.ts
similarity index 100%
rename from src/ui/types/ui-types.ts
rename to src/types/ui-types.ts
diff --git a/src/ui/AppShell.tsx b/src/ui/AppShell.tsx
index 6d620eef..d9368574 100644
--- a/src/ui/AppShell.tsx
+++ b/src/ui/AppShell.tsx
@@ -50,11 +50,11 @@ function sshHostToHost(h: SSHHostWithStatus): Host {
notes: h.notes,
pin: h.pin ?? false,
macAddress: h.macAddress,
+ enableSsh: h.enableSsh ?? (h.connectionType === "ssh" || !h.connectionType),
enableTerminal: h.enableTerminal ?? true,
enableTunnel: h.enableTunnel ?? false,
enableFileManager: h.enableFileManager ?? false,
enableDocker: h.enableDocker ?? false,
- enableSsh: h.connectionType === "ssh" || !h.connectionType,
enableRdp: h.connectionType === "rdp",
enableVnc: h.connectionType === "vnc",
enableTelnet: h.connectionType === "telnet",
diff --git a/src/ui/components/section-card.tsx b/src/ui/components/section-card.tsx
index a1622b96..cfb98dd4 100644
--- a/src/ui/components/section-card.tsx
+++ b/src/ui/components/section-card.tsx
@@ -59,17 +59,20 @@ export function SettingRow({
export function FakeSwitch({
defaultChecked = false,
+ checked,
onChange,
}: {
defaultChecked?: boolean;
+ checked?: boolean;
onChange?: (v: boolean) => void;
}) {
- const [on, setOn] = useState(defaultChecked);
+ const [internalOn, setInternalOn] = useState(defaultChecked);
+ const on = checked !== undefined ? checked : internalOn;
return (
)}
- {user.isOidc && user.passwordHash && (
-
-
-
- )}
{
try {
- await deleteUser(user.userId);
+ await deleteUser(user.username);
setUsers((prev) =>
- prev.filter((u) => u.userId !== user.userId),
+ prev.filter((u) => u.id !== user.id),
+ );
+ toast.success(`User "${user.username}" deleted`);
+ } catch (e: any) {
+ toast.error(
+ e?.response?.data?.error || "Failed to delete user",
);
- } catch {
- /* ignore */
}
}}
>
@@ -529,6 +1098,7 @@ export function AdminSettingsPanel() {
variant="ghost"
size="icon"
className="size-6 text-muted-foreground hover:text-foreground"
+ onClick={loadSessions}
>
@@ -564,6 +1134,17 @@ export function AdminSettingsPanel() {
variant="ghost"
size="sm"
className="text-[10px] text-muted-foreground hover:text-destructive h-6 px-1.5"
+ onClick={async () => {
+ try {
+ await revokeAllUserSessions(session.userId);
+ setSessions((prev) =>
+ prev.filter((s) => s.userId !== session.userId),
+ );
+ toast.success("All sessions for user revoked");
+ } catch {
+ toast.error("Failed to revoke sessions");
+ }
+ }}
>
All
@@ -578,7 +1159,7 @@ export function AdminSettingsPanel() {
prev.filter((s) => s.id !== session.id),
);
} catch {
- /* ignore */
+ toast.error("Failed to revoke session");
}
}}
>
@@ -621,13 +1202,23 @@ export function AdminSettingsPanel() {
-
+ setNewRoleName(e.target.value)}
+ className="text-xs"
+ />
-
+ setNewRoleDisplayName(e.target.value)}
+ className="text-xs"
+ />
@@ -644,7 +1237,12 @@ export function AdminSettingsPanel() {
variant="ghost"
size="sm"
className="text-xs"
- onClick={() => setShowCreateRole(false)}
+ onClick={() => {
+ setShowCreateRole(false);
+ setNewRoleName("");
+ setNewRoleDisplayName("");
+ setNewRoleDescription("");
+ }}
>
Cancel
@@ -652,8 +1250,10 @@ export function AdminSettingsPanel() {
variant="outline"
size="sm"
className="text-xs border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
+ onClick={handleCreateRole}
+ disabled={createRoleLoading}
>
- Create
+ {createRoleLoading ? "Creating..." : "Create"}
@@ -684,13 +1284,6 @@ export function AdminSettingsPanel() {
{!role.isSystem && (
-
-
-
{
await deleteRole(role.id);
setRoles((prev) => prev.filter((r) => r.id !== role.id));
+ toast.success(`Role "${role.displayName}" deleted`);
}}
>
@@ -726,15 +1320,17 @@ export function AdminSettingsPanel() {
variant="outline"
size="sm"
className="self-start text-xs border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand mt-1"
+ onClick={handleExportDatabase}
+ disabled={exportLoading}
>
- Export
+ {exportLoading ? "Exporting..." : "Export"}
Import Database
{importFile
- ? `Selected: ${importFile}`
+ ? `Selected: ${importFile.name}`
: "Restore from a .sqlite backup file"}
@@ -742,9 +1338,7 @@ export function AdminSettingsPanel() {
- setImportFile(e.target.files?.[0]?.name ?? null)
- }
+ onChange={(e) => setImportFile(e.target.files?.[0] ?? null)}
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
/>
- Import
+ {importLoading ? "Importing..." : "Import"}
)}
@@ -786,6 +1382,7 @@ export function AdminSettingsPanel() {
variant="ghost"
size="icon"
className="size-6 text-muted-foreground hover:text-foreground"
+ onClick={loadApiKeys}
>
@@ -793,7 +1390,10 @@ export function AdminSettingsPanel() {
variant="outline"
size="sm"
className="h-6 text-[10px] border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
- onClick={() => setShowCreateKey((o) => !o)}
+ onClick={() => {
+ setShowCreateKey((o) => !o);
+ setCreatedKeyToken(null);
+ }}
>
Create
@@ -805,41 +1405,108 @@ export function AdminSettingsPanel() {
New API Key
-
-
-
-
-
-
-
-
-
-
-
-
-
- setShowCreateKey(false)}
- >
- Cancel
-
-
- Create Key
-
-
+ {createdKeyToken ? (
+
+
+ Key created — copy it now, it won't be shown again.
+
+
+
+ {createdKeyToken}
+
+ {
+ navigator.clipboard.writeText(createdKeyToken);
+ toast.info("Copied to clipboard");
+ }}
+ className="text-muted-foreground hover:text-accent-brand shrink-0"
+ >
+
+
+
+
{
+ setShowCreateKey(false);
+ setCreatedKeyToken(null);
+ }}
+ >
+ Done
+
+
+ ) : (
+ <>
+
+
+ setNewKeyName(e.target.value)}
+ className="text-xs"
+ />
+
+
+
+ setNewKeyUserId(e.target.value)}
+ className="text-xs font-mono"
+ />
+ {users.length > 0 && (
+
+ )}
+
+
+
+ setNewKeyExpiry(e.target.value)}
+ className="text-xs"
+ />
+
+
+ setShowCreateKey(false)}
+ >
+ Cancel
+
+
+ {newKeyLoading ? "Creating..." : "Create Key"}
+
+
+ >
+ )}
)}
{apiKeys.map((key) => (
@@ -874,8 +1541,13 @@ export function AdminSettingsPanel() {
size="icon"
className="size-6 text-muted-foreground hover:text-destructive shrink-0"
onClick={async () => {
- await deleteApiKey(key.id);
- setApiKeys((prev) => prev.filter((k) => k.id !== key.id));
+ try {
+ await deleteApiKey(key.id);
+ setApiKeys((prev) => prev.filter((k) => k.id !== key.id));
+ toast.success(`Key "${key.name}" revoked`);
+ } catch {
+ toast.error("Failed to revoke key");
+ }
}}
>
@@ -885,7 +1557,7 @@ export function AdminSettingsPanel() {
- {/* Dialogs */}
+ {/* Create User Dialog */}
+ {/* Edit User Dialog */}
+ {/* Link Account Dialog */}