2026-06-18 19:26:48 +00:00
import { useEffect , useRef , useState } from 'react'
Wire up Profile/Appearance/Security in user menu (#21)
* Add editable display-name field to generic integrations
Lets users set a custom name for Proxmox, Docker, AWS, Remote Desktop,
Netbird, Cloudflare, Uptime Kuma, and Weather integrations, separate
from the host/IP field, mirroring the SSH host rename pattern.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kF4hZWEkRCPPvCZTeXxn4
* Surface the new-integration name field as a labeled input
The name field for new generic integrations was a faint header input
with only placeholder text, easy to miss. Move it into the form grid
as a proper labeled "Name" field next to the other connection fields.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kF4hZWEkRCPPvCZTeXxn4
* Add file upload for SSH private key and certificate fields
Lets users pick a key file from disk (e.g. ~/.ssh) instead of pasting
its contents into the Private Key / OPKSSH Certificate fields.
* Fix SSH private key paste corrupting multi-line PEM format
Private Key and Certificate fields were single-line <input> elements,
which strip newlines on paste and corrupt PEM-formatted keys (causing
'Unsupported key format' errors). Render them as multi-line textareas
instead so pasted keys keep their line breaks.
* Add JSON-converted bookmark import file for Archnest data import
Converts homarr-bookmarks.md into the format expected by /api/data/import.
* Auto-populate bookmark icons via favicon service in import JSON
Each bookmark now points to Google's favicon endpoint for its domain
instead of having no icon at all.
* Wire up Profile/Appearance/Security in the user menu
Profile, Appearance, and Security were dead "#" links. Added URL-based
tab deep-linking to the Settings page (?tab=profile|appearance|security|...)
and pointed the menu items at it. Added a Security tab placeholder ahead
of password/sessions/login-log/SSO work.
---------
Co-authored-by: Claude <noreply@anthropic.com>
2026-06-20 09:44:43 -04:00
import { useSearchParams } from 'react-router-dom'
2026-06-20 21:44:27 +00:00
import { api , ApiError , type Integration , type AuthSession , type LoginEvent , type ManagedUser , type MeshStatus } from '../lib/api'
2026-06-18 20:08:30 +00:00
import { useAuth } from '../lib/AuthContext'
2026-06-18 18:44:26 +00:00
import {
User ,
Palette ,
Plug ,
Bell ,
Database ,
Info ,
Eye ,
EyeOff ,
Check ,
Download ,
Upload ,
Trash2 ,
2026-06-18 18:50:43 +00:00
Camera ,
Fix integration save data loss; add SSH host card collapse (#16)
* Add editable display-name field to generic integrations
Lets users set a custom name for Proxmox, Docker, AWS, Remote Desktop,
Netbird, Cloudflare, Uptime Kuma, and Weather integrations, separate
from the host/IP field, mirroring the SSH host rename pattern.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kF4hZWEkRCPPvCZTeXxn4
* Surface the new-integration name field as a labeled input
The name field for new generic integrations was a faint header input
with only placeholder text, easy to miss. Move it into the form grid
as a proper labeled "Name" field next to the other connection fields.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kF4hZWEkRCPPvCZTeXxn4
* Add file upload for SSH private key and certificate fields
Lets users pick a key file from disk (e.g. ~/.ssh) instead of pasting
its contents into the Private Key / OPKSSH Certificate fields.
* Fix SSH private key paste corrupting multi-line PEM format
Private Key and Certificate fields were single-line <input> elements,
which strip newlines on paste and corrupt PEM-formatted keys (causing
'Unsupported key format' errors). Render them as multi-line textareas
instead so pasted keys keep their line breaks.
* Fix integration save wiping untouched config fields
The PUT /api/integrations/:id route fully overwrites config_json with
whatever config object is sent (no merge), but buildPayload only
included fields the user had actually edited. Saving after editing
just one field (e.g. pasting a new SSH key) silently dropped every
other config field. Merge the existing integration's config into the
payload before sending.
* Add collapse/expand for SSH host cards
Click the chevron to collapse a host's card once it's configured.
Collapsed cards keep all field state in memory (just hidden), and
auto-collapse after a successful Save.
* Install openssh-client in backend image for certificate-auth SSH
Certificate-based SSH connections shell out to the system ssh binary
via node-pty (ssh2 has no OpenSSH certificate support), but the
alpine runtime image never installed openssh-client. This caused
'execvp(3) failed: No such file or directory' for any host with an
OPKSSH certificate configured.
---------
Co-authored-by: Claude <noreply@anthropic.com>
2026-06-20 08:30:21 -04:00
ChevronDown ,
ChevronRight ,
Wire up Profile/Appearance/Security in user menu (#21)
* Add editable display-name field to generic integrations
Lets users set a custom name for Proxmox, Docker, AWS, Remote Desktop,
Netbird, Cloudflare, Uptime Kuma, and Weather integrations, separate
from the host/IP field, mirroring the SSH host rename pattern.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kF4hZWEkRCPPvCZTeXxn4
* Surface the new-integration name field as a labeled input
The name field for new generic integrations was a faint header input
with only placeholder text, easy to miss. Move it into the form grid
as a proper labeled "Name" field next to the other connection fields.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kF4hZWEkRCPPvCZTeXxn4
* Add file upload for SSH private key and certificate fields
Lets users pick a key file from disk (e.g. ~/.ssh) instead of pasting
its contents into the Private Key / OPKSSH Certificate fields.
* Fix SSH private key paste corrupting multi-line PEM format
Private Key and Certificate fields were single-line <input> elements,
which strip newlines on paste and corrupt PEM-formatted keys (causing
'Unsupported key format' errors). Render them as multi-line textareas
instead so pasted keys keep their line breaks.
* Add JSON-converted bookmark import file for Archnest data import
Converts homarr-bookmarks.md into the format expected by /api/data/import.
* Auto-populate bookmark icons via favicon service in import JSON
Each bookmark now points to Google's favicon endpoint for its domain
instead of having no icon at all.
* Wire up Profile/Appearance/Security in the user menu
Profile, Appearance, and Security were dead "#" links. Added URL-based
tab deep-linking to the Settings page (?tab=profile|appearance|security|...)
and pointed the menu items at it. Added a Security tab placeholder ahead
of password/sessions/login-log/SSO work.
---------
Co-authored-by: Claude <noreply@anthropic.com>
2026-06-20 09:44:43 -04:00
Shield ,
Add auth Phase 2: password change, sessions, and login audit log (#27)
Builds out the Settings → Security tab (previously a "coming soon"
placeholder) and the backend behind it. Still single-user; multi-user
and SSO remain Phases 3-4.
Backend:
- New `sessions` table (id, user_id, user_agent, ip, created_at,
last_seen_at) and `login_events` table (user_id, username, ip,
user_agent, success, created_at).
- Login and setup now mint a session row and embed its id as a `sid`
claim in the JWT. The `authenticate` hook validates that the session
still exists (and bumps last_seen_at), so revoking a session genuinely
invalidates its token instead of relying on the JWT signature alone.
Tokens minted before sessions existed have no `sid` and stay valid
until expiry, for backward compatibility.
- Every login attempt (success and failure) is recorded in login_events
for the audit trail.
- New endpoints: PUT /api/auth/password (verifies current via bcrypt,
hashes new at cost 12, revokes all *other* sessions on success),
GET /api/auth/sessions, DELETE /api/auth/sessions/:id (can't revoke
the current one), POST /api/auth/logout (revokes current session),
GET /api/auth/login-events?limit.
- AuthContext.logout() now calls POST /api/auth/logout best-effort so
signing out revokes the server session, not just the local token.
Frontend:
- SecuritySection: change-password form (current/new/confirm with
show/hide and client-side validation), active-sessions list (device
description from user-agent, IP, last-seen relative time, per-session
"Sign out" for non-current sessions), and a recent login-activity feed
(success/failure dot, user, IP, relative time).
- api.ts: changePassword/listSessions/revokeSession/logout/
listLoginEvents + AuthSession/LoginEvent types.
Verified end-to-end against a throwaway backend instance: session
creation, second-device session, failed-login logging, cross-session
revocation invalidating the revoked token, password change keeping the
current session alive while revoking others, and logout invalidating the
current session. Frontend + backend both type-check clean.
Co-authored-by: Samuel James <ssamjame@amazon.com>
Co-authored-by: Kiro <noreply@kiro.dev>
2026-06-20 11:50:56 -04:00
Monitor ,
LogOut ,
Add auth Phase 3: multi-user accounts with admin/member roles (#28)
Implements Phase 3 of the auth roadmap: multiple user accounts (cap 10),
an admin/member role model, and admin-only gating of config-mutating
routes. Dashboard data stays shared across all users (per the product
decision in HANDOFF.md — this is a household/self-hosted dashboard, not
a multi-tenant app), so there is no per-user data isolation.
Schema (backend/src/db/index.ts):
- Idempotent migration adds `role` (default 'admin') and `active`
(default 1) columns to `users` when missing. The 'admin' default means
the pre-existing single user is backfilled to admin on deploy and keeps
full access; newly created users are inserted explicitly as 'member'.
Verified against a production-like old schema (columns added, existing
user backfilled to admin/active).
Auth + access control:
- `/api/setup` creates the first user as admin. Login enforces `active`
(deactivated accounts get 403) and embeds the live role in the session.
- `app.authenticate` now reads role+active fresh from the DB on every
request (not from the possibly-stale JWT claim), rejects inactive
accounts, and stashes the role on req.user.
- New `requireAdmin` (auth + role check) and `adminOnly` (role check for
routes already behind the plugin-level authenticate hook) decorators.
User management (admin-only, in auth.ts):
- GET/POST/PUT/DELETE /api/users — list, create (admin sets a temp
password; no public signup), change role, activate/deactivate, delete.
- 10-user cap enforced server-side; guard rails prevent removing the last
active admin (demote/deactivate/delete) and deleting your own account;
deactivating or deleting a user drops their sessions immediately.
Admin-only route gating (members get 403):
- integrations create/update/delete/test, tunnels create/delete, data
export/import. Read routes and tunnel connect/disconnect stay open to
all authenticated users, as do all the SSH/Docker/RDP tools and
bookmarks (members are trusted to use the tooling, per product decision).
Frontend:
- api.ts: listUsers/createUser/updateUser/deleteUser + ManagedUser type;
role+active added to AuthUser.
- Settings: new admin-only "Users" section (create form, role toggle,
activate/deactivate, delete, 10-cap indicator). Nav filters the Users
tab by role and guards ?tab= deep-links. Data & Backup shows an
admin-only notice for members; Integrations shows a read-only banner
for members. (Backend remains the real enforcement boundary.)
Verified end-to-end against a throwaway backend: role assignment,
member 403s on every admin-only route + 200s on shared/read routes,
admin 200/201s, last-admin guards (409/400), deactivation killing an
active session and blocking re-login (then reactivation restoring it),
and the 10-user cap (409 on the 11th). Both frontend and backend
type-check clean.
Co-authored-by: Samuel James <ssamjame@amazon.com>
Co-authored-by: Kiro <noreply@kiro.dev>
2026-06-20 12:43:24 -04:00
Users ,
UserPlus ,
2026-06-20 21:44:27 +00:00
Network ,
2026-06-18 18:44:26 +00:00
} from 'lucide-react'
const navSections = [
{ id : 'profile' , label : 'Profile' , icon : User } ,
{ id : 'appearance' , label : 'Appearance' , icon : Palette } ,
Wire up Profile/Appearance/Security in user menu (#21)
* Add editable display-name field to generic integrations
Lets users set a custom name for Proxmox, Docker, AWS, Remote Desktop,
Netbird, Cloudflare, Uptime Kuma, and Weather integrations, separate
from the host/IP field, mirroring the SSH host rename pattern.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kF4hZWEkRCPPvCZTeXxn4
* Surface the new-integration name field as a labeled input
The name field for new generic integrations was a faint header input
with only placeholder text, easy to miss. Move it into the form grid
as a proper labeled "Name" field next to the other connection fields.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kF4hZWEkRCPPvCZTeXxn4
* Add file upload for SSH private key and certificate fields
Lets users pick a key file from disk (e.g. ~/.ssh) instead of pasting
its contents into the Private Key / OPKSSH Certificate fields.
* Fix SSH private key paste corrupting multi-line PEM format
Private Key and Certificate fields were single-line <input> elements,
which strip newlines on paste and corrupt PEM-formatted keys (causing
'Unsupported key format' errors). Render them as multi-line textareas
instead so pasted keys keep their line breaks.
* Add JSON-converted bookmark import file for Archnest data import
Converts homarr-bookmarks.md into the format expected by /api/data/import.
* Auto-populate bookmark icons via favicon service in import JSON
Each bookmark now points to Google's favicon endpoint for its domain
instead of having no icon at all.
* Wire up Profile/Appearance/Security in the user menu
Profile, Appearance, and Security were dead "#" links. Added URL-based
tab deep-linking to the Settings page (?tab=profile|appearance|security|...)
and pointed the menu items at it. Added a Security tab placeholder ahead
of password/sessions/login-log/SSO work.
---------
Co-authored-by: Claude <noreply@anthropic.com>
2026-06-20 09:44:43 -04:00
{ id : 'security' , label : 'Security' , icon : Shield } ,
Add auth Phase 3: multi-user accounts with admin/member roles (#28)
Implements Phase 3 of the auth roadmap: multiple user accounts (cap 10),
an admin/member role model, and admin-only gating of config-mutating
routes. Dashboard data stays shared across all users (per the product
decision in HANDOFF.md — this is a household/self-hosted dashboard, not
a multi-tenant app), so there is no per-user data isolation.
Schema (backend/src/db/index.ts):
- Idempotent migration adds `role` (default 'admin') and `active`
(default 1) columns to `users` when missing. The 'admin' default means
the pre-existing single user is backfilled to admin on deploy and keeps
full access; newly created users are inserted explicitly as 'member'.
Verified against a production-like old schema (columns added, existing
user backfilled to admin/active).
Auth + access control:
- `/api/setup` creates the first user as admin. Login enforces `active`
(deactivated accounts get 403) and embeds the live role in the session.
- `app.authenticate` now reads role+active fresh from the DB on every
request (not from the possibly-stale JWT claim), rejects inactive
accounts, and stashes the role on req.user.
- New `requireAdmin` (auth + role check) and `adminOnly` (role check for
routes already behind the plugin-level authenticate hook) decorators.
User management (admin-only, in auth.ts):
- GET/POST/PUT/DELETE /api/users — list, create (admin sets a temp
password; no public signup), change role, activate/deactivate, delete.
- 10-user cap enforced server-side; guard rails prevent removing the last
active admin (demote/deactivate/delete) and deleting your own account;
deactivating or deleting a user drops their sessions immediately.
Admin-only route gating (members get 403):
- integrations create/update/delete/test, tunnels create/delete, data
export/import. Read routes and tunnel connect/disconnect stay open to
all authenticated users, as do all the SSH/Docker/RDP tools and
bookmarks (members are trusted to use the tooling, per product decision).
Frontend:
- api.ts: listUsers/createUser/updateUser/deleteUser + ManagedUser type;
role+active added to AuthUser.
- Settings: new admin-only "Users" section (create form, role toggle,
activate/deactivate, delete, 10-cap indicator). Nav filters the Users
tab by role and guards ?tab= deep-links. Data & Backup shows an
admin-only notice for members; Integrations shows a read-only banner
for members. (Backend remains the real enforcement boundary.)
Verified end-to-end against a throwaway backend: role assignment,
member 403s on every admin-only route + 200s on shared/read routes,
admin 200/201s, last-admin guards (409/400), deactivation killing an
active session and blocking re-login (then reactivation restoring it),
and the 10-user cap (409 on the 11th). Both frontend and backend
type-check clean.
Co-authored-by: Samuel James <ssamjame@amazon.com>
Co-authored-by: Kiro <noreply@kiro.dev>
2026-06-20 12:43:24 -04:00
{ id : 'users' , label : 'Users' , icon : Users , adminOnly : true } ,
2026-06-20 21:44:27 +00:00
{ id : 'mesh' , label : 'Mesh' , icon : Network , adminOnly : true } ,
2026-06-18 18:44:26 +00:00
{ id : 'integrations' , label : 'Integrations' , icon : Plug } ,
{ id : 'notifications' , label : 'Notifications' , icon : Bell } ,
{ id : 'data' , label : 'Data & Backup' , icon : Database } ,
{ id : 'about' , label : 'About' , icon : Info } ,
]
const accentColors = [
{ name : 'Gold' , color : '#C8A434' } ,
{ name : 'Teal' , color : '#2DD4BF' } ,
{ name : 'Purple' , color : '#A855F7' } ,
{ name : 'Blue' , color : '#3B82F6' } ,
{ name : 'Green' , color : '#2ECC71' } ,
{ name : 'Red' , color : '#E74C3C' } ,
]
Add file upload for SSH private key and certificate fields (#15)
* Add editable display-name field to generic integrations
Lets users set a custom name for Proxmox, Docker, AWS, Remote Desktop,
Netbird, Cloudflare, Uptime Kuma, and Weather integrations, separate
from the host/IP field, mirroring the SSH host rename pattern.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kF4hZWEkRCPPvCZTeXxn4
* Surface the new-integration name field as a labeled input
The name field for new generic integrations was a faint header input
with only placeholder text, easy to miss. Move it into the form grid
as a proper labeled "Name" field next to the other connection fields.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kF4hZWEkRCPPvCZTeXxn4
* Add file upload for SSH private key and certificate fields
Lets users pick a key file from disk (e.g. ~/.ssh) instead of pasting
its contents into the Private Key / OPKSSH Certificate fields.
* Fix SSH private key paste corrupting multi-line PEM format
Private Key and Certificate fields were single-line <input> elements,
which strip newlines on paste and corrupt PEM-formatted keys (causing
'Unsupported key format' errors). Render them as multi-line textareas
instead so pasted keys keep their line breaks.
---------
Co-authored-by: Claude <noreply@anthropic.com>
2026-06-20 08:11:32 -04:00
type FieldDef = { key : string ; label : string ; secret? : boolean ; hint? : string ; placeholder? : string ; file? : boolean }
2026-06-20 10:18:04 +00:00
const integrationTypeDefs : { type : string ; name : string ; multiInstance? : boolean ; fields : FieldDef [ ] } [ ] = [
{ type : 'proxmox' , name : 'Proxmox' , multiInstance : true , fields : [
{ key : 'baseUrl' , label : 'Host URL' , hint : 'e.g. https://192.168.1.10:8006' , placeholder : 'https://192.168.1.10:8006' } ,
{
key : 'apiKey' ,
label : 'API Token' ,
secret : true ,
hint : 'Must be the FULL token string from Datacenter → Permissions → API Tokens, in the form USER@REALM!TOKENID=SECRET — not just the secret.' ,
placeholder : 'root@pam!archnest=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx' ,
} ,
] } ,
{ type : 'docker' , name : 'Docker' , multiInstance : true , fields : [
{ key : 'baseUrl' , label : 'Socket / Remote URL' , hint : 'Unix socket path or remote daemon URL, e.g. unix:///var/run/docker.sock or tcp://host:2375' , placeholder : 'unix:///var/run/docker.sock' } ,
] } ,
{ type : 'netbird' , name : 'NetBird' , fields : [
{ key : 'apiKey' , label : 'API Key' , secret : true , hint : 'Personal access token from NetBird dashboard → Settings → Access Tokens.' } ,
] } ,
{ type : 'cloudflare' , name : 'Cloudflare' , fields : [
{ key : 'apiKey' , label : 'API Token' , secret : true , hint : 'A scoped API token (not your Global API Key) from My Profile → API Tokens.' } ,
{ key : 'zoneId' , label : 'Zone ID' , hint : 'Found on the domain overview page in the Cloudflare dashboard.' } ,
] } ,
{ type : 'aws' , name : 'AWS' , multiInstance : true , fields : [
{ key : 'accessKey' , label : 'Access Key ID' , hint : 'IAM user access key, e.g. AKIAIOSFODNN7EXAMPLE' } ,
{ key : 'secretKey' , label : 'Secret Access Key' , secret : true , hint : 'IAM user secret key — paired with the Access Key ID above.' } ,
{ key : 'region' , label : 'Region' , hint : 'e.g. us-east-1' , placeholder : 'us-east-1' } ,
] } ,
{ type : 'uptime_kuma' , name : 'Uptime Kuma' , fields : [
{ key : 'baseUrl' , label : 'URL' , placeholder : 'https://uptime.example.com' } ,
2026-06-21 11:00:47 +00:00
{ key : 'username' , label : 'Username' , secret : true } ,
{ key : 'password' , label : 'Password' , secret : true } ,
2026-06-20 10:18:04 +00:00
] } ,
2026-06-18 19:26:48 +00:00
{ type : 'weather' , name : 'Weather API' , fields : [ { key : 'location' , label : 'Location' } , { key : 'units' , label : 'Units' } ] } ,
2026-06-20 10:18:04 +00:00
{ type : 'remote_desktop' , name : 'Remote Desktop' , multiInstance : true , fields : [
2026-06-19 15:25:10 +00:00
{ key : 'protocol' , label : 'Protocol (rdp / vnc / telnet)' } ,
{ key : 'hostname' , label : 'Hostname' } ,
{ key : 'port' , label : 'Port' } ,
{ key : 'username' , label : 'Username' } ,
{ key : 'domain' , label : 'Domain (RDP only)' } ,
{ key : 'password' , label : 'Password' , secret : true } ,
] } ,
2026-06-19 11:04:46 +00:00
]
const sshFields : FieldDef [ ] = [
{ key : 'host' , label : 'Host / IP' } ,
{ key : 'port' , label : 'Port (default 22)' } ,
{ key : 'username' , label : 'Username' } ,
{ key : 'password' , label : 'Password' , secret : true } ,
Add file upload for SSH private key and certificate fields (#15)
* Add editable display-name field to generic integrations
Lets users set a custom name for Proxmox, Docker, AWS, Remote Desktop,
Netbird, Cloudflare, Uptime Kuma, and Weather integrations, separate
from the host/IP field, mirroring the SSH host rename pattern.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kF4hZWEkRCPPvCZTeXxn4
* Surface the new-integration name field as a labeled input
The name field for new generic integrations was a faint header input
with only placeholder text, easy to miss. Move it into the form grid
as a proper labeled "Name" field next to the other connection fields.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kF4hZWEkRCPPvCZTeXxn4
* Add file upload for SSH private key and certificate fields
Lets users pick a key file from disk (e.g. ~/.ssh) instead of pasting
its contents into the Private Key / OPKSSH Certificate fields.
* Fix SSH private key paste corrupting multi-line PEM format
Private Key and Certificate fields were single-line <input> elements,
which strip newlines on paste and corrupt PEM-formatted keys (causing
'Unsupported key format' errors). Render them as multi-line textareas
instead so pasted keys keep their line breaks.
---------
Co-authored-by: Claude <noreply@anthropic.com>
2026-06-20 08:11:32 -04:00
{ key : 'privateKey' , label : 'Private Key (PEM)' , secret : true , file : true } ,
2026-06-19 11:04:46 +00:00
{ key : 'passphrase' , label : 'Key Passphrase' , secret : true } ,
Add file upload for SSH private key and certificate fields (#15)
* Add editable display-name field to generic integrations
Lets users set a custom name for Proxmox, Docker, AWS, Remote Desktop,
Netbird, Cloudflare, Uptime Kuma, and Weather integrations, separate
from the host/IP field, mirroring the SSH host rename pattern.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kF4hZWEkRCPPvCZTeXxn4
* Surface the new-integration name field as a labeled input
The name field for new generic integrations was a faint header input
with only placeholder text, easy to miss. Move it into the form grid
as a proper labeled "Name" field next to the other connection fields.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kF4hZWEkRCPPvCZTeXxn4
* Add file upload for SSH private key and certificate fields
Lets users pick a key file from disk (e.g. ~/.ssh) instead of pasting
its contents into the Private Key / OPKSSH Certificate fields.
* Fix SSH private key paste corrupting multi-line PEM format
Private Key and Certificate fields were single-line <input> elements,
which strip newlines on paste and corrupt PEM-formatted keys (causing
'Unsupported key format' errors). Render them as multi-line textareas
instead so pasted keys keep their line breaks.
---------
Co-authored-by: Claude <noreply@anthropic.com>
2026-06-20 08:11:32 -04:00
{ key : 'certificate' , label : 'OPKSSH Certificate (id_key-cert.pub)' , secret : true , file : true } ,
2026-06-18 18:44:26 +00:00
]
const cardBase : React.CSSProperties = {
backgroundColor : 'rgba(10, 10, 12, 0.92)' ,
border : '1px solid rgba(200, 164, 52, 0.08)' ,
borderRadius : '12px' ,
padding : '22px' ,
position : 'relative' ,
}
const sectionTitle : React.CSSProperties = {
fontSize : '11px' ,
textTransform : 'uppercase' ,
letterSpacing : '1.5px' ,
color : '#7A7D85' ,
fontWeight : 500 ,
marginBottom : '16px' ,
}
const labelStyle : React.CSSProperties = {
fontSize : '11px' ,
color : '#7A7D85' ,
marginBottom : '6px' ,
display : 'block' ,
}
const inputStyle : React.CSSProperties = {
width : '100%' ,
height : '34px' ,
borderRadius : '8px' ,
border : '1px solid rgba(200,164,52,0.12)' ,
backgroundColor : 'rgba(255,255,255,0.03)' ,
color : '#E8E6E0' ,
fontSize : '12px' ,
padding : '0 12px' ,
outline : 'none' ,
}
function Toggle ( { on , onClick } : { on : boolean ; onClick : ( ) = > void } ) {
return (
< button
onClick = { onClick }
className = "cursor-pointer border-none"
style = { {
width : '38px' ,
height : '20px' ,
borderRadius : '10px' ,
backgroundColor : on ? '#C8A434' : 'rgba(255,255,255,0.08)' ,
position : 'relative' ,
transition : 'background-color 0.2s ease' ,
flexShrink : 0 ,
} }
>
< span
style = { {
position : 'absolute' ,
top : '2px' ,
left : on ? '20px' : '2px' ,
width : '16px' ,
height : '16px' ,
borderRadius : '50%' ,
backgroundColor : '#0A0B0D' ,
transition : 'left 0.2s ease' ,
} }
/ >
< / button >
)
}
2026-06-18 20:08:30 +00:00
function GoldButton ( { children , danger , onClick , disabled } : { children : React.ReactNode ; danger? : boolean ; onClick ? : ( ) = > void ; disabled? : boolean } ) {
2026-06-18 18:44:26 +00:00
return (
< button
2026-06-18 20:08:30 +00:00
onClick = { onClick }
disabled = { disabled }
2026-06-18 18:44:26 +00:00
className = "flex items-center gap-2 cursor-pointer transition-colors whitespace-nowrap"
style = { {
fontSize : '12px' ,
fontWeight : 600 ,
color : danger ? '#E74C3C' : '#0A0B0D' ,
backgroundColor : danger ? 'transparent' : '#C8A434' ,
border : danger ? '1px solid rgba(231,76,60,0.4)' : 'none' ,
borderRadius : '8px' ,
padding : '9px 16px' ,
boxShadow : danger ? 'none' : '0 0 14px rgba(200,164,52,0.2)' ,
2026-06-18 20:08:30 +00:00
opacity : disabled ? 0.6 : 1 ,
2026-06-18 18:44:26 +00:00
} }
>
{ children }
< / button >
)
}
function ProfileSection() {
2026-06-18 20:08:30 +00:00
const { user , setUser } = useAuth ( )
2026-06-18 18:50:43 +00:00
const fileInputRef = useRef < HTMLInputElement > ( null )
2026-06-18 20:08:30 +00:00
const [ displayName , setDisplayName ] = useState ( user ? . display_name ? ? '' )
const [ email , setEmail ] = useState ( user ? . email ? ? '' )
const [ avatar , setAvatar ] = useState < string | null > ( user ? . avatar_data_url ? ? null )
const [ saving , setSaving ] = useState ( false )
const [ savedMsg , setSavedMsg ] = useState ( '' )
useEffect ( ( ) = > {
setDisplayName ( user ? . display_name ? ? '' )
setEmail ( user ? . email ? ? '' )
setAvatar ( user ? . avatar_data_url ? ? null )
} , [ user ] )
const initials = ( displayName || user ? . username || '?' ) . slice ( 0 , 2 ) . toUpperCase ( )
2026-06-18 18:50:43 +00:00
function handleAvatarChange ( e : React.ChangeEvent < HTMLInputElement > ) {
const file = e . target . files ? . [ 0 ]
if ( ! file ) return
const reader = new FileReader ( )
reader . onload = ( ) = > setAvatar ( reader . result as string )
reader . readAsDataURL ( file )
}
2026-06-18 20:08:30 +00:00
async function handleSave() {
setSaving ( true )
setSavedMsg ( '' )
try {
const { user : updated } = await api . updateMe ( { displayName , email , avatarDataUrl : avatar } )
setUser ( updated )
setSavedMsg ( 'Saved' )
} catch ( err ) {
setSavedMsg ( err instanceof ApiError ? err . message : 'Failed to save' )
} finally {
setSaving ( false )
}
}
2026-06-18 18:44:26 +00:00
return (
< div style = { cardBase } >
< h3 style = { sectionTitle } > Profile < / h3 >
< div className = "flex items-center gap-4" style = { { marginBottom : '24px' } } >
< div
2026-06-18 18:50:43 +00:00
onClick = { ( ) = > fileInputRef . current ? . click ( ) }
className = "relative rounded-full border-2 flex items-center justify-center font-bold cursor-pointer group"
style = { {
width : '64px' ,
height : '64px' ,
borderColor : '#C8A434' ,
color : '#C8A434' ,
fontSize : '20px' ,
backgroundColor : 'rgba(200,164,52,0.08)' ,
backgroundImage : avatar ? ` url( ${ avatar } ) ` : undefined ,
backgroundSize : 'cover' ,
backgroundPosition : 'center' ,
overflow : 'hidden' ,
} }
title = "Upload photo"
2026-06-18 18:44:26 +00:00
>
2026-06-18 20:08:30 +00:00
{ ! avatar && initials }
2026-06-18 18:50:43 +00:00
< div
className = "absolute inset-0 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"
style = { { backgroundColor : 'rgba(0,0,0,0.55)' } }
>
< Camera size = { 18 } color = "#E8E6E0" / >
< / div >
2026-06-18 18:44:26 +00:00
< / div >
2026-06-18 18:50:43 +00:00
< input ref = { fileInputRef } type = "file" accept = "image/*" onChange = { handleAvatarChange } className = "hidden" / >
2026-06-18 18:44:26 +00:00
< div >
2026-06-18 20:08:30 +00:00
< span style = { { fontSize : '15px' , color : '#E8E6E0' , fontWeight : 600 } } > { displayName || user ? . username } < / span >
< br / >
< span style = { { fontSize : '12px' , color : '#7A7D85' } } > { email || 'No email set' } < / span >
2026-06-18 18:44:26 +00:00
< / div >
< / div >
< div className = "grid grid-cols-2 gap-4" style = { { marginBottom : '20px' } } >
< div >
< label style = { labelStyle } > Display Name < / label >
2026-06-18 20:08:30 +00:00
< input style = { inputStyle } value = { displayName } onChange = { ( e ) = > setDisplayName ( e . target . value ) } / >
2026-06-18 18:44:26 +00:00
< / div >
< div >
< label style = { labelStyle } > Email < / label >
2026-06-18 20:08:30 +00:00
< input style = { inputStyle } value = { email } onChange = { ( e ) = > setEmail ( e . target . value ) } / >
2026-06-18 18:44:26 +00:00
< / div >
< / div >
2026-06-18 20:08:30 +00:00
< div className = "flex items-center gap-3" >
< GoldButton onClick = { handleSave } disabled = { saving } >
< Check size = { 14 } / >
{ saving ? 'Saving…' : 'Save Changes' }
< / GoldButton >
{ savedMsg && < span style = { { fontSize : '12px' , color : '#7A7D85' } } > { savedMsg } < / span > }
< / div >
2026-06-18 18:44:26 +00:00
< / div >
)
}
function AppearanceSection() {
const [ theme , setTheme ] = useState < 'dark' | 'light' > ( 'dark' )
const [ accent , setAccent ] = useState ( 'Gold' )
const [ fontSize , setFontSize ] = useState ( 13 )
const [ radius , setRadius ] = useState ( 12 )
const [ sidebarExpanded , setSidebarExpanded ] = useState ( true )
const [ animations , setAnimations ] = useState ( true )
return (
< div style = { cardBase } >
< h3 style = { sectionTitle } > Appearance < / h3 >
< div className = "flex items-center justify-between" style = { { marginBottom : '20px' } } >
< span style = { { fontSize : '13px' , color : '#E8E6E0' } } > Theme < / span >
< div className = "flex items-center gap-1" style = { { backgroundColor : 'rgba(255,255,255,0.03)' , borderRadius : '8px' , padding : '3px' } } >
{ ( [ 'dark' , 'light' ] as const ) . map ( ( t ) = > (
< button
key = { t }
onClick = { ( ) = > setTheme ( t ) }
className = "cursor-pointer border-none capitalize"
style = { {
fontSize : '11px' ,
padding : '6px 14px' ,
borderRadius : '6px' ,
color : theme === t ? '#0A0B0D' : '#7A7D85' ,
backgroundColor : theme === t ? '#C8A434' : 'transparent' ,
fontWeight : 600 ,
} }
>
{ t }
< / button >
) ) }
< / div >
< / div >
< div style = { { marginBottom : '20px' } } >
< span style = { { fontSize : '13px' , color : '#E8E6E0' , marginBottom : '10px' , display : 'block' } } > Accent Color < / span >
< div className = "flex items-center gap-3" >
{ accentColors . map ( ( a ) = > (
< button
key = { a . name }
onClick = { ( ) = > setAccent ( a . name ) }
title = { a . name }
className = "cursor-pointer border-none rounded-full flex items-center justify-center"
style = { {
width : '28px' ,
height : '28px' ,
backgroundColor : a.color ,
outline : accent === a . name ? ` 2px solid ${ a . color } ` : 'none' ,
outlineOffset : '3px' ,
} }
>
{ accent === a . name && < Check size = { 14 } color = "#0A0B0D" / > }
< / button >
) ) }
< / div >
< / div >
< div style = { { marginBottom : '20px' } } >
< div className = "flex items-center justify-between" style = { { marginBottom : '8px' } } >
< span style = { { fontSize : '13px' , color : '#E8E6E0' } } > Font Size < / span >
< span style = { { fontSize : '11px' , color : '#C8A434' } } > { fontSize } px < / span >
< / div >
< input
type = "range"
min = { 12 }
max = { 16 }
value = { fontSize }
onChange = { ( e ) = > setFontSize ( Number ( e . target . value ) ) }
className = "w-full"
style = { { accentColor : '#C8A434' } }
/ >
< / div >
< div style = { { marginBottom : '20px' } } >
< div className = "flex items-center justify-between" style = { { marginBottom : '8px' } } >
< span style = { { fontSize : '13px' , color : '#E8E6E0' } } > Card Border Radius < / span >
< span style = { { fontSize : '11px' , color : '#C8A434' } } > { radius } px < / span >
< / div >
< input
type = "range"
min = { 4 }
max = { 16 }
value = { radius }
onChange = { ( e ) = > setRadius ( Number ( e . target . value ) ) }
className = "w-full"
style = { { accentColor : '#C8A434' } }
/ >
< / div >
< div className = "flex items-center justify-between" style = { { marginBottom : '16px' } } >
< span style = { { fontSize : '13px' , color : '#E8E6E0' } } > Sidebar Expanded by Default < / span >
< Toggle on = { sidebarExpanded } onClick = { ( ) = > setSidebarExpanded ( ( v ) = > ! v ) } / >
< / div >
< div className = "flex items-center justify-between" >
< span style = { { fontSize : '13px' , color : '#E8E6E0' } } > Animations < / span >
< Toggle on = { animations } onClick = { ( ) = > setAnimations ( ( v ) = > ! v ) } / >
< / div >
< / div >
)
}
2026-06-19 11:04:46 +00:00
function SshHostsSection() {
const [ hosts , setHosts ] = useState < Integration [ ] | null > ( null )
const [ revealed , setRevealed ] = useState < Set < string > > ( new Set ( ) )
const [ drafts , setDrafts ] = useState < Record < number , Record < string , string > > > ( { } )
const [ statusMsg , setStatusMsg ] = useState < Record < number , string > > ( { } )
const [ busy , setBusy ] = useState < Set < number > > ( new Set ( ) )
Fix integration save data loss; add SSH host card collapse (#16)
* Add editable display-name field to generic integrations
Lets users set a custom name for Proxmox, Docker, AWS, Remote Desktop,
Netbird, Cloudflare, Uptime Kuma, and Weather integrations, separate
from the host/IP field, mirroring the SSH host rename pattern.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kF4hZWEkRCPPvCZTeXxn4
* Surface the new-integration name field as a labeled input
The name field for new generic integrations was a faint header input
with only placeholder text, easy to miss. Move it into the form grid
as a proper labeled "Name" field next to the other connection fields.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kF4hZWEkRCPPvCZTeXxn4
* Add file upload for SSH private key and certificate fields
Lets users pick a key file from disk (e.g. ~/.ssh) instead of pasting
its contents into the Private Key / OPKSSH Certificate fields.
* Fix SSH private key paste corrupting multi-line PEM format
Private Key and Certificate fields were single-line <input> elements,
which strip newlines on paste and corrupt PEM-formatted keys (causing
'Unsupported key format' errors). Render them as multi-line textareas
instead so pasted keys keep their line breaks.
* Fix integration save wiping untouched config fields
The PUT /api/integrations/:id route fully overwrites config_json with
whatever config object is sent (no merge), but buildPayload only
included fields the user had actually edited. Saving after editing
just one field (e.g. pasting a new SSH key) silently dropped every
other config field. Merge the existing integration's config into the
payload before sending.
* Add collapse/expand for SSH host cards
Click the chevron to collapse a host's card once it's configured.
Collapsed cards keep all field state in memory (just hidden), and
auto-collapse after a successful Save.
* Install openssh-client in backend image for certificate-auth SSH
Certificate-based SSH connections shell out to the system ssh binary
via node-pty (ssh2 has no OpenSSH certificate support), but the
alpine runtime image never installed openssh-client. This caused
'execvp(3) failed: No such file or directory' for any host with an
OPKSSH certificate configured.
---------
Co-authored-by: Claude <noreply@anthropic.com>
2026-06-20 08:30:21 -04:00
const [ collapsed , setCollapsed ] = useState < Set < number > > ( new Set ( ) )
2026-06-19 11:04:46 +00:00
const [ newDrafts , setNewDrafts ] = useState < { key : number ; values : Record < string , string > } [ ] > ( [ ] )
const nextNewKey = useRef ( - 1 )
Add file upload for SSH private key and certificate fields (#15)
* Add editable display-name field to generic integrations
Lets users set a custom name for Proxmox, Docker, AWS, Remote Desktop,
Netbird, Cloudflare, Uptime Kuma, and Weather integrations, separate
from the host/IP field, mirroring the SSH host rename pattern.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kF4hZWEkRCPPvCZTeXxn4
* Surface the new-integration name field as a labeled input
The name field for new generic integrations was a faint header input
with only placeholder text, easy to miss. Move it into the form grid
as a proper labeled "Name" field next to the other connection fields.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kF4hZWEkRCPPvCZTeXxn4
* Add file upload for SSH private key and certificate fields
Lets users pick a key file from disk (e.g. ~/.ssh) instead of pasting
its contents into the Private Key / OPKSSH Certificate fields.
* Fix SSH private key paste corrupting multi-line PEM format
Private Key and Certificate fields were single-line <input> elements,
which strip newlines on paste and corrupt PEM-formatted keys (causing
'Unsupported key format' errors). Render them as multi-line textareas
instead so pasted keys keep their line breaks.
---------
Co-authored-by: Claude <noreply@anthropic.com>
2026-06-20 08:11:32 -04:00
const fileInputRefs = useRef < Record < string , HTMLInputElement | null > > ( { } )
Default-collapse already-configured SSH hosts on page load (#19)
* Add editable display-name field to generic integrations
Lets users set a custom name for Proxmox, Docker, AWS, Remote Desktop,
Netbird, Cloudflare, Uptime Kuma, and Weather integrations, separate
from the host/IP field, mirroring the SSH host rename pattern.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kF4hZWEkRCPPvCZTeXxn4
* Surface the new-integration name field as a labeled input
The name field for new generic integrations was a faint header input
with only placeholder text, easy to miss. Move it into the form grid
as a proper labeled "Name" field next to the other connection fields.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kF4hZWEkRCPPvCZTeXxn4
* Add file upload for SSH private key and certificate fields
Lets users pick a key file from disk (e.g. ~/.ssh) instead of pasting
its contents into the Private Key / OPKSSH Certificate fields.
* Fix SSH private key paste corrupting multi-line PEM format
Private Key and Certificate fields were single-line <input> elements,
which strip newlines on paste and corrupt PEM-formatted keys (causing
'Unsupported key format' errors). Render them as multi-line textareas
instead so pasted keys keep their line breaks.
* Default-collapse already-configured SSH hosts on page load
Previously every SSH host card reset to expanded on each page visit,
showing blank secret fields that looked like saved keys had been
deleted. Hosts with saved secrets now start collapsed on first load.
---------
Co-authored-by: Claude <noreply@anthropic.com>
2026-06-20 09:00:04 -04:00
const collapseInitialized = useRef ( false )
2026-06-19 11:04:46 +00:00
Fix integration save data loss; add SSH host card collapse (#16)
* Add editable display-name field to generic integrations
Lets users set a custom name for Proxmox, Docker, AWS, Remote Desktop,
Netbird, Cloudflare, Uptime Kuma, and Weather integrations, separate
from the host/IP field, mirroring the SSH host rename pattern.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kF4hZWEkRCPPvCZTeXxn4
* Surface the new-integration name field as a labeled input
The name field for new generic integrations was a faint header input
with only placeholder text, easy to miss. Move it into the form grid
as a proper labeled "Name" field next to the other connection fields.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kF4hZWEkRCPPvCZTeXxn4
* Add file upload for SSH private key and certificate fields
Lets users pick a key file from disk (e.g. ~/.ssh) instead of pasting
its contents into the Private Key / OPKSSH Certificate fields.
* Fix SSH private key paste corrupting multi-line PEM format
Private Key and Certificate fields were single-line <input> elements,
which strip newlines on paste and corrupt PEM-formatted keys (causing
'Unsupported key format' errors). Render them as multi-line textareas
instead so pasted keys keep their line breaks.
* Fix integration save wiping untouched config fields
The PUT /api/integrations/:id route fully overwrites config_json with
whatever config object is sent (no merge), but buildPayload only
included fields the user had actually edited. Saving after editing
just one field (e.g. pasting a new SSH key) silently dropped every
other config field. Merge the existing integration's config into the
payload before sending.
* Add collapse/expand for SSH host cards
Click the chevron to collapse a host's card once it's configured.
Collapsed cards keep all field state in memory (just hidden), and
auto-collapse after a successful Save.
* Install openssh-client in backend image for certificate-auth SSH
Certificate-based SSH connections shell out to the system ssh binary
via node-pty (ssh2 has no OpenSSH certificate support), but the
alpine runtime image never installed openssh-client. This caused
'execvp(3) failed: No such file or directory' for any host with an
OPKSSH certificate configured.
---------
Co-authored-by: Claude <noreply@anthropic.com>
2026-06-20 08:30:21 -04:00
function toggleCollapsed ( id : number ) {
setCollapsed ( ( prev ) = > {
const next = new Set ( prev )
if ( next . has ( id ) ) next . delete ( id )
else next . add ( id )
return next
} )
}
2026-06-19 11:04:46 +00:00
useEffect ( ( ) = > {
refresh ( )
} , [ ] )
function refresh() {
Default-collapse already-configured SSH hosts on page load (#19)
* Add editable display-name field to generic integrations
Lets users set a custom name for Proxmox, Docker, AWS, Remote Desktop,
Netbird, Cloudflare, Uptime Kuma, and Weather integrations, separate
from the host/IP field, mirroring the SSH host rename pattern.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kF4hZWEkRCPPvCZTeXxn4
* Surface the new-integration name field as a labeled input
The name field for new generic integrations was a faint header input
with only placeholder text, easy to miss. Move it into the form grid
as a proper labeled "Name" field next to the other connection fields.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kF4hZWEkRCPPvCZTeXxn4
* Add file upload for SSH private key and certificate fields
Lets users pick a key file from disk (e.g. ~/.ssh) instead of pasting
its contents into the Private Key / OPKSSH Certificate fields.
* Fix SSH private key paste corrupting multi-line PEM format
Private Key and Certificate fields were single-line <input> elements,
which strip newlines on paste and corrupt PEM-formatted keys (causing
'Unsupported key format' errors). Render them as multi-line textareas
instead so pasted keys keep their line breaks.
* Default-collapse already-configured SSH hosts on page load
Previously every SSH host card reset to expanded on each page visit,
showing blank secret fields that looked like saved keys had been
deleted. Hosts with saved secrets now start collapsed on first load.
---------
Co-authored-by: Claude <noreply@anthropic.com>
2026-06-20 09:00:04 -04:00
api . listIntegrations ( ) . then ( ( { integrations } ) = > {
const sshHosts = integrations . filter ( ( i ) = > i . type === 'ssh' )
setHosts ( sshHosts )
if ( ! collapseInitialized . current ) {
collapseInitialized . current = true
setCollapsed ( new Set ( sshHosts . filter ( ( h ) = > h . secretKeys . length > 0 ) . map ( ( h ) = > h . id ) ) )
}
} )
2026-06-19 11:04:46 +00:00
}
function toggleReveal ( key : string ) {
setRevealed ( ( prev ) = > {
const next = new Set ( prev )
if ( next . has ( key ) ) next . delete ( key )
else next . add ( key )
return next
} )
}
function setBusyFlag ( id : number , value : boolean ) {
setBusy ( ( prev ) = > {
const next = new Set ( prev )
if ( value ) next . add ( id )
else next . delete ( id )
return next
} )
}
function addNewHost() {
const key = nextNewKey . current --
setNewDrafts ( ( prev ) = > [ . . . prev , { key , values : { } } ] )
}
function setNewDraftField ( key : number , fieldKey : string , value : string ) {
setNewDrafts ( ( prev ) = > prev . map ( ( d ) = > ( d . key === key ? { . . . d , values : { . . . d . values , [ fieldKey ] : value } } : d ) ) )
}
function removeNewDraft ( key : number ) {
setNewDrafts ( ( prev ) = > prev . filter ( ( d ) = > d . key !== key ) )
}
function setDraftField ( id : number , fieldKey : string , value : string ) {
setDrafts ( ( prev ) = > ( { . . . prev , [ id ] : { . . . prev [ id ] , [ fieldKey ] : value } } ) )
}
2026-06-19 11:12:33 +00:00
const fieldsWithJumpHost = ( ) : FieldDef [ ] = > [
2026-06-19 11:04:46 +00:00
. . . sshFields ,
{ key : 'jumpHostIntegrationId' , label : 'Jump Host (optional)' } ,
2026-06-19 11:28:51 +00:00
{ key : 'sessionLogging' , label : 'Record session to disk' } ,
2026-06-19 11:04:46 +00:00
]
Fix integration save data loss; add SSH host card collapse (#16)
* Add editable display-name field to generic integrations
Lets users set a custom name for Proxmox, Docker, AWS, Remote Desktop,
Netbird, Cloudflare, Uptime Kuma, and Weather integrations, separate
from the host/IP field, mirroring the SSH host rename pattern.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kF4hZWEkRCPPvCZTeXxn4
* Surface the new-integration name field as a labeled input
The name field for new generic integrations was a faint header input
with only placeholder text, easy to miss. Move it into the form grid
as a proper labeled "Name" field next to the other connection fields.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kF4hZWEkRCPPvCZTeXxn4
* Add file upload for SSH private key and certificate fields
Lets users pick a key file from disk (e.g. ~/.ssh) instead of pasting
its contents into the Private Key / OPKSSH Certificate fields.
* Fix SSH private key paste corrupting multi-line PEM format
Private Key and Certificate fields were single-line <input> elements,
which strip newlines on paste and corrupt PEM-formatted keys (causing
'Unsupported key format' errors). Render them as multi-line textareas
instead so pasted keys keep their line breaks.
* Fix integration save wiping untouched config fields
The PUT /api/integrations/:id route fully overwrites config_json with
whatever config object is sent (no merge), but buildPayload only
included fields the user had actually edited. Saving after editing
just one field (e.g. pasting a new SSH key) silently dropped every
other config field. Merge the existing integration's config into the
payload before sending.
* Add collapse/expand for SSH host cards
Click the chevron to collapse a host's card once it's configured.
Collapsed cards keep all field state in memory (just hidden), and
auto-collapse after a successful Save.
* Install openssh-client in backend image for certificate-auth SSH
Certificate-based SSH connections shell out to the system ssh binary
via node-pty (ssh2 has no OpenSSH certificate support), but the
alpine runtime image never installed openssh-client. This caused
'execvp(3) failed: No such file or directory' for any host with an
OPKSSH certificate configured.
---------
Co-authored-by: Claude <noreply@anthropic.com>
2026-06-20 08:30:21 -04:00
function buildPayload ( fields : FieldDef [ ] , values : Record < string , string > , existing? : Integration ) {
const config : Record < string , string > = { . . . ( existing ? . config ? ? { } ) }
2026-06-19 11:04:46 +00:00
const secrets : Record < string , string > = { }
for ( const f of fields ) {
const value = values [ f . key ]
if ( value === undefined ) continue
if ( f . secret ) secrets [ f . key ] = value
else config [ f . key ] = value
}
return { config , secrets }
}
async function handleSaveExisting ( host : Integration ) {
setBusyFlag ( host . id , true )
setStatusMsg ( ( prev ) = > ( { . . . prev , [ host . id ] : '' } ) )
try {
const draft = drafts [ host . id ] ? ? { }
Fix integration save data loss; add SSH host card collapse (#16)
* Add editable display-name field to generic integrations
Lets users set a custom name for Proxmox, Docker, AWS, Remote Desktop,
Netbird, Cloudflare, Uptime Kuma, and Weather integrations, separate
from the host/IP field, mirroring the SSH host rename pattern.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kF4hZWEkRCPPvCZTeXxn4
* Surface the new-integration name field as a labeled input
The name field for new generic integrations was a faint header input
with only placeholder text, easy to miss. Move it into the form grid
as a proper labeled "Name" field next to the other connection fields.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kF4hZWEkRCPPvCZTeXxn4
* Add file upload for SSH private key and certificate fields
Lets users pick a key file from disk (e.g. ~/.ssh) instead of pasting
its contents into the Private Key / OPKSSH Certificate fields.
* Fix SSH private key paste corrupting multi-line PEM format
Private Key and Certificate fields were single-line <input> elements,
which strip newlines on paste and corrupt PEM-formatted keys (causing
'Unsupported key format' errors). Render them as multi-line textareas
instead so pasted keys keep their line breaks.
* Fix integration save wiping untouched config fields
The PUT /api/integrations/:id route fully overwrites config_json with
whatever config object is sent (no merge), but buildPayload only
included fields the user had actually edited. Saving after editing
just one field (e.g. pasting a new SSH key) silently dropped every
other config field. Merge the existing integration's config into the
payload before sending.
* Add collapse/expand for SSH host cards
Click the chevron to collapse a host's card once it's configured.
Collapsed cards keep all field state in memory (just hidden), and
auto-collapse after a successful Save.
* Install openssh-client in backend image for certificate-auth SSH
Certificate-based SSH connections shell out to the system ssh binary
via node-pty (ssh2 has no OpenSSH certificate support), but the
alpine runtime image never installed openssh-client. This caused
'execvp(3) failed: No such file or directory' for any host with an
OPKSSH certificate configured.
---------
Co-authored-by: Claude <noreply@anthropic.com>
2026-06-20 08:30:21 -04:00
const { config , secrets } = buildPayload ( fieldsWithJumpHost ( ) , draft , host )
2026-06-20 07:23:22 -04:00
const name = draft . __name ? . trim ( )
const { integration } = await api . updateIntegration ( host . id , { . . . ( name ? { name } : { } ) , config , secrets } )
2026-06-19 11:04:46 +00:00
setHosts ( ( prev ) = > ( prev ? ? [ ] ) . map ( ( h ) = > ( h . id === integration . id ? integration : h ) ) )
setStatusMsg ( ( prev ) = > ( { . . . prev , [ host . id ] : 'Saved' } ) )
Fix integration save data loss; add SSH host card collapse (#16)
* Add editable display-name field to generic integrations
Lets users set a custom name for Proxmox, Docker, AWS, Remote Desktop,
Netbird, Cloudflare, Uptime Kuma, and Weather integrations, separate
from the host/IP field, mirroring the SSH host rename pattern.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kF4hZWEkRCPPvCZTeXxn4
* Surface the new-integration name field as a labeled input
The name field for new generic integrations was a faint header input
with only placeholder text, easy to miss. Move it into the form grid
as a proper labeled "Name" field next to the other connection fields.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kF4hZWEkRCPPvCZTeXxn4
* Add file upload for SSH private key and certificate fields
Lets users pick a key file from disk (e.g. ~/.ssh) instead of pasting
its contents into the Private Key / OPKSSH Certificate fields.
* Fix SSH private key paste corrupting multi-line PEM format
Private Key and Certificate fields were single-line <input> elements,
which strip newlines on paste and corrupt PEM-formatted keys (causing
'Unsupported key format' errors). Render them as multi-line textareas
instead so pasted keys keep their line breaks.
* Fix integration save wiping untouched config fields
The PUT /api/integrations/:id route fully overwrites config_json with
whatever config object is sent (no merge), but buildPayload only
included fields the user had actually edited. Saving after editing
just one field (e.g. pasting a new SSH key) silently dropped every
other config field. Merge the existing integration's config into the
payload before sending.
* Add collapse/expand for SSH host cards
Click the chevron to collapse a host's card once it's configured.
Collapsed cards keep all field state in memory (just hidden), and
auto-collapse after a successful Save.
* Install openssh-client in backend image for certificate-auth SSH
Certificate-based SSH connections shell out to the system ssh binary
via node-pty (ssh2 has no OpenSSH certificate support), but the
alpine runtime image never installed openssh-client. This caused
'execvp(3) failed: No such file or directory' for any host with an
OPKSSH certificate configured.
---------
Co-authored-by: Claude <noreply@anthropic.com>
2026-06-20 08:30:21 -04:00
setCollapsed ( ( prev ) = > new Set ( prev ) . add ( host . id ) )
2026-06-19 11:04:46 +00:00
} catch ( err ) {
setStatusMsg ( ( prev ) = > ( { . . . prev , [ host . id ] : err instanceof ApiError ? err . message : 'Save failed' } ) )
} finally {
setBusyFlag ( host . id , false )
}
}
async function handleSaveNew ( key : number , values : Record < string , string > ) {
setBusyFlag ( key , true )
try {
const { config , secrets } = buildPayload ( fieldsWithJumpHost ( ) , values )
Add explicit Host Name field for SSH hosts (#14)
* Add editable display-name field to generic integrations
Lets users set a custom name for Proxmox, Docker, AWS, Remote Desktop,
Netbird, Cloudflare, Uptime Kuma, and Weather integrations, separate
from the host/IP field, mirroring the SSH host rename pattern.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kF4hZWEkRCPPvCZTeXxn4
* Surface the new-integration name field as a labeled input
The name field for new generic integrations was a faint header input
with only placeholder text, easy to miss. Move it into the form grid
as a proper labeled "Name" field next to the other connection fields.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kF4hZWEkRCPPvCZTeXxn4
* Add explicit Host Name field for SSH hosts
The SSH host name was only editable via a small inline header input,
not a clearly labeled form field like the other options (Host / IP,
Port, etc). Add a proper "Host Name" labeled field to both existing
and new SSH host forms.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kF4hZWEkRCPPvCZTeXxn4
---------
Co-authored-by: Claude <noreply@anthropic.com>
2026-06-20 07:48:22 -04:00
const name = values . __name ? . trim ( ) || ( values . host ? ` SSH: ${ values . host } ` : 'SSH Host' )
2026-06-19 11:04:46 +00:00
await api . createIntegration ( { type : 'ssh' , name , config , secrets } )
removeNewDraft ( key )
refresh ( )
} catch ( err ) {
setStatusMsg ( ( prev ) = > ( { . . . prev , [ key ] : err instanceof ApiError ? err . message : 'Save failed' } ) )
} finally {
setBusyFlag ( key , false )
}
}
async function handleTest ( host : Integration ) {
setBusyFlag ( host . id , true )
try {
const result = await api . testIntegration ( host . id )
setStatusMsg ( ( prev ) = > ( { . . . prev , [ host . id ] : result . message } ) )
refresh ( )
} catch ( err ) {
setStatusMsg ( ( prev ) = > ( { . . . prev , [ host . id ] : err instanceof ApiError ? err . message : 'Test failed' } ) )
} finally {
setBusyFlag ( host . id , false )
}
}
async function handleDelete ( host : Integration ) {
setBusyFlag ( host . id , true )
try {
await api . deleteIntegration ( host . id )
refresh ( )
} catch ( err ) {
setStatusMsg ( ( prev ) = > ( { . . . prev , [ host . id ] : err instanceof ApiError ? err . message : 'Delete failed' } ) )
setBusyFlag ( host . id , false )
}
}
function renderFields (
fields : FieldDef [ ] ,
values : Record < string , string > ,
onChange : ( fieldKey : string , value : string ) = > void ,
idForReveal : number ,
existing : Integration | undefined ,
excludeHostId? : number ,
) {
return fields . map ( ( f ) = > {
const key = ` ${ idForReveal } - ${ f . key } `
if ( f . key === 'jumpHostIntegrationId' ) {
const options = ( hosts ? ? [ ] ) . filter ( ( h ) = > h . id !== excludeHostId )
const savedValue = existing ? . config [ f . key ] ? ? ''
const value = values [ f . key ] ? ? savedValue
return (
< div key = { key } >
< label style = { labelStyle } > { f . label } < / label >
< select style = { inputStyle } value = { value } onChange = { ( e ) = > onChange ( f . key , e . target . value ) } >
< option value = "" > None < / option >
{ options . map ( ( h ) = > (
< option key = { h . id } value = { String ( h . id ) } >
{ h . name }
< / option >
) ) }
< / select >
< / div >
)
}
2026-06-19 11:28:51 +00:00
if ( f . key === 'sessionLogging' ) {
const savedValue = existing ? . config [ f . key ] === 'true'
const value = values [ f . key ] !== undefined ? values [ f . key ] === 'true' : savedValue
return (
< div key = { key } className = "flex items-end pb-1.5" >
< label className = "flex items-center gap-2 text-xs" style = { { color : '#E8E6E0' } } >
< input type = "checkbox" checked = { value } onChange = { ( e ) = > onChange ( f . key , e . target . checked ? 'true' : 'false' ) } / >
{ f . label }
< / label >
< / div >
)
}
2026-06-19 11:04:46 +00:00
const isRevealed = revealed . has ( key )
const savedValue = f . secret ? '' : existing ? . config [ f . key ] ? ? ''
const value = values [ f . key ] ? ? savedValue
Add file upload for SSH private key and certificate fields (#15)
* Add editable display-name field to generic integrations
Lets users set a custom name for Proxmox, Docker, AWS, Remote Desktop,
Netbird, Cloudflare, Uptime Kuma, and Weather integrations, separate
from the host/IP field, mirroring the SSH host rename pattern.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kF4hZWEkRCPPvCZTeXxn4
* Surface the new-integration name field as a labeled input
The name field for new generic integrations was a faint header input
with only placeholder text, easy to miss. Move it into the form grid
as a proper labeled "Name" field next to the other connection fields.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kF4hZWEkRCPPvCZTeXxn4
* Add file upload for SSH private key and certificate fields
Lets users pick a key file from disk (e.g. ~/.ssh) instead of pasting
its contents into the Private Key / OPKSSH Certificate fields.
* Fix SSH private key paste corrupting multi-line PEM format
Private Key and Certificate fields were single-line <input> elements,
which strip newlines on paste and corrupt PEM-formatted keys (causing
'Unsupported key format' errors). Render them as multi-line textareas
instead so pasted keys keep their line breaks.
---------
Co-authored-by: Claude <noreply@anthropic.com>
2026-06-20 08:11:32 -04:00
if ( f . file ) {
Show saved indicator for secret fields instead of appearing deleted (#18)
* Add editable display-name field to generic integrations
Lets users set a custom name for Proxmox, Docker, AWS, Remote Desktop,
Netbird, Cloudflare, Uptime Kuma, and Weather integrations, separate
from the host/IP field, mirroring the SSH host rename pattern.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kF4hZWEkRCPPvCZTeXxn4
* Surface the new-integration name field as a labeled input
The name field for new generic integrations was a faint header input
with only placeholder text, easy to miss. Move it into the form grid
as a proper labeled "Name" field next to the other connection fields.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kF4hZWEkRCPPvCZTeXxn4
* Add file upload for SSH private key and certificate fields
Lets users pick a key file from disk (e.g. ~/.ssh) instead of pasting
its contents into the Private Key / OPKSSH Certificate fields.
* Fix SSH private key paste corrupting multi-line PEM format
Private Key and Certificate fields were single-line <input> elements,
which strip newlines on paste and corrupt PEM-formatted keys (causing
'Unsupported key format' errors). Render them as multi-line textareas
instead so pasted keys keep their line breaks.
* Show saved indicator for secret fields instead of appearing blank/deleted
GET /api/integrations never returns decrypted secret values (by design),
so after navigating away and back, secret/key fields rendered empty -
looking exactly like the saved key had been deleted, even though it was
still intact and encrypted in the database. Expose which secret keys
exist (names only, never values) via secretKeys, and use it to label
fields as "saved" with an appropriate placeholder instead of blank.
---------
Co-authored-by: Claude <noreply@anthropic.com>
2026-06-20 08:53:56 -04:00
const isSaved = existing ? . secretKeys ? . includes ( f . key )
Add file upload for SSH private key and certificate fields (#15)
* Add editable display-name field to generic integrations
Lets users set a custom name for Proxmox, Docker, AWS, Remote Desktop,
Netbird, Cloudflare, Uptime Kuma, and Weather integrations, separate
from the host/IP field, mirroring the SSH host rename pattern.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kF4hZWEkRCPPvCZTeXxn4
* Surface the new-integration name field as a labeled input
The name field for new generic integrations was a faint header input
with only placeholder text, easy to miss. Move it into the form grid
as a proper labeled "Name" field next to the other connection fields.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kF4hZWEkRCPPvCZTeXxn4
* Add file upload for SSH private key and certificate fields
Lets users pick a key file from disk (e.g. ~/.ssh) instead of pasting
its contents into the Private Key / OPKSSH Certificate fields.
* Fix SSH private key paste corrupting multi-line PEM format
Private Key and Certificate fields were single-line <input> elements,
which strip newlines on paste and corrupt PEM-formatted keys (causing
'Unsupported key format' errors). Render them as multi-line textareas
instead so pasted keys keep their line breaks.
---------
Co-authored-by: Claude <noreply@anthropic.com>
2026-06-20 08:11:32 -04:00
return (
< div key = { key } className = "col-span-3" >
Show saved indicator for secret fields instead of appearing deleted (#18)
* Add editable display-name field to generic integrations
Lets users set a custom name for Proxmox, Docker, AWS, Remote Desktop,
Netbird, Cloudflare, Uptime Kuma, and Weather integrations, separate
from the host/IP field, mirroring the SSH host rename pattern.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kF4hZWEkRCPPvCZTeXxn4
* Surface the new-integration name field as a labeled input
The name field for new generic integrations was a faint header input
with only placeholder text, easy to miss. Move it into the form grid
as a proper labeled "Name" field next to the other connection fields.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kF4hZWEkRCPPvCZTeXxn4
* Add file upload for SSH private key and certificate fields
Lets users pick a key file from disk (e.g. ~/.ssh) instead of pasting
its contents into the Private Key / OPKSSH Certificate fields.
* Fix SSH private key paste corrupting multi-line PEM format
Private Key and Certificate fields were single-line <input> elements,
which strip newlines on paste and corrupt PEM-formatted keys (causing
'Unsupported key format' errors). Render them as multi-line textareas
instead so pasted keys keep their line breaks.
* Show saved indicator for secret fields instead of appearing blank/deleted
GET /api/integrations never returns decrypted secret values (by design),
so after navigating away and back, secret/key fields rendered empty -
looking exactly like the saved key had been deleted, even though it was
still intact and encrypted in the database. Expose which secret keys
exist (names only, never values) via secretKeys, and use it to label
fields as "saved" with an appropriate placeholder instead of blank.
---------
Co-authored-by: Claude <noreply@anthropic.com>
2026-06-20 08:53:56 -04:00
< label style = { labelStyle } >
{ f . label }
{ isSaved && < span style = { { color : '#2ECC71' , fontWeight : 400 } } > · saved < / span > }
< / label >
Add file upload for SSH private key and certificate fields (#15)
* Add editable display-name field to generic integrations
Lets users set a custom name for Proxmox, Docker, AWS, Remote Desktop,
Netbird, Cloudflare, Uptime Kuma, and Weather integrations, separate
from the host/IP field, mirroring the SSH host rename pattern.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kF4hZWEkRCPPvCZTeXxn4
* Surface the new-integration name field as a labeled input
The name field for new generic integrations was a faint header input
with only placeholder text, easy to miss. Move it into the form grid
as a proper labeled "Name" field next to the other connection fields.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kF4hZWEkRCPPvCZTeXxn4
* Add file upload for SSH private key and certificate fields
Lets users pick a key file from disk (e.g. ~/.ssh) instead of pasting
its contents into the Private Key / OPKSSH Certificate fields.
* Fix SSH private key paste corrupting multi-line PEM format
Private Key and Certificate fields were single-line <input> elements,
which strip newlines on paste and corrupt PEM-formatted keys (causing
'Unsupported key format' errors). Render them as multi-line textareas
instead so pasted keys keep their line breaks.
---------
Co-authored-by: Claude <noreply@anthropic.com>
2026-06-20 08:11:32 -04:00
< div className = "relative" >
< textarea
style = { { . . . inputStyle , height : '90px' , paddingRight : '32px' , fontFamily : 'monospace' , resize : 'vertical' , whiteSpace : 'pre' } }
value = { value }
onChange = { ( e ) = > onChange ( f . key , e . target . value ) }
Show saved indicator for secret fields instead of appearing deleted (#18)
* Add editable display-name field to generic integrations
Lets users set a custom name for Proxmox, Docker, AWS, Remote Desktop,
Netbird, Cloudflare, Uptime Kuma, and Weather integrations, separate
from the host/IP field, mirroring the SSH host rename pattern.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kF4hZWEkRCPPvCZTeXxn4
* Surface the new-integration name field as a labeled input
The name field for new generic integrations was a faint header input
with only placeholder text, easy to miss. Move it into the form grid
as a proper labeled "Name" field next to the other connection fields.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kF4hZWEkRCPPvCZTeXxn4
* Add file upload for SSH private key and certificate fields
Lets users pick a key file from disk (e.g. ~/.ssh) instead of pasting
its contents into the Private Key / OPKSSH Certificate fields.
* Fix SSH private key paste corrupting multi-line PEM format
Private Key and Certificate fields were single-line <input> elements,
which strip newlines on paste and corrupt PEM-formatted keys (causing
'Unsupported key format' errors). Render them as multi-line textareas
instead so pasted keys keep their line breaks.
* Show saved indicator for secret fields instead of appearing blank/deleted
GET /api/integrations never returns decrypted secret values (by design),
so after navigating away and back, secret/key fields rendered empty -
looking exactly like the saved key had been deleted, even though it was
still intact and encrypted in the database. Expose which secret keys
exist (names only, never values) via secretKeys, and use it to label
fields as "saved" with an appropriate placeholder instead of blank.
---------
Co-authored-by: Claude <noreply@anthropic.com>
2026-06-20 08:53:56 -04:00
placeholder = { isSaved ? '•••••••••••• (saved — paste to replace)' : 'Paste key contents here, or upload a file' }
Add file upload for SSH private key and certificate fields (#15)
* Add editable display-name field to generic integrations
Lets users set a custom name for Proxmox, Docker, AWS, Remote Desktop,
Netbird, Cloudflare, Uptime Kuma, and Weather integrations, separate
from the host/IP field, mirroring the SSH host rename pattern.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kF4hZWEkRCPPvCZTeXxn4
* Surface the new-integration name field as a labeled input
The name field for new generic integrations was a faint header input
with only placeholder text, easy to miss. Move it into the form grid
as a proper labeled "Name" field next to the other connection fields.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kF4hZWEkRCPPvCZTeXxn4
* Add file upload for SSH private key and certificate fields
Lets users pick a key file from disk (e.g. ~/.ssh) instead of pasting
its contents into the Private Key / OPKSSH Certificate fields.
* Fix SSH private key paste corrupting multi-line PEM format
Private Key and Certificate fields were single-line <input> elements,
which strip newlines on paste and corrupt PEM-formatted keys (causing
'Unsupported key format' errors). Render them as multi-line textareas
instead so pasted keys keep their line breaks.
---------
Co-authored-by: Claude <noreply@anthropic.com>
2026-06-20 08:11:32 -04:00
/ >
< input
type = "file"
ref = { ( el ) = > { fileInputRefs . current [ key ] = el } }
style = { { display : 'none' } }
onChange = { ( e ) = > {
const file = e . target . files ? . [ 0 ]
if ( ! file ) return
const reader = new FileReader ( )
reader . onload = ( ) = > onChange ( f . key , String ( reader . result ? ? '' ) . trim ( ) )
reader . readAsText ( file )
e . target . value = ''
} }
/ >
< button
onClick = { ( ) = > fileInputRefs . current [ key ] ? . click ( ) }
title = "Upload from file"
className = "absolute cursor-pointer border-none bg-transparent"
style = { { right : '8px' , top : '8px' , color : '#7A7D85' } }
>
< Upload size = { 13 } / >
< / button >
< / div >
< / div >
)
}
Show saved indicator for secret fields instead of appearing deleted (#18)
* Add editable display-name field to generic integrations
Lets users set a custom name for Proxmox, Docker, AWS, Remote Desktop,
Netbird, Cloudflare, Uptime Kuma, and Weather integrations, separate
from the host/IP field, mirroring the SSH host rename pattern.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kF4hZWEkRCPPvCZTeXxn4
* Surface the new-integration name field as a labeled input
The name field for new generic integrations was a faint header input
with only placeholder text, easy to miss. Move it into the form grid
as a proper labeled "Name" field next to the other connection fields.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kF4hZWEkRCPPvCZTeXxn4
* Add file upload for SSH private key and certificate fields
Lets users pick a key file from disk (e.g. ~/.ssh) instead of pasting
its contents into the Private Key / OPKSSH Certificate fields.
* Fix SSH private key paste corrupting multi-line PEM format
Private Key and Certificate fields were single-line <input> elements,
which strip newlines on paste and corrupt PEM-formatted keys (causing
'Unsupported key format' errors). Render them as multi-line textareas
instead so pasted keys keep their line breaks.
* Show saved indicator for secret fields instead of appearing blank/deleted
GET /api/integrations never returns decrypted secret values (by design),
so after navigating away and back, secret/key fields rendered empty -
looking exactly like the saved key had been deleted, even though it was
still intact and encrypted in the database. Expose which secret keys
exist (names only, never values) via secretKeys, and use it to label
fields as "saved" with an appropriate placeholder instead of blank.
---------
Co-authored-by: Claude <noreply@anthropic.com>
2026-06-20 08:53:56 -04:00
const isSavedSecret = f . secret && existing ? . secretKeys ? . includes ( f . key )
2026-06-19 11:04:46 +00:00
return (
< div key = { key } >
Show saved indicator for secret fields instead of appearing deleted (#18)
* Add editable display-name field to generic integrations
Lets users set a custom name for Proxmox, Docker, AWS, Remote Desktop,
Netbird, Cloudflare, Uptime Kuma, and Weather integrations, separate
from the host/IP field, mirroring the SSH host rename pattern.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kF4hZWEkRCPPvCZTeXxn4
* Surface the new-integration name field as a labeled input
The name field for new generic integrations was a faint header input
with only placeholder text, easy to miss. Move it into the form grid
as a proper labeled "Name" field next to the other connection fields.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kF4hZWEkRCPPvCZTeXxn4
* Add file upload for SSH private key and certificate fields
Lets users pick a key file from disk (e.g. ~/.ssh) instead of pasting
its contents into the Private Key / OPKSSH Certificate fields.
* Fix SSH private key paste corrupting multi-line PEM format
Private Key and Certificate fields were single-line <input> elements,
which strip newlines on paste and corrupt PEM-formatted keys (causing
'Unsupported key format' errors). Render them as multi-line textareas
instead so pasted keys keep their line breaks.
* Show saved indicator for secret fields instead of appearing blank/deleted
GET /api/integrations never returns decrypted secret values (by design),
so after navigating away and back, secret/key fields rendered empty -
looking exactly like the saved key had been deleted, even though it was
still intact and encrypted in the database. Expose which secret keys
exist (names only, never values) via secretKeys, and use it to label
fields as "saved" with an appropriate placeholder instead of blank.
---------
Co-authored-by: Claude <noreply@anthropic.com>
2026-06-20 08:53:56 -04:00
< label style = { labelStyle } >
{ f . label }
{ isSavedSecret && < span style = { { color : '#2ECC71' , fontWeight : 400 } } > · saved < / span > }
< / label >
2026-06-19 11:04:46 +00:00
< div className = "relative" >
< input
Add file upload for SSH private key and certificate fields (#15)
* Add editable display-name field to generic integrations
Lets users set a custom name for Proxmox, Docker, AWS, Remote Desktop,
Netbird, Cloudflare, Uptime Kuma, and Weather integrations, separate
from the host/IP field, mirroring the SSH host rename pattern.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kF4hZWEkRCPPvCZTeXxn4
* Surface the new-integration name field as a labeled input
The name field for new generic integrations was a faint header input
with only placeholder text, easy to miss. Move it into the form grid
as a proper labeled "Name" field next to the other connection fields.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kF4hZWEkRCPPvCZTeXxn4
* Add file upload for SSH private key and certificate fields
Lets users pick a key file from disk (e.g. ~/.ssh) instead of pasting
its contents into the Private Key / OPKSSH Certificate fields.
* Fix SSH private key paste corrupting multi-line PEM format
Private Key and Certificate fields were single-line <input> elements,
which strip newlines on paste and corrupt PEM-formatted keys (causing
'Unsupported key format' errors). Render them as multi-line textareas
instead so pasted keys keep their line breaks.
---------
Co-authored-by: Claude <noreply@anthropic.com>
2026-06-20 08:11:32 -04:00
style = { { . . . inputStyle , paddingRight : f.secret ? '32px' : undefined } }
2026-06-19 11:04:46 +00:00
type = { f . secret && ! isRevealed ? 'password' : 'text' }
value = { value }
onChange = { ( e ) = > onChange ( f . key , e . target . value ) }
Show saved indicator for secret fields instead of appearing deleted (#18)
* Add editable display-name field to generic integrations
Lets users set a custom name for Proxmox, Docker, AWS, Remote Desktop,
Netbird, Cloudflare, Uptime Kuma, and Weather integrations, separate
from the host/IP field, mirroring the SSH host rename pattern.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kF4hZWEkRCPPvCZTeXxn4
* Surface the new-integration name field as a labeled input
The name field for new generic integrations was a faint header input
with only placeholder text, easy to miss. Move it into the form grid
as a proper labeled "Name" field next to the other connection fields.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kF4hZWEkRCPPvCZTeXxn4
* Add file upload for SSH private key and certificate fields
Lets users pick a key file from disk (e.g. ~/.ssh) instead of pasting
its contents into the Private Key / OPKSSH Certificate fields.
* Fix SSH private key paste corrupting multi-line PEM format
Private Key and Certificate fields were single-line <input> elements,
which strip newlines on paste and corrupt PEM-formatted keys (causing
'Unsupported key format' errors). Render them as multi-line textareas
instead so pasted keys keep their line breaks.
* Show saved indicator for secret fields instead of appearing blank/deleted
GET /api/integrations never returns decrypted secret values (by design),
so after navigating away and back, secret/key fields rendered empty -
looking exactly like the saved key had been deleted, even though it was
still intact and encrypted in the database. Expose which secret keys
exist (names only, never values) via secretKeys, and use it to label
fields as "saved" with an appropriate placeholder instead of blank.
---------
Co-authored-by: Claude <noreply@anthropic.com>
2026-06-20 08:53:56 -04:00
placeholder = { isSavedSecret ? '•••••••••••• (saved — type to replace)' : 'Not configured' }
2026-06-19 11:04:46 +00:00
/ >
{ f . secret && (
< button
onClick = { ( ) = > toggleReveal ( key ) }
className = "absolute cursor-pointer border-none bg-transparent"
style = { { right : '8px' , top : '50%' , transform : 'translateY(-50%)' , color : '#7A7D85' } }
>
{ isRevealed ? < EyeOff size = { 13 } / > : < Eye size = { 13 } / > }
< / button >
) }
< / div >
< / div >
)
} )
}
if ( ! hosts ) {
return (
< div style = { cardBase } >
< p style = { { fontSize : '12px' , color : '#7A7D85' } } > Loading SSH hosts … < / p >
< / div >
)
}
return (
< div className = "flex flex-col gap-4" >
{ hosts . map ( ( host ) = > {
const online = host . status === 'connected'
const draft = drafts [ host . id ] ? ? { }
Fix integration save data loss; add SSH host card collapse (#16)
* Add editable display-name field to generic integrations
Lets users set a custom name for Proxmox, Docker, AWS, Remote Desktop,
Netbird, Cloudflare, Uptime Kuma, and Weather integrations, separate
from the host/IP field, mirroring the SSH host rename pattern.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kF4hZWEkRCPPvCZTeXxn4
* Surface the new-integration name field as a labeled input
The name field for new generic integrations was a faint header input
with only placeholder text, easy to miss. Move it into the form grid
as a proper labeled "Name" field next to the other connection fields.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kF4hZWEkRCPPvCZTeXxn4
* Add file upload for SSH private key and certificate fields
Lets users pick a key file from disk (e.g. ~/.ssh) instead of pasting
its contents into the Private Key / OPKSSH Certificate fields.
* Fix SSH private key paste corrupting multi-line PEM format
Private Key and Certificate fields were single-line <input> elements,
which strip newlines on paste and corrupt PEM-formatted keys (causing
'Unsupported key format' errors). Render them as multi-line textareas
instead so pasted keys keep their line breaks.
* Fix integration save wiping untouched config fields
The PUT /api/integrations/:id route fully overwrites config_json with
whatever config object is sent (no merge), but buildPayload only
included fields the user had actually edited. Saving after editing
just one field (e.g. pasting a new SSH key) silently dropped every
other config field. Merge the existing integration's config into the
payload before sending.
* Add collapse/expand for SSH host cards
Click the chevron to collapse a host's card once it's configured.
Collapsed cards keep all field state in memory (just hidden), and
auto-collapse after a successful Save.
* Install openssh-client in backend image for certificate-auth SSH
Certificate-based SSH connections shell out to the system ssh binary
via node-pty (ssh2 has no OpenSSH certificate support), but the
alpine runtime image never installed openssh-client. This caused
'execvp(3) failed: No such file or directory' for any host with an
OPKSSH certificate configured.
---------
Co-authored-by: Claude <noreply@anthropic.com>
2026-06-20 08:30:21 -04:00
const isCollapsed = collapsed . has ( host . id )
2026-06-19 11:04:46 +00:00
return (
< div key = { host . id } style = { cardBase } >
Fix integration save data loss; add SSH host card collapse (#16)
* Add editable display-name field to generic integrations
Lets users set a custom name for Proxmox, Docker, AWS, Remote Desktop,
Netbird, Cloudflare, Uptime Kuma, and Weather integrations, separate
from the host/IP field, mirroring the SSH host rename pattern.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kF4hZWEkRCPPvCZTeXxn4
* Surface the new-integration name field as a labeled input
The name field for new generic integrations was a faint header input
with only placeholder text, easy to miss. Move it into the form grid
as a proper labeled "Name" field next to the other connection fields.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kF4hZWEkRCPPvCZTeXxn4
* Add file upload for SSH private key and certificate fields
Lets users pick a key file from disk (e.g. ~/.ssh) instead of pasting
its contents into the Private Key / OPKSSH Certificate fields.
* Fix SSH private key paste corrupting multi-line PEM format
Private Key and Certificate fields were single-line <input> elements,
which strip newlines on paste and corrupt PEM-formatted keys (causing
'Unsupported key format' errors). Render them as multi-line textareas
instead so pasted keys keep their line breaks.
* Fix integration save wiping untouched config fields
The PUT /api/integrations/:id route fully overwrites config_json with
whatever config object is sent (no merge), but buildPayload only
included fields the user had actually edited. Saving after editing
just one field (e.g. pasting a new SSH key) silently dropped every
other config field. Merge the existing integration's config into the
payload before sending.
* Add collapse/expand for SSH host cards
Click the chevron to collapse a host's card once it's configured.
Collapsed cards keep all field state in memory (just hidden), and
auto-collapse after a successful Save.
* Install openssh-client in backend image for certificate-auth SSH
Certificate-based SSH connections shell out to the system ssh binary
via node-pty (ssh2 has no OpenSSH certificate support), but the
alpine runtime image never installed openssh-client. This caused
'execvp(3) failed: No such file or directory' for any host with an
OPKSSH certificate configured.
---------
Co-authored-by: Claude <noreply@anthropic.com>
2026-06-20 08:30:21 -04:00
< div className = "flex items-center justify-between" style = { { marginBottom : isCollapsed ? 0 : '16px' } } >
< button
onClick = { ( ) = > toggleCollapsed ( host . id ) }
className = "flex items-center gap-2.5 cursor-pointer border-none bg-transparent"
style = { { padding : 0 } }
title = { isCollapsed ? 'Expand' : 'Collapse' }
>
{ isCollapsed ? < ChevronRight size = { 14 } color = "#7A7D85" / > : < ChevronDown size = { 14 } color = "#7A7D85" / > }
2026-06-19 11:04:46 +00:00
< span
style = { {
width : '8px' ,
height : '8px' ,
borderRadius : '50%' ,
backgroundColor : online ? '#2ECC71' : '#4A4D55' ,
boxShadow : online ? '0 0 6px rgba(46,204,113,0.6)' : 'none' ,
} }
/ >
Add explicit Host Name field for SSH hosts (#14)
* Add editable display-name field to generic integrations
Lets users set a custom name for Proxmox, Docker, AWS, Remote Desktop,
Netbird, Cloudflare, Uptime Kuma, and Weather integrations, separate
from the host/IP field, mirroring the SSH host rename pattern.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kF4hZWEkRCPPvCZTeXxn4
* Surface the new-integration name field as a labeled input
The name field for new generic integrations was a faint header input
with only placeholder text, easy to miss. Move it into the form grid
as a proper labeled "Name" field next to the other connection fields.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kF4hZWEkRCPPvCZTeXxn4
* Add explicit Host Name field for SSH hosts
The SSH host name was only editable via a small inline header input,
not a clearly labeled form field like the other options (Host / IP,
Port, etc). Add a proper "Host Name" labeled field to both existing
and new SSH host forms.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kF4hZWEkRCPPvCZTeXxn4
---------
Co-authored-by: Claude <noreply@anthropic.com>
2026-06-20 07:48:22 -04:00
< span style = { { fontSize : '13px' , color : '#E8E6E0' , fontWeight : 600 } } > { draft . __name ? ? host . name } < / span >
Fix integration save data loss; add SSH host card collapse (#16)
* Add editable display-name field to generic integrations
Lets users set a custom name for Proxmox, Docker, AWS, Remote Desktop,
Netbird, Cloudflare, Uptime Kuma, and Weather integrations, separate
from the host/IP field, mirroring the SSH host rename pattern.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kF4hZWEkRCPPvCZTeXxn4
* Surface the new-integration name field as a labeled input
The name field for new generic integrations was a faint header input
with only placeholder text, easy to miss. Move it into the form grid
as a proper labeled "Name" field next to the other connection fields.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kF4hZWEkRCPPvCZTeXxn4
* Add file upload for SSH private key and certificate fields
Lets users pick a key file from disk (e.g. ~/.ssh) instead of pasting
its contents into the Private Key / OPKSSH Certificate fields.
* Fix SSH private key paste corrupting multi-line PEM format
Private Key and Certificate fields were single-line <input> elements,
which strip newlines on paste and corrupt PEM-formatted keys (causing
'Unsupported key format' errors). Render them as multi-line textareas
instead so pasted keys keep their line breaks.
* Fix integration save wiping untouched config fields
The PUT /api/integrations/:id route fully overwrites config_json with
whatever config object is sent (no merge), but buildPayload only
included fields the user had actually edited. Saving after editing
just one field (e.g. pasting a new SSH key) silently dropped every
other config field. Merge the existing integration's config into the
payload before sending.
* Add collapse/expand for SSH host cards
Click the chevron to collapse a host's card once it's configured.
Collapsed cards keep all field state in memory (just hidden), and
auto-collapse after a successful Save.
* Install openssh-client in backend image for certificate-auth SSH
Certificate-based SSH connections shell out to the system ssh binary
via node-pty (ssh2 has no OpenSSH certificate support), but the
alpine runtime image never installed openssh-client. This caused
'execvp(3) failed: No such file or directory' for any host with an
OPKSSH certificate configured.
---------
Co-authored-by: Claude <noreply@anthropic.com>
2026-06-20 08:30:21 -04:00
< / button >
2026-06-19 11:04:46 +00:00
< div className = "flex items-center gap-2" >
{ statusMsg [ host . id ] && < span style = { { fontSize : '11px' , color : '#7A7D85' } } > { statusMsg [ host . id ] } < / span > }
Fix integration save data loss; add SSH host card collapse (#16)
* Add editable display-name field to generic integrations
Lets users set a custom name for Proxmox, Docker, AWS, Remote Desktop,
Netbird, Cloudflare, Uptime Kuma, and Weather integrations, separate
from the host/IP field, mirroring the SSH host rename pattern.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kF4hZWEkRCPPvCZTeXxn4
* Surface the new-integration name field as a labeled input
The name field for new generic integrations was a faint header input
with only placeholder text, easy to miss. Move it into the form grid
as a proper labeled "Name" field next to the other connection fields.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kF4hZWEkRCPPvCZTeXxn4
* Add file upload for SSH private key and certificate fields
Lets users pick a key file from disk (e.g. ~/.ssh) instead of pasting
its contents into the Private Key / OPKSSH Certificate fields.
* Fix SSH private key paste corrupting multi-line PEM format
Private Key and Certificate fields were single-line <input> elements,
which strip newlines on paste and corrupt PEM-formatted keys (causing
'Unsupported key format' errors). Render them as multi-line textareas
instead so pasted keys keep their line breaks.
* Fix integration save wiping untouched config fields
The PUT /api/integrations/:id route fully overwrites config_json with
whatever config object is sent (no merge), but buildPayload only
included fields the user had actually edited. Saving after editing
just one field (e.g. pasting a new SSH key) silently dropped every
other config field. Merge the existing integration's config into the
payload before sending.
* Add collapse/expand for SSH host cards
Click the chevron to collapse a host's card once it's configured.
Collapsed cards keep all field state in memory (just hidden), and
auto-collapse after a successful Save.
* Install openssh-client in backend image for certificate-auth SSH
Certificate-based SSH connections shell out to the system ssh binary
via node-pty (ssh2 has no OpenSSH certificate support), but the
alpine runtime image never installed openssh-client. This caused
'execvp(3) failed: No such file or directory' for any host with an
OPKSSH certificate configured.
---------
Co-authored-by: Claude <noreply@anthropic.com>
2026-06-20 08:30:21 -04:00
{ ! isCollapsed && (
< button
onClick = { ( ) = > handleSaveExisting ( host ) }
disabled = { busy . has ( host . id ) }
className = "cursor-pointer border-none"
style = { { fontSize : '11px' , fontWeight : 600 , color : '#0A0B0D' , backgroundColor : '#C8A434' , borderRadius : '6px' , padding : '6px 12px' , opacity : busy.has ( host . id ) ? 0.6 : 1 } }
>
Save
< / button >
) }
2026-06-19 11:04:46 +00:00
< button
onClick = { ( ) = > handleTest ( host ) }
disabled = { busy . has ( host . id ) }
className = "cursor-pointer border-none"
style = { { fontSize : '11px' , fontWeight : 600 , color : '#C8A434' , backgroundColor : 'rgba(200,164,52,0.08)' , border : '1px solid rgba(200,164,52,0.2)' , borderRadius : '6px' , padding : '6px 12px' , opacity : busy.has ( host . id ) ? 0.6 : 1 } }
>
Test Connection
< / button >
< button
onClick = { ( ) = > handleDelete ( host ) }
disabled = { busy . has ( host . id ) }
className = "cursor-pointer border-none"
style = { { fontSize : '11px' , fontWeight : 600 , color : '#E74C3C' , backgroundColor : 'transparent' , border : '1px solid rgba(231,76,60,0.3)' , borderRadius : '6px' , padding : '6px 12px' , opacity : busy.has ( host . id ) ? 0.6 : 1 } }
>
< Trash2 size = { 12 } / >
< / button >
< / div >
< / div >
Fix integration save data loss; add SSH host card collapse (#16)
* Add editable display-name field to generic integrations
Lets users set a custom name for Proxmox, Docker, AWS, Remote Desktop,
Netbird, Cloudflare, Uptime Kuma, and Weather integrations, separate
from the host/IP field, mirroring the SSH host rename pattern.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kF4hZWEkRCPPvCZTeXxn4
* Surface the new-integration name field as a labeled input
The name field for new generic integrations was a faint header input
with only placeholder text, easy to miss. Move it into the form grid
as a proper labeled "Name" field next to the other connection fields.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kF4hZWEkRCPPvCZTeXxn4
* Add file upload for SSH private key and certificate fields
Lets users pick a key file from disk (e.g. ~/.ssh) instead of pasting
its contents into the Private Key / OPKSSH Certificate fields.
* Fix SSH private key paste corrupting multi-line PEM format
Private Key and Certificate fields were single-line <input> elements,
which strip newlines on paste and corrupt PEM-formatted keys (causing
'Unsupported key format' errors). Render them as multi-line textareas
instead so pasted keys keep their line breaks.
* Fix integration save wiping untouched config fields
The PUT /api/integrations/:id route fully overwrites config_json with
whatever config object is sent (no merge), but buildPayload only
included fields the user had actually edited. Saving after editing
just one field (e.g. pasting a new SSH key) silently dropped every
other config field. Merge the existing integration's config into the
payload before sending.
* Add collapse/expand for SSH host cards
Click the chevron to collapse a host's card once it's configured.
Collapsed cards keep all field state in memory (just hidden), and
auto-collapse after a successful Save.
* Install openssh-client in backend image for certificate-auth SSH
Certificate-based SSH connections shell out to the system ssh binary
via node-pty (ssh2 has no OpenSSH certificate support), but the
alpine runtime image never installed openssh-client. This caused
'execvp(3) failed: No such file or directory' for any host with an
OPKSSH certificate configured.
---------
Co-authored-by: Claude <noreply@anthropic.com>
2026-06-20 08:30:21 -04:00
{ ! isCollapsed && (
< div className = "grid grid-cols-3 gap-4" style = { { marginTop : '16px' } } >
< div >
< label style = { labelStyle } > Host Name < / label >
< input
style = { inputStyle }
value = { draft . __name ? ? host . name }
onChange = { ( e ) = > setDraftField ( host . id , '__name' , e . target . value ) }
placeholder = "Not configured"
/ >
< / div >
{ renderFields ( fieldsWithJumpHost ( ) , draft , ( k , v ) = > setDraftField ( host . id , k , v ) , host . id , host , host . id ) }
Add explicit Host Name field for SSH hosts (#14)
* Add editable display-name field to generic integrations
Lets users set a custom name for Proxmox, Docker, AWS, Remote Desktop,
Netbird, Cloudflare, Uptime Kuma, and Weather integrations, separate
from the host/IP field, mirroring the SSH host rename pattern.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kF4hZWEkRCPPvCZTeXxn4
* Surface the new-integration name field as a labeled input
The name field for new generic integrations was a faint header input
with only placeholder text, easy to miss. Move it into the form grid
as a proper labeled "Name" field next to the other connection fields.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kF4hZWEkRCPPvCZTeXxn4
* Add explicit Host Name field for SSH hosts
The SSH host name was only editable via a small inline header input,
not a clearly labeled form field like the other options (Host / IP,
Port, etc). Add a proper "Host Name" labeled field to both existing
and new SSH host forms.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kF4hZWEkRCPPvCZTeXxn4
---------
Co-authored-by: Claude <noreply@anthropic.com>
2026-06-20 07:48:22 -04:00
< / div >
Fix integration save data loss; add SSH host card collapse (#16)
* Add editable display-name field to generic integrations
Lets users set a custom name for Proxmox, Docker, AWS, Remote Desktop,
Netbird, Cloudflare, Uptime Kuma, and Weather integrations, separate
from the host/IP field, mirroring the SSH host rename pattern.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kF4hZWEkRCPPvCZTeXxn4
* Surface the new-integration name field as a labeled input
The name field for new generic integrations was a faint header input
with only placeholder text, easy to miss. Move it into the form grid
as a proper labeled "Name" field next to the other connection fields.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kF4hZWEkRCPPvCZTeXxn4
* Add file upload for SSH private key and certificate fields
Lets users pick a key file from disk (e.g. ~/.ssh) instead of pasting
its contents into the Private Key / OPKSSH Certificate fields.
* Fix SSH private key paste corrupting multi-line PEM format
Private Key and Certificate fields were single-line <input> elements,
which strip newlines on paste and corrupt PEM-formatted keys (causing
'Unsupported key format' errors). Render them as multi-line textareas
instead so pasted keys keep their line breaks.
* Fix integration save wiping untouched config fields
The PUT /api/integrations/:id route fully overwrites config_json with
whatever config object is sent (no merge), but buildPayload only
included fields the user had actually edited. Saving after editing
just one field (e.g. pasting a new SSH key) silently dropped every
other config field. Merge the existing integration's config into the
payload before sending.
* Add collapse/expand for SSH host cards
Click the chevron to collapse a host's card once it's configured.
Collapsed cards keep all field state in memory (just hidden), and
auto-collapse after a successful Save.
* Install openssh-client in backend image for certificate-auth SSH
Certificate-based SSH connections shell out to the system ssh binary
via node-pty (ssh2 has no OpenSSH certificate support), but the
alpine runtime image never installed openssh-client. This caused
'execvp(3) failed: No such file or directory' for any host with an
OPKSSH certificate configured.
---------
Co-authored-by: Claude <noreply@anthropic.com>
2026-06-20 08:30:21 -04:00
) }
2026-06-19 11:04:46 +00:00
< / div >
)
} ) }
{ newDrafts . map ( ( d ) = > (
< div key = { d . key } style = { cardBase } >
< div className = "flex items-center justify-between" style = { { marginBottom : '16px' } } >
< span style = { { fontSize : '13px' , color : '#E8E6E0' , fontWeight : 600 } } > New SSH Host < / span >
< div className = "flex items-center gap-2" >
{ statusMsg [ d . key ] && < span style = { { fontSize : '11px' , color : '#7A7D85' } } > { statusMsg [ d . key ] } < / span > }
< button
onClick = { ( ) = > handleSaveNew ( d . key , d . values ) }
disabled = { busy . has ( d . key ) }
className = "cursor-pointer border-none"
style = { { fontSize : '11px' , fontWeight : 600 , color : '#0A0B0D' , backgroundColor : '#C8A434' , borderRadius : '6px' , padding : '6px 12px' , opacity : busy.has ( d . key ) ? 0.6 : 1 } }
>
Save
< / button >
< button
onClick = { ( ) = > removeNewDraft ( d . key ) }
className = "cursor-pointer border-none"
style = { { fontSize : '11px' , fontWeight : 600 , color : '#7A7D85' , backgroundColor : 'transparent' , border : '1px solid rgba(255,255,255,0.08)' , borderRadius : '6px' , padding : '6px 12px' } }
>
Cancel
< / button >
< / div >
< / div >
< div className = "grid grid-cols-3 gap-4" >
Add explicit Host Name field for SSH hosts (#14)
* Add editable display-name field to generic integrations
Lets users set a custom name for Proxmox, Docker, AWS, Remote Desktop,
Netbird, Cloudflare, Uptime Kuma, and Weather integrations, separate
from the host/IP field, mirroring the SSH host rename pattern.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kF4hZWEkRCPPvCZTeXxn4
* Surface the new-integration name field as a labeled input
The name field for new generic integrations was a faint header input
with only placeholder text, easy to miss. Move it into the form grid
as a proper labeled "Name" field next to the other connection fields.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kF4hZWEkRCPPvCZTeXxn4
* Add explicit Host Name field for SSH hosts
The SSH host name was only editable via a small inline header input,
not a clearly labeled form field like the other options (Host / IP,
Port, etc). Add a proper "Host Name" labeled field to both existing
and new SSH host forms.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kF4hZWEkRCPPvCZTeXxn4
---------
Co-authored-by: Claude <noreply@anthropic.com>
2026-06-20 07:48:22 -04:00
< div >
< label style = { labelStyle } > Host Name < / label >
< input
style = { inputStyle }
value = { d . values . __name ? ? '' }
onChange = { ( e ) = > setNewDraftField ( d . key , '__name' , e . target . value ) }
placeholder = "SSH Host"
/ >
< / div >
2026-06-19 11:04:46 +00:00
{ renderFields ( fieldsWithJumpHost ( ) , d . values , ( k , v ) = > setNewDraftField ( d . key , k , v ) , d . key , undefined ) }
< / div >
< / div >
) ) }
< button
onClick = { addNewHost }
className = "cursor-pointer border-none self-start"
style = { { fontSize : '12px' , fontWeight : 600 , color : '#C8A434' , backgroundColor : 'rgba(200,164,52,0.08)' , border : '1px solid rgba(200,164,52,0.2)' , borderRadius : '8px' , padding : '9px 16px' } }
>
+ Add SSH Host
< / button >
< / div >
)
}
2026-06-20 10:18:04 +00:00
type NewIntegrationDraft = { id : number ; type : string ; values : Record < string , string > }
2026-06-20 22:15:43 +00:00
function dockerHostInfo ( baseUrl : string ) : { host : string ; port : string } | null {
if ( ! baseUrl || baseUrl . startsWith ( 'unix://' ) ) return null
try {
const u = new URL ( baseUrl . replace ( /^tcp:\/\// , 'http://' ) )
if ( u . protocol !== 'http:' && u . protocol !== 'https:' ) return null
if ( ! u . hostname ) return null
return { host : u.hostname , port : u.port || '2375' }
} catch {
return null
}
}
function DockerSetupHint ( { baseUrl } : { baseUrl : string } ) {
const [ copied , setCopied ] = useState ( false )
const info = dockerHostInfo ( baseUrl )
if ( ! info ) return null
const script = ` sudo mkdir -p /etc/systemd/system/docker.service.d
sudo tee / etc / systemd / system / docker . service . d / override . conf > / d e v / n u l l < < E O F
[ Service ]
ExecStart =
ExecStart = / u s r / b i n / d o c k e r d - H f d : / / - H t c p : / / $ { i n f o . h o s t } : $ { i n f o . p o r t }
EOF
sudo systemctl daemon - reload
sudo systemctl restart docker
curl http : //${info.host}:${info.port}/version`
return (
< div
style = { {
marginTop : '12px' ,
padding : '12px 14px' ,
borderRadius : '8px' ,
border : '1px solid rgba(200,164,52,0.12)' ,
backgroundColor : 'rgba(255,255,255,0.02)' ,
} }
>
< div className = "flex items-center justify-between" style = { { marginBottom : '8px' } } >
< span style = { { fontSize : '11px' , color : '#7A7D85' } } >
Run this on < strong style = { { color : '#E8E6E0' } } > { info . host } < / strong > to expose its Docker API on port { info . port } :
< / span >
< button
onClick = { ( ) = > {
navigator . clipboard . writeText ( script )
setCopied ( true )
setTimeout ( ( ) = > setCopied ( false ) , 1500 )
} }
className = "flex items-center gap-1 cursor-pointer border-none bg-transparent"
style = { { fontSize : '10.5px' , fontWeight : 600 , color : copied ? '#2ECC71' : '#C8A434' , flexShrink : 0 } }
>
{ copied ? < Check size = { 11 } / > : null }
{ copied ? 'Copied' : 'Copy script' }
< / button >
< / div >
< pre
style = { {
fontSize : '10.5px' ,
color : '#9DA0A8' ,
fontFamily : 'monospace' ,
whiteSpace : 'pre-wrap' ,
wordBreak : 'break-all' ,
margin : 0 ,
lineHeight : 1.5 ,
} }
>
{ script }
< / pre >
< / div >
)
}
2026-06-21 16:03:51 -04:00
// Universal per-integration icon override — applies to every integration type, not
// just one. Stored as config.iconUrl and takes priority over the built-in CDN icon
// chain in Infrastructure.tsx's Node Status tile. Accepts a pasted URL or an
// uploaded image file (embedded as a base64 data URI — no separate file storage
// needed since config_json already holds arbitrary string values).
function IconField ( { value , onChange } : { value : string ; onChange : ( value : string ) = > void } ) {
const fileInputRef = useRef < HTMLInputElement > ( null )
return (
< div style = { { marginTop : '12px' } } >
< label style = { labelStyle } > Icon ( Node Status tile ) < / label >
< div className = "flex items-center gap-2" >
{ value && (
< img src = { value } width = { 20 } height = { 20 } style = { { borderRadius : '4px' , flexShrink : 0 } } alt = "" / >
) }
< input
style = { { . . . inputStyle , flex : 1 } }
type = "text"
value = { value }
onChange = { ( e ) = > onChange ( e . target . value ) }
placeholder = "Paste an image URL, or upload a file"
/ >
< input
type = "file"
ref = { fileInputRef }
accept = "image/*"
style = { { display : 'none' } }
onChange = { ( e ) = > {
const file = e . target . files ? . [ 0 ]
if ( ! file ) return
const reader = new FileReader ( )
reader . onload = ( ) = > onChange ( String ( reader . result ? ? '' ) )
reader . readAsDataURL ( file )
e . target . value = ''
} }
/ >
< button
onClick = { ( ) = > fileInputRef . current ? . click ( ) }
title = "Upload an icon file"
className = "cursor-pointer border-none flex-shrink-0"
style = { { fontSize : '11px' , fontWeight : 600 , color : '#C8A434' , backgroundColor : 'rgba(200,164,52,0.08)' , border : '1px solid rgba(200,164,52,0.2)' , borderRadius : '6px' , padding : '8px 10px' } }
>
< Upload size = { 13 } / >
< / button >
< / div >
< / div >
)
}
2026-06-18 18:44:26 +00:00
function IntegrationsSection() {
Add auth Phase 3: multi-user accounts with admin/member roles (#28)
Implements Phase 3 of the auth roadmap: multiple user accounts (cap 10),
an admin/member role model, and admin-only gating of config-mutating
routes. Dashboard data stays shared across all users (per the product
decision in HANDOFF.md — this is a household/self-hosted dashboard, not
a multi-tenant app), so there is no per-user data isolation.
Schema (backend/src/db/index.ts):
- Idempotent migration adds `role` (default 'admin') and `active`
(default 1) columns to `users` when missing. The 'admin' default means
the pre-existing single user is backfilled to admin on deploy and keeps
full access; newly created users are inserted explicitly as 'member'.
Verified against a production-like old schema (columns added, existing
user backfilled to admin/active).
Auth + access control:
- `/api/setup` creates the first user as admin. Login enforces `active`
(deactivated accounts get 403) and embeds the live role in the session.
- `app.authenticate` now reads role+active fresh from the DB on every
request (not from the possibly-stale JWT claim), rejects inactive
accounts, and stashes the role on req.user.
- New `requireAdmin` (auth + role check) and `adminOnly` (role check for
routes already behind the plugin-level authenticate hook) decorators.
User management (admin-only, in auth.ts):
- GET/POST/PUT/DELETE /api/users — list, create (admin sets a temp
password; no public signup), change role, activate/deactivate, delete.
- 10-user cap enforced server-side; guard rails prevent removing the last
active admin (demote/deactivate/delete) and deleting your own account;
deactivating or deleting a user drops their sessions immediately.
Admin-only route gating (members get 403):
- integrations create/update/delete/test, tunnels create/delete, data
export/import. Read routes and tunnel connect/disconnect stay open to
all authenticated users, as do all the SSH/Docker/RDP tools and
bookmarks (members are trusted to use the tooling, per product decision).
Frontend:
- api.ts: listUsers/createUser/updateUser/deleteUser + ManagedUser type;
role+active added to AuthUser.
- Settings: new admin-only "Users" section (create form, role toggle,
activate/deactivate, delete, 10-cap indicator). Nav filters the Users
tab by role and guards ?tab= deep-links. Data & Backup shows an
admin-only notice for members; Integrations shows a read-only banner
for members. (Backend remains the real enforcement boundary.)
Verified end-to-end against a throwaway backend: role assignment,
member 403s on every admin-only route + 200s on shared/read routes,
admin 200/201s, last-admin guards (409/400), deactivation killing an
active session and blocking re-login (then reactivation restoring it),
and the 10-user cap (409 on the 11th). Both frontend and backend
type-check clean.
Co-authored-by: Samuel James <ssamjame@amazon.com>
Co-authored-by: Kiro <noreply@kiro.dev>
2026-06-20 12:43:24 -04:00
const { user } = useAuth ( )
const isAdmin = user ? . role === 'admin'
2026-06-18 19:26:48 +00:00
const [ integrations , setIntegrations ] = useState < Integration [ ] | null > ( null )
2026-06-18 18:44:26 +00:00
const [ revealed , setRevealed ] = useState < Set < string > > ( new Set ( ) )
2026-06-20 10:18:04 +00:00
const [ editDrafts , setEditDrafts ] = useState < Record < number , Record < string , string > > > ( { } )
const [ newDrafts , setNewDrafts ] = useState < NewIntegrationDraft [ ] > ( [ ] )
2026-06-18 19:26:48 +00:00
const [ statusMsg , setStatusMsg ] = useState < Record < string , string > > ( { } )
const [ busy , setBusy ] = useState < Set < string > > ( new Set ( ) )
2026-06-20 10:18:04 +00:00
const nextDraftId = useRef ( - 1 )
2026-06-18 19:26:48 +00:00
useEffect ( ( ) = > {
api . listIntegrations ( ) . then ( ( { integrations } ) = > setIntegrations ( integrations ) )
} , [ ] )
2026-06-18 18:44:26 +00:00
function toggleReveal ( key : string ) {
setRevealed ( ( prev ) = > {
const next = new Set ( prev )
if ( next . has ( key ) ) next . delete ( key )
else next . add ( key )
return next
} )
}
2026-06-20 10:18:04 +00:00
function setBusyFlag ( rowKey : string , value : boolean ) {
2026-06-18 19:26:48 +00:00
setBusy ( ( prev ) = > {
const next = new Set ( prev )
2026-06-20 10:18:04 +00:00
if ( value ) next . add ( rowKey )
else next . delete ( rowKey )
2026-06-18 19:26:48 +00:00
return next
} )
}
2026-06-20 10:18:04 +00:00
function setEditField ( integrationId : number , fieldKey : string , value : string ) {
setEditDrafts ( ( prev ) = > ( { . . . prev , [ integrationId ] : { . . . prev [ integrationId ] , [ fieldKey ] : value } } ) )
}
function setNewDraftField ( draftId : number , fieldKey : string , value : string ) {
setNewDrafts ( ( prev ) = > prev . map ( ( d ) = > ( d . id === draftId ? { . . . d , values : { . . . d . values , [ fieldKey ] : value } } : d ) ) )
}
function addNewDraft ( type : string ) {
const id = nextDraftId . current --
setNewDrafts ( ( prev ) = > [ . . . prev , { id , type , values : { } } ] )
}
function removeNewDraft ( id : number ) {
setNewDrafts ( ( prev ) = > prev . filter ( ( d ) = > d . id !== id ) )
}
Fix integration save data loss; add SSH host card collapse (#16)
* Add editable display-name field to generic integrations
Lets users set a custom name for Proxmox, Docker, AWS, Remote Desktop,
Netbird, Cloudflare, Uptime Kuma, and Weather integrations, separate
from the host/IP field, mirroring the SSH host rename pattern.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kF4hZWEkRCPPvCZTeXxn4
* Surface the new-integration name field as a labeled input
The name field for new generic integrations was a faint header input
with only placeholder text, easy to miss. Move it into the form grid
as a proper labeled "Name" field next to the other connection fields.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kF4hZWEkRCPPvCZTeXxn4
* Add file upload for SSH private key and certificate fields
Lets users pick a key file from disk (e.g. ~/.ssh) instead of pasting
its contents into the Private Key / OPKSSH Certificate fields.
* Fix SSH private key paste corrupting multi-line PEM format
Private Key and Certificate fields were single-line <input> elements,
which strip newlines on paste and corrupt PEM-formatted keys (causing
'Unsupported key format' errors). Render them as multi-line textareas
instead so pasted keys keep their line breaks.
* Fix integration save wiping untouched config fields
The PUT /api/integrations/:id route fully overwrites config_json with
whatever config object is sent (no merge), but buildPayload only
included fields the user had actually edited. Saving after editing
just one field (e.g. pasting a new SSH key) silently dropped every
other config field. Merge the existing integration's config into the
payload before sending.
* Add collapse/expand for SSH host cards
Click the chevron to collapse a host's card once it's configured.
Collapsed cards keep all field state in memory (just hidden), and
auto-collapse after a successful Save.
* Install openssh-client in backend image for certificate-auth SSH
Certificate-based SSH connections shell out to the system ssh binary
via node-pty (ssh2 has no OpenSSH certificate support), but the
alpine runtime image never installed openssh-client. This caused
'execvp(3) failed: No such file or directory' for any host with an
OPKSSH certificate configured.
---------
Co-authored-by: Claude <noreply@anthropic.com>
2026-06-20 08:30:21 -04:00
function buildPayload ( def : ( typeof integrationTypeDefs ) [ number ] , values : Record < string , string > , existing? : Integration ) {
const config : Record < string , string > = { . . . ( existing ? . config ? ? { } ) }
2026-06-20 10:18:04 +00:00
const secrets : Record < string , string > = { }
for ( const f of def . fields ) {
const value = values [ f . key ]
if ( value === undefined ) continue
if ( f . secret ) secrets [ f . key ] = value
else config [ f . key ] = value
}
2026-06-21 16:03:51 -04:00
// iconUrl is universal across every integration type (not a per-type field),
// overriding the built-in Node Status icon for this integration.
if ( values . iconUrl !== undefined ) config . iconUrl = values . iconUrl
2026-06-20 10:18:04 +00:00
return { config , secrets }
2026-06-18 19:26:48 +00:00
}
2026-06-20 10:18:04 +00:00
async function handleSaveExisting ( def : ( typeof integrationTypeDefs ) [ number ] , existing : Integration ) {
const rowKey = ` e- ${ existing . id } `
setBusyFlag ( rowKey , true )
setStatusMsg ( ( prev ) = > ( { . . . prev , [ rowKey ] : '' } ) )
2026-06-18 19:26:48 +00:00
try {
Fix integration save data loss; add SSH host card collapse (#16)
* Add editable display-name field to generic integrations
Lets users set a custom name for Proxmox, Docker, AWS, Remote Desktop,
Netbird, Cloudflare, Uptime Kuma, and Weather integrations, separate
from the host/IP field, mirroring the SSH host rename pattern.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kF4hZWEkRCPPvCZTeXxn4
* Surface the new-integration name field as a labeled input
The name field for new generic integrations was a faint header input
with only placeholder text, easy to miss. Move it into the form grid
as a proper labeled "Name" field next to the other connection fields.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kF4hZWEkRCPPvCZTeXxn4
* Add file upload for SSH private key and certificate fields
Lets users pick a key file from disk (e.g. ~/.ssh) instead of pasting
its contents into the Private Key / OPKSSH Certificate fields.
* Fix SSH private key paste corrupting multi-line PEM format
Private Key and Certificate fields were single-line <input> elements,
which strip newlines on paste and corrupt PEM-formatted keys (causing
'Unsupported key format' errors). Render them as multi-line textareas
instead so pasted keys keep their line breaks.
* Fix integration save wiping untouched config fields
The PUT /api/integrations/:id route fully overwrites config_json with
whatever config object is sent (no merge), but buildPayload only
included fields the user had actually edited. Saving after editing
just one field (e.g. pasting a new SSH key) silently dropped every
other config field. Merge the existing integration's config into the
payload before sending.
* Add collapse/expand for SSH host cards
Click the chevron to collapse a host's card once it's configured.
Collapsed cards keep all field state in memory (just hidden), and
auto-collapse after a successful Save.
* Install openssh-client in backend image for certificate-auth SSH
Certificate-based SSH connections shell out to the system ssh binary
via node-pty (ssh2 has no OpenSSH certificate support), but the
alpine runtime image never installed openssh-client. This caused
'execvp(3) failed: No such file or directory' for any host with an
OPKSSH certificate configured.
---------
Co-authored-by: Claude <noreply@anthropic.com>
2026-06-20 08:30:21 -04:00
const { config , secrets } = buildPayload ( def , editDrafts [ existing . id ] ? ? { } , existing )
Add editable name field to generic integrations (#13)
* Add editable display-name field to generic integrations
Lets users set a custom name for Proxmox, Docker, AWS, Remote Desktop,
Netbird, Cloudflare, Uptime Kuma, and Weather integrations, separate
from the host/IP field, mirroring the SSH host rename pattern.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kF4hZWEkRCPPvCZTeXxn4
* Surface the new-integration name field as a labeled input
The name field for new generic integrations was a faint header input
with only placeholder text, easy to miss. Move it into the form grid
as a proper labeled "Name" field next to the other connection fields.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kF4hZWEkRCPPvCZTeXxn4
---------
Co-authored-by: Claude <noreply@anthropic.com>
2026-06-20 07:40:03 -04:00
const name = editDrafts [ existing . id ] ? . __name ? . trim ( )
const { integration } = await api . updateIntegration ( existing . id , { . . . ( name ? { name } : { } ) , config , secrets } )
2026-06-20 10:18:04 +00:00
setIntegrations ( ( prev ) = > ( prev ? ? [ ] ) . map ( ( i ) = > ( i . id === integration . id ? integration : i ) ) )
setStatusMsg ( ( prev ) = > ( { . . . prev , [ rowKey ] : 'Saved' } ) )
2026-06-18 19:26:48 +00:00
} catch ( err ) {
2026-06-20 10:18:04 +00:00
setStatusMsg ( ( prev ) = > ( { . . . prev , [ rowKey ] : err instanceof ApiError ? err . message : 'Save failed' } ) )
2026-06-18 19:26:48 +00:00
} finally {
2026-06-20 10:18:04 +00:00
setBusyFlag ( rowKey , false )
2026-06-18 19:26:48 +00:00
}
}
2026-06-20 10:18:04 +00:00
async function handleSaveNew ( def : ( typeof integrationTypeDefs ) [ number ] , draft : NewIntegrationDraft ) {
const rowKey = ` n- ${ draft . id } `
setBusyFlag ( rowKey , true )
setStatusMsg ( ( prev ) = > ( { . . . prev , [ rowKey ] : '' } ) )
try {
const { config , secrets } = buildPayload ( def , draft . values )
Add editable name field to generic integrations (#13)
* Add editable display-name field to generic integrations
Lets users set a custom name for Proxmox, Docker, AWS, Remote Desktop,
Netbird, Cloudflare, Uptime Kuma, and Weather integrations, separate
from the host/IP field, mirroring the SSH host rename pattern.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kF4hZWEkRCPPvCZTeXxn4
* Surface the new-integration name field as a labeled input
The name field for new generic integrations was a faint header input
with only placeholder text, easy to miss. Move it into the form grid
as a proper labeled "Name" field next to the other connection fields.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kF4hZWEkRCPPvCZTeXxn4
---------
Co-authored-by: Claude <noreply@anthropic.com>
2026-06-20 07:40:03 -04:00
const name = draft . values . __name ? . trim ( ) || def . name
const { integration } = await api . createIntegration ( { type : def . type , name , config , secrets } )
2026-06-20 10:18:04 +00:00
setIntegrations ( ( prev ) = > [ . . . ( prev ? ? [ ] ) , integration ] )
removeNewDraft ( draft . id )
} catch ( err ) {
setStatusMsg ( ( prev ) = > ( { . . . prev , [ rowKey ] : err instanceof ApiError ? err . message : 'Save failed' } ) )
} finally {
setBusyFlag ( rowKey , false )
2026-06-18 19:26:48 +00:00
}
2026-06-20 10:18:04 +00:00
}
async function handleTest ( existing : Integration ) {
const rowKey = ` e- ${ existing . id } `
setBusyFlag ( rowKey , true )
2026-06-18 19:26:48 +00:00
try {
const result = await api . testIntegration ( existing . id )
2026-06-20 10:18:04 +00:00
setStatusMsg ( ( prev ) = > ( { . . . prev , [ rowKey ] : result . message } ) )
2026-06-18 19:26:48 +00:00
const { integrations } = await api . listIntegrations ( )
setIntegrations ( integrations )
} catch ( err ) {
2026-06-20 10:18:04 +00:00
setStatusMsg ( ( prev ) = > ( { . . . prev , [ rowKey ] : err instanceof ApiError ? err . message : 'Test failed' } ) )
2026-06-18 19:26:48 +00:00
} finally {
2026-06-20 10:18:04 +00:00
setBusyFlag ( rowKey , false )
}
}
async function handleDelete ( existing : Integration ) {
if ( ! window . confirm ( ` Remove this ${ existing . name } integration? ` ) ) return
const rowKey = ` e- ${ existing . id } `
setBusyFlag ( rowKey , true )
try {
await api . deleteIntegration ( existing . id )
setIntegrations ( ( prev ) = > ( prev ? ? [ ] ) . filter ( ( i ) = > i . id !== existing . id ) )
} catch ( err ) {
setStatusMsg ( ( prev ) = > ( { . . . prev , [ rowKey ] : err instanceof ApiError ? err . message : 'Delete failed' } ) )
setBusyFlag ( rowKey , false )
2026-06-18 19:26:48 +00:00
}
}
if ( ! integrations ) {
return (
< div style = { cardBase } >
< p style = { { fontSize : '12px' , color : '#7A7D85' } } > Loading integrations … < / p >
< / div >
)
}
Show saved indicator for secret fields instead of appearing deleted (#18)
* Add editable display-name field to generic integrations
Lets users set a custom name for Proxmox, Docker, AWS, Remote Desktop,
Netbird, Cloudflare, Uptime Kuma, and Weather integrations, separate
from the host/IP field, mirroring the SSH host rename pattern.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kF4hZWEkRCPPvCZTeXxn4
* Surface the new-integration name field as a labeled input
The name field for new generic integrations was a faint header input
with only placeholder text, easy to miss. Move it into the form grid
as a proper labeled "Name" field next to the other connection fields.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kF4hZWEkRCPPvCZTeXxn4
* Add file upload for SSH private key and certificate fields
Lets users pick a key file from disk (e.g. ~/.ssh) instead of pasting
its contents into the Private Key / OPKSSH Certificate fields.
* Fix SSH private key paste corrupting multi-line PEM format
Private Key and Certificate fields were single-line <input> elements,
which strip newlines on paste and corrupt PEM-formatted keys (causing
'Unsupported key format' errors). Render them as multi-line textareas
instead so pasted keys keep their line breaks.
* Show saved indicator for secret fields instead of appearing blank/deleted
GET /api/integrations never returns decrypted secret values (by design),
so after navigating away and back, secret/key fields rendered empty -
looking exactly like the saved key had been deleted, even though it was
still intact and encrypted in the database. Expose which secret keys
exist (names only, never values) via secretKeys, and use it to label
fields as "saved" with an appropriate placeholder instead of blank.
---------
Co-authored-by: Claude <noreply@anthropic.com>
2026-06-20 08:53:56 -04:00
function renderFields ( def : ( typeof integrationTypeDefs ) [ number ] , rowKey : string , getValue : ( f : FieldDef ) = > string , onChange : ( f : FieldDef , value : string ) = > void , placeholderForSecret : string , existing? : Integration ) {
2026-06-20 10:18:04 +00:00
return (
< div className = "grid grid-cols-3 gap-4" >
{ def . fields . map ( ( f ) = > {
const key = ` ${ rowKey } - ${ f . key } `
const isRevealed = revealed . has ( key )
Show saved indicator for secret fields instead of appearing deleted (#18)
* Add editable display-name field to generic integrations
Lets users set a custom name for Proxmox, Docker, AWS, Remote Desktop,
Netbird, Cloudflare, Uptime Kuma, and Weather integrations, separate
from the host/IP field, mirroring the SSH host rename pattern.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kF4hZWEkRCPPvCZTeXxn4
* Surface the new-integration name field as a labeled input
The name field for new generic integrations was a faint header input
with only placeholder text, easy to miss. Move it into the form grid
as a proper labeled "Name" field next to the other connection fields.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kF4hZWEkRCPPvCZTeXxn4
* Add file upload for SSH private key and certificate fields
Lets users pick a key file from disk (e.g. ~/.ssh) instead of pasting
its contents into the Private Key / OPKSSH Certificate fields.
* Fix SSH private key paste corrupting multi-line PEM format
Private Key and Certificate fields were single-line <input> elements,
which strip newlines on paste and corrupt PEM-formatted keys (causing
'Unsupported key format' errors). Render them as multi-line textareas
instead so pasted keys keep their line breaks.
* Show saved indicator for secret fields instead of appearing blank/deleted
GET /api/integrations never returns decrypted secret values (by design),
so after navigating away and back, secret/key fields rendered empty -
looking exactly like the saved key had been deleted, even though it was
still intact and encrypted in the database. Expose which secret keys
exist (names only, never values) via secretKeys, and use it to label
fields as "saved" with an appropriate placeholder instead of blank.
---------
Co-authored-by: Claude <noreply@anthropic.com>
2026-06-20 08:53:56 -04:00
const isSavedSecret = f . secret && existing ? . secretKeys ? . includes ( f . key )
2026-06-20 10:18:04 +00:00
return (
< div key = { key } >
Show saved indicator for secret fields instead of appearing deleted (#18)
* Add editable display-name field to generic integrations
Lets users set a custom name for Proxmox, Docker, AWS, Remote Desktop,
Netbird, Cloudflare, Uptime Kuma, and Weather integrations, separate
from the host/IP field, mirroring the SSH host rename pattern.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kF4hZWEkRCPPvCZTeXxn4
* Surface the new-integration name field as a labeled input
The name field for new generic integrations was a faint header input
with only placeholder text, easy to miss. Move it into the form grid
as a proper labeled "Name" field next to the other connection fields.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kF4hZWEkRCPPvCZTeXxn4
* Add file upload for SSH private key and certificate fields
Lets users pick a key file from disk (e.g. ~/.ssh) instead of pasting
its contents into the Private Key / OPKSSH Certificate fields.
* Fix SSH private key paste corrupting multi-line PEM format
Private Key and Certificate fields were single-line <input> elements,
which strip newlines on paste and corrupt PEM-formatted keys (causing
'Unsupported key format' errors). Render them as multi-line textareas
instead so pasted keys keep their line breaks.
* Show saved indicator for secret fields instead of appearing blank/deleted
GET /api/integrations never returns decrypted secret values (by design),
so after navigating away and back, secret/key fields rendered empty -
looking exactly like the saved key had been deleted, even though it was
still intact and encrypted in the database. Expose which secret keys
exist (names only, never values) via secretKeys, and use it to label
fields as "saved" with an appropriate placeholder instead of blank.
---------
Co-authored-by: Claude <noreply@anthropic.com>
2026-06-20 08:53:56 -04:00
< label style = { labelStyle } >
{ f . label }
{ isSavedSecret && < span style = { { color : '#2ECC71' , fontWeight : 400 } } > · saved < / span > }
< / label >
2026-06-20 10:18:04 +00:00
< div className = "relative" >
< input
style = { inputStyle }
type = { f . secret && ! isRevealed ? 'password' : 'text' }
value = { getValue ( f ) }
onChange = { ( e ) = > onChange ( f , e . target . value ) }
Show saved indicator for secret fields instead of appearing deleted (#18)
* Add editable display-name field to generic integrations
Lets users set a custom name for Proxmox, Docker, AWS, Remote Desktop,
Netbird, Cloudflare, Uptime Kuma, and Weather integrations, separate
from the host/IP field, mirroring the SSH host rename pattern.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kF4hZWEkRCPPvCZTeXxn4
* Surface the new-integration name field as a labeled input
The name field for new generic integrations was a faint header input
with only placeholder text, easy to miss. Move it into the form grid
as a proper labeled "Name" field next to the other connection fields.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kF4hZWEkRCPPvCZTeXxn4
* Add file upload for SSH private key and certificate fields
Lets users pick a key file from disk (e.g. ~/.ssh) instead of pasting
its contents into the Private Key / OPKSSH Certificate fields.
* Fix SSH private key paste corrupting multi-line PEM format
Private Key and Certificate fields were single-line <input> elements,
which strip newlines on paste and corrupt PEM-formatted keys (causing
'Unsupported key format' errors). Render them as multi-line textareas
instead so pasted keys keep their line breaks.
* Show saved indicator for secret fields instead of appearing blank/deleted
GET /api/integrations never returns decrypted secret values (by design),
so after navigating away and back, secret/key fields rendered empty -
looking exactly like the saved key had been deleted, even though it was
still intact and encrypted in the database. Expose which secret keys
exist (names only, never values) via secretKeys, and use it to label
fields as "saved" with an appropriate placeholder instead of blank.
---------
Co-authored-by: Claude <noreply@anthropic.com>
2026-06-20 08:53:56 -04:00
placeholder = { f . secret ? ( isSavedSecret ? ` ${ placeholderForSecret } (saved — type to replace) ` : placeholderForSecret ) : f . placeholder ? ? 'Not configured' }
2026-06-20 10:18:04 +00:00
/ >
{ f . secret && (
< button
onClick = { ( ) = > toggleReveal ( key ) }
className = "absolute cursor-pointer border-none bg-transparent"
style = { { right : '8px' , top : '50%' , transform : 'translateY(-50%)' , color : '#7A7D85' } }
>
{ isRevealed ? < EyeOff size = { 13 } / > : < Eye size = { 13 } / > }
< / button >
) }
< / div >
{ f . hint && (
< p style = { { fontSize : '10.5px' , color : '#7A7D85' , marginTop : '4px' , lineHeight : 1.4 , maxWidth : '260px' } } > { f . hint } < / p >
) }
< / div >
)
} ) }
< / div >
)
}
2026-06-18 18:44:26 +00:00
return (
< div className = "flex flex-col gap-4" >
Add auth Phase 3: multi-user accounts with admin/member roles (#28)
Implements Phase 3 of the auth roadmap: multiple user accounts (cap 10),
an admin/member role model, and admin-only gating of config-mutating
routes. Dashboard data stays shared across all users (per the product
decision in HANDOFF.md — this is a household/self-hosted dashboard, not
a multi-tenant app), so there is no per-user data isolation.
Schema (backend/src/db/index.ts):
- Idempotent migration adds `role` (default 'admin') and `active`
(default 1) columns to `users` when missing. The 'admin' default means
the pre-existing single user is backfilled to admin on deploy and keeps
full access; newly created users are inserted explicitly as 'member'.
Verified against a production-like old schema (columns added, existing
user backfilled to admin/active).
Auth + access control:
- `/api/setup` creates the first user as admin. Login enforces `active`
(deactivated accounts get 403) and embeds the live role in the session.
- `app.authenticate` now reads role+active fresh from the DB on every
request (not from the possibly-stale JWT claim), rejects inactive
accounts, and stashes the role on req.user.
- New `requireAdmin` (auth + role check) and `adminOnly` (role check for
routes already behind the plugin-level authenticate hook) decorators.
User management (admin-only, in auth.ts):
- GET/POST/PUT/DELETE /api/users — list, create (admin sets a temp
password; no public signup), change role, activate/deactivate, delete.
- 10-user cap enforced server-side; guard rails prevent removing the last
active admin (demote/deactivate/delete) and deleting your own account;
deactivating or deleting a user drops their sessions immediately.
Admin-only route gating (members get 403):
- integrations create/update/delete/test, tunnels create/delete, data
export/import. Read routes and tunnel connect/disconnect stay open to
all authenticated users, as do all the SSH/Docker/RDP tools and
bookmarks (members are trusted to use the tooling, per product decision).
Frontend:
- api.ts: listUsers/createUser/updateUser/deleteUser + ManagedUser type;
role+active added to AuthUser.
- Settings: new admin-only "Users" section (create form, role toggle,
activate/deactivate, delete, 10-cap indicator). Nav filters the Users
tab by role and guards ?tab= deep-links. Data & Backup shows an
admin-only notice for members; Integrations shows a read-only banner
for members. (Backend remains the real enforcement boundary.)
Verified end-to-end against a throwaway backend: role assignment,
member 403s on every admin-only route + 200s on shared/read routes,
admin 200/201s, last-admin guards (409/400), deactivation killing an
active session and blocking re-login (then reactivation restoring it),
and the 10-user cap (409 on the 11th). Both frontend and backend
type-check clean.
Co-authored-by: Samuel James <ssamjame@amazon.com>
Co-authored-by: Kiro <noreply@kiro.dev>
2026-06-20 12:43:24 -04:00
{ ! isAdmin && (
< div
style = { {
padding : '12px 14px' ,
borderRadius : '10px' ,
border : '1px solid rgba(200,164,52,0.2)' ,
backgroundColor : 'rgba(200,164,52,0.06)' ,
fontSize : '12px' ,
color : '#C8A434' ,
} }
>
You have member access — integrations are read - only . Ask an administrator to add or change connections .
< / div >
) }
2026-06-19 11:04:46 +00:00
< div >
< h3 style = { sectionTitle } > SSH Hosts < / h3 >
< SshHostsSection / >
< / div >
< div >
< h3 style = { sectionTitle } > Other Integrations < / h3 >
< / div >
2026-06-18 19:26:48 +00:00
{ integrationTypeDefs . map ( ( def ) = > {
2026-06-20 10:18:04 +00:00
const existingRows = integrations . filter ( ( i ) = > i . type === def . type )
const draftRows = newDrafts . filter ( ( d ) = > d . type === def . type )
const canAddAnother = def . multiInstance || existingRows . length + draftRows . length === 0
2026-06-18 19:26:48 +00:00
return (
2026-06-20 10:18:04 +00:00
< div key = { def . type } className = "flex flex-col gap-3" >
{ existingRows . map ( ( existing ) = > {
const rowKey = ` e- ${ existing . id } `
const online = existing . status === 'connected'
const draft = editDrafts [ existing . id ] ? ? { }
return (
< div key = { existing . id } style = { cardBase } >
< div className = "flex items-center justify-between" style = { { marginBottom : '16px' } } >
< div className = "flex items-center gap-2.5" >
< span
style = { {
width : '8px' ,
height : '8px' ,
borderRadius : '50%' ,
backgroundColor : online ? '#2ECC71' : '#4A4D55' ,
boxShadow : online ? '0 0 6px rgba(46,204,113,0.6)' : 'none' ,
} }
2026-06-18 19:26:48 +00:00
/ >
Add editable name field to generic integrations (#13)
* Add editable display-name field to generic integrations
Lets users set a custom name for Proxmox, Docker, AWS, Remote Desktop,
Netbird, Cloudflare, Uptime Kuma, and Weather integrations, separate
from the host/IP field, mirroring the SSH host rename pattern.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kF4hZWEkRCPPvCZTeXxn4
* Surface the new-integration name field as a labeled input
The name field for new generic integrations was a faint header input
with only placeholder text, easy to miss. Move it into the form grid
as a proper labeled "Name" field next to the other connection fields.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kF4hZWEkRCPPvCZTeXxn4
---------
Co-authored-by: Claude <noreply@anthropic.com>
2026-06-20 07:40:03 -04:00
< input
value = { draft . __name ? ? existing . name ? ? def . name }
onChange = { ( e ) = > setEditField ( existing . id , '__name' , e . target . value ) }
style = { { fontSize : '13px' , color : '#E8E6E0' , fontWeight : 600 , backgroundColor : 'transparent' , border : 'none' , outline : 'none' , padding : 0 , width : '160px' } }
/ >
2026-06-20 10:18:04 +00:00
{ existing . config . baseUrl || existing . config . hostname ? (
< span style = { { fontSize : '11px' , color : '#7A7D85' } } > { existing . config . baseUrl || existing . config . hostname } < / span >
) : null }
< / div >
< div className = "flex items-center gap-2" >
{ statusMsg [ rowKey ] && (
< span style = { { fontSize : '11px' , color : '#7A7D85' } } > { statusMsg [ rowKey ] } < / span >
2026-06-18 19:26:48 +00:00
) }
2026-06-20 10:18:04 +00:00
< button
onClick = { ( ) = > handleSaveExisting ( def , existing ) }
disabled = { busy . has ( rowKey ) }
className = "cursor-pointer border-none"
style = { { fontSize : '11px' , fontWeight : 600 , color : '#0A0B0D' , backgroundColor : '#C8A434' , borderRadius : '6px' , padding : '6px 12px' , opacity : busy.has ( rowKey ) ? 0.6 : 1 } }
>
Save
< / button >
< button
onClick = { ( ) = > handleTest ( existing ) }
disabled = { busy . has ( rowKey ) }
className = "cursor-pointer border-none"
style = { { fontSize : '11px' , fontWeight : 600 , color : '#C8A434' , backgroundColor : 'rgba(200,164,52,0.08)' , border : '1px solid rgba(200,164,52,0.2)' , borderRadius : '6px' , padding : '6px 12px' , opacity : busy.has ( rowKey ) ? 0.6 : 1 } }
>
Test Connection
< / button >
< button
onClick = { ( ) = > handleDelete ( existing ) }
disabled = { busy . has ( rowKey ) }
className = "cursor-pointer border-none"
style = { { fontSize : '11px' , fontWeight : 600 , color : '#E74C3C' , backgroundColor : 'rgba(231,76,60,0.08)' , border : '1px solid rgba(231,76,60,0.2)' , borderRadius : '6px' , padding : '6px 12px' , opacity : busy.has ( rowKey ) ? 0.6 : 1 } }
>
Remove
< / button >
2026-06-18 19:26:48 +00:00
< / div >
2026-06-18 18:44:26 +00:00
< / div >
2026-06-20 10:18:04 +00:00
{ renderFields (
def ,
rowKey ,
( f ) = > draft [ f . key ] ? ? ( f . secret ? '' : existing . config [ f . key ] ? ? '' ) ,
( f , value ) = > setEditField ( existing . id , f . key , value ) ,
'••••••••••••' ,
Show saved indicator for secret fields instead of appearing deleted (#18)
* Add editable display-name field to generic integrations
Lets users set a custom name for Proxmox, Docker, AWS, Remote Desktop,
Netbird, Cloudflare, Uptime Kuma, and Weather integrations, separate
from the host/IP field, mirroring the SSH host rename pattern.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kF4hZWEkRCPPvCZTeXxn4
* Surface the new-integration name field as a labeled input
The name field for new generic integrations was a faint header input
with only placeholder text, easy to miss. Move it into the form grid
as a proper labeled "Name" field next to the other connection fields.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kF4hZWEkRCPPvCZTeXxn4
* Add file upload for SSH private key and certificate fields
Lets users pick a key file from disk (e.g. ~/.ssh) instead of pasting
its contents into the Private Key / OPKSSH Certificate fields.
* Fix SSH private key paste corrupting multi-line PEM format
Private Key and Certificate fields were single-line <input> elements,
which strip newlines on paste and corrupt PEM-formatted keys (causing
'Unsupported key format' errors). Render them as multi-line textareas
instead so pasted keys keep their line breaks.
* Show saved indicator for secret fields instead of appearing blank/deleted
GET /api/integrations never returns decrypted secret values (by design),
so after navigating away and back, secret/key fields rendered empty -
looking exactly like the saved key had been deleted, even though it was
still intact and encrypted in the database. Expose which secret keys
exist (names only, never values) via secretKeys, and use it to label
fields as "saved" with an appropriate placeholder instead of blank.
---------
Co-authored-by: Claude <noreply@anthropic.com>
2026-06-20 08:53:56 -04:00
existing ,
2026-06-20 10:18:04 +00:00
) }
2026-06-21 16:03:51 -04:00
< IconField
value = { draft . iconUrl ? ? existing . config . iconUrl ? ? '' }
onChange = { ( value ) = > setEditField ( existing . id , 'iconUrl' , value ) }
/ >
2026-06-20 22:15:43 +00:00
{ def . type === 'docker' && (
< DockerSetupHint baseUrl = { draft . baseUrl ? ? existing . config . baseUrl ? ? '' } / >
) }
2026-06-20 10:18:04 +00:00
< / div >
)
} ) }
{ draftRows . map ( ( draft ) = > {
const rowKey = ` n- ${ draft . id } `
return (
< div key = { draft . id } style = { cardBase } >
< div className = "flex items-center justify-between" style = { { marginBottom : '16px' } } >
< span style = { { fontSize : '13px' , color : '#E8E6E0' , fontWeight : 600 } } > New { def . name } < / span >
< div className = "flex items-center gap-2" >
{ statusMsg [ rowKey ] && (
< span style = { { fontSize : '11px' , color : '#7A7D85' } } > { statusMsg [ rowKey ] } < / span >
) }
< button
onClick = { ( ) = > handleSaveNew ( def , draft ) }
disabled = { busy . has ( rowKey ) }
className = "cursor-pointer border-none"
style = { { fontSize : '11px' , fontWeight : 600 , color : '#0A0B0D' , backgroundColor : '#C8A434' , borderRadius : '6px' , padding : '6px 12px' , opacity : busy.has ( rowKey ) ? 0.6 : 1 } }
>
Save
< / button >
< button
onClick = { ( ) = > removeNewDraft ( draft . id ) }
className = "cursor-pointer border-none"
style = { { fontSize : '11px' , fontWeight : 600 , color : '#7A7D85' , backgroundColor : 'rgba(255,255,255,0.04)' , border : '1px solid rgba(255,255,255,0.1)' , borderRadius : '6px' , padding : '6px 12px' } }
>
Cancel
< / button >
< / div >
< / div >
Add editable name field to generic integrations (#13)
* Add editable display-name field to generic integrations
Lets users set a custom name for Proxmox, Docker, AWS, Remote Desktop,
Netbird, Cloudflare, Uptime Kuma, and Weather integrations, separate
from the host/IP field, mirroring the SSH host rename pattern.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kF4hZWEkRCPPvCZTeXxn4
* Surface the new-integration name field as a labeled input
The name field for new generic integrations was a faint header input
with only placeholder text, easy to miss. Move it into the form grid
as a proper labeled "Name" field next to the other connection fields.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kF4hZWEkRCPPvCZTeXxn4
---------
Co-authored-by: Claude <noreply@anthropic.com>
2026-06-20 07:40:03 -04:00
< div className = "grid grid-cols-3 gap-4" style = { { marginBottom : '16px' } } >
< div >
< label style = { labelStyle } > Name < / label >
< input
style = { inputStyle }
value = { draft . values . __name ? ? '' }
onChange = { ( e ) = > setNewDraftField ( draft . id , '__name' , e . target . value ) }
placeholder = { def . name }
/ >
< / div >
< / div >
2026-06-20 10:18:04 +00:00
{ renderFields (
def ,
rowKey ,
( f ) = > draft . values [ f . key ] ? ? '' ,
( f , value ) = > setNewDraftField ( draft . id , f . key , value ) ,
'' ,
) }
2026-06-21 16:03:51 -04:00
< IconField
value = { draft . values . iconUrl ? ? '' }
onChange = { ( value ) = > setNewDraftField ( draft . id , 'iconUrl' , value ) }
/ >
2026-06-20 22:15:43 +00:00
{ def . type === 'docker' && < DockerSetupHint baseUrl = { draft . values . baseUrl ? ? '' } / > }
2026-06-20 10:18:04 +00:00
< / div >
)
} ) }
{ existingRows . length === 0 && draftRows . length === 0 && (
< button
onClick = { ( ) = > addNewDraft ( def . type ) }
className = "cursor-pointer self-start"
style = { { fontSize : '12px' , fontWeight : 600 , color : '#C8A434' , backgroundColor : 'rgba(200,164,52,0.08)' , border : '1px solid rgba(200,164,52,0.2)' , borderRadius : '8px' , padding : '9px 16px' } }
>
+ Add { def . name }
< / button >
) }
{ canAddAnother && ( existingRows . length > 0 || draftRows . length > 0 ) && (
< button
onClick = { ( ) = > addNewDraft ( def . type ) }
className = "cursor-pointer self-start"
style = { { fontSize : '12px' , fontWeight : 600 , color : '#C8A434' , backgroundColor : 'rgba(200,164,52,0.08)' , border : '1px solid rgba(200,164,52,0.2)' , borderRadius : '8px' , padding : '9px 16px' } }
>
+ Add Another { def . name }
< / button >
) }
2026-06-18 18:44:26 +00:00
< / div >
2026-06-18 19:26:48 +00:00
)
} ) }
2026-06-18 18:44:26 +00:00
< / div >
)
}
function NotificationsSection() {
const [ enabled , setEnabled ] = useState ( true )
const [ email , setEmail ] = useState ( true )
const [ push , setPush ] = useState ( false )
const [ sound , setSound ] = useState ( true )
return (
< div style = { cardBase } >
< h3 style = { sectionTitle } > Notifications < / h3 >
< div className = "flex items-center justify-between" style = { { marginBottom : '20px' } } >
< span style = { { fontSize : '13px' , color : '#E8E6E0' } } > Enable Notifications < / span >
< Toggle on = { enabled } onClick = { ( ) = > setEnabled ( ( v ) = > ! v ) } / >
< / div >
< div style = { { marginBottom : '20px' } } >
< label style = { labelStyle } > Alert Threshold < / label >
< select style = { { . . . inputStyle , width : '220px' } } defaultValue = "all" >
< option value = "all" > All < / option >
< option value = "critical" > Critical Only < / option >
< option value = "warning" > Warning & Above < / option >
< / select >
< / div >
< div className = "flex items-center justify-between" style = { { marginBottom : '16px' } } >
< span style = { { fontSize : '13px' , color : '#E8E6E0' } } > Email Notifications < / span >
< Toggle on = { email } onClick = { ( ) = > setEmail ( ( v ) = > ! v ) } / >
< / div >
< div className = "flex items-center justify-between" style = { { marginBottom : '16px' } } >
< span style = { { fontSize : '13px' , color : '#E8E6E0' } } > Browser Push < / span >
< Toggle on = { push } onClick = { ( ) = > setPush ( ( v ) = > ! v ) } / >
< / div >
< div className = "flex items-center justify-between" style = { { marginBottom : '8px' } } >
< span style = { { fontSize : '13px' , color : '#E8E6E0' } } > Sound < / span >
< Toggle on = { sound } onClick = { ( ) = > setSound ( ( v ) = > ! v ) } / >
< / div >
{ sound && (
< input type = "range" min = { 0 } max = { 100 } defaultValue = { 70 } className = "w-full" style = { { accentColor : '#C8A434' } } / >
) }
< / div >
)
}
function DataBackupSection() {
Add auth Phase 3: multi-user accounts with admin/member roles (#28)
Implements Phase 3 of the auth roadmap: multiple user accounts (cap 10),
an admin/member role model, and admin-only gating of config-mutating
routes. Dashboard data stays shared across all users (per the product
decision in HANDOFF.md — this is a household/self-hosted dashboard, not
a multi-tenant app), so there is no per-user data isolation.
Schema (backend/src/db/index.ts):
- Idempotent migration adds `role` (default 'admin') and `active`
(default 1) columns to `users` when missing. The 'admin' default means
the pre-existing single user is backfilled to admin on deploy and keeps
full access; newly created users are inserted explicitly as 'member'.
Verified against a production-like old schema (columns added, existing
user backfilled to admin/active).
Auth + access control:
- `/api/setup` creates the first user as admin. Login enforces `active`
(deactivated accounts get 403) and embeds the live role in the session.
- `app.authenticate` now reads role+active fresh from the DB on every
request (not from the possibly-stale JWT claim), rejects inactive
accounts, and stashes the role on req.user.
- New `requireAdmin` (auth + role check) and `adminOnly` (role check for
routes already behind the plugin-level authenticate hook) decorators.
User management (admin-only, in auth.ts):
- GET/POST/PUT/DELETE /api/users — list, create (admin sets a temp
password; no public signup), change role, activate/deactivate, delete.
- 10-user cap enforced server-side; guard rails prevent removing the last
active admin (demote/deactivate/delete) and deleting your own account;
deactivating or deleting a user drops their sessions immediately.
Admin-only route gating (members get 403):
- integrations create/update/delete/test, tunnels create/delete, data
export/import. Read routes and tunnel connect/disconnect stay open to
all authenticated users, as do all the SSH/Docker/RDP tools and
bookmarks (members are trusted to use the tooling, per product decision).
Frontend:
- api.ts: listUsers/createUser/updateUser/deleteUser + ManagedUser type;
role+active added to AuthUser.
- Settings: new admin-only "Users" section (create form, role toggle,
activate/deactivate, delete, 10-cap indicator). Nav filters the Users
tab by role and guards ?tab= deep-links. Data & Backup shows an
admin-only notice for members; Integrations shows a read-only banner
for members. (Backend remains the real enforcement boundary.)
Verified end-to-end against a throwaway backend: role assignment,
member 403s on every admin-only route + 200s on shared/read routes,
admin 200/201s, last-admin guards (409/400), deactivation killing an
active session and blocking re-login (then reactivation restoring it),
and the 10-user cap (409 on the 11th). Both frontend and backend
type-check clean.
Co-authored-by: Samuel James <ssamjame@amazon.com>
Co-authored-by: Kiro <noreply@kiro.dev>
2026-06-20 12:43:24 -04:00
const { user } = useAuth ( )
2026-06-19 16:13:29 +00:00
const [ busy , setBusy ] = useState ( false )
const [ message , setMessage ] = useState < string | null > ( null )
const [ error , setError ] = useState < string | null > ( null )
const importRef = useRef < HTMLInputElement | null > ( null )
Add auth Phase 3: multi-user accounts with admin/member roles (#28)
Implements Phase 3 of the auth roadmap: multiple user accounts (cap 10),
an admin/member role model, and admin-only gating of config-mutating
routes. Dashboard data stays shared across all users (per the product
decision in HANDOFF.md — this is a household/self-hosted dashboard, not
a multi-tenant app), so there is no per-user data isolation.
Schema (backend/src/db/index.ts):
- Idempotent migration adds `role` (default 'admin') and `active`
(default 1) columns to `users` when missing. The 'admin' default means
the pre-existing single user is backfilled to admin on deploy and keeps
full access; newly created users are inserted explicitly as 'member'.
Verified against a production-like old schema (columns added, existing
user backfilled to admin/active).
Auth + access control:
- `/api/setup` creates the first user as admin. Login enforces `active`
(deactivated accounts get 403) and embeds the live role in the session.
- `app.authenticate` now reads role+active fresh from the DB on every
request (not from the possibly-stale JWT claim), rejects inactive
accounts, and stashes the role on req.user.
- New `requireAdmin` (auth + role check) and `adminOnly` (role check for
routes already behind the plugin-level authenticate hook) decorators.
User management (admin-only, in auth.ts):
- GET/POST/PUT/DELETE /api/users — list, create (admin sets a temp
password; no public signup), change role, activate/deactivate, delete.
- 10-user cap enforced server-side; guard rails prevent removing the last
active admin (demote/deactivate/delete) and deleting your own account;
deactivating or deleting a user drops their sessions immediately.
Admin-only route gating (members get 403):
- integrations create/update/delete/test, tunnels create/delete, data
export/import. Read routes and tunnel connect/disconnect stay open to
all authenticated users, as do all the SSH/Docker/RDP tools and
bookmarks (members are trusted to use the tooling, per product decision).
Frontend:
- api.ts: listUsers/createUser/updateUser/deleteUser + ManagedUser type;
role+active added to AuthUser.
- Settings: new admin-only "Users" section (create form, role toggle,
activate/deactivate, delete, 10-cap indicator). Nav filters the Users
tab by role and guards ?tab= deep-links. Data & Backup shows an
admin-only notice for members; Integrations shows a read-only banner
for members. (Backend remains the real enforcement boundary.)
Verified end-to-end against a throwaway backend: role assignment,
member 403s on every admin-only route + 200s on shared/read routes,
admin 200/201s, last-admin guards (409/400), deactivation killing an
active session and blocking re-login (then reactivation restoring it),
and the 10-user cap (409 on the 11th). Both frontend and backend
type-check clean.
Co-authored-by: Samuel James <ssamjame@amazon.com>
Co-authored-by: Kiro <noreply@kiro.dev>
2026-06-20 12:43:24 -04:00
if ( user ? . role !== 'admin' ) {
return (
< div style = { cardBase } >
< h3 style = { sectionTitle } > Data & amp ; Backup < / h3 >
< p style = { { fontSize : '13px' , color : '#7A7D85' } } >
Backup export and import are restricted to administrators .
< / p >
< / div >
)
}
2026-06-19 16:13:29 +00:00
async function handleExport() {
setBusy ( true )
setError ( null )
setMessage ( null )
try {
const data = await api . exportData ( )
const blob = new Blob ( [ JSON . stringify ( data , null , 2 ) ] , { type : 'application/json' } )
const url = URL . createObjectURL ( blob )
const a = document . createElement ( 'a' )
a . href = url
a . download = ` archnest-backup- ${ new Date ( ) . toISOString ( ) . slice ( 0 , 10 ) } .json `
a . click ( )
URL . revokeObjectURL ( url )
setMessage ( ` Exported ${ data . integrations . length } integrations, ${ data . bookmarks . length } bookmarks, ${ data . tunnels . length } tunnels. ` )
} catch ( err ) {
setError ( err instanceof Error ? err . message : 'Export failed' )
} finally {
setBusy ( false )
}
}
async function handleImportFile ( file : File ) {
setBusy ( true )
setError ( null )
setMessage ( null )
try {
const text = await file . text ( )
const parsed = JSON . parse ( text )
const result = await api . importData ( parsed )
const c = result . imported
setMessage ( ` Imported ${ c . integrations } integrations, ${ c . bookmarkCategories } categories, ${ c . bookmarks } bookmarks, ${ c . tunnels } tunnels. ` )
} catch ( err ) {
setError ( err instanceof Error ? err . message : 'Import failed — is this a valid ArchNest backup file?' )
} finally {
setBusy ( false )
}
}
2026-06-18 18:44:26 +00:00
return (
< div style = { cardBase } >
< h3 style = { sectionTitle } > Data & Backup < / h3 >
2026-06-19 16:13:29 +00:00
< p style = { { fontSize : '12px' , color : '#7A7D85' , marginBottom : '16px' , maxWidth : '460px' } } >
Export a portable backup of all integrations ( including their credentials ) , bookmarks , and tunnels as a single JSON file , or
import one into this instance . Imports are additive — existing data is kept and the backup ' s items are added alongside it .
The backup contains plaintext credentials , so store it securely .
< / p >
< div className = "flex flex-col gap-3" style = { { maxWidth : '460px' } } >
2026-06-18 18:44:26 +00:00
< div className = "flex items-center justify-between" >
2026-06-19 16:13:29 +00:00
< span style = { { fontSize : '13px' , color : '#E8E6E0' } } > Export all data ( JSON ) < / span >
< GoldButton onClick = { handleExport } disabled = { busy } >
< Download size = { 13 } / > Export
< / GoldButton >
2026-06-18 18:44:26 +00:00
< / div >
< div className = "flex items-center justify-between" >
2026-06-19 16:13:29 +00:00
< span style = { { fontSize : '13px' , color : '#E8E6E0' } } > Import from backup ( JSON ) < / span >
< GoldButton onClick = { ( ) = > importRef . current ? . click ( ) } disabled = { busy } >
< Upload size = { 13 } / > Import
< / GoldButton >
< input
ref = { importRef }
type = "file"
accept = "application/json,.json"
className = "hidden"
onChange = { ( e ) = > {
const file = e . target . files ? . [ 0 ]
if ( file ) handleImportFile ( file )
e . target . value = ''
} }
/ >
2026-06-18 18:44:26 +00:00
< / div >
2026-06-19 16:13:29 +00:00
{ message && < p style = { { fontSize : '12px' , color : '#2ECC71' } } > { message } < / p > }
{ error && < p style = { { fontSize : '12px' , color : '#E74C3C' } } > { error } < / p > }
2026-06-18 18:44:26 +00:00
< / div >
< / div >
)
}
function AboutSection() {
const rows : [ string , string ] [ ] = [
[ 'App' , 'ArchNest Dashboard v1.0.0' ] ,
[ 'Author' , 'Samuel James' ] ,
[ 'Repo' , 'github.com/SamuelSJames/archnest' ] ,
[ 'Stack' , 'React 19, Vite, TypeScript' ] ,
[ 'License' , 'MIT' ] ,
]
return (
< div style = { cardBase } >
< h3 style = { sectionTitle } > About < / h3 >
< div className = "flex flex-col gap-3" >
{ rows . map ( ( [ label , value ] ) = > (
< div key = { label } className = "flex items-center justify-between" >
< span style = { { fontSize : '12px' , color : '#7A7D85' } } > { label } < / span >
< span style = { { fontSize : '12px' , color : '#E8E6E0' } } > { value } < / span >
< / div >
) ) }
< / div >
< / div >
)
}
Add auth Phase 2: password change, sessions, and login audit log (#27)
Builds out the Settings → Security tab (previously a "coming soon"
placeholder) and the backend behind it. Still single-user; multi-user
and SSO remain Phases 3-4.
Backend:
- New `sessions` table (id, user_id, user_agent, ip, created_at,
last_seen_at) and `login_events` table (user_id, username, ip,
user_agent, success, created_at).
- Login and setup now mint a session row and embed its id as a `sid`
claim in the JWT. The `authenticate` hook validates that the session
still exists (and bumps last_seen_at), so revoking a session genuinely
invalidates its token instead of relying on the JWT signature alone.
Tokens minted before sessions existed have no `sid` and stay valid
until expiry, for backward compatibility.
- Every login attempt (success and failure) is recorded in login_events
for the audit trail.
- New endpoints: PUT /api/auth/password (verifies current via bcrypt,
hashes new at cost 12, revokes all *other* sessions on success),
GET /api/auth/sessions, DELETE /api/auth/sessions/:id (can't revoke
the current one), POST /api/auth/logout (revokes current session),
GET /api/auth/login-events?limit.
- AuthContext.logout() now calls POST /api/auth/logout best-effort so
signing out revokes the server session, not just the local token.
Frontend:
- SecuritySection: change-password form (current/new/confirm with
show/hide and client-side validation), active-sessions list (device
description from user-agent, IP, last-seen relative time, per-session
"Sign out" for non-current sessions), and a recent login-activity feed
(success/failure dot, user, IP, relative time).
- api.ts: changePassword/listSessions/revokeSession/logout/
listLoginEvents + AuthSession/LoginEvent types.
Verified end-to-end against a throwaway backend instance: session
creation, second-device session, failed-login logging, cross-session
revocation invalidating the revoked token, password change keeping the
current session alive while revoking others, and logout invalidating the
current session. Frontend + backend both type-check clean.
Co-authored-by: Samuel James <ssamjame@amazon.com>
Co-authored-by: Kiro <noreply@kiro.dev>
2026-06-20 11:50:56 -04:00
function relativeTime ( iso : string ) : string {
// SQLite datetime('now') returns UTC without a timezone marker; treat it as UTC.
const ts = Date . parse ( iso . includes ( 'T' ) ? iso : iso.replace ( ' ' , 'T' ) + 'Z' )
if ( Number . isNaN ( ts ) ) return iso
const diffMs = Date . now ( ) - ts
const sec = Math . round ( diffMs / 1000 )
if ( sec < 60 ) return 'just now'
const min = Math . round ( sec / 60 )
if ( min < 60 ) return ` ${ min } m ago `
const hr = Math . round ( min / 60 )
if ( hr < 24 ) return ` ${ hr } h ago `
const day = Math . round ( hr / 24 )
if ( day < 30 ) return ` ${ day } d ago `
return new Date ( ts ) . toLocaleDateString ( )
}
function describeUserAgent ( ua : string | null ) : string {
if ( ! ua ) return 'Unknown device'
let os = 'Unknown OS'
if ( /Windows/i . test ( ua ) ) os = 'Windows'
else if ( /Macintosh|Mac OS/i . test ( ua ) ) os = 'macOS'
else if ( /Android/i . test ( ua ) ) os = 'Android'
else if ( /iPhone|iPad|iOS/i . test ( ua ) ) os = 'iOS'
else if ( /Linux/i . test ( ua ) ) os = 'Linux'
let browser = ''
if ( /Edg\//i . test ( ua ) ) browser = 'Edge'
else if ( /Chrome\//i . test ( ua ) && ! /Chromium/i . test ( ua ) ) browser = 'Chrome'
else if ( /Firefox\//i . test ( ua ) ) browser = 'Firefox'
else if ( /Safari\//i . test ( ua ) && ! /Chrome/i . test ( ua ) ) browser = 'Safari'
return browser ? ` ${ browser } on ${ os } ` : os
}
Wire up Profile/Appearance/Security in user menu (#21)
* Add editable display-name field to generic integrations
Lets users set a custom name for Proxmox, Docker, AWS, Remote Desktop,
Netbird, Cloudflare, Uptime Kuma, and Weather integrations, separate
from the host/IP field, mirroring the SSH host rename pattern.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kF4hZWEkRCPPvCZTeXxn4
* Surface the new-integration name field as a labeled input
The name field for new generic integrations was a faint header input
with only placeholder text, easy to miss. Move it into the form grid
as a proper labeled "Name" field next to the other connection fields.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kF4hZWEkRCPPvCZTeXxn4
* Add file upload for SSH private key and certificate fields
Lets users pick a key file from disk (e.g. ~/.ssh) instead of pasting
its contents into the Private Key / OPKSSH Certificate fields.
* Fix SSH private key paste corrupting multi-line PEM format
Private Key and Certificate fields were single-line <input> elements,
which strip newlines on paste and corrupt PEM-formatted keys (causing
'Unsupported key format' errors). Render them as multi-line textareas
instead so pasted keys keep their line breaks.
* Add JSON-converted bookmark import file for Archnest data import
Converts homarr-bookmarks.md into the format expected by /api/data/import.
* Auto-populate bookmark icons via favicon service in import JSON
Each bookmark now points to Google's favicon endpoint for its domain
instead of having no icon at all.
* Wire up Profile/Appearance/Security in the user menu
Profile, Appearance, and Security were dead "#" links. Added URL-based
tab deep-linking to the Settings page (?tab=profile|appearance|security|...)
and pointed the menu items at it. Added a Security tab placeholder ahead
of password/sessions/login-log/SSO work.
---------
Co-authored-by: Claude <noreply@anthropic.com>
2026-06-20 09:44:43 -04:00
function SecuritySection() {
Add auth Phase 2: password change, sessions, and login audit log (#27)
Builds out the Settings → Security tab (previously a "coming soon"
placeholder) and the backend behind it. Still single-user; multi-user
and SSO remain Phases 3-4.
Backend:
- New `sessions` table (id, user_id, user_agent, ip, created_at,
last_seen_at) and `login_events` table (user_id, username, ip,
user_agent, success, created_at).
- Login and setup now mint a session row and embed its id as a `sid`
claim in the JWT. The `authenticate` hook validates that the session
still exists (and bumps last_seen_at), so revoking a session genuinely
invalidates its token instead of relying on the JWT signature alone.
Tokens minted before sessions existed have no `sid` and stay valid
until expiry, for backward compatibility.
- Every login attempt (success and failure) is recorded in login_events
for the audit trail.
- New endpoints: PUT /api/auth/password (verifies current via bcrypt,
hashes new at cost 12, revokes all *other* sessions on success),
GET /api/auth/sessions, DELETE /api/auth/sessions/:id (can't revoke
the current one), POST /api/auth/logout (revokes current session),
GET /api/auth/login-events?limit.
- AuthContext.logout() now calls POST /api/auth/logout best-effort so
signing out revokes the server session, not just the local token.
Frontend:
- SecuritySection: change-password form (current/new/confirm with
show/hide and client-side validation), active-sessions list (device
description from user-agent, IP, last-seen relative time, per-session
"Sign out" for non-current sessions), and a recent login-activity feed
(success/failure dot, user, IP, relative time).
- api.ts: changePassword/listSessions/revokeSession/logout/
listLoginEvents + AuthSession/LoginEvent types.
Verified end-to-end against a throwaway backend instance: session
creation, second-device session, failed-login logging, cross-session
revocation invalidating the revoked token, password change keeping the
current session alive while revoking others, and logout invalidating the
current session. Frontend + backend both type-check clean.
Co-authored-by: Samuel James <ssamjame@amazon.com>
Co-authored-by: Kiro <noreply@kiro.dev>
2026-06-20 11:50:56 -04:00
// Change-password form
const [ currentPassword , setCurrentPassword ] = useState ( '' )
const [ newPassword , setNewPassword ] = useState ( '' )
const [ confirmPassword , setConfirmPassword ] = useState ( '' )
const [ showCurrent , setShowCurrent ] = useState ( false )
const [ showNew , setShowNew ] = useState ( false )
const [ changing , setChanging ] = useState ( false )
const [ pwMsg , setPwMsg ] = useState < { text : string ; ok : boolean } | null > ( null )
// Sessions + login events
const [ sessions , setSessions ] = useState < AuthSession [ ] > ( [ ] )
const [ events , setEvents ] = useState < LoginEvent [ ] > ( [ ] )
const [ loading , setLoading ] = useState ( true )
async function loadActivity() {
try {
const [ s , e ] = await Promise . all ( [ api . listSessions ( ) , api . listLoginEvents ( 15 ) ] )
setSessions ( s . sessions )
setEvents ( e . events )
} catch {
// leave existing data on transient failure
} finally {
setLoading ( false )
}
}
useEffect ( ( ) = > {
loadActivity ( )
} , [ ] )
async function handleChangePassword() {
setPwMsg ( null )
if ( newPassword . length < 8 ) {
setPwMsg ( { text : 'New password must be at least 8 characters' , ok : false } )
return
}
if ( newPassword !== confirmPassword ) {
setPwMsg ( { text : 'New passwords do not match' , ok : false } )
return
}
setChanging ( true )
try {
await api . changePassword ( currentPassword , newPassword )
setPwMsg ( { text : 'Password changed. Other sessions were signed out.' , ok : true } )
setCurrentPassword ( '' )
setNewPassword ( '' )
setConfirmPassword ( '' )
loadActivity ( )
} catch ( err ) {
setPwMsg ( { text : err instanceof ApiError ? err . message : 'Failed to change password' , ok : false } )
} finally {
setChanging ( false )
}
}
async function handleRevoke ( id : string ) {
try {
await api . revokeSession ( id )
setSessions ( ( prev ) = > prev . filter ( ( s ) = > s . id !== id ) )
} catch {
loadActivity ( )
}
}
const pwInputWrap : React.CSSProperties = { position : 'relative' }
const eyeBtn : React.CSSProperties = {
position : 'absolute' ,
right : '10px' ,
top : '50%' ,
transform : 'translateY(-50%)' ,
background : 'none' ,
border : 'none' ,
cursor : 'pointer' ,
color : '#7A7D85' ,
display : 'flex' ,
}
Wire up Profile/Appearance/Security in user menu (#21)
* Add editable display-name field to generic integrations
Lets users set a custom name for Proxmox, Docker, AWS, Remote Desktop,
Netbird, Cloudflare, Uptime Kuma, and Weather integrations, separate
from the host/IP field, mirroring the SSH host rename pattern.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kF4hZWEkRCPPvCZTeXxn4
* Surface the new-integration name field as a labeled input
The name field for new generic integrations was a faint header input
with only placeholder text, easy to miss. Move it into the form grid
as a proper labeled "Name" field next to the other connection fields.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kF4hZWEkRCPPvCZTeXxn4
* Add file upload for SSH private key and certificate fields
Lets users pick a key file from disk (e.g. ~/.ssh) instead of pasting
its contents into the Private Key / OPKSSH Certificate fields.
* Fix SSH private key paste corrupting multi-line PEM format
Private Key and Certificate fields were single-line <input> elements,
which strip newlines on paste and corrupt PEM-formatted keys (causing
'Unsupported key format' errors). Render them as multi-line textareas
instead so pasted keys keep their line breaks.
* Add JSON-converted bookmark import file for Archnest data import
Converts homarr-bookmarks.md into the format expected by /api/data/import.
* Auto-populate bookmark icons via favicon service in import JSON
Each bookmark now points to Google's favicon endpoint for its domain
instead of having no icon at all.
* Wire up Profile/Appearance/Security in the user menu
Profile, Appearance, and Security were dead "#" links. Added URL-based
tab deep-linking to the Settings page (?tab=profile|appearance|security|...)
and pointed the menu items at it. Added a Security tab placeholder ahead
of password/sessions/login-log/SSO work.
---------
Co-authored-by: Claude <noreply@anthropic.com>
2026-06-20 09:44:43 -04:00
return (
Add auth Phase 2: password change, sessions, and login audit log (#27)
Builds out the Settings → Security tab (previously a "coming soon"
placeholder) and the backend behind it. Still single-user; multi-user
and SSO remain Phases 3-4.
Backend:
- New `sessions` table (id, user_id, user_agent, ip, created_at,
last_seen_at) and `login_events` table (user_id, username, ip,
user_agent, success, created_at).
- Login and setup now mint a session row and embed its id as a `sid`
claim in the JWT. The `authenticate` hook validates that the session
still exists (and bumps last_seen_at), so revoking a session genuinely
invalidates its token instead of relying on the JWT signature alone.
Tokens minted before sessions existed have no `sid` and stay valid
until expiry, for backward compatibility.
- Every login attempt (success and failure) is recorded in login_events
for the audit trail.
- New endpoints: PUT /api/auth/password (verifies current via bcrypt,
hashes new at cost 12, revokes all *other* sessions on success),
GET /api/auth/sessions, DELETE /api/auth/sessions/:id (can't revoke
the current one), POST /api/auth/logout (revokes current session),
GET /api/auth/login-events?limit.
- AuthContext.logout() now calls POST /api/auth/logout best-effort so
signing out revokes the server session, not just the local token.
Frontend:
- SecuritySection: change-password form (current/new/confirm with
show/hide and client-side validation), active-sessions list (device
description from user-agent, IP, last-seen relative time, per-session
"Sign out" for non-current sessions), and a recent login-activity feed
(success/failure dot, user, IP, relative time).
- api.ts: changePassword/listSessions/revokeSession/logout/
listLoginEvents + AuthSession/LoginEvent types.
Verified end-to-end against a throwaway backend instance: session
creation, second-device session, failed-login logging, cross-session
revocation invalidating the revoked token, password change keeping the
current session alive while revoking others, and logout invalidating the
current session. Frontend + backend both type-check clean.
Co-authored-by: Samuel James <ssamjame@amazon.com>
Co-authored-by: Kiro <noreply@kiro.dev>
2026-06-20 11:50:56 -04:00
< div className = "flex flex-col gap-5" >
{ /* Change password */ }
< div style = { cardBase } >
< h3 style = { sectionTitle } > Change Password < / h3 >
< div className = "flex flex-col gap-4" style = { { maxWidth : '420px' } } >
< div >
< label style = { labelStyle } > Current Password < / label >
< div style = { pwInputWrap } >
< input
style = { { . . . inputStyle , paddingRight : '38px' } }
type = { showCurrent ? 'text' : 'password' }
autoComplete = "current-password"
value = { currentPassword }
onChange = { ( e ) = > setCurrentPassword ( e . target . value ) }
/ >
< button style = { eyeBtn } onClick = { ( ) = > setShowCurrent ( ( v ) = > ! v ) } type = "button" title = { showCurrent ? 'Hide' : 'Show' } >
{ showCurrent ? < EyeOff size = { 15 } / > : < Eye size = { 15 } / > }
< / button >
< / div >
< / div >
< div >
< label style = { labelStyle } > New Password < / label >
< div style = { pwInputWrap } >
< input
style = { { . . . inputStyle , paddingRight : '38px' } }
type = { showNew ? 'text' : 'password' }
autoComplete = "new-password"
value = { newPassword }
onChange = { ( e ) = > setNewPassword ( e . target . value ) }
/ >
< button style = { eyeBtn } onClick = { ( ) = > setShowNew ( ( v ) = > ! v ) } type = "button" title = { showNew ? 'Hide' : 'Show' } >
{ showNew ? < EyeOff size = { 15 } / > : < Eye size = { 15 } / > }
< / button >
< / div >
< / div >
< div >
< label style = { labelStyle } > Confirm New Password < / label >
< input
style = { inputStyle }
type = { showNew ? 'text' : 'password' }
autoComplete = "new-password"
value = { confirmPassword }
onChange = { ( e ) = > setConfirmPassword ( e . target . value ) }
/ >
< / div >
< div className = "flex items-center gap-3" >
< GoldButton
onClick = { handleChangePassword }
disabled = { changing || ! currentPassword || ! newPassword || ! confirmPassword }
>
{ changing ? 'Saving…' : 'Update Password' }
< / GoldButton >
{ pwMsg && (
< span style = { { fontSize : '12px' , color : pwMsg.ok ? '#2ECC71' : '#E74C3C' } } > { pwMsg . text } < / span >
) }
< / div >
< / div >
< / div >
{ /* Active sessions */ }
< div style = { cardBase } >
< h3 style = { sectionTitle } > Active Sessions < / h3 >
{ loading ? (
< p style = { { fontSize : '13px' , color : '#7A7D85' } } > Loading … < / p >
) : sessions . length === 0 ? (
< p style = { { fontSize : '13px' , color : '#7A7D85' } } > No active sessions . < / p >
) : (
< div className = "flex flex-col gap-2" >
{ sessions . map ( ( s ) = > (
< div
key = { s . id }
className = "flex items-center gap-3"
style = { {
padding : '12px 14px' ,
borderRadius : '8px' ,
border : '1px solid rgba(200,164,52,0.08)' ,
backgroundColor : 'rgba(255,255,255,0.02)' ,
} }
>
< Monitor size = { 18 } color = { s . current ? '#C8A434' : '#7A7D85' } style = { { flexShrink : 0 } } / >
< div className = "flex-1 min-w-0" >
< div style = { { fontSize : '13px' , color : '#E8E6E0' , fontWeight : 500 } } >
{ describeUserAgent ( s . userAgent ) }
{ s . current && (
< span style = { { fontSize : '10px' , color : '#C8A434' , marginLeft : '8px' , fontWeight : 600 } } > THIS DEVICE < / span >
) }
< / div >
< div style = { { fontSize : '11px' , color : '#7A7D85' } } >
{ s . ip ? ? 'unknown IP' } · last active { relativeTime ( s . lastSeenAt ) }
< / div >
< / div >
{ ! s . current && (
< button
onClick = { ( ) = > handleRevoke ( s . id ) }
className = "flex items-center gap-1.5 cursor-pointer transition-colors whitespace-nowrap"
style = { {
fontSize : '11px' ,
fontWeight : 600 ,
color : '#E74C3C' ,
background : 'transparent' ,
border : '1px solid rgba(231,76,60,0.4)' ,
borderRadius : '7px' ,
padding : '6px 12px' ,
} }
>
< LogOut size = { 13 } / > Sign out
< / button >
) }
< / div >
) ) }
< / div >
) }
< / div >
{ /* Login activity */ }
< div style = { cardBase } >
< h3 style = { sectionTitle } > Recent Login Activity < / h3 >
{ loading ? (
< p style = { { fontSize : '13px' , color : '#7A7D85' } } > Loading … < / p >
) : events . length === 0 ? (
< p style = { { fontSize : '13px' , color : '#7A7D85' } } > No login activity recorded yet . < / p >
) : (
< div className = "flex flex-col" >
{ events . map ( ( e , i ) = > (
< div
key = { e . id }
className = "flex items-center gap-3"
style = { {
padding : '10px 0' ,
borderTop : i === 0 ? 'none' : '1px solid rgba(200,164,52,0.06)' ,
} }
>
< span
style = { {
width : '8px' ,
height : '8px' ,
borderRadius : '50%' ,
backgroundColor : e.success ? '#2ECC71' : '#E74C3C' ,
flexShrink : 0 ,
} }
/ >
< div className = "flex-1 min-w-0" >
< span style = { { fontSize : '13px' , color : '#E8E6E0' } } >
{ e . success ? 'Successful login' : 'Failed login' }
< / span >
< span style = { { fontSize : '11px' , color : '#7A7D85' , marginLeft : '8px' } } >
{ e . username ? ? 'unknown' } · { e . ip ? ? 'unknown IP' }
< / span >
< / div >
< span style = { { fontSize : '11px' , color : '#7A7D85' , flexShrink : 0 } } > { relativeTime ( e . createdAt ) } < / span >
< / div >
) ) }
< / div >
) }
< p style = { { fontSize : '11px' , color : '#7A7D85' , marginTop : '14px' } } >
SSO ( Authentik ) and multi - user accounts are planned — see the project roadmap .
< / p >
< / div >
Wire up Profile/Appearance/Security in user menu (#21)
* Add editable display-name field to generic integrations
Lets users set a custom name for Proxmox, Docker, AWS, Remote Desktop,
Netbird, Cloudflare, Uptime Kuma, and Weather integrations, separate
from the host/IP field, mirroring the SSH host rename pattern.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kF4hZWEkRCPPvCZTeXxn4
* Surface the new-integration name field as a labeled input
The name field for new generic integrations was a faint header input
with only placeholder text, easy to miss. Move it into the form grid
as a proper labeled "Name" field next to the other connection fields.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kF4hZWEkRCPPvCZTeXxn4
* Add file upload for SSH private key and certificate fields
Lets users pick a key file from disk (e.g. ~/.ssh) instead of pasting
its contents into the Private Key / OPKSSH Certificate fields.
* Fix SSH private key paste corrupting multi-line PEM format
Private Key and Certificate fields were single-line <input> elements,
which strip newlines on paste and corrupt PEM-formatted keys (causing
'Unsupported key format' errors). Render them as multi-line textareas
instead so pasted keys keep their line breaks.
* Add JSON-converted bookmark import file for Archnest data import
Converts homarr-bookmarks.md into the format expected by /api/data/import.
* Auto-populate bookmark icons via favicon service in import JSON
Each bookmark now points to Google's favicon endpoint for its domain
instead of having no icon at all.
* Wire up Profile/Appearance/Security in the user menu
Profile, Appearance, and Security were dead "#" links. Added URL-based
tab deep-linking to the Settings page (?tab=profile|appearance|security|...)
and pointed the menu items at it. Added a Security tab placeholder ahead
of password/sessions/login-log/SSO work.
---------
Co-authored-by: Claude <noreply@anthropic.com>
2026-06-20 09:44:43 -04:00
< / div >
)
}
Add auth Phase 3: multi-user accounts with admin/member roles (#28)
Implements Phase 3 of the auth roadmap: multiple user accounts (cap 10),
an admin/member role model, and admin-only gating of config-mutating
routes. Dashboard data stays shared across all users (per the product
decision in HANDOFF.md — this is a household/self-hosted dashboard, not
a multi-tenant app), so there is no per-user data isolation.
Schema (backend/src/db/index.ts):
- Idempotent migration adds `role` (default 'admin') and `active`
(default 1) columns to `users` when missing. The 'admin' default means
the pre-existing single user is backfilled to admin on deploy and keeps
full access; newly created users are inserted explicitly as 'member'.
Verified against a production-like old schema (columns added, existing
user backfilled to admin/active).
Auth + access control:
- `/api/setup` creates the first user as admin. Login enforces `active`
(deactivated accounts get 403) and embeds the live role in the session.
- `app.authenticate` now reads role+active fresh from the DB on every
request (not from the possibly-stale JWT claim), rejects inactive
accounts, and stashes the role on req.user.
- New `requireAdmin` (auth + role check) and `adminOnly` (role check for
routes already behind the plugin-level authenticate hook) decorators.
User management (admin-only, in auth.ts):
- GET/POST/PUT/DELETE /api/users — list, create (admin sets a temp
password; no public signup), change role, activate/deactivate, delete.
- 10-user cap enforced server-side; guard rails prevent removing the last
active admin (demote/deactivate/delete) and deleting your own account;
deactivating or deleting a user drops their sessions immediately.
Admin-only route gating (members get 403):
- integrations create/update/delete/test, tunnels create/delete, data
export/import. Read routes and tunnel connect/disconnect stay open to
all authenticated users, as do all the SSH/Docker/RDP tools and
bookmarks (members are trusted to use the tooling, per product decision).
Frontend:
- api.ts: listUsers/createUser/updateUser/deleteUser + ManagedUser type;
role+active added to AuthUser.
- Settings: new admin-only "Users" section (create form, role toggle,
activate/deactivate, delete, 10-cap indicator). Nav filters the Users
tab by role and guards ?tab= deep-links. Data & Backup shows an
admin-only notice for members; Integrations shows a read-only banner
for members. (Backend remains the real enforcement boundary.)
Verified end-to-end against a throwaway backend: role assignment,
member 403s on every admin-only route + 200s on shared/read routes,
admin 200/201s, last-admin guards (409/400), deactivation killing an
active session and blocking re-login (then reactivation restoring it),
and the 10-user cap (409 on the 11th). Both frontend and backend
type-check clean.
Co-authored-by: Samuel James <ssamjame@amazon.com>
Co-authored-by: Kiro <noreply@kiro.dev>
2026-06-20 12:43:24 -04:00
const MAX_USERS = 10
function UsersSection() {
const { user : currentUser } = useAuth ( )
const [ users , setUsers ] = useState < ManagedUser [ ] > ( [ ] )
const [ loading , setLoading ] = useState ( true )
const [ error , setError ] = useState ( '' )
// Create-user form
const [ showCreate , setShowCreate ] = useState ( false )
const [ newUsername , setNewUsername ] = useState ( '' )
const [ newPassword , setNewPassword ] = useState ( '' )
const [ newRole , setNewRole ] = useState < 'admin' | 'member' > ( 'member' )
const [ creating , setCreating ] = useState ( false )
const [ createMsg , setCreateMsg ] = useState < { text : string ; ok : boolean } | null > ( null )
async function load() {
try {
const { users } = await api . listUsers ( )
setUsers ( users )
setError ( '' )
} catch ( err ) {
setError ( err instanceof ApiError ? err . message : 'Failed to load users' )
} finally {
setLoading ( false )
}
}
useEffect ( ( ) = > {
load ( )
} , [ ] )
async function handleCreate() {
setCreateMsg ( null )
if ( newUsername . length < 3 ) {
setCreateMsg ( { text : 'Username must be at least 3 characters' , ok : false } )
return
}
if ( newPassword . length < 8 ) {
setCreateMsg ( { text : 'Temporary password must be at least 8 characters' , ok : false } )
return
}
setCreating ( true )
try {
await api . createUser ( { username : newUsername , password : newPassword , role : newRole } )
setCreateMsg ( { text : ` User " ${ newUsername } " created ` , ok : true } )
setNewUsername ( '' )
setNewPassword ( '' )
setNewRole ( 'member' )
setShowCreate ( false )
load ( )
} catch ( err ) {
setCreateMsg ( { text : err instanceof ApiError ? err . message : 'Failed to create user' , ok : false } )
} finally {
setCreating ( false )
}
}
async function handleRoleToggle ( u : ManagedUser ) {
const nextRole = u . role === 'admin' ? 'member' : 'admin'
try {
await api . updateUser ( u . id , { role : nextRole } )
load ( )
} catch ( err ) {
setError ( err instanceof ApiError ? err . message : 'Failed to update role' )
}
}
async function handleActiveToggle ( u : ManagedUser ) {
try {
await api . updateUser ( u . id , { active : ! u . active } )
load ( )
} catch ( err ) {
setError ( err instanceof ApiError ? err . message : 'Failed to update user' )
}
}
async function handleDelete ( u : ManagedUser ) {
if ( ! window . confirm ( ` Delete user " ${ u . username } "? This cannot be undone. ` ) ) return
try {
await api . deleteUser ( u . id )
load ( )
} catch ( err ) {
setError ( err instanceof ApiError ? err . message : 'Failed to delete user' )
}
}
const atCap = users . length >= MAX_USERS
return (
< div className = "flex flex-col gap-5" >
< div style = { cardBase } >
< div className = "flex items-center justify-between" style = { { marginBottom : '16px' } } >
< h3 style = { { . . . sectionTitle , marginBottom : 0 } } >
Users < span style = { { color : '#7A7D85' , fontWeight : 400 } } > · { users . length } / { MAX_USERS } < / span >
< / h3 >
< GoldButton onClick = { ( ) = > setShowCreate ( ( v ) = > ! v ) } disabled = { atCap } >
< UserPlus size = { 14 } / > { showCreate ? 'Cancel' : 'Add User' }
< / GoldButton >
< / div >
{ atCap && ! showCreate && (
< p style = { { fontSize : '12px' , color : '#E67E22' , marginBottom : '14px' } } >
User limit reached ( { MAX_USERS } ) . Delete a user to add another .
< / p >
) }
{ showCreate && (
< div
className = "flex flex-col gap-3"
style = { { marginBottom : '18px' , padding : '16px' , borderRadius : '10px' , border : '1px solid rgba(200,164,52,0.12)' , backgroundColor : 'rgba(255,255,255,0.02)' } }
>
< div className = "grid grid-cols-2 gap-3" >
< div >
< label style = { labelStyle } > Username < / label >
< input style = { inputStyle } value = { newUsername } onChange = { ( e ) = > setNewUsername ( e . target . value ) } autoComplete = "off" / >
< / div >
< div >
< label style = { labelStyle } > Temporary Password < / label >
< input style = { inputStyle } type = "text" value = { newPassword } onChange = { ( e ) = > setNewPassword ( e . target . value ) } autoComplete = "off" / >
< / div >
< / div >
< div >
< label style = { labelStyle } > Role < / label >
< div className = "flex gap-2" >
{ ( [ 'member' , 'admin' ] as const ) . map ( ( r ) = > (
< button
key = { r }
onClick = { ( ) = > setNewRole ( r ) }
className = "cursor-pointer transition-colors"
style = { {
fontSize : '12px' ,
textTransform : 'capitalize' ,
padding : '7px 16px' ,
borderRadius : '8px' ,
border : newRole === r ? '1px solid #C8A434' : '1px solid rgba(200,164,52,0.12)' ,
backgroundColor : newRole === r ? 'rgba(200,164,52,0.12)' : 'transparent' ,
color : newRole === r ? '#C8A434' : '#7A7D85' ,
} }
>
{ r }
< / button >
) ) }
< / div >
< / div >
< p style = { { fontSize : '11px' , color : '#7A7D85' } } >
The user signs in with this temporary password and can change it under Security .
< / p >
< div className = "flex items-center gap-3" >
< GoldButton onClick = { handleCreate } disabled = { creating || ! newUsername || ! newPassword } >
{ creating ? 'Creating…' : 'Create User' }
< / GoldButton >
{ createMsg && < span style = { { fontSize : '12px' , color : createMsg.ok ? '#2ECC71' : '#E74C3C' } } > { createMsg . text } < / span > }
< / div >
< / div >
) }
{ error && < p style = { { fontSize : '12px' , color : '#E74C3C' , marginBottom : '12px' } } > { error } < / p > }
{ loading ? (
< p style = { { fontSize : '13px' , color : '#7A7D85' } } > Loading … < / p >
) : (
< div className = "flex flex-col gap-2" >
{ users . map ( ( u ) = > {
const isSelf = currentUser ? . id === u . id
return (
< div
key = { u . id }
className = "flex items-center gap-3"
style = { {
padding : '12px 14px' ,
borderRadius : '8px' ,
border : '1px solid rgba(200,164,52,0.08)' ,
backgroundColor : 'rgba(255,255,255,0.02)' ,
opacity : u.active ? 1 : 0.55 ,
} }
>
< div
className = "rounded-full flex items-center justify-center font-bold shrink-0"
style = { { width : '34px' , height : '34px' , fontSize : '12px' , color : '#C8A434' , border : '1px solid rgba(200,164,52,0.4)' , backgroundColor : 'rgba(200,164,52,0.08)' } }
>
{ ( u . displayName || u . username ) . slice ( 0 , 2 ) . toUpperCase ( ) }
< / div >
< div className = "flex-1 min-w-0" >
< div style = { { fontSize : '13px' , color : '#E8E6E0' , fontWeight : 500 } } >
{ u . username }
{ isSelf && < span style = { { fontSize : '10px' , color : '#C8A434' , marginLeft : '8px' , fontWeight : 600 } } > YOU < / span > }
{ ! u . active && < span style = { { fontSize : '10px' , color : '#E67E22' , marginLeft : '8px' , fontWeight : 600 } } > DEACTIVATED < / span > }
< / div >
< div style = { { fontSize : '11px' , color : '#7A7D85' } } > { u . email || 'No email' } < / div >
< / div >
< button
onClick = { ( ) = > handleRoleToggle ( u ) }
disabled = { isSelf }
className = "cursor-pointer transition-colors shrink-0"
title = { isSelf ? "You can't change your own role" : ` Make ${ u . role === 'admin' ? 'member' : 'admin' } ` }
style = { {
fontSize : '11px' ,
fontWeight : 600 ,
textTransform : 'uppercase' ,
letterSpacing : '0.5px' ,
padding : '5px 10px' ,
borderRadius : '6px' ,
border : 'none' ,
color : u.role === 'admin' ? '#C8A434' : '#7A7D85' ,
backgroundColor : u.role === 'admin' ? 'rgba(200,164,52,0.12)' : 'rgba(255,255,255,0.05)' ,
opacity : isSelf ? 0.5 : 1 ,
cursor : isSelf ? 'default' : 'pointer' ,
} }
>
{ u . role }
< / button >
{ ! isSelf && (
< >
< button
onClick = { ( ) = > handleActiveToggle ( u ) }
className = "cursor-pointer shrink-0"
style = { { fontSize : '11px' , fontWeight : 600 , padding : '6px 11px' , borderRadius : '7px' , border : '1px solid rgba(200,164,52,0.2)' , background : 'transparent' , color : '#7A7D85' } }
>
{ u . active ? 'Deactivate' : 'Activate' }
< / button >
< button
onClick = { ( ) = > handleDelete ( u ) }
className = "flex items-center cursor-pointer shrink-0"
title = "Delete user"
style = { { padding : '6px' , borderRadius : '7px' , border : '1px solid rgba(231,76,60,0.4)' , background : 'transparent' , color : '#E74C3C' } }
>
< Trash2 size = { 14 } / >
< / button >
< / >
) }
< / div >
)
} ) }
< / div >
) }
< / div >
< / div >
)
}
2026-06-20 21:44:27 +00:00
function MeshSection() {
const [ status , setStatus ] = useState < MeshStatus | null > ( null )
const [ loading , setLoading ] = useState ( true )
const [ error , setError ] = useState ( '' )
const [ toggling , setToggling ] = useState ( false )
const [ cidr , setCidr ] = useState ( '' )
const [ testIp , setTestIp ] = useState ( '' )
const [ verifying , setVerifying ] = useState ( false )
const [ verifyResult , setVerifyResult ] = useState < { ok : boolean ; message : string ; hostMeshIp : string | null } | null > ( null )
const [ overriding , setOverriding ] = useState ( false )
async function load() {
try {
const s = await api . getMeshStatus ( )
setStatus ( s )
setCidr ( s . cidr ? ? '' )
setError ( '' )
} catch ( err ) {
setError ( err instanceof ApiError ? err . message : 'Failed to load mesh status' )
} finally {
setLoading ( false )
}
}
useEffect ( ( ) = > {
load ( )
} , [ ] )
async function handleToggle() {
if ( ! status ) return
setToggling ( true )
try {
const s = await api . setMeshRequired ( ! status . required )
setStatus ( s )
} catch ( err ) {
setError ( err instanceof ApiError ? err . message : 'Failed to update mesh requirement' )
} finally {
setToggling ( false )
}
}
async function handleVerify() {
setError ( '' )
setVerifyResult ( null )
setVerifying ( true )
try {
const result = await api . verifyMesh ( cidr , testIp || undefined )
setVerifyResult ( result )
load ( )
} catch ( err ) {
setError ( err instanceof ApiError ? err . message : 'Failed to verify mesh' )
} finally {
setVerifying ( false )
}
}
async function handleOverride() {
setOverriding ( true )
try {
const s = await api . overrideMesh ( )
setStatus ( s )
} catch ( err ) {
setError ( err instanceof ApiError ? err . message : 'Failed to skip mesh setup' )
} finally {
setOverriding ( false )
}
}
if ( loading ) {
return (
< div className = "flex flex-col gap-5" >
< div style = { cardBase } >
< p style = { { fontSize : '12px' , color : '#7A7D85' } } > Loading … < / p >
< / div >
< / div >
)
}
return (
< div className = "flex flex-col gap-5" >
< div style = { cardBase } >
< div className = "flex items-center justify-between" style = { { marginBottom : '14px' } } >
< h3 style = { { . . . sectionTitle , marginBottom : 0 } } > Mesh Network Gate < / h3 >
< div className = "flex items-center gap-2" >
< span style = { { fontSize : '12px' , color : '#7A7D85' } } > { status ? . required ? 'Required' : 'Not required' } < / span >
< Toggle on = { ! ! status ? . required } onClick = { handleToggle } / >
< / div >
< / div >
< p style = { { fontSize : '12px' , color : '#7A7D85' , marginBottom : '14px' } } >
When enabled , admins must verify this host is on a private mesh network ( NetBird , WireGuard ,
ZeroTier , Tailscale , etc . ) before accessing the rest of the app . Members are never blocked — they
just see a notice banner until an admin finishes verification .
< / p >
{ toggling && < p style = { { fontSize : '11px' , color : '#7A7D85' } } > Updating … < / p > }
{ status && (
< div
className = "flex flex-col gap-1"
style = { { marginBottom : '4px' , padding : '12px' , borderRadius : '8px' , backgroundColor : 'rgba(255,255,255,0.02)' , border : '1px solid rgba(255,255,255,0.06)' } }
>
< p style = { { fontSize : '11px' , color : '#7A7D85' } } >
Verified : < span style = { { color : status.verified ? '#2ECC71' : '#E8E6E0' } } > { status . verified ? 'Yes' : 'No' } < / span >
{ status . verifiedVia && ` ( ${ status . verifiedVia } ) ` }
< / p >
{ status . cidr && < p style = { { fontSize : '11px' , color : '#7A7D85' } } > CIDR : { status . cidr } < / p > }
{ status . hostMeshIp && < p style = { { fontSize : '11px' , color : '#7A7D85' } } > Host mesh IP : { status . hostMeshIp } < / p > }
< p style = { { fontSize : '11px' , color : '#7A7D85' } } >
Override : < span style = { { color : status.overridden ? '#E67E22' : '#E8E6E0' } } > { status . overridden ? 'Active (gate skipped)' : 'None' } < / span >
< / p >
< / div >
) }
< / div >
< div style = { cardBase } >
< h3 style = { sectionTitle } > Configure & Verify < / h3 >
< label style = { labelStyle } > Mesh Network CIDR < / label >
< input
style = { inputStyle }
value = { cidr }
onChange = { ( e ) = > setCidr ( e . target . value ) }
placeholder = "e.g. 100.64.0.0/10 (NetBird/Tailscale CGNAT range)"
/ >
< label style = { { . . . labelStyle , marginTop : '12px' } } > Peer / Gateway IP on the mesh ( optional ) < / label >
< input
style = { inputStyle }
value = { testIp }
onChange = { ( e ) = > setTestIp ( e . target . value ) }
placeholder = "e.g. 100.64.0.1 — only needed if this host's own IP isn't in the mesh range"
/ >
< p style = { { fontSize : '10px' , color : '#5C5F66' , marginTop : '6px' } } >
If this host reaches the mesh through routing instead of holding a local mesh IP ( e . g . a VPC
peered into the mesh ) , give us an address on the mesh we can ping to confirm reachability .
< / p >
{ error && < p style = { { fontSize : '12px' , color : '#E74C3C' , marginTop : '12px' } } > { error } < / p > }
{ verifyResult && (
< p style = { { fontSize : '12px' , color : verifyResult.ok ? '#2ECC71' : '#E74C3C' , marginTop : '12px' } } >
{ verifyResult . message }
{ verifyResult . hostMeshIp && ` — host mesh IP: ${ verifyResult . hostMeshIp } ` }
< / p >
) }
< div className = "flex items-center justify-between" style = { { marginTop : '16px' } } >
< GoldButton onClick = { handleVerify } disabled = { verifying || ! cidr } >
{ verifying ? 'Verifying…' : 'Verify Connection' }
< / GoldButton >
< button
onClick = { handleOverride }
disabled = { overriding }
className = "cursor-pointer"
style = { {
fontSize : '11px' ,
color : '#7A7D85' ,
background : 'transparent' ,
border : '1px solid rgba(255,255,255,0.12)' ,
borderRadius : '6px' ,
padding : '8px 14px' ,
opacity : overriding ? 0.6 : 1 ,
} }
>
{ overriding ? 'Skipping…' : 'Skip for now' }
< / button >
< / div >
< / div >
< / div >
)
}
2026-06-19 11:12:33 +00:00
const sectionComponents : Record < string , ( ) = > React . ReactElement > = {
2026-06-18 18:44:26 +00:00
profile : ProfileSection ,
appearance : AppearanceSection ,
Wire up Profile/Appearance/Security in user menu (#21)
* Add editable display-name field to generic integrations
Lets users set a custom name for Proxmox, Docker, AWS, Remote Desktop,
Netbird, Cloudflare, Uptime Kuma, and Weather integrations, separate
from the host/IP field, mirroring the SSH host rename pattern.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kF4hZWEkRCPPvCZTeXxn4
* Surface the new-integration name field as a labeled input
The name field for new generic integrations was a faint header input
with only placeholder text, easy to miss. Move it into the form grid
as a proper labeled "Name" field next to the other connection fields.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kF4hZWEkRCPPvCZTeXxn4
* Add file upload for SSH private key and certificate fields
Lets users pick a key file from disk (e.g. ~/.ssh) instead of pasting
its contents into the Private Key / OPKSSH Certificate fields.
* Fix SSH private key paste corrupting multi-line PEM format
Private Key and Certificate fields were single-line <input> elements,
which strip newlines on paste and corrupt PEM-formatted keys (causing
'Unsupported key format' errors). Render them as multi-line textareas
instead so pasted keys keep their line breaks.
* Add JSON-converted bookmark import file for Archnest data import
Converts homarr-bookmarks.md into the format expected by /api/data/import.
* Auto-populate bookmark icons via favicon service in import JSON
Each bookmark now points to Google's favicon endpoint for its domain
instead of having no icon at all.
* Wire up Profile/Appearance/Security in the user menu
Profile, Appearance, and Security were dead "#" links. Added URL-based
tab deep-linking to the Settings page (?tab=profile|appearance|security|...)
and pointed the menu items at it. Added a Security tab placeholder ahead
of password/sessions/login-log/SSO work.
---------
Co-authored-by: Claude <noreply@anthropic.com>
2026-06-20 09:44:43 -04:00
security : SecuritySection ,
Add auth Phase 3: multi-user accounts with admin/member roles (#28)
Implements Phase 3 of the auth roadmap: multiple user accounts (cap 10),
an admin/member role model, and admin-only gating of config-mutating
routes. Dashboard data stays shared across all users (per the product
decision in HANDOFF.md — this is a household/self-hosted dashboard, not
a multi-tenant app), so there is no per-user data isolation.
Schema (backend/src/db/index.ts):
- Idempotent migration adds `role` (default 'admin') and `active`
(default 1) columns to `users` when missing. The 'admin' default means
the pre-existing single user is backfilled to admin on deploy and keeps
full access; newly created users are inserted explicitly as 'member'.
Verified against a production-like old schema (columns added, existing
user backfilled to admin/active).
Auth + access control:
- `/api/setup` creates the first user as admin. Login enforces `active`
(deactivated accounts get 403) and embeds the live role in the session.
- `app.authenticate` now reads role+active fresh from the DB on every
request (not from the possibly-stale JWT claim), rejects inactive
accounts, and stashes the role on req.user.
- New `requireAdmin` (auth + role check) and `adminOnly` (role check for
routes already behind the plugin-level authenticate hook) decorators.
User management (admin-only, in auth.ts):
- GET/POST/PUT/DELETE /api/users — list, create (admin sets a temp
password; no public signup), change role, activate/deactivate, delete.
- 10-user cap enforced server-side; guard rails prevent removing the last
active admin (demote/deactivate/delete) and deleting your own account;
deactivating or deleting a user drops their sessions immediately.
Admin-only route gating (members get 403):
- integrations create/update/delete/test, tunnels create/delete, data
export/import. Read routes and tunnel connect/disconnect stay open to
all authenticated users, as do all the SSH/Docker/RDP tools and
bookmarks (members are trusted to use the tooling, per product decision).
Frontend:
- api.ts: listUsers/createUser/updateUser/deleteUser + ManagedUser type;
role+active added to AuthUser.
- Settings: new admin-only "Users" section (create form, role toggle,
activate/deactivate, delete, 10-cap indicator). Nav filters the Users
tab by role and guards ?tab= deep-links. Data & Backup shows an
admin-only notice for members; Integrations shows a read-only banner
for members. (Backend remains the real enforcement boundary.)
Verified end-to-end against a throwaway backend: role assignment,
member 403s on every admin-only route + 200s on shared/read routes,
admin 200/201s, last-admin guards (409/400), deactivation killing an
active session and blocking re-login (then reactivation restoring it),
and the 10-user cap (409 on the 11th). Both frontend and backend
type-check clean.
Co-authored-by: Samuel James <ssamjame@amazon.com>
Co-authored-by: Kiro <noreply@kiro.dev>
2026-06-20 12:43:24 -04:00
users : UsersSection ,
2026-06-20 21:44:27 +00:00
mesh : MeshSection ,
2026-06-18 18:44:26 +00:00
integrations : IntegrationsSection ,
notifications : NotificationsSection ,
data : DataBackupSection ,
about : AboutSection ,
}
export default function Settings() {
Wire up Profile/Appearance/Security in user menu (#21)
* Add editable display-name field to generic integrations
Lets users set a custom name for Proxmox, Docker, AWS, Remote Desktop,
Netbird, Cloudflare, Uptime Kuma, and Weather integrations, separate
from the host/IP field, mirroring the SSH host rename pattern.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kF4hZWEkRCPPvCZTeXxn4
* Surface the new-integration name field as a labeled input
The name field for new generic integrations was a faint header input
with only placeholder text, easy to miss. Move it into the form grid
as a proper labeled "Name" field next to the other connection fields.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kF4hZWEkRCPPvCZTeXxn4
* Add file upload for SSH private key and certificate fields
Lets users pick a key file from disk (e.g. ~/.ssh) instead of pasting
its contents into the Private Key / OPKSSH Certificate fields.
* Fix SSH private key paste corrupting multi-line PEM format
Private Key and Certificate fields were single-line <input> elements,
which strip newlines on paste and corrupt PEM-formatted keys (causing
'Unsupported key format' errors). Render them as multi-line textareas
instead so pasted keys keep their line breaks.
* Add JSON-converted bookmark import file for Archnest data import
Converts homarr-bookmarks.md into the format expected by /api/data/import.
* Auto-populate bookmark icons via favicon service in import JSON
Each bookmark now points to Google's favicon endpoint for its domain
instead of having no icon at all.
* Wire up Profile/Appearance/Security in the user menu
Profile, Appearance, and Security were dead "#" links. Added URL-based
tab deep-linking to the Settings page (?tab=profile|appearance|security|...)
and pointed the menu items at it. Added a Security tab placeholder ahead
of password/sessions/login-log/SSO work.
---------
Co-authored-by: Claude <noreply@anthropic.com>
2026-06-20 09:44:43 -04:00
const [ searchParams , setSearchParams ] = useSearchParams ( )
Add auth Phase 3: multi-user accounts with admin/member roles (#28)
Implements Phase 3 of the auth roadmap: multiple user accounts (cap 10),
an admin/member role model, and admin-only gating of config-mutating
routes. Dashboard data stays shared across all users (per the product
decision in HANDOFF.md — this is a household/self-hosted dashboard, not
a multi-tenant app), so there is no per-user data isolation.
Schema (backend/src/db/index.ts):
- Idempotent migration adds `role` (default 'admin') and `active`
(default 1) columns to `users` when missing. The 'admin' default means
the pre-existing single user is backfilled to admin on deploy and keeps
full access; newly created users are inserted explicitly as 'member'.
Verified against a production-like old schema (columns added, existing
user backfilled to admin/active).
Auth + access control:
- `/api/setup` creates the first user as admin. Login enforces `active`
(deactivated accounts get 403) and embeds the live role in the session.
- `app.authenticate` now reads role+active fresh from the DB on every
request (not from the possibly-stale JWT claim), rejects inactive
accounts, and stashes the role on req.user.
- New `requireAdmin` (auth + role check) and `adminOnly` (role check for
routes already behind the plugin-level authenticate hook) decorators.
User management (admin-only, in auth.ts):
- GET/POST/PUT/DELETE /api/users — list, create (admin sets a temp
password; no public signup), change role, activate/deactivate, delete.
- 10-user cap enforced server-side; guard rails prevent removing the last
active admin (demote/deactivate/delete) and deleting your own account;
deactivating or deleting a user drops their sessions immediately.
Admin-only route gating (members get 403):
- integrations create/update/delete/test, tunnels create/delete, data
export/import. Read routes and tunnel connect/disconnect stay open to
all authenticated users, as do all the SSH/Docker/RDP tools and
bookmarks (members are trusted to use the tooling, per product decision).
Frontend:
- api.ts: listUsers/createUser/updateUser/deleteUser + ManagedUser type;
role+active added to AuthUser.
- Settings: new admin-only "Users" section (create form, role toggle,
activate/deactivate, delete, 10-cap indicator). Nav filters the Users
tab by role and guards ?tab= deep-links. Data & Backup shows an
admin-only notice for members; Integrations shows a read-only banner
for members. (Backend remains the real enforcement boundary.)
Verified end-to-end against a throwaway backend: role assignment,
member 403s on every admin-only route + 200s on shared/read routes,
admin 200/201s, last-admin guards (409/400), deactivation killing an
active session and blocking re-login (then reactivation restoring it),
and the 10-user cap (409 on the 11th). Both frontend and backend
type-check clean.
Co-authored-by: Samuel James <ssamjame@amazon.com>
Co-authored-by: Kiro <noreply@kiro.dev>
2026-06-20 12:43:24 -04:00
const { user } = useAuth ( )
const isAdmin = user ? . role === 'admin'
const visibleSections = navSections . filter ( ( s ) = > ! s . adminOnly || isAdmin )
Wire up Profile/Appearance/Security in user menu (#21)
* Add editable display-name field to generic integrations
Lets users set a custom name for Proxmox, Docker, AWS, Remote Desktop,
Netbird, Cloudflare, Uptime Kuma, and Weather integrations, separate
from the host/IP field, mirroring the SSH host rename pattern.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kF4hZWEkRCPPvCZTeXxn4
* Surface the new-integration name field as a labeled input
The name field for new generic integrations was a faint header input
with only placeholder text, easy to miss. Move it into the form grid
as a proper labeled "Name" field next to the other connection fields.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kF4hZWEkRCPPvCZTeXxn4
* Add file upload for SSH private key and certificate fields
Lets users pick a key file from disk (e.g. ~/.ssh) instead of pasting
its contents into the Private Key / OPKSSH Certificate fields.
* Fix SSH private key paste corrupting multi-line PEM format
Private Key and Certificate fields were single-line <input> elements,
which strip newlines on paste and corrupt PEM-formatted keys (causing
'Unsupported key format' errors). Render them as multi-line textareas
instead so pasted keys keep their line breaks.
* Add JSON-converted bookmark import file for Archnest data import
Converts homarr-bookmarks.md into the format expected by /api/data/import.
* Auto-populate bookmark icons via favicon service in import JSON
Each bookmark now points to Google's favicon endpoint for its domain
instead of having no icon at all.
* Wire up Profile/Appearance/Security in the user menu
Profile, Appearance, and Security were dead "#" links. Added URL-based
tab deep-linking to the Settings page (?tab=profile|appearance|security|...)
and pointed the menu items at it. Added a Security tab placeholder ahead
of password/sessions/login-log/SSO work.
---------
Co-authored-by: Claude <noreply@anthropic.com>
2026-06-20 09:44:43 -04:00
const requestedTab = searchParams . get ( 'tab' )
Add auth Phase 3: multi-user accounts with admin/member roles (#28)
Implements Phase 3 of the auth roadmap: multiple user accounts (cap 10),
an admin/member role model, and admin-only gating of config-mutating
routes. Dashboard data stays shared across all users (per the product
decision in HANDOFF.md — this is a household/self-hosted dashboard, not
a multi-tenant app), so there is no per-user data isolation.
Schema (backend/src/db/index.ts):
- Idempotent migration adds `role` (default 'admin') and `active`
(default 1) columns to `users` when missing. The 'admin' default means
the pre-existing single user is backfilled to admin on deploy and keeps
full access; newly created users are inserted explicitly as 'member'.
Verified against a production-like old schema (columns added, existing
user backfilled to admin/active).
Auth + access control:
- `/api/setup` creates the first user as admin. Login enforces `active`
(deactivated accounts get 403) and embeds the live role in the session.
- `app.authenticate` now reads role+active fresh from the DB on every
request (not from the possibly-stale JWT claim), rejects inactive
accounts, and stashes the role on req.user.
- New `requireAdmin` (auth + role check) and `adminOnly` (role check for
routes already behind the plugin-level authenticate hook) decorators.
User management (admin-only, in auth.ts):
- GET/POST/PUT/DELETE /api/users — list, create (admin sets a temp
password; no public signup), change role, activate/deactivate, delete.
- 10-user cap enforced server-side; guard rails prevent removing the last
active admin (demote/deactivate/delete) and deleting your own account;
deactivating or deleting a user drops their sessions immediately.
Admin-only route gating (members get 403):
- integrations create/update/delete/test, tunnels create/delete, data
export/import. Read routes and tunnel connect/disconnect stay open to
all authenticated users, as do all the SSH/Docker/RDP tools and
bookmarks (members are trusted to use the tooling, per product decision).
Frontend:
- api.ts: listUsers/createUser/updateUser/deleteUser + ManagedUser type;
role+active added to AuthUser.
- Settings: new admin-only "Users" section (create form, role toggle,
activate/deactivate, delete, 10-cap indicator). Nav filters the Users
tab by role and guards ?tab= deep-links. Data & Backup shows an
admin-only notice for members; Integrations shows a read-only banner
for members. (Backend remains the real enforcement boundary.)
Verified end-to-end against a throwaway backend: role assignment,
member 403s on every admin-only route + 200s on shared/read routes,
admin 200/201s, last-admin guards (409/400), deactivation killing an
active session and blocking re-login (then reactivation restoring it),
and the 10-user cap (409 on the 11th). Both frontend and backend
type-check clean.
Co-authored-by: Samuel James <ssamjame@amazon.com>
Co-authored-by: Kiro <noreply@kiro.dev>
2026-06-20 12:43:24 -04:00
const requestedAllowed =
requestedTab && sectionComponents [ requestedTab ] && visibleSections . some ( ( s ) = > s . id === requestedTab )
const active = requestedAllowed ? requestedTab ! : 'profile'
2026-06-18 18:44:26 +00:00
const ActiveSection = sectionComponents [ active ]
Wire up Profile/Appearance/Security in user menu (#21)
* Add editable display-name field to generic integrations
Lets users set a custom name for Proxmox, Docker, AWS, Remote Desktop,
Netbird, Cloudflare, Uptime Kuma, and Weather integrations, separate
from the host/IP field, mirroring the SSH host rename pattern.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kF4hZWEkRCPPvCZTeXxn4
* Surface the new-integration name field as a labeled input
The name field for new generic integrations was a faint header input
with only placeholder text, easy to miss. Move it into the form grid
as a proper labeled "Name" field next to the other connection fields.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kF4hZWEkRCPPvCZTeXxn4
* Add file upload for SSH private key and certificate fields
Lets users pick a key file from disk (e.g. ~/.ssh) instead of pasting
its contents into the Private Key / OPKSSH Certificate fields.
* Fix SSH private key paste corrupting multi-line PEM format
Private Key and Certificate fields were single-line <input> elements,
which strip newlines on paste and corrupt PEM-formatted keys (causing
'Unsupported key format' errors). Render them as multi-line textareas
instead so pasted keys keep their line breaks.
* Add JSON-converted bookmark import file for Archnest data import
Converts homarr-bookmarks.md into the format expected by /api/data/import.
* Auto-populate bookmark icons via favicon service in import JSON
Each bookmark now points to Google's favicon endpoint for its domain
instead of having no icon at all.
* Wire up Profile/Appearance/Security in the user menu
Profile, Appearance, and Security were dead "#" links. Added URL-based
tab deep-linking to the Settings page (?tab=profile|appearance|security|...)
and pointed the menu items at it. Added a Security tab placeholder ahead
of password/sessions/login-log/SSO work.
---------
Co-authored-by: Claude <noreply@anthropic.com>
2026-06-20 09:44:43 -04:00
function setActive ( id : string ) {
setSearchParams ( { tab : id } )
}
2026-06-18 18:44:26 +00:00
return (
< div className = "flex h-full w-full gap-5" >
{ /* Settings nav */ }
< div className = "flex flex-col gap-1 shrink-0" style = { { width : '200px' } } >
Add auth Phase 3: multi-user accounts with admin/member roles (#28)
Implements Phase 3 of the auth roadmap: multiple user accounts (cap 10),
an admin/member role model, and admin-only gating of config-mutating
routes. Dashboard data stays shared across all users (per the product
decision in HANDOFF.md — this is a household/self-hosted dashboard, not
a multi-tenant app), so there is no per-user data isolation.
Schema (backend/src/db/index.ts):
- Idempotent migration adds `role` (default 'admin') and `active`
(default 1) columns to `users` when missing. The 'admin' default means
the pre-existing single user is backfilled to admin on deploy and keeps
full access; newly created users are inserted explicitly as 'member'.
Verified against a production-like old schema (columns added, existing
user backfilled to admin/active).
Auth + access control:
- `/api/setup` creates the first user as admin. Login enforces `active`
(deactivated accounts get 403) and embeds the live role in the session.
- `app.authenticate` now reads role+active fresh from the DB on every
request (not from the possibly-stale JWT claim), rejects inactive
accounts, and stashes the role on req.user.
- New `requireAdmin` (auth + role check) and `adminOnly` (role check for
routes already behind the plugin-level authenticate hook) decorators.
User management (admin-only, in auth.ts):
- GET/POST/PUT/DELETE /api/users — list, create (admin sets a temp
password; no public signup), change role, activate/deactivate, delete.
- 10-user cap enforced server-side; guard rails prevent removing the last
active admin (demote/deactivate/delete) and deleting your own account;
deactivating or deleting a user drops their sessions immediately.
Admin-only route gating (members get 403):
- integrations create/update/delete/test, tunnels create/delete, data
export/import. Read routes and tunnel connect/disconnect stay open to
all authenticated users, as do all the SSH/Docker/RDP tools and
bookmarks (members are trusted to use the tooling, per product decision).
Frontend:
- api.ts: listUsers/createUser/updateUser/deleteUser + ManagedUser type;
role+active added to AuthUser.
- Settings: new admin-only "Users" section (create form, role toggle,
activate/deactivate, delete, 10-cap indicator). Nav filters the Users
tab by role and guards ?tab= deep-links. Data & Backup shows an
admin-only notice for members; Integrations shows a read-only banner
for members. (Backend remains the real enforcement boundary.)
Verified end-to-end against a throwaway backend: role assignment,
member 403s on every admin-only route + 200s on shared/read routes,
admin 200/201s, last-admin guards (409/400), deactivation killing an
active session and blocking re-login (then reactivation restoring it),
and the 10-user cap (409 on the 11th). Both frontend and backend
type-check clean.
Co-authored-by: Samuel James <ssamjame@amazon.com>
Co-authored-by: Kiro <noreply@kiro.dev>
2026-06-20 12:43:24 -04:00
{ visibleSections . map ( ( s ) = > {
2026-06-18 18:44:26 +00:00
const Icon = s . icon
const isActive = active === s . id
return (
< button
key = { s . id }
onClick = { ( ) = > setActive ( s . id ) }
className = "flex items-center gap-2.5 cursor-pointer border-none bg-transparent transition-colors"
style = { {
fontSize : '13px' ,
fontWeight : 500 ,
padding : '10px 14px' ,
borderRadius : '8px' ,
color : isActive ? '#C8A434' : '#7A7D85' ,
backgroundColor : isActive ? 'rgba(200,164,52,0.1)' : 'transparent' ,
} }
>
< Icon size = { 15 } / >
{ s . label }
< / button >
)
} ) }
< / div >
{ /* Content */ }
< div className = "flex-1 overflow-y-auto" style = { { scrollbarWidth : 'none' } } >
< ActiveSection / >
< / div >
< / div >
)
}