Skip to content

Commit 5e99e82

Browse files
committed
webviewer: make basic auth runtime-configurable; add settings UI, i18n, password confirmation, validation, and friendly auth pages
1 parent be2605b commit 5e99e82

File tree

4 files changed

+160
-12
lines changed

4 files changed

+160
-12
lines changed

web_viewer/__init__.py

Lines changed: 45 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
from dotenv import dotenv_values
1111
from typing import List, Optional
1212
from base64 import b64decode
13+
from html import escape
1314

1415
# Load config from .env and environment
1516
config: dict = {**dotenv_values(".env"), **environ}
@@ -272,11 +273,33 @@ async def options_settings(_: web.Request):
272273
'Access-Control-Allow-Headers': 'Content-Type',
273274
})
274275

275-
# --- Basic Auth Middleware and Config ---
276-
AUTH_ENABLED = config.get("AUTH_ENABLED", "false").lower() == "true"
277-
AUTH_USERNAME = config.get("AUTH_USERNAME", "admin")
278-
AUTH_PASSWORD = config.get("AUTH_PASSWORD", "changeme")
279276

277+
def _auth_html_response(status: int, title: str, message: str, include_www_authenticate: bool = False) -> web.Response:
278+
"""Return a small friendly HTML response for auth errors."""
279+
safe_title = escape(title)
280+
safe_message = escape(message)
281+
body = f"""
282+
<!doctype html>
283+
<html>
284+
<head>
285+
<meta charset="utf-8" />
286+
<meta name="viewport" content="width=device-width,initial-scale=1" />
287+
<title>{safe_title}</title>
288+
<style>body{{font-family:system-ui,-apple-system,Segoe UI,Roboto,Helvetica,Arial;line-height:1.4;margin:24px;color:#222}}h2{{margin-top:0}}.hint{{color:#666;font-size:0.9em}}</style>
289+
</head>
290+
<body>
291+
<h2>{safe_title}</h2>
292+
<p>{safe_message}</p>
293+
<p class="hint">If you are the device owner you can update authentication in the <strong>Settings</strong> panel of this web viewer.</p>
294+
</body>
295+
</html>
296+
"""
297+
headers = {}
298+
if include_www_authenticate:
299+
headers["WWW-Authenticate"] = "Basic realm='WebViewer'"
300+
return web.Response(status=status, text=body, content_type='text/html', headers=headers)
301+
302+
# --- Basic Auth Middleware (reads auth settings dynamically) ---
280303
@web.middleware
281304
async def basic_auth_middleware(request, handler):
282305
# Allow access to /ws only if from loopback
@@ -287,23 +310,35 @@ async def basic_auth_middleware(request, handler):
287310
if ip == "127.0.0.1" or ip == "::1":
288311
return await handler(request)
289312
else:
290-
return web.Response(status=403, text="Forbidden")
291-
if not AUTH_ENABLED:
313+
return _auth_html_response(403, "Forbidden", "Access to the websocket endpoint is restricted to localhost.")
314+
# Read auth settings from persistent `settings` (updated by web UI).
315+
try:
316+
import settings as app_settings
317+
auth_enabled = app_settings.get_setting("AUTH_ENABLED", config.get("AUTH_ENABLED", "false")).lower() == "true"
318+
auth_username = app_settings.get_setting("AUTH_USERNAME", config.get("AUTH_USERNAME", "admin"))
319+
auth_password = app_settings.get_setting("AUTH_PASSWORD", config.get("AUTH_PASSWORD", "changeme"))
320+
except Exception:
321+
# Fallback to env/config if settings module not available for any reason
322+
auth_enabled = config.get("AUTH_ENABLED", "false").lower() == "true"
323+
auth_username = config.get("AUTH_USERNAME", "admin")
324+
auth_password = config.get("AUTH_PASSWORD", "changeme")
325+
326+
if not auth_enabled:
292327
return await handler(request)
293328
# Allow unauthenticated access to static files
294329
if request.path.startswith("/static") or request.path.startswith("/build"):
295330
return await handler(request)
296331
auth_header = request.headers.get("Authorization")
297332
if not auth_header or not auth_header.startswith("Basic "):
298-
return web.Response(status=401, headers={"WWW-Authenticate": "Basic realm='WebViewer'"}, text="Unauthorized")
333+
return _auth_html_response(401, "Authentication required", "This web viewer is protected. Please provide HTTP Basic credentials.", include_www_authenticate=True)
299334
try:
300335
encoded = auth_header.split(" ", 1)[1]
301336
decoded = b64decode(encoded).decode()
302337
username, password = decoded.split(":", 1)
303338
except Exception:
304-
return web.Response(status=401, headers={"WWW-Authenticate": "Basic realm='WebViewer'"}, text="Invalid auth header")
305-
if username != AUTH_USERNAME or password != AUTH_PASSWORD:
306-
return web.Response(status=401, headers={"WWW-Authenticate": "Basic realm='WebViewer'"}, text="Invalid credentials")
339+
return _auth_html_response(401, "Invalid authentication", "Invalid Authorization header. Please provide valid HTTP Basic credentials.", include_www_authenticate=True)
340+
if username != auth_username or password != auth_password:
341+
return _auth_html_response(401, "Invalid credentials", "The username or password you provided is incorrect.", include_www_authenticate=True)
307342
return await handler(request)
308343

309344
def create_runner():

web_viewer/fe_src/src/components/SettingsPopover.tsx

Lines changed: 97 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,9 @@ interface Settings {
1919
BATTERY_FULL_NOTIFY_ENABLED: string;
2020
BATTERY_FULL_NOTIFY_BODY: string;
2121
ABNORMAL_NOTIFY_BODY: string;
22+
AUTH_ENABLED: string;
23+
AUTH_USERNAME: string;
24+
AUTH_PASSWORD: string;
2225
}
2326

2427
const SettingsPopover = forwardRef<HTMLDivElement, SettingsPopoverProps>(({ onClose }, ref) => {
@@ -28,6 +31,7 @@ const SettingsPopover = forwardRef<HTMLDivElement, SettingsPopoverProps>(({ onCl
2831
const [loading, setLoading] = useState(true);
2932
const [saving, setSaving] = useState(false);
3033
const [message, setMessage] = useState<{text: string, type: 'success' | 'error'} | null>(null);
34+
const [passwordConfirm, setPasswordConfirm] = useState<string>('');
3135

3236
useEffect(() => {
3337
fetchSettings();
@@ -50,6 +54,9 @@ const SettingsPopover = forwardRef<HTMLDivElement, SettingsPopoverProps>(({ onCl
5054
BATTERY_FULL_NOTIFY_ENABLED: 'true',
5155
BATTERY_FULL_NOTIFY_BODY: 'Pin đã sạc đầy 100%. Có thể bật bình nóng lạnh để tối ưu sử dụng.',
5256
ABNORMAL_NOTIFY_BODY: 'Tiêu thụ điện bất thường, vui lòng kiểm tra xem vòi nước đã khoá chưa.'
57+
,AUTH_ENABLED: 'false'
58+
,AUTH_USERNAME: 'admin'
59+
,AUTH_PASSWORD: 'changeme'
5360
};
5461
const merged = { ...defaults, ...data };
5562
setSettings(merged);
@@ -66,18 +73,45 @@ const SettingsPopover = forwardRef<HTMLDivElement, SettingsPopoverProps>(({ onCl
6673
if (!settings) return;
6774
setSaving(true);
6875
setMessage(null);
76+
// If auth enabled, ensure username and password present and confirmation match
77+
if (settings.AUTH_ENABLED === 'true') {
78+
if (!settings.AUTH_USERNAME || settings.AUTH_USERNAME.trim() === '') {
79+
setMessage({text: t('settings.authUsernameRequired'), type: 'error'});
80+
setSaving(false);
81+
return;
82+
}
83+
if (!settings.AUTH_PASSWORD || settings.AUTH_PASSWORD === '') {
84+
setMessage({text: t('settings.authPasswordRequired'), type: 'error'});
85+
setSaving(false);
86+
return;
87+
}
88+
if (settings.AUTH_PASSWORD !== passwordConfirm) {
89+
setMessage({text: t('settings.authPasswordMismatch'), type: 'error'});
90+
setSaving(false);
91+
return;
92+
}
93+
}
6994
try {
95+
// Do not send password confirmation to backend
96+
const payload = { ...settings } as any;
97+
delete payload.AUTH_PASSWORD_CONFIRM;
7098
const res = await fetch(`${import.meta.env.VITE_API_BASE_URL}/settings`, {
7199
method: 'POST',
72100
headers: {
73101
'Content-Type': 'application/json',
74102
},
75-
body: JSON.stringify(settings),
103+
body: JSON.stringify(payload),
76104
});
77105
const data = await res.json();
78106
if (data.success) {
107+
const prevAuthEnabled = originalSettings ? originalSettings.AUTH_ENABLED === 'true' : false;
79108
setOriginalSettings(settings);
80109
setMessage({text: t('settings.saveSuccess'), type: 'success'});
110+
// If auth changed from disabled -> enabled, reload page to ensure auth middleware and client state take effect
111+
if (!prevAuthEnabled && settings.AUTH_ENABLED === 'true') {
112+
// small delay to allow UI message to show briefly
113+
setTimeout(() => window.location.reload(), 300);
114+
}
81115
} else {
82116
setMessage({text: t('settings.saveError'), type: 'error'});
83117
}
@@ -91,6 +125,14 @@ const SettingsPopover = forwardRef<HTMLDivElement, SettingsPopoverProps>(({ onCl
91125

92126
const updateSetting = (key: keyof Settings, value: string) => {
93127
if (settings) {
128+
// If enabling auth, reset password fields so user must re-enter
129+
if (key === 'AUTH_ENABLED' && value === 'true' && settings.AUTH_ENABLED !== 'true') {
130+
// When enabling auth from disabled state, require re-entering the password
131+
// but keep the existing username so user doesn't have to retype it.
132+
setPasswordConfirm('');
133+
setSettings({ ...settings, AUTH_ENABLED: value, AUTH_PASSWORD: '' });
134+
return;
135+
}
94136
setSettings({ ...settings, [key]: value });
95137
}
96138
};
@@ -222,6 +264,53 @@ const SettingsPopover = forwardRef<HTMLDivElement, SettingsPopoverProps>(({ onCl
222264
</div>
223265
</div>
224266
<div className="settings-section">
267+
<h4>{t("settings.authSection")}</h4>
268+
<div className="setting-item">
269+
<label>
270+
<input
271+
type="checkbox"
272+
checked={settings.AUTH_ENABLED === "true"}
273+
onChange={(e) =>
274+
updateSetting(
275+
"AUTH_ENABLED",
276+
e.target.checked ? "true" : "false"
277+
)
278+
}
279+
/>
280+
{t("settings.authEnabled")}
281+
</label>
282+
</div>
283+
<div className="setting-item">
284+
<label>{t("settings.authUsername")}</label>
285+
<input
286+
type="text"
287+
value={settings.AUTH_USERNAME}
288+
onChange={(e) => updateSetting("AUTH_USERNAME", e.target.value)}
289+
disabled={settings.AUTH_ENABLED !== "true"}
290+
/>
291+
</div>
292+
<div className="setting-item">
293+
<label>{t("settings.authPassword")}</label>
294+
<input
295+
type="password"
296+
value={settings.AUTH_PASSWORD}
297+
onChange={(e) => updateSetting("AUTH_PASSWORD", e.target.value)}
298+
disabled={settings.AUTH_ENABLED !== "true"}
299+
/>
300+
</div>
301+
<div className="setting-item">
302+
<label>{t("settings.authPasswordConfirm")}</label>
303+
<input
304+
type="password"
305+
value={passwordConfirm}
306+
onChange={(e) => setPasswordConfirm(e.target.value)}
307+
disabled={settings.AUTH_ENABLED !== "true"}
308+
/>
309+
</div>
310+
<p className="setting-descrtiption">
311+
{t("settings.authDescription")}
312+
</p>
313+
225314
<h4>{t("settings.offGridWarningSection")}</h4>
226315
<div className="setting-item">
227316
<label>
@@ -280,7 +369,13 @@ const SettingsPopover = forwardRef<HTMLDivElement, SettingsPopoverProps>(({ onCl
280369
</div>
281370
</div>
282371
<div className="settings-actions">
283-
<button onClick={handleSave} disabled={saving || !hasChanges()}>
372+
<button onClick={handleSave} disabled={
373+
saving || !hasChanges() || (settings.AUTH_ENABLED === 'true' && (
374+
!settings.AUTH_USERNAME || settings.AUTH_USERNAME.trim() === '' ||
375+
!settings.AUTH_PASSWORD || settings.AUTH_PASSWORD === '' ||
376+
settings.AUTH_PASSWORD !== passwordConfirm
377+
))
378+
}>
284379
{saving ? t("settings.saving") : t("settings.save")}
285380
</button>
286381
</div>

web_viewer/fe_src/src/locales/en.json

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,15 @@
133133
"batterySection": "Battery settings",
134134
"batteryFullNotifyEnabled": "Enable battery full notification",
135135
"notifyBody": "Notification body",
136+
"authSection": "Authentication",
137+
"authEnabled": "Require authentication for web viewer",
138+
"authUsername": "Username",
139+
"authPassword": "Password",
140+
"authDescription": "When enabled, users must authenticate to access the web viewer.",
141+
"authPasswordConfirm": "Confirm password",
142+
"authPasswordMismatch": "Password and confirmation do not match",
143+
"authUsernameRequired": "Username is required when authentication is enabled",
144+
"authPasswordRequired": "Password is required when authentication is enabled",
136145
"save": "Save",
137146
"saving": "Saving...",
138147
"saveSuccess": "Settings saved successfully",

web_viewer/fe_src/src/locales/vi.json

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,15 @@
130130
"maxBatteryPower": "Công suất pin tối đa",
131131
"offGridWarningEnabled": "Bật cảnh báo",
132132
"offGridWarningDescription": "Cảnh báo khi EPS >= ngưỡng công suất và PV < EPS / 2 và (SOC < ngưỡng SOC hoặc công suất xả pin > công suất pin tối đa)",
133+
"authSection": "Xác thực",
134+
"authEnabled": "Yêu cầu đăng nhập cho giao diện web",
135+
"authUsername": "Tên đăng nhập",
136+
"authPassword": "Mật khẩu",
137+
"authDescription": "Khi bật, người dùng phải đăng nhập để truy cập giao diện web.",
138+
"authPasswordConfirm": "Xác nhận mật khẩu",
139+
"authPasswordMismatch": "Mật khẩu và xác nhận không khớp",
140+
"authUsernameRequired": "Tên đăng nhập là bắt buộc khi bật xác thực",
141+
"authPasswordRequired": "Mật khẩu là bắt buộc khi bật xác thực",
133142
"batterySection": "Cài đặt pin",
134143
"batteryFullNotifyEnabled": "Bật thông báo khi pin đầy",
135144
"notifyBody": "Nội dung thông báo",

0 commit comments

Comments
 (0)