Add users and network settings tabs with admin protections.
List and delete accounts with last-admin and Administrators-group guards, and expose editable network settings via the configuration UI.
This commit is contained in:
+468
-6
@@ -1,16 +1,27 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import {
|
||||
deleteGroup,
|
||||
deleteUser,
|
||||
DEFAULT_NETWORK_SETTINGS,
|
||||
DEFAULT_SECURITY_SETTINGS,
|
||||
fetchGroups,
|
||||
fetchMe,
|
||||
fetchNetwork,
|
||||
fetchSecurity,
|
||||
fetchSetupStatus,
|
||||
fetchStatus,
|
||||
fetchUsers,
|
||||
type AccessMode,
|
||||
type Group,
|
||||
type GroupScopeKind,
|
||||
type NetworkSettings,
|
||||
type SecuritySettings,
|
||||
type User,
|
||||
saveNetwork,
|
||||
saveSecurity,
|
||||
upsertGroup,
|
||||
} from './api/client'
|
||||
import { LoginPage } from './LoginPage'
|
||||
import {
|
||||
navigateTo,
|
||||
parseLocation,
|
||||
@@ -19,6 +30,11 @@ import {
|
||||
type ConfigTabId,
|
||||
type SectionId,
|
||||
} from './navigation'
|
||||
import {
|
||||
validateHostname,
|
||||
validateListenAddress,
|
||||
} from './passwordPolicy'
|
||||
import { SetupWizard } from './SetupWizard'
|
||||
import {
|
||||
applyThemeFromPreference,
|
||||
getSystemPrefersDark,
|
||||
@@ -30,16 +46,20 @@ import './App.css'
|
||||
|
||||
const NAV_ITEMS: ReadonlyArray<{ id: SectionId; label: string }> = [
|
||||
{ id: 'overview', label: 'Overview' },
|
||||
{ id: 'vms', label: 'VMs' },
|
||||
{ id: 'containers', label: 'Containers' },
|
||||
{ id: 'docker', label: 'Docker' },
|
||||
{ id: 'vms', label: 'Virtual Machines' },
|
||||
]
|
||||
|
||||
const SECTION_TITLES: Record<SectionId, string> = {
|
||||
overview: 'Overview',
|
||||
vms: 'VMs',
|
||||
vms: 'Virtual Machines',
|
||||
docker: 'Docker',
|
||||
containers: 'Containers',
|
||||
profile: 'Profile',
|
||||
configuration: 'Configuration',
|
||||
setup: 'Setup',
|
||||
login: 'Sign in',
|
||||
}
|
||||
|
||||
const CONFIG_TABS: ReadonlyArray<{ id: ConfigTabId; label: string }> = [
|
||||
@@ -64,6 +84,16 @@ const DEFAULT_PERMISSIONS = [
|
||||
'roles.manage',
|
||||
] as const
|
||||
|
||||
const ADMINISTRATORS_GROUP_NAME = 'Administrators'
|
||||
|
||||
function formatUserTimestamp(value: string): string {
|
||||
const parsed = Date.parse(value)
|
||||
if (Number.isNaN(parsed)) {
|
||||
return value || '—'
|
||||
}
|
||||
return new Date(parsed).toLocaleString()
|
||||
}
|
||||
|
||||
function CogIcon() {
|
||||
return (
|
||||
<svg
|
||||
@@ -292,6 +322,12 @@ function GroupsConfigTab() {
|
||||
<button
|
||||
type="button"
|
||||
className="groups-delete"
|
||||
disabled={group.name === ADMINISTRATORS_GROUP_NAME}
|
||||
title={
|
||||
group.name === ADMINISTRATORS_GROUP_NAME
|
||||
? 'Administrators group cannot be deleted'
|
||||
: undefined
|
||||
}
|
||||
onClick={() => void handleDeleteGroup(group.name)}
|
||||
>
|
||||
Delete
|
||||
@@ -380,6 +416,312 @@ function GroupsConfigTab() {
|
||||
)
|
||||
}
|
||||
|
||||
function UsersConfigTab() {
|
||||
const [users, setUsers] = useState<ReadonlyArray<User>>([])
|
||||
const [isLoadingUsers, setIsLoadingUsers] = useState(true)
|
||||
const [usersLoadError, setUsersLoadError] = useState<string | null>(null)
|
||||
const [actionError, setActionError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
let isCancelled = false
|
||||
|
||||
async function loadUsers() {
|
||||
setIsLoadingUsers(true)
|
||||
setUsersLoadError(null)
|
||||
try {
|
||||
const response = await fetchUsers()
|
||||
if (!isCancelled) {
|
||||
setUsers(response.users)
|
||||
}
|
||||
} catch (err) {
|
||||
if (!isCancelled) {
|
||||
setUsersLoadError(
|
||||
err instanceof Error ? err.message : 'Failed to load users',
|
||||
)
|
||||
}
|
||||
} finally {
|
||||
if (!isCancelled) {
|
||||
setIsLoadingUsers(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void loadUsers()
|
||||
|
||||
return () => {
|
||||
isCancelled = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
async function handleDeleteUser(userId: string) {
|
||||
setActionError(null)
|
||||
|
||||
try {
|
||||
const response = await deleteUser(userId)
|
||||
setUsers(response.users)
|
||||
} catch (err) {
|
||||
setActionError(
|
||||
err instanceof Error ? err.message : 'Failed to delete user',
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="groups-panel users-panel">
|
||||
<div className="groups-list">
|
||||
<p className="config-hint">Configured accounts</p>
|
||||
{actionError ? (
|
||||
<p className="config-empty groups-error" role="alert">
|
||||
{actionError}
|
||||
</p>
|
||||
) : null}
|
||||
{isLoadingUsers ? (
|
||||
<p className="config-empty">Loading…</p>
|
||||
) : usersLoadError ? (
|
||||
<p className="config-empty" role="alert">
|
||||
{usersLoadError}
|
||||
</p>
|
||||
) : users.length === 0 ? (
|
||||
<p className="config-empty">No users configured yet.</p>
|
||||
) : (
|
||||
<ul className="groups-ul">
|
||||
{users.map((user) => (
|
||||
<li key={user.id} className="groups-li">
|
||||
<div className="groups-li-content">
|
||||
<div className="groups-li-title">
|
||||
{user.username}
|
||||
{user.is_admin ? (
|
||||
<span className="user-badge">Admin</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="groups-li-meta">
|
||||
Groups:{' '}
|
||||
{user.group_names.length > 0
|
||||
? user.group_names.join(', ')
|
||||
: '—'}
|
||||
</div>
|
||||
<div className="groups-li-meta">
|
||||
Enabled: {user.enabled ? 'yes' : 'no'} · TOTP:{' '}
|
||||
{user.totp_confirmed ? 'confirmed' : 'not confirmed'}
|
||||
</div>
|
||||
<div className="groups-li-meta">
|
||||
Created: {formatUserTimestamp(user.created_at)} · Password
|
||||
changed: {formatUserTimestamp(user.password_changed_at)}
|
||||
</div>
|
||||
<div className="groups-li-meta">ID: {user.id}</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="groups-delete"
|
||||
disabled={!user.can_delete}
|
||||
title={
|
||||
user.can_delete
|
||||
? undefined
|
||||
: 'Cannot delete the last administrator'
|
||||
}
|
||||
onClick={() => void handleDeleteUser(user.id)}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function NetworkConfigTab() {
|
||||
const [draft, setDraft] = useState<NetworkSettings>(DEFAULT_NETWORK_SETTINGS)
|
||||
const [rulesText, setRulesText] = useState('')
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [loadError, setLoadError] = useState<string | null>(null)
|
||||
const [submitError, setSubmitError] = useState<string | null>(null)
|
||||
const [saveSucceeded, setSaveSucceeded] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
let isCancelled = false
|
||||
|
||||
async function loadNetwork() {
|
||||
setIsLoading(true)
|
||||
setLoadError(null)
|
||||
try {
|
||||
const response = await fetchNetwork()
|
||||
if (!isCancelled) {
|
||||
setDraft(response.network)
|
||||
setRulesText(response.network.rules.join('\n'))
|
||||
}
|
||||
} catch (err) {
|
||||
if (!isCancelled) {
|
||||
setLoadError(
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: 'Failed to load network settings',
|
||||
)
|
||||
}
|
||||
} finally {
|
||||
if (!isCancelled) {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void loadNetwork()
|
||||
|
||||
return () => {
|
||||
isCancelled = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
async function handleSave() {
|
||||
setSubmitError(null)
|
||||
setSaveSucceeded(false)
|
||||
|
||||
const listenError = validateListenAddress(draft.listen_address)
|
||||
if (listenError) {
|
||||
setSubmitError(listenError)
|
||||
return
|
||||
}
|
||||
const hostnameError = validateHostname(draft.public_hostname)
|
||||
if (hostnameError) {
|
||||
setSubmitError(hostnameError)
|
||||
return
|
||||
}
|
||||
|
||||
const rules = rulesText
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line.length > 0)
|
||||
|
||||
try {
|
||||
const response = await saveNetwork({ ...draft, rules })
|
||||
setDraft(response.network)
|
||||
setRulesText(response.network.rules.join('\n'))
|
||||
setSaveSucceeded(true)
|
||||
} catch (err) {
|
||||
setSubmitError(
|
||||
err instanceof Error ? err.message : 'Failed to save network settings',
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return <p className="config-empty">Loading…</p>
|
||||
}
|
||||
|
||||
if (loadError) {
|
||||
return (
|
||||
<p className="config-empty" role="alert">
|
||||
{loadError}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="security-panel network-panel">
|
||||
<p className="config-hint">
|
||||
Listen bind address and IP access control for the admin UI.
|
||||
</p>
|
||||
|
||||
{submitError ? (
|
||||
<p className="config-empty groups-error" role="alert">
|
||||
{submitError}
|
||||
</p>
|
||||
) : null}
|
||||
{saveSucceeded ? (
|
||||
<p className="config-hint" role="status">
|
||||
Network settings saved.
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<div className="config-field">
|
||||
<label htmlFor="network-listen">Listen address</label>
|
||||
<input
|
||||
id="network-listen"
|
||||
className="config-input"
|
||||
value={draft.listen_address}
|
||||
onChange={(event) => {
|
||||
setDraft((previous) => ({
|
||||
...previous,
|
||||
listen_address: event.target.value,
|
||||
}))
|
||||
setSaveSucceeded(false)
|
||||
}}
|
||||
/>
|
||||
<p className="config-hint">
|
||||
Default 0.0.0.0 (all interfaces). Applied on next process restart.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="config-field">
|
||||
<label htmlFor="network-hostname">Public hostname</label>
|
||||
<input
|
||||
id="network-hostname"
|
||||
className="config-input"
|
||||
placeholder="admin.mydomain.ext"
|
||||
value={draft.public_hostname}
|
||||
onChange={(event) => {
|
||||
setDraft((previous) => ({
|
||||
...previous,
|
||||
public_hostname: event.target.value,
|
||||
}))
|
||||
setSaveSucceeded(false)
|
||||
}}
|
||||
/>
|
||||
<p className="config-hint">
|
||||
Leave empty for local access by IP. When set, the UI must be visited
|
||||
via that exact hostname.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="config-field">
|
||||
<label htmlFor="network-access-mode">Access mode</label>
|
||||
<select
|
||||
id="network-access-mode"
|
||||
className="config-input"
|
||||
value={draft.access_mode}
|
||||
onChange={(event) => {
|
||||
setDraft((previous) => ({
|
||||
...previous,
|
||||
access_mode: event.target.value as AccessMode,
|
||||
}))
|
||||
setSaveSucceeded(false)
|
||||
}}
|
||||
>
|
||||
<option value="open">Open (no IP filter)</option>
|
||||
<option value="whitelist">Whitelist (deny non-matching)</option>
|
||||
<option value="blacklist">Blacklist (deny matching)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{draft.access_mode !== 'open' ? (
|
||||
<div className="config-field">
|
||||
<label htmlFor="network-rules">IP / CIDR rules (one per line)</label>
|
||||
<textarea
|
||||
id="network-rules"
|
||||
className="config-input wizard-rules"
|
||||
rows={5}
|
||||
value={rulesText}
|
||||
onChange={(event) => {
|
||||
setRulesText(event.target.value)
|
||||
setSaveSucceeded(false)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="groups-save"
|
||||
onClick={() => void handleSave()}
|
||||
>
|
||||
Save network settings
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function validateSecurityDraft(security: SecuritySettings): string | null {
|
||||
if (
|
||||
!Number.isInteger(security.idle_timeout_minutes) ||
|
||||
@@ -672,10 +1014,18 @@ function ConfigTabPanelContent({
|
||||
return <GroupsConfigTab />
|
||||
}
|
||||
|
||||
if (activeConfigTab === 'users') {
|
||||
return <UsersConfigTab />
|
||||
}
|
||||
|
||||
if (activeConfigTab === 'security') {
|
||||
return <SecurityConfigTab />
|
||||
}
|
||||
|
||||
if (activeConfigTab === 'network') {
|
||||
return <NetworkConfigTab />
|
||||
}
|
||||
|
||||
return <p className="config-empty">Coming soon</p>
|
||||
}
|
||||
|
||||
@@ -759,6 +1109,12 @@ function ConfigurationPanel({
|
||||
)
|
||||
}
|
||||
|
||||
type BootState =
|
||||
| { kind: 'loading' }
|
||||
| { kind: 'setup'; clientIp: string }
|
||||
| { kind: 'login' }
|
||||
| { kind: 'ready'; username: string }
|
||||
|
||||
function App() {
|
||||
const initialLocation = parseLocation(window.location.pathname)
|
||||
const [activeSection, setActiveSection] = useState<SectionId>(
|
||||
@@ -767,9 +1123,11 @@ function App() {
|
||||
const [activeConfigTab, setActiveConfigTab] = useState<ConfigTabId>(
|
||||
initialLocation.configTab,
|
||||
)
|
||||
// Stub until auth provides the logged-in display name.
|
||||
const [profileDisplayName] = useState<string | null>(null)
|
||||
const [profileDisplayName, setProfileDisplayName] = useState<string | null>(
|
||||
null,
|
||||
)
|
||||
const [backendVersion, setBackendVersion] = useState('…')
|
||||
const [bootState, setBootState] = useState<BootState>({ kind: 'loading' })
|
||||
const { themePreference, updateThemePreference } = useThemePreference()
|
||||
|
||||
const profileLabel = profileDisplayName ?? 'Profile'
|
||||
@@ -785,6 +1143,58 @@ function App() {
|
||||
useEffect(() => {
|
||||
let isCancelled = false
|
||||
|
||||
async function boot() {
|
||||
try {
|
||||
const setupStatus = await fetchSetupStatus()
|
||||
if (isCancelled) {
|
||||
return
|
||||
}
|
||||
if (!setupStatus.completed) {
|
||||
setBootState({ kind: 'setup', clientIp: setupStatus.client_ip })
|
||||
navigateTo('/setup')
|
||||
return
|
||||
}
|
||||
|
||||
const me = await fetchMe()
|
||||
if (isCancelled) {
|
||||
return
|
||||
}
|
||||
if (!me) {
|
||||
setBootState({ kind: 'login' })
|
||||
navigateTo('/login')
|
||||
return
|
||||
}
|
||||
|
||||
setProfileDisplayName(me.username)
|
||||
setBootState({ kind: 'ready', username: me.username })
|
||||
if (
|
||||
window.location.pathname === '/setup' ||
|
||||
window.location.pathname === '/login'
|
||||
) {
|
||||
navigateTo('/')
|
||||
}
|
||||
} catch {
|
||||
if (!isCancelled) {
|
||||
setBootState({ kind: 'login' })
|
||||
navigateTo('/login')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void boot()
|
||||
|
||||
return () => {
|
||||
isCancelled = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (bootState.kind !== 'ready') {
|
||||
return
|
||||
}
|
||||
|
||||
let isCancelled = false
|
||||
|
||||
async function loadBackendVersion() {
|
||||
try {
|
||||
const status = await fetchStatus()
|
||||
@@ -803,9 +1213,12 @@ function App() {
|
||||
return () => {
|
||||
isCancelled = true
|
||||
}
|
||||
}, [])
|
||||
}, [bootState.kind])
|
||||
|
||||
function goToSection(section: SectionId) {
|
||||
if (section === 'setup' || section === 'login') {
|
||||
return
|
||||
}
|
||||
const nextTab = section === 'configuration' ? activeConfigTab : 'overview'
|
||||
setActiveSection(section)
|
||||
if (section !== 'configuration') {
|
||||
@@ -820,6 +1233,39 @@ function App() {
|
||||
navigateTo(pathFor('configuration', tab))
|
||||
}
|
||||
|
||||
if (bootState.kind === 'loading') {
|
||||
return (
|
||||
<div className="wizard-shell">
|
||||
<p className="config-hint">Loading…</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (bootState.kind === 'setup') {
|
||||
return (
|
||||
<SetupWizard
|
||||
clientIp={bootState.clientIp}
|
||||
onCompleted={() => {
|
||||
setBootState({ kind: 'login' })
|
||||
navigateTo('/login')
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (bootState.kind === 'login') {
|
||||
return (
|
||||
<LoginPage
|
||||
onLoggedIn={(username) => {
|
||||
setProfileDisplayName(username)
|
||||
setBootState({ kind: 'ready', username })
|
||||
navigateTo('/')
|
||||
setActiveSection('overview')
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="app-shell">
|
||||
<aside className="sidebar" aria-label="Main menu">
|
||||
@@ -830,7 +1276,23 @@ function App() {
|
||||
</div>
|
||||
|
||||
<nav className="sidebar-nav" aria-label="Sections">
|
||||
{NAV_ITEMS.map((item) => (
|
||||
{NAV_ITEMS.slice(0, 1).map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
className={
|
||||
activeSection === item.id
|
||||
? 'nav-item nav-item-active'
|
||||
: 'nav-item'
|
||||
}
|
||||
aria-current={activeSection === item.id ? 'page' : undefined}
|
||||
onClick={() => goToSection(item.id)}
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
<div className="nav-section-divider" aria-hidden="true" />
|
||||
{NAV_ITEMS.slice(1).map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
|
||||
Reference in New Issue
Block a user