@@ -110,11 +110,28 @@ sub handle_client {
|
||
| 110 | 110 |
return send_html($client, 200, app_html()); |
| 111 | 111 |
} |
| 112 | 112 |
if ($method eq 'GET' && $path eq '/healthz') {
|
| 113 |
- return send_json($client, 200, { ok => json_bool(1), data => $opt{data} });
|
|
| 113 |
+ return send_json($client, 200, { ok => json_bool(1) });
|
|
| 114 | 114 |
} |
| 115 | 115 |
if ($method eq 'GET' && $path eq '/api/session') {
|
| 116 | 116 |
return send_json($client, 200, { authenticated => is_authenticated(\%headers) ? json_bool(1) : json_bool(0) });
|
| 117 | 117 |
} |
| 118 |
+ if ($method eq 'POST' && $path eq '/api/login') {
|
|
| 119 |
+ return send_json($client, 503, { error => 'otp_not_configured' }) unless $ENV{HOST_MANAGER_TOTP_SECRET};
|
|
| 120 |
+ my $payload = request_payload(\%headers, $body); |
|
| 121 |
+ my $otp = $payload->{otp} || '';
|
|
| 122 |
+ if (!verify_totp($ENV{HOST_MANAGER_TOTP_SECRET} || '', $otp)) {
|
|
| 123 |
+ return send_json($client, 401, { error => 'invalid_otp' });
|
|
| 124 |
+ } |
|
| 125 |
+ my $token = create_session(); |
|
| 126 |
+ return send_json($client, 200, { ok => json_bool(1) }, [ "Set-Cookie: hm_session=$token; HttpOnly; SameSite=Strict; Path=/" ]);
|
|
| 127 |
+ } |
|
| 128 |
+ if ($method eq 'POST' && $path eq '/api/logout') {
|
|
| 129 |
+ expire_session(\%headers); |
|
| 130 |
+ return send_json($client, 200, { ok => json_bool(1) }, [ "Set-Cookie: hm_session=deleted; Max-Age=0; Path=/" ]);
|
|
| 131 |
+ } |
|
| 132 |
+ |
|
| 133 |
+ return send_json($client, 401, { error => 'authentication_required' }) unless is_authenticated(\%headers);
|
|
| 134 |
+ |
|
| 118 | 135 |
if ($method eq 'GET' && $path eq '/api/hosts') {
|
| 119 | 136 |
my $registry = load_registry(); |
| 120 | 137 |
return send_json($client, 200, registry_payload($registry)); |
@@ -130,24 +147,8 @@ sub handle_client {
|
||
| 130 | 147 |
my $registry = load_registry(); |
| 131 | 148 |
return send_download($client, 200, json_encode(render_monitoring($registry)), 'application/json; charset=utf-8', 'monitoring-hosts.json'); |
| 132 | 149 |
} |
| 133 |
- if ($method eq 'POST' && $path eq '/api/login') {
|
|
| 134 |
- return send_json($client, 503, { error => 'otp_not_configured' }) unless $ENV{HOST_MANAGER_TOTP_SECRET};
|
|
| 135 |
- my $payload = request_payload(\%headers, $body); |
|
| 136 |
- my $otp = $payload->{otp} || '';
|
|
| 137 |
- if (!verify_totp($ENV{HOST_MANAGER_TOTP_SECRET} || '', $otp)) {
|
|
| 138 |
- return send_json($client, 401, { error => 'invalid_otp' });
|
|
| 139 |
- } |
|
| 140 |
- my $token = create_session(); |
|
| 141 |
- return send_json($client, 200, { ok => json_bool(1) }, [ "Set-Cookie: hm_session=$token; HttpOnly; SameSite=Strict; Path=/" ]);
|
|
| 142 |
- } |
|
| 143 |
- if ($method eq 'POST' && $path eq '/api/logout') {
|
|
| 144 |
- expire_session(\%headers); |
|
| 145 |
- return send_json($client, 200, { ok => json_bool(1) }, [ "Set-Cookie: hm_session=deleted; Max-Age=0; Path=/" ]);
|
|
| 146 |
- } |
|
| 147 | 150 |
|
| 148 | 151 |
if ($method eq 'POST' && $path =~ m{^/api/}) {
|
| 149 |
- return send_json($client, 401, { error => 'authentication_required' }) unless is_authenticated(\%headers);
|
|
| 150 |
- |
|
| 151 | 152 |
if ($path eq '/api/hosts/upsert') {
|
| 152 | 153 |
my $payload = request_payload(\%headers, $body); |
| 153 | 154 |
return upsert_host($client, $payload); |
@@ -831,7 +832,7 @@ sub app_html {
|
||
| 831 | 832 |
button, .linkbtn { border: 1px solid var(--line); background: #fff; color: var(--ink); border-radius: 6px; padding: 7px 10px; min-height: 34px; cursor: pointer; text-decoration: none; display: inline-flex; align-items: center; gap: 6px; }
|
| 832 | 833 |
button.primary { background: var(--accent); border-color: var(--accent); color: #fff; }
|
| 833 | 834 |
button.danger { color: var(--bad); }
|
| 834 |
- button:disabled { opacity: .55; cursor: not-allowed; }
|
|
| 835 |
+ button:disabled, .linkbtn[aria-disabled="true"] { opacity: .55; cursor: not-allowed; pointer-events: none; }
|
|
| 835 | 836 |
input, select, textarea { width: 100%; border: 1px solid var(--line); border-radius: 6px; padding: 8px; background: #fff; color: var(--ink); }
|
| 836 | 837 |
textarea { min-height: 74px; resize: vertical; }
|
| 837 | 838 |
table { width: 100%; border-collapse: collapse; table-layout: fixed; }
|
@@ -862,7 +863,7 @@ sub app_html {
|
||
| 862 | 863 |
<header> |
| 863 | 864 |
<h1>Host Manager</h1> |
| 864 | 865 |
<form class="auth" id="login-form"> |
| 865 |
- <span id="auth-state" class="muted">read-only</span> |
|
| 866 |
+ <span id="auth-state" class="muted">locked</span> |
|
| 866 | 867 |
<input id="otp" name="otp" inputmode="numeric" pattern="[0-9]*" autocomplete="one-time-code" placeholder="OTP"> |
| 867 | 868 |
<button class="primary" type="submit">Login</button> |
| 868 | 869 |
<button type="button" id="logout">Logout</button> |
@@ -870,11 +871,11 @@ sub app_html {
|
||
| 870 | 871 |
</header> |
| 871 | 872 |
<main> |
| 872 | 873 |
<section class="toolbar"> |
| 873 |
- <button id="refresh">Refresh</button> |
|
| 874 |
- <a class="linkbtn" href="/download/hosts.yaml">hosts.yaml</a> |
|
| 875 |
- <a class="linkbtn" href="/download/local-hosts.tsv">local-hosts.tsv</a> |
|
| 876 |
- <a class="linkbtn" href="/download/monitoring.json">monitoring.json</a> |
|
| 877 |
- <button id="write-tsv">Write local-hosts.tsv</button> |
|
| 874 |
+ <button id="refresh" data-auth-required>Refresh</button> |
|
| 875 |
+ <a class="linkbtn" data-auth-required href="/download/hosts.yaml">hosts.yaml</a> |
|
| 876 |
+ <a class="linkbtn" data-auth-required href="/download/local-hosts.tsv">local-hosts.tsv</a> |
|
| 877 |
+ <a class="linkbtn" data-auth-required href="/download/monitoring.json">monitoring.json</a> |
|
| 878 |
+ <button id="write-tsv" data-auth-required>Write local-hosts.tsv</button> |
|
| 878 | 879 |
<span id="message" class="muted"></span> |
| 879 | 880 |
</section> |
| 880 | 881 |
|
@@ -947,13 +948,36 @@ sub app_html {
|
||
| 947 | 948 |
async function refresh() {
|
| 948 | 949 |
const session = await api('/api/session');
|
| 949 | 950 |
state.authenticated = session.authenticated; |
| 950 |
- $('auth-state').textContent = state.authenticated ? 'authenticated' : 'read-only';
|
|
| 951 |
+ updateAuthUi(); |
|
| 952 |
+ if (!state.authenticated) {
|
|
| 953 |
+ state.hosts = []; |
|
| 954 |
+ state.problems = []; |
|
| 955 |
+ renderLocked(); |
|
| 956 |
+ return; |
|
| 957 |
+ } |
|
| 951 | 958 |
const data = await api('/api/hosts');
|
| 952 | 959 |
state.hosts = data.hosts || []; |
| 953 | 960 |
state.problems = data.problems || []; |
| 954 | 961 |
render(data); |
| 955 | 962 |
} |
| 956 | 963 |
|
| 964 |
+ function updateAuthUi() {
|
|
| 965 |
+ $('auth-state').textContent = state.authenticated ? 'authenticated' : 'locked';
|
|
| 966 |
+ document.querySelectorAll('[data-auth-required]').forEach(el => {
|
|
| 967 |
+ if (el.tagName === 'A') {
|
|
| 968 |
+ el.setAttribute('aria-disabled', state.authenticated ? 'false' : 'true');
|
|
| 969 |
+ } else {
|
|
| 970 |
+ el.disabled = !state.authenticated; |
|
| 971 |
+ } |
|
| 972 |
+ }); |
|
| 973 |
+ } |
|
| 974 |
+ |
|
| 975 |
+ function renderLocked() {
|
|
| 976 |
+ $('stats').innerHTML = '<span class="stat">authentication required</span>';
|
|
| 977 |
+ $('problems').innerHTML = '<div class="muted" style="padding: 8px 0">Login with OTP to access host data, downloads, and configuration actions.</div>';
|
|
| 978 |
+ $('hosts').innerHTML = '';
|
|
| 979 |
+ } |
|
| 980 |
+ |
|
| 957 | 981 |
function render(data) {
|
| 958 | 982 |
$('stats').innerHTML = [
|
| 959 | 983 |
['hosts', data.counts.hosts], |