diff --git a/backend/atlas_portal/firefly_user_sync.py b/backend/atlas_portal/firefly_user_sync.py new file mode 100644 index 0000000..00c9e39 --- /dev/null +++ b/backend/atlas_portal/firefly_user_sync.py @@ -0,0 +1,136 @@ +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"firefly-user-sync-{safe_user}-{now}" + + job: dict[str, Any] = { + "apiVersion": "batch/v1", + "kind": "Job", + "metadata": { + "name": job_name, + "namespace": settings.FIREFLY_NAMESPACE, + "labels": { + "app": "firefly-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 {"FIREFLY_USER_EMAIL", "FIREFLY_USER_PASSWORD"} + ) + ] + env.append({"name": "FIREFLY_USER_EMAIL", "value": email}) + env.append({"name": "FIREFLY_USER_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.FIREFLY_NAMESPACE + cronjob_name = settings.FIREFLY_USER_SYNC_CRONJOB + if not namespace or not cronjob_name: + raise RuntimeError("firefly 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.FIREFLY_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} diff --git a/backend/atlas_portal/provisioning.py b/backend/atlas_portal/provisioning.py index 11aa8df..cff0950 100644 --- a/backend/atlas_portal/provisioning.py +++ b/backend/atlas_portal/provisioning.py @@ -13,6 +13,7 @@ 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 .firefly_user_sync import trigger as trigger_firefly_user_sync from .wger_user_sync import trigger as trigger_wger_user_sync @@ -20,6 +21,8 @@ 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" +FIREFLY_PASSWORD_ATTR = "firefly_password" +FIREFLY_PASSWORD_UPDATED_ATTR = "firefly_password_updated_at" REQUIRED_PROVISION_TASKS: tuple[str, ...] = ( "keycloak_user", "keycloak_password", @@ -28,6 +31,7 @@ REQUIRED_PROVISION_TASKS: tuple[str, ...] = ( "mailu_sync", "nextcloud_mail_sync", "wger_account", + "firefly_account", "vaultwarden_invite", ) @@ -402,6 +406,52 @@ def provision_access_request(request_code: str) -> ProvisionResult: except Exception as exc: _upsert_task(conn, request_code, "wger_account", "error", _safe_error_detail(exc, "failed to provision wger")) + # Task: ensure firefly account exists + try: + if not user_id: + raise RuntimeError("missing user id") + + full = admin_client().get_user(user_id) + attrs = full.get("attributes") or {} + firefly_password = "" + firefly_password_updated_at = "" + if isinstance(attrs, dict): + raw_pw = attrs.get(FIREFLY_PASSWORD_ATTR) + if isinstance(raw_pw, list) and raw_pw and isinstance(raw_pw[0], str): + firefly_password = raw_pw[0] + elif isinstance(raw_pw, str) and raw_pw: + firefly_password = raw_pw + raw_updated = attrs.get(FIREFLY_PASSWORD_UPDATED_ATTR) + if isinstance(raw_updated, list) and raw_updated and isinstance(raw_updated[0], str): + firefly_password_updated_at = raw_updated[0] + elif isinstance(raw_updated, str) and raw_updated: + firefly_password_updated_at = raw_updated + + if not firefly_password: + firefly_password = random_password(24) + admin_client().set_user_attribute(username, FIREFLY_PASSWORD_ATTR, firefly_password) + + firefly_email = mailu_email or contact_email or f"{username}@{settings.MAILU_DOMAIN}" + + if not firefly_password_updated_at: + result = trigger_firefly_user_sync(username, firefly_email, firefly_password, wait=True) + status_val = result.get("status") if isinstance(result, dict) else "error" + if status_val != "ok": + raise RuntimeError(f"firefly sync {status_val}") + + now_iso = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + admin_client().set_user_attribute(username, FIREFLY_PASSWORD_UPDATED_ATTR, now_iso) + + _upsert_task(conn, request_code, "firefly_account", "ok", None) + except Exception as exc: + _upsert_task( + conn, + request_code, + "firefly_account", + "error", + _safe_error_detail(exc, "failed to provision firefly"), + ) + # Task: ensure Vaultwarden account exists (invite flow) try: if not user_id: diff --git a/backend/atlas_portal/routes/access_requests.py b/backend/atlas_portal/routes/access_requests.py index f501efa..2557e24 100644 --- a/backend/atlas_portal/routes/access_requests.py +++ b/backend/atlas_portal/routes/access_requests.py @@ -63,6 +63,8 @@ ONBOARDING_STEPS: tuple[str, ...] = ( "vaultwarden_mobile_app", "health_data_notice", "wger_login", + "actual_login", + "firefly_login", "keycloak_password_rotated", "keycloak_mfa_optional", "element_recovery_key", diff --git a/backend/atlas_portal/routes/account.py b/backend/atlas_portal/routes/account.py index 3df445b..17841b4 100644 --- a/backend/atlas_portal/routes/account.py +++ b/backend/atlas_portal/routes/account.py @@ -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 ..firefly_user_sync import trigger as trigger_firefly_user_sync from ..wger_user_sync import trigger as trigger_wger_user_sync @@ -44,6 +45,9 @@ def register(app) -> None: wger_status = "ready" wger_password = "" wger_password_updated_at = "" + firefly_status = "ready" + firefly_password = "" + firefly_password_updated_at = "" vaultwarden_email = "" vaultwarden_status = "" vaultwarden_synced_at = "" @@ -55,6 +59,7 @@ def register(app) -> None: if not admin_client().ready(): mailu_status = "server not configured" wger_status = "server not configured" + firefly_status = "server not configured" jellyfin_status = "server not configured" jellyfin_sync_status = "unknown" jellyfin_sync_detail = "keycloak admin not configured" @@ -103,6 +108,16 @@ def register(app) -> None: 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_firefly_password = attrs.get("firefly_password") + if isinstance(raw_firefly_password, list) and raw_firefly_password: + firefly_password = str(raw_firefly_password[0]) + elif isinstance(raw_firefly_password, str) and raw_firefly_password: + firefly_password = raw_firefly_password + raw_firefly_updated = attrs.get("firefly_password_updated_at") + if isinstance(raw_firefly_updated, list) and raw_firefly_updated: + firefly_password_updated_at = str(raw_firefly_updated[0]) + elif isinstance(raw_firefly_updated, str) and raw_firefly_updated: + firefly_password_updated_at = raw_firefly_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]) @@ -126,6 +141,8 @@ def register(app) -> None: or not mailu_app_password or not wger_password or not wger_password_updated_at + or not firefly_password + or not firefly_password_updated_at or not vaultwarden_email or not vaultwarden_status or not vaultwarden_synced_at @@ -178,6 +195,18 @@ def register(app) -> None: 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 firefly_password: + raw_firefly_password = attrs.get("firefly_password") + if isinstance(raw_firefly_password, list) and raw_firefly_password: + firefly_password = str(raw_firefly_password[0]) + elif isinstance(raw_firefly_password, str) and raw_firefly_password: + firefly_password = raw_firefly_password + if not firefly_password_updated_at: + raw_firefly_updated = attrs.get("firefly_password_updated_at") + if isinstance(raw_firefly_updated, list) and raw_firefly_updated: + firefly_password_updated_at = str(raw_firefly_updated[0]) + elif isinstance(raw_firefly_updated, str) and raw_firefly_updated: + firefly_password_updated_at = raw_firefly_updated if not vaultwarden_email: raw_vw_email = attrs.get("vaultwarden_email") if isinstance(raw_vw_email, list) and raw_vw_email: @@ -200,6 +229,7 @@ def register(app) -> None: mailu_status = "unavailable" nextcloud_mail_status = "unavailable" wger_status = "unavailable" + firefly_status = "unavailable" vaultwarden_status = "unavailable" jellyfin_status = "unavailable" jellyfin_sync_status = "unknown" @@ -214,6 +244,9 @@ def register(app) -> None: if not wger_password and wger_status == "ready": wger_status = "needs provisioning" + if not firefly_password and firefly_status == "ready": + firefly_status = "needs provisioning" + if nextcloud_mail_status == "unknown": try: count_val = int(nextcloud_mail_account_count) if nextcloud_mail_account_count else 0 @@ -259,6 +292,12 @@ def register(app) -> None: "password": wger_password, "password_updated_at": wger_password_updated_at, }, + "firefly": { + "status": firefly_status, + "username": mailu_email or username, + "password": firefly_password, + "password_updated_at": firefly_password_updated_at, + }, "vaultwarden": { "status": vaultwarden_status, "username": vaultwarden_username, @@ -375,6 +414,57 @@ def register(app) -> None: return jsonify({"status": "ok", "password": password}) + @app.route("/api/account/firefly/reset", methods=["POST"]) + @require_auth + def account_firefly_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(24) + + try: + result = trigger_firefly_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\"firefly sync {status_val}\") + except Exception as exc: + message = str(exc).strip() or \"firefly sync failed\" + return jsonify({\"error\": message}), 502 + + try: + admin_client().set_user_attribute(username, \"firefly_password\", password) + admin_client().set_user_attribute( + username, + \"firefly_password_updated_at\", + time.strftime(\"%Y-%m-%dT%H:%M:%SZ\", time.gmtime()), + ) + except Exception: + return jsonify({\"error\": \"failed to store firefly 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: diff --git a/backend/atlas_portal/settings.py b/backend/atlas_portal/settings.py index c94eaa9..ec426de 100644 --- a/backend/atlas_portal/settings.py +++ b/backend/atlas_portal/settings.py @@ -98,6 +98,9 @@ NEXTCLOUD_MAIL_SYNC_JOB_TTL_SEC = int(os.getenv("NEXTCLOUD_MAIL_SYNC_JOB_TTL_SEC 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")) +FIREFLY_NAMESPACE = os.getenv("FIREFLY_NAMESPACE", "finance").strip() +FIREFLY_USER_SYNC_CRONJOB = os.getenv("FIREFLY_USER_SYNC_CRONJOB", "firefly-user-sync").strip() +FIREFLY_USER_SYNC_WAIT_TIMEOUT_SEC = float(os.getenv("FIREFLY_USER_SYNC_WAIT_TIMEOUT_SEC", "90")) SMTP_HOST = os.getenv("SMTP_HOST", "mailu-front.mailu-mailserver.svc.cluster.local").strip() SMTP_PORT = int(os.getenv("SMTP_PORT", "25")) diff --git a/frontend/src/data/sample.js b/frontend/src/data/sample.js index 7bfa399..66ac5f3 100644 --- a/frontend/src/data/sample.js +++ b/frontend/src/data/sample.js @@ -149,6 +149,20 @@ export function fallbackServices() { summary: "Workout + nutrition tracking with the wger mobile app.", link: "https://health.bstein.dev", }, + { + name: "Actual Budget", + icon: "💸", + category: "finance", + summary: "Local-first budgeting with Keycloak SSO.", + link: "https://budget.bstein.dev", + }, + { + name: "Firefly III", + icon: "💵", + category: "finance", + summary: "Personal finance manager with Abacus mobile sync.", + link: "https://money.bstein.dev", + }, { name: "Grafana", icon: "📈", diff --git a/frontend/src/views/AccountView.vue b/frontend/src/views/AccountView.vue index ea958fa..cf1d38f 100644 --- a/frontend/src/views/AccountView.vue +++ b/frontend/src/views/AccountView.vue @@ -231,6 +231,69 @@ +
+
+

Firefly III

+ + {{ firefly.status }} + +
+

+ Personal finance manager for budgets and spending. Sign in with the credentials below and set the server URL + to money.bstein.dev in the Abacus mobile app. +

+
+
+ URL + money.bstein.dev +
+
+ Username + {{ firefly.username || auth.email || auth.username }} +
+
+ Password updated + {{ firefly.passwordUpdatedAt || "unknown" }} +
+
+
+ +
+
+
+
Password
+
+ + +
+
+
{{ firefly.revealPassword ? firefly.password : "••••••••••••••••" }}
+
Use this in Firefly III and the Abacus app.
+
+
No password available yet. Try resetting or check back later.
+
+
{{ firefly.error }}
+
+
+

Jellyfin

@@ -366,6 +429,16 @@ const wger = reactive({ error: "", }); +const firefly = reactive({ + status: "loading", + username: "", + password: "", + passwordUpdatedAt: "", + revealPassword: false, + resetting: false, + error: "", +}); + const admin = reactive({ enabled: false, loading: false, @@ -388,6 +461,7 @@ onMounted(() => { jellyfin.status = "login required"; vaultwarden.status = "login required"; wger.status = "login required"; + firefly.status = "login required"; } }); @@ -401,6 +475,7 @@ watch( jellyfin.status = "login required"; vaultwarden.status = "login required"; wger.status = "login required"; + firefly.status = "login required"; admin.enabled = false; admin.requests = []; return; @@ -417,6 +492,7 @@ async function refreshOverview() { vaultwarden.error = ""; nextcloudMail.error = ""; wger.error = ""; + firefly.error = ""; try { const resp = await authFetch("/api/account/overview", { headers: { Accept: "application/json" }, @@ -438,6 +514,10 @@ async function refreshOverview() { wger.username = data.wger?.username || auth.username; wger.password = data.wger?.password || ""; wger.passwordUpdatedAt = data.wger?.password_updated_at || ""; + firefly.status = data.firefly?.status || "unknown"; + firefly.username = data.firefly?.username || auth.email || auth.username; + firefly.password = data.firefly?.password || ""; + firefly.passwordUpdatedAt = data.firefly?.password_updated_at || ""; vaultwarden.status = data.vaultwarden?.status || "unknown"; vaultwarden.username = data.vaultwarden?.username || auth.email || auth.username; vaultwarden.syncedAt = data.vaultwarden?.synced_at || ""; @@ -449,6 +529,7 @@ async function refreshOverview() { mailu.status = "unavailable"; nextcloudMail.status = "unavailable"; wger.status = "unavailable"; + firefly.status = "unavailable"; vaultwarden.status = "unavailable"; jellyfin.status = "unavailable"; jellyfin.syncStatus = ""; @@ -457,6 +538,7 @@ async function refreshOverview() { mailu.error = message; nextcloudMail.error = message; wger.error = message; + firefly.error = message; vaultwarden.error = message; jellyfin.error = message; } @@ -540,6 +622,25 @@ async function resetWger() { } } +async function resetFirefly() { + firefly.error = ""; + firefly.resetting = true; + try { + const resp = await authFetch("/api/account/firefly/reset", { method: "POST" }); + const data = await resp.json().catch(() => ({})); + if (!resp.ok) throw new Error(data.error || `status ${resp.status}`); + if (data.password) { + firefly.password = data.password; + firefly.revealPassword = true; + } + await refreshOverview(); + } catch (err) { + firefly.error = err.message || "Reset failed"; + } finally { + firefly.resetting = false; + } +} + async function syncNextcloudMail() { nextcloudMail.error = ""; nextcloudMail.syncing = true; diff --git a/frontend/src/views/AppsView.vue b/frontend/src/views/AppsView.vue index 1ad80aa..8a70e5b 100644 --- a/frontend/src/views/AppsView.vue +++ b/frontend/src/views/AppsView.vue @@ -76,6 +76,29 @@ const sections = [ }, ], }, + { + title: "Finance", + description: "Personal budgeting and expense tracking.", + groups: [ + { + title: "Money", + apps: [ + { + name: "Actual Budget", + url: "https://budget.bstein.dev", + target: "_blank", + description: "Local-first budgets and envelopes with SSO.", + }, + { + name: "Firefly III", + url: "https://money.bstein.dev", + target: "_blank", + description: "Expense tracking with Abacus mobile sync.", + }, + ], + }, + ], + }, { title: "Security", description: "Passwords for humans, secrets for infrastructure.", diff --git a/frontend/src/views/OnboardingView.vue b/frontend/src/views/OnboardingView.vue index 0f08f74..bfa2ef9 100644 --- a/frontend/src/views/OnboardingView.vue +++ b/frontend/src/views/OnboardingView.vue @@ -244,6 +244,45 @@

+
  • + +

    + Open budget.bstein.dev and sign in + with your Keycloak account. +

    +
  • + +
  • + +

    + Open money.bstein.dev and sign in + with the credentials from your Account page. In the Abacus app, set the server URL + to money.bstein.dev and log in once. +

    +
  • +