portal: add wger account provisioning
This commit is contained in:
parent
d19e683375
commit
3d40da7e77
@ -13,10 +13,13 @@ from .keycloak import admin_client
|
||||
from .nextcloud_mail_sync import trigger as trigger_nextcloud_mail_sync
|
||||
from .utils import random_password
|
||||
from .vaultwarden import invite_user
|
||||
from .wger_user_sync import trigger as trigger_wger_user_sync
|
||||
|
||||
|
||||
MAILU_EMAIL_ATTR = "mailu_email"
|
||||
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, ...] = (
|
||||
"keycloak_user",
|
||||
"keycloak_password",
|
||||
@ -24,6 +27,7 @@ REQUIRED_PROVISION_TASKS: tuple[str, ...] = (
|
||||
"mailu_app_password",
|
||||
"mailu_sync",
|
||||
"nextcloud_mail_sync",
|
||||
"wger_account",
|
||||
"vaultwarden_invite",
|
||||
)
|
||||
|
||||
@ -358,6 +362,46 @@ def provision_access_request(request_code: str) -> ProvisionResult:
|
||||
except Exception as exc:
|
||||
_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)
|
||||
try:
|
||||
if not user_id:
|
||||
|
||||
@ -61,6 +61,8 @@ ONBOARDING_STEPS: tuple[str, ...] = (
|
||||
"vaultwarden_browser_extension",
|
||||
"vaultwarden_desktop_app",
|
||||
"vaultwarden_mobile_app",
|
||||
"health_data_notice",
|
||||
"wger_login",
|
||||
"keycloak_password_rotated",
|
||||
"keycloak_mfa_optional",
|
||||
"element_recovery_key",
|
||||
|
||||
@ -11,6 +11,7 @@ from .. import settings
|
||||
from ..keycloak import admin_client, require_auth, require_account_access
|
||||
from ..nextcloud_mail_sync import trigger as trigger_nextcloud_mail_sync
|
||||
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:
|
||||
@ -40,6 +41,9 @@ def register(app) -> None:
|
||||
nextcloud_mail_primary_email = ""
|
||||
nextcloud_mail_account_count = ""
|
||||
nextcloud_mail_synced_at = ""
|
||||
wger_status = "ready"
|
||||
wger_password = ""
|
||||
wger_password_updated_at = ""
|
||||
vaultwarden_email = ""
|
||||
vaultwarden_status = ""
|
||||
vaultwarden_synced_at = ""
|
||||
@ -50,6 +54,7 @@ def register(app) -> None:
|
||||
|
||||
if not admin_client().ready():
|
||||
mailu_status = "server not configured"
|
||||
wger_status = "server not configured"
|
||||
jellyfin_status = "server not configured"
|
||||
jellyfin_sync_status = "unknown"
|
||||
jellyfin_sync_detail = "keycloak admin not configured"
|
||||
@ -88,6 +93,16 @@ def register(app) -> None:
|
||||
nextcloud_mail_synced_at = str(raw_synced[0])
|
||||
elif isinstance(raw_synced, str) and 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")
|
||||
if isinstance(raw_vw_email, list) and raw_vw_email:
|
||||
vaultwarden_email = str(raw_vw_email[0])
|
||||
@ -149,6 +164,18 @@ def register(app) -> None:
|
||||
nextcloud_mail_synced_at = str(raw_synced[0])
|
||||
elif isinstance(raw_synced, str) and 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:
|
||||
raw_vw_email = attrs.get("vaultwarden_email")
|
||||
if isinstance(raw_vw_email, list) and raw_vw_email:
|
||||
@ -170,6 +197,7 @@ def register(app) -> None:
|
||||
except Exception:
|
||||
mailu_status = "unavailable"
|
||||
nextcloud_mail_status = "unavailable"
|
||||
wger_status = "unavailable"
|
||||
vaultwarden_status = "unavailable"
|
||||
jellyfin_status = "unavailable"
|
||||
jellyfin_sync_status = "unknown"
|
||||
@ -181,6 +209,9 @@ def register(app) -> None:
|
||||
if not mailu_app_password and mailu_status == "ready":
|
||||
mailu_status = "needs app password"
|
||||
|
||||
if not wger_password and wger_status == "ready":
|
||||
wger_status = "needs provisioning"
|
||||
|
||||
if nextcloud_mail_status == "unknown":
|
||||
try:
|
||||
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,
|
||||
"synced_at": nextcloud_mail_synced_at,
|
||||
},
|
||||
"wger": {
|
||||
"status": wger_status,
|
||||
"username": username,
|
||||
"password": wger_password,
|
||||
"password_updated_at": wger_password_updated_at,
|
||||
},
|
||||
"vaultwarden": {
|
||||
"status": vaultwarden_status,
|
||||
"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"])
|
||||
@require_auth
|
||||
def account_nextcloud_mail_sync() -> Any:
|
||||
|
||||
@ -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_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_PORT = int(os.getenv("SMTP_PORT", "25"))
|
||||
SMTP_USERNAME = os.getenv("SMTP_USERNAME", "").strip()
|
||||
|
||||
137
backend/atlas_portal/wger_user_sync.py
Normal file
137
backend/atlas_portal/wger_user_sync.py
Normal 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}
|
||||
@ -165,6 +165,69 @@
|
||||
</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="module-head">
|
||||
<h2>Jellyfin</h2>
|
||||
@ -290,6 +353,16 @@ const nextcloudMail = reactive({
|
||||
error: "",
|
||||
});
|
||||
|
||||
const wger = reactive({
|
||||
status: "loading",
|
||||
username: "",
|
||||
password: "",
|
||||
passwordUpdatedAt: "",
|
||||
revealPassword: false,
|
||||
resetting: false,
|
||||
error: "",
|
||||
});
|
||||
|
||||
const admin = reactive({
|
||||
enabled: false,
|
||||
loading: false,
|
||||
@ -311,6 +384,7 @@ onMounted(() => {
|
||||
nextcloudMail.status = "login required";
|
||||
jellyfin.status = "login required";
|
||||
vaultwarden.status = "login required";
|
||||
wger.status = "login required";
|
||||
}
|
||||
});
|
||||
|
||||
@ -323,6 +397,7 @@ watch(
|
||||
nextcloudMail.status = "login required";
|
||||
jellyfin.status = "login required";
|
||||
vaultwarden.status = "login required";
|
||||
wger.status = "login required";
|
||||
admin.enabled = false;
|
||||
admin.requests = [];
|
||||
return;
|
||||
@ -338,6 +413,7 @@ async function refreshOverview() {
|
||||
jellyfin.error = "";
|
||||
vaultwarden.error = "";
|
||||
nextcloudMail.error = "";
|
||||
wger.error = "";
|
||||
try {
|
||||
const resp = await authFetch("/api/account/overview", {
|
||||
headers: { Accept: "application/json" },
|
||||
@ -355,6 +431,10 @@ async function refreshOverview() {
|
||||
nextcloudMail.primaryEmail = data.nextcloud_mail?.primary_email || "";
|
||||
nextcloudMail.accountCount = data.nextcloud_mail?.account_count || "";
|
||||
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.username = data.vaultwarden?.username || auth.email || auth.username;
|
||||
vaultwarden.syncedAt = data.vaultwarden?.synced_at || "";
|
||||
@ -365,6 +445,7 @@ async function refreshOverview() {
|
||||
} catch (err) {
|
||||
mailu.status = "unavailable";
|
||||
nextcloudMail.status = "unavailable";
|
||||
wger.status = "unavailable";
|
||||
vaultwarden.status = "unavailable";
|
||||
jellyfin.status = "unavailable";
|
||||
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.";
|
||||
mailu.error = message;
|
||||
nextcloudMail.error = message;
|
||||
wger.error = message;
|
||||
vaultwarden.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() {
|
||||
nextcloudMail.error = "";
|
||||
nextcloudMail.syncing = true;
|
||||
|
||||
@ -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",
|
||||
description: "Build and ship: source control, CI, registry, and GitOps.",
|
||||
|
||||
@ -204,6 +204,46 @@
|
||||
</p>
|
||||
</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')">
|
||||
<label>
|
||||
<input type="checkbox" :checked="isStepDone('keycloak_password_rotated')" disabled />
|
||||
@ -494,6 +534,8 @@ function requiredStepOrder() {
|
||||
"vaultwarden_browser_extension",
|
||||
"vaultwarden_desktop_app",
|
||||
"vaultwarden_mobile_app",
|
||||
"health_data_notice",
|
||||
"wger_login",
|
||||
"keycloak_password_rotated",
|
||||
"element_recovery_key",
|
||||
"element_recovery_key_stored",
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user