portal: add wger account provisioning

This commit is contained in:
Brad Stein 2026-01-14 17:32:20 -03:00
parent d19e683375
commit 3d40da7e77
8 changed files with 436 additions and 1 deletions

View File

@ -13,10 +13,13 @@ from .keycloak import admin_client
from .nextcloud_mail_sync import trigger as trigger_nextcloud_mail_sync from .nextcloud_mail_sync import trigger as trigger_nextcloud_mail_sync
from .utils import random_password from .utils import random_password
from .vaultwarden import invite_user from .vaultwarden import invite_user
from .wger_user_sync import trigger as trigger_wger_user_sync
MAILU_EMAIL_ATTR = "mailu_email" MAILU_EMAIL_ATTR = "mailu_email"
MAILU_APP_PASSWORD_ATTR = "mailu_app_password" MAILU_APP_PASSWORD_ATTR = "mailu_app_password"
WGER_PASSWORD_ATTR = "wger_password"
WGER_PASSWORD_UPDATED_ATTR = "wger_password_updated_at"
REQUIRED_PROVISION_TASKS: tuple[str, ...] = ( REQUIRED_PROVISION_TASKS: tuple[str, ...] = (
"keycloak_user", "keycloak_user",
"keycloak_password", "keycloak_password",
@ -24,6 +27,7 @@ REQUIRED_PROVISION_TASKS: tuple[str, ...] = (
"mailu_app_password", "mailu_app_password",
"mailu_sync", "mailu_sync",
"nextcloud_mail_sync", "nextcloud_mail_sync",
"wger_account",
"vaultwarden_invite", "vaultwarden_invite",
) )
@ -358,6 +362,46 @@ def provision_access_request(request_code: str) -> ProvisionResult:
except Exception as exc: except Exception as exc:
_upsert_task(conn, request_code, "nextcloud_mail_sync", "error", _safe_error_detail(exc, "failed to sync nextcloud")) _upsert_task(conn, request_code, "nextcloud_mail_sync", "error", _safe_error_detail(exc, "failed to sync nextcloud"))
# Task: ensure wger account exists
try:
if not user_id:
raise RuntimeError("missing user id")
full = admin_client().get_user(user_id)
attrs = full.get("attributes") or {}
wger_password = ""
wger_password_updated_at = ""
if isinstance(attrs, dict):
raw_pw = attrs.get(WGER_PASSWORD_ATTR)
if isinstance(raw_pw, list) and raw_pw and isinstance(raw_pw[0], str):
wger_password = raw_pw[0]
elif isinstance(raw_pw, str) and raw_pw:
wger_password = raw_pw
raw_updated = attrs.get(WGER_PASSWORD_UPDATED_ATTR)
if isinstance(raw_updated, list) and raw_updated and isinstance(raw_updated[0], str):
wger_password_updated_at = raw_updated[0]
elif isinstance(raw_updated, str) and raw_updated:
wger_password_updated_at = raw_updated
if not wger_password:
wger_password = random_password(20)
admin_client().set_user_attribute(username, WGER_PASSWORD_ATTR, wger_password)
wger_email = mailu_email or contact_email or f"{username}@{settings.MAILU_DOMAIN}"
if not wger_password_updated_at:
result = trigger_wger_user_sync(username, wger_email, wger_password, wait=True)
status_val = result.get("status") if isinstance(result, dict) else "error"
if status_val != "ok":
raise RuntimeError(f"wger sync {status_val}")
now_iso = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
admin_client().set_user_attribute(username, WGER_PASSWORD_UPDATED_ATTR, now_iso)
_upsert_task(conn, request_code, "wger_account", "ok", None)
except Exception as exc:
_upsert_task(conn, request_code, "wger_account", "error", _safe_error_detail(exc, "failed to provision wger"))
# Task: ensure Vaultwarden account exists (invite flow) # Task: ensure Vaultwarden account exists (invite flow)
try: try:
if not user_id: if not user_id:

View File

@ -61,6 +61,8 @@ ONBOARDING_STEPS: tuple[str, ...] = (
"vaultwarden_browser_extension", "vaultwarden_browser_extension",
"vaultwarden_desktop_app", "vaultwarden_desktop_app",
"vaultwarden_mobile_app", "vaultwarden_mobile_app",
"health_data_notice",
"wger_login",
"keycloak_password_rotated", "keycloak_password_rotated",
"keycloak_mfa_optional", "keycloak_mfa_optional",
"element_recovery_key", "element_recovery_key",

View File

@ -11,6 +11,7 @@ from .. import settings
from ..keycloak import admin_client, require_auth, require_account_access from ..keycloak import admin_client, require_auth, require_account_access
from ..nextcloud_mail_sync import trigger as trigger_nextcloud_mail_sync from ..nextcloud_mail_sync import trigger as trigger_nextcloud_mail_sync
from ..utils import random_password from ..utils import random_password
from ..wger_user_sync import trigger as trigger_wger_user_sync
def _tcp_check(host: str, port: int, timeout_sec: float) -> bool: def _tcp_check(host: str, port: int, timeout_sec: float) -> bool:
@ -40,6 +41,9 @@ def register(app) -> None:
nextcloud_mail_primary_email = "" nextcloud_mail_primary_email = ""
nextcloud_mail_account_count = "" nextcloud_mail_account_count = ""
nextcloud_mail_synced_at = "" nextcloud_mail_synced_at = ""
wger_status = "ready"
wger_password = ""
wger_password_updated_at = ""
vaultwarden_email = "" vaultwarden_email = ""
vaultwarden_status = "" vaultwarden_status = ""
vaultwarden_synced_at = "" vaultwarden_synced_at = ""
@ -50,6 +54,7 @@ def register(app) -> None:
if not admin_client().ready(): if not admin_client().ready():
mailu_status = "server not configured" mailu_status = "server not configured"
wger_status = "server not configured"
jellyfin_status = "server not configured" jellyfin_status = "server not configured"
jellyfin_sync_status = "unknown" jellyfin_sync_status = "unknown"
jellyfin_sync_detail = "keycloak admin not configured" jellyfin_sync_detail = "keycloak admin not configured"
@ -88,6 +93,16 @@ def register(app) -> None:
nextcloud_mail_synced_at = str(raw_synced[0]) nextcloud_mail_synced_at = str(raw_synced[0])
elif isinstance(raw_synced, str) and raw_synced: elif isinstance(raw_synced, str) and raw_synced:
nextcloud_mail_synced_at = raw_synced nextcloud_mail_synced_at = raw_synced
raw_wger_password = attrs.get("wger_password")
if isinstance(raw_wger_password, list) and raw_wger_password:
wger_password = str(raw_wger_password[0])
elif isinstance(raw_wger_password, str) and raw_wger_password:
wger_password = raw_wger_password
raw_wger_updated = attrs.get("wger_password_updated_at")
if isinstance(raw_wger_updated, list) and raw_wger_updated:
wger_password_updated_at = str(raw_wger_updated[0])
elif isinstance(raw_wger_updated, str) and raw_wger_updated:
wger_password_updated_at = raw_wger_updated
raw_vw_email = attrs.get("vaultwarden_email") raw_vw_email = attrs.get("vaultwarden_email")
if isinstance(raw_vw_email, list) and raw_vw_email: if isinstance(raw_vw_email, list) and raw_vw_email:
vaultwarden_email = str(raw_vw_email[0]) vaultwarden_email = str(raw_vw_email[0])
@ -149,6 +164,18 @@ def register(app) -> None:
nextcloud_mail_synced_at = str(raw_synced[0]) nextcloud_mail_synced_at = str(raw_synced[0])
elif isinstance(raw_synced, str) and raw_synced: elif isinstance(raw_synced, str) and raw_synced:
nextcloud_mail_synced_at = raw_synced nextcloud_mail_synced_at = raw_synced
if not wger_password:
raw_wger_password = attrs.get("wger_password")
if isinstance(raw_wger_password, list) and raw_wger_password:
wger_password = str(raw_wger_password[0])
elif isinstance(raw_wger_password, str) and raw_wger_password:
wger_password = raw_wger_password
if not wger_password_updated_at:
raw_wger_updated = attrs.get("wger_password_updated_at")
if isinstance(raw_wger_updated, list) and raw_wger_updated:
wger_password_updated_at = str(raw_wger_updated[0])
elif isinstance(raw_wger_updated, str) and raw_wger_updated:
wger_password_updated_at = raw_wger_updated
if not vaultwarden_email: if not vaultwarden_email:
raw_vw_email = attrs.get("vaultwarden_email") raw_vw_email = attrs.get("vaultwarden_email")
if isinstance(raw_vw_email, list) and raw_vw_email: if isinstance(raw_vw_email, list) and raw_vw_email:
@ -170,6 +197,7 @@ def register(app) -> None:
except Exception: except Exception:
mailu_status = "unavailable" mailu_status = "unavailable"
nextcloud_mail_status = "unavailable" nextcloud_mail_status = "unavailable"
wger_status = "unavailable"
vaultwarden_status = "unavailable" vaultwarden_status = "unavailable"
jellyfin_status = "unavailable" jellyfin_status = "unavailable"
jellyfin_sync_status = "unknown" jellyfin_sync_status = "unknown"
@ -181,6 +209,9 @@ def register(app) -> None:
if not mailu_app_password and mailu_status == "ready": if not mailu_app_password and mailu_status == "ready":
mailu_status = "needs app password" mailu_status = "needs app password"
if not wger_password and wger_status == "ready":
wger_status = "needs provisioning"
if nextcloud_mail_status == "unknown": if nextcloud_mail_status == "unknown":
try: try:
count_val = int(nextcloud_mail_account_count) if nextcloud_mail_account_count else 0 count_val = int(nextcloud_mail_account_count) if nextcloud_mail_account_count else 0
@ -220,6 +251,12 @@ def register(app) -> None:
"account_count": nextcloud_mail_account_count, "account_count": nextcloud_mail_account_count,
"synced_at": nextcloud_mail_synced_at, "synced_at": nextcloud_mail_synced_at,
}, },
"wger": {
"status": wger_status,
"username": username,
"password": wger_password,
"password_updated_at": wger_password_updated_at,
},
"vaultwarden": { "vaultwarden": {
"status": vaultwarden_status, "status": vaultwarden_status,
"username": vaultwarden_username, "username": vaultwarden_username,
@ -285,6 +322,57 @@ def register(app) -> None:
} }
) )
@app.route("/api/account/wger/reset", methods=["POST"])
@require_auth
def account_wger_reset() -> Any:
ok, resp = require_account_access()
if not ok:
return resp
if not admin_client().ready():
return jsonify({"error": "server not configured"}), 503
username = g.keycloak_username
if not username:
return jsonify({"error": "missing username"}), 400
keycloak_email = g.keycloak_email or ""
mailu_email = ""
try:
user = admin_client().find_user(username) or {}
attrs = user.get("attributes") if isinstance(user, dict) else None
if isinstance(attrs, dict):
raw_mailu = attrs.get("mailu_email")
if isinstance(raw_mailu, list) and raw_mailu:
mailu_email = str(raw_mailu[0])
elif isinstance(raw_mailu, str) and raw_mailu:
mailu_email = raw_mailu
except Exception:
pass
email = mailu_email or keycloak_email or f"{username}@{settings.MAILU_DOMAIN}"
password = random_password()
try:
result = trigger_wger_user_sync(username, email, password, wait=True)
status_val = result.get("status") if isinstance(result, dict) else "error"
if status_val != "ok":
raise RuntimeError(f"wger sync {status_val}")
except Exception as exc:
message = str(exc).strip() or "wger sync failed"
return jsonify({"error": message}), 502
try:
admin_client().set_user_attribute(username, "wger_password", password)
admin_client().set_user_attribute(
username,
"wger_password_updated_at",
time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
)
except Exception:
return jsonify({"error": "failed to store wger password"}), 502
return jsonify({"status": "ok", "password": password})
@app.route("/api/account/nextcloud/mail/sync", methods=["POST"]) @app.route("/api/account/nextcloud/mail/sync", methods=["POST"])
@require_auth @require_auth
def account_nextcloud_mail_sync() -> Any: def account_nextcloud_mail_sync() -> Any:

View File

@ -95,6 +95,10 @@ NEXTCLOUD_MAIL_SYNC_CRONJOB = os.getenv("NEXTCLOUD_MAIL_SYNC_CRONJOB", "nextclou
NEXTCLOUD_MAIL_SYNC_WAIT_TIMEOUT_SEC = float(os.getenv("NEXTCLOUD_MAIL_SYNC_WAIT_TIMEOUT_SEC", "90")) NEXTCLOUD_MAIL_SYNC_WAIT_TIMEOUT_SEC = float(os.getenv("NEXTCLOUD_MAIL_SYNC_WAIT_TIMEOUT_SEC", "90"))
NEXTCLOUD_MAIL_SYNC_JOB_TTL_SEC = int(os.getenv("NEXTCLOUD_MAIL_SYNC_JOB_TTL_SEC", "3600")) NEXTCLOUD_MAIL_SYNC_JOB_TTL_SEC = int(os.getenv("NEXTCLOUD_MAIL_SYNC_JOB_TTL_SEC", "3600"))
WGER_NAMESPACE = os.getenv("WGER_NAMESPACE", "health").strip()
WGER_USER_SYNC_CRONJOB = os.getenv("WGER_USER_SYNC_CRONJOB", "wger-user-sync").strip()
WGER_USER_SYNC_WAIT_TIMEOUT_SEC = float(os.getenv("WGER_USER_SYNC_WAIT_TIMEOUT_SEC", "60"))
SMTP_HOST = os.getenv("SMTP_HOST", "mailu-front.mailu-mailserver.svc.cluster.local").strip() SMTP_HOST = os.getenv("SMTP_HOST", "mailu-front.mailu-mailserver.svc.cluster.local").strip()
SMTP_PORT = int(os.getenv("SMTP_PORT", "25")) SMTP_PORT = int(os.getenv("SMTP_PORT", "25"))
SMTP_USERNAME = os.getenv("SMTP_USERNAME", "").strip() SMTP_USERNAME = os.getenv("SMTP_USERNAME", "").strip()

View File

@ -0,0 +1,137 @@
from __future__ import annotations
import re
import time
from typing import Any
from . import settings
from .k8s import get_json, post_json
def _safe_name_fragment(value: str, max_len: int = 24) -> str:
cleaned = re.sub(r"[^a-z0-9-]+", "-", (value or "").lower()).strip("-")
if not cleaned:
cleaned = "user"
return cleaned[:max_len].rstrip("-") or "user"
def _job_from_cronjob(
cronjob: dict[str, Any],
username: str,
email: str,
password: str,
) -> dict[str, Any]:
spec = cronjob.get("spec") if isinstance(cronjob.get("spec"), dict) else {}
jt = spec.get("jobTemplate") if isinstance(spec.get("jobTemplate"), dict) else {}
job_spec = jt.get("spec") if isinstance(jt.get("spec"), dict) else {}
now = int(time.time())
safe_user = _safe_name_fragment(username)
job_name = f"wger-user-sync-{safe_user}-{now}"
job: dict[str, Any] = {
"apiVersion": "batch/v1",
"kind": "Job",
"metadata": {
"name": job_name,
"namespace": settings.WGER_NAMESPACE,
"labels": {
"app": "wger-user-sync",
"atlas.bstein.dev/trigger": "portal",
"atlas.bstein.dev/username": safe_user,
},
},
"spec": job_spec,
}
tpl = job.get("spec", {}).get("template", {})
pod_spec = tpl.get("spec") if isinstance(tpl.get("spec"), dict) else {}
containers = pod_spec.get("containers") if isinstance(pod_spec.get("containers"), list) else []
if containers and isinstance(containers[0], dict):
env = containers[0].get("env")
if not isinstance(env, list):
env = []
env = [
e
for e in env
if not (
isinstance(e, dict)
and e.get("name") in {"WGER_USERNAME", "WGER_EMAIL", "WGER_PASSWORD"}
)
]
env.append({"name": "WGER_USERNAME", "value": username})
env.append({"name": "WGER_EMAIL", "value": email})
env.append({"name": "WGER_PASSWORD", "value": password})
containers[0]["env"] = env
pod_spec["containers"] = containers
tpl["spec"] = pod_spec
job["spec"]["template"] = tpl
return job
def _job_succeeded(job: dict[str, Any]) -> bool:
status = job.get("status") if isinstance(job.get("status"), dict) else {}
if int(status.get("succeeded") or 0) > 0:
return True
conditions = status.get("conditions") if isinstance(status.get("conditions"), list) else []
for cond in conditions:
if not isinstance(cond, dict):
continue
if cond.get("type") == "Complete" and cond.get("status") == "True":
return True
return False
def _job_failed(job: dict[str, Any]) -> bool:
status = job.get("status") if isinstance(job.get("status"), dict) else {}
if int(status.get("failed") or 0) > 0:
return True
conditions = status.get("conditions") if isinstance(status.get("conditions"), list) else []
for cond in conditions:
if not isinstance(cond, dict):
continue
if cond.get("type") == "Failed" and cond.get("status") == "True":
return True
return False
def trigger(username: str, email: str, password: str, wait: bool = True) -> dict[str, Any]:
username = (username or "").strip()
if not username:
raise RuntimeError("missing username")
if not password:
raise RuntimeError("missing password")
namespace = settings.WGER_NAMESPACE
cronjob_name = settings.WGER_USER_SYNC_CRONJOB
if not namespace or not cronjob_name:
raise RuntimeError("wger sync not configured")
cronjob = get_json(f"/apis/batch/v1/namespaces/{namespace}/cronjobs/{cronjob_name}")
job_payload = _job_from_cronjob(cronjob, username, email, password)
created = post_json(f"/apis/batch/v1/namespaces/{namespace}/jobs", job_payload)
job_name = (
created.get("metadata", {}).get("name")
if isinstance(created.get("metadata"), dict)
else job_payload.get("metadata", {}).get("name")
)
if not isinstance(job_name, str) or not job_name:
raise RuntimeError("job name missing")
if not wait:
return {"job": job_name, "status": "queued"}
deadline = time.time() + float(settings.WGER_USER_SYNC_WAIT_TIMEOUT_SEC)
last_state = "running"
while time.time() < deadline:
job = get_json(f"/apis/batch/v1/namespaces/{namespace}/jobs/{job_name}")
if _job_succeeded(job):
return {"job": job_name, "status": "ok"}
if _job_failed(job):
return {"job": job_name, "status": "error"}
time.sleep(2)
last_state = "running"
return {"job": job_name, "status": last_state}

View File

@ -123,7 +123,7 @@
</div> </div>
</div> </div>
<div class="account-stack"> <div class="account-stack">
<div class="card module"> <div class="card module">
<div class="module-head"> <div class="module-head">
<h2>Vaultwarden</h2> <h2>Vaultwarden</h2>
@ -165,6 +165,69 @@
</div> </div>
</div> </div>
<div class="card module">
<div class="module-head">
<h2>Atlas Health (wger)</h2>
<span
class="pill mono"
:class="
wger.status === 'ready'
? 'pill-ok'
: wger.status === 'needs provisioning' || wger.status === 'login required'
? 'pill-warn'
: wger.status === 'unavailable' || wger.status === 'error'
? 'pill-bad'
: ''
"
>
{{ wger.status }}
</span>
</div>
<p class="muted">
Workout + nutrition tracking with the wger mobile app. Sign in with the credentials below and set the server
URL to health.bstein.dev in the app.
</p>
<div class="kv">
<div class="row">
<span class="k mono">URL</span>
<a class="v mono link" href="https://health.bstein.dev" target="_blank" rel="noreferrer">health.bstein.dev</a>
</div>
<div class="row">
<span class="k mono">Username</span>
<span class="v mono">{{ wger.username || auth.username }}</span>
</div>
<div class="row">
<span class="k mono">Updated</span>
<span class="v mono">{{ wger.passwordUpdatedAt || "unknown" }}</span>
</div>
</div>
<div class="actions">
<button class="pill mono" type="button" :disabled="wger.resetting" @click="resetWger">
{{ wger.resetting ? "Resetting..." : "Reset wger password" }}
</button>
</div>
<div v-if="wger.password" class="secret-box">
<div class="secret-head">
<div class="pill mono">Password</div>
<div class="secret-actions">
<button class="copy mono" type="button" @click="copy('wger-password', wger.password)">
copy
<span v-if="copied['wger-password']" class="copied">copied</span>
</button>
<button class="copy mono" type="button" @click="wger.revealPassword = !wger.revealPassword">
{{ wger.revealPassword ? "hide" : "show" }}
</button>
</div>
</div>
<div class="mono secret">{{ wger.revealPassword ? wger.password : "••••••••••••••••" }}</div>
<div class="hint mono">Use this in the wger mobile app and web UI.</div>
</div>
<div v-else class="hint mono">No password available yet. Try resetting or check back later.</div>
<div v-if="wger.error" class="error-box">
<div class="mono">{{ wger.error }}</div>
</div>
</div>
<div class="card module"> <div class="card module">
<div class="module-head"> <div class="module-head">
<h2>Jellyfin</h2> <h2>Jellyfin</h2>
@ -290,6 +353,16 @@ const nextcloudMail = reactive({
error: "", error: "",
}); });
const wger = reactive({
status: "loading",
username: "",
password: "",
passwordUpdatedAt: "",
revealPassword: false,
resetting: false,
error: "",
});
const admin = reactive({ const admin = reactive({
enabled: false, enabled: false,
loading: false, loading: false,
@ -311,6 +384,7 @@ onMounted(() => {
nextcloudMail.status = "login required"; nextcloudMail.status = "login required";
jellyfin.status = "login required"; jellyfin.status = "login required";
vaultwarden.status = "login required"; vaultwarden.status = "login required";
wger.status = "login required";
} }
}); });
@ -323,6 +397,7 @@ watch(
nextcloudMail.status = "login required"; nextcloudMail.status = "login required";
jellyfin.status = "login required"; jellyfin.status = "login required";
vaultwarden.status = "login required"; vaultwarden.status = "login required";
wger.status = "login required";
admin.enabled = false; admin.enabled = false;
admin.requests = []; admin.requests = [];
return; return;
@ -338,6 +413,7 @@ async function refreshOverview() {
jellyfin.error = ""; jellyfin.error = "";
vaultwarden.error = ""; vaultwarden.error = "";
nextcloudMail.error = ""; nextcloudMail.error = "";
wger.error = "";
try { try {
const resp = await authFetch("/api/account/overview", { const resp = await authFetch("/api/account/overview", {
headers: { Accept: "application/json" }, headers: { Accept: "application/json" },
@ -355,6 +431,10 @@ async function refreshOverview() {
nextcloudMail.primaryEmail = data.nextcloud_mail?.primary_email || ""; nextcloudMail.primaryEmail = data.nextcloud_mail?.primary_email || "";
nextcloudMail.accountCount = data.nextcloud_mail?.account_count || ""; nextcloudMail.accountCount = data.nextcloud_mail?.account_count || "";
nextcloudMail.syncedAt = data.nextcloud_mail?.synced_at || ""; nextcloudMail.syncedAt = data.nextcloud_mail?.synced_at || "";
wger.status = data.wger?.status || "unknown";
wger.username = data.wger?.username || auth.username;
wger.password = data.wger?.password || "";
wger.passwordUpdatedAt = data.wger?.password_updated_at || "";
vaultwarden.status = data.vaultwarden?.status || "unknown"; vaultwarden.status = data.vaultwarden?.status || "unknown";
vaultwarden.username = data.vaultwarden?.username || auth.email || auth.username; vaultwarden.username = data.vaultwarden?.username || auth.email || auth.username;
vaultwarden.syncedAt = data.vaultwarden?.synced_at || ""; vaultwarden.syncedAt = data.vaultwarden?.synced_at || "";
@ -365,6 +445,7 @@ async function refreshOverview() {
} catch (err) { } catch (err) {
mailu.status = "unavailable"; mailu.status = "unavailable";
nextcloudMail.status = "unavailable"; nextcloudMail.status = "unavailable";
wger.status = "unavailable";
vaultwarden.status = "unavailable"; vaultwarden.status = "unavailable";
jellyfin.status = "unavailable"; jellyfin.status = "unavailable";
jellyfin.syncStatus = ""; jellyfin.syncStatus = "";
@ -372,6 +453,7 @@ async function refreshOverview() {
const message = err?.message ? `Failed to load account status (${err.message})` : "Failed to load account status."; const message = err?.message ? `Failed to load account status (${err.message})` : "Failed to load account status.";
mailu.error = message; mailu.error = message;
nextcloudMail.error = message; nextcloudMail.error = message;
wger.error = message;
vaultwarden.error = message; vaultwarden.error = message;
jellyfin.error = message; jellyfin.error = message;
} }
@ -436,6 +518,25 @@ async function rotateMailu() {
} }
} }
async function resetWger() {
wger.error = "";
wger.resetting = true;
try {
const resp = await authFetch("/api/account/wger/reset", { method: "POST" });
const data = await resp.json().catch(() => ({}));
if (!resp.ok) throw new Error(data.error || `status ${resp.status}`);
if (data.password) {
wger.password = data.password;
wger.revealPassword = true;
}
await refreshOverview();
} catch (err) {
wger.error = err.message || "Reset failed";
} finally {
wger.resetting = false;
}
}
async function syncNextcloudMail() { async function syncNextcloudMail() {
nextcloudMail.error = ""; nextcloudMail.error = "";
nextcloudMail.syncing = true; nextcloudMail.syncing = true;

View File

@ -150,6 +150,23 @@ const sections = [
}, },
], ],
}, },
{
title: "Health",
description: "Private wellness tracking with mobile apps.",
groups: [
{
title: "Fitness",
apps: [
{
name: "Atlas Health",
url: "https://health.bstein.dev",
target: "_blank",
description: "Workout and nutrition tracking (wger).",
},
],
},
],
},
{ {
title: "Dev", title: "Dev",
description: "Build and ship: source control, CI, registry, and GitOps.", description: "Build and ship: source control, CI, registry, and GitOps.",

View File

@ -204,6 +204,46 @@
</p> </p>
</li> </li>
<li class="check-item" :class="checkItemClass('health_data_notice')">
<label>
<input
type="checkbox"
:checked="isStepDone('health_data_notice')"
:disabled="!auth.authenticated || loading || isStepBlocked('health_data_notice')"
@change="toggleStep('health_data_notice', $event)"
/>
<span>Review the health data notice</span>
<span class="pill mono auto-pill" :class="stepPillClass('health_data_notice')">
{{ stepPillLabel("health_data_notice") }}
</span>
</label>
<p class="muted">
Atlas Health is a personal wellness tool, not medical advice. Use it at your own risk. Your health data
belongs to you and will never be sold or used beyond providing the service. We apply best practices to
protect it, but no system is risk-free.
</p>
</li>
<li class="check-item" :class="checkItemClass('wger_login')">
<label>
<input
type="checkbox"
:checked="isStepDone('wger_login')"
:disabled="!auth.authenticated || loading || isStepBlocked('wger_login')"
@change="toggleStep('wger_login', $event)"
/>
<span>Sign in to Atlas Health (wger)</span>
<span class="pill mono auto-pill" :class="stepPillClass('wger_login')">
{{ stepPillLabel("wger_login") }}
</span>
</label>
<p class="muted">
Open <a href="https://health.bstein.dev" target="_blank" rel="noreferrer">health.bstein.dev</a> and sign in
with the credentials shown on your <a href="/account">Account</a> page. In the mobile app, set the server
URL to health.bstein.dev and log in once.
</p>
</li>
<li class="check-item" :class="checkItemClass('keycloak_password_rotated')"> <li class="check-item" :class="checkItemClass('keycloak_password_rotated')">
<label> <label>
<input type="checkbox" :checked="isStepDone('keycloak_password_rotated')" disabled /> <input type="checkbox" :checked="isStepDone('keycloak_password_rotated')" disabled />
@ -494,6 +534,8 @@ function requiredStepOrder() {
"vaultwarden_browser_extension", "vaultwarden_browser_extension",
"vaultwarden_desktop_app", "vaultwarden_desktop_app",
"vaultwarden_mobile_app", "vaultwarden_mobile_app",
"health_data_notice",
"wger_login",
"keycloak_password_rotated", "keycloak_password_rotated",
"element_recovery_key", "element_recovery_key",
"element_recovery_key_stored", "element_recovery_key_stored",