portal: add firefly accounts and finance onboarding

This commit is contained in:
Brad Stein 2026-01-16 23:50:07 -03:00
parent f3a2da2ed5
commit ef1cbd7527
9 changed files with 460 additions and 0 deletions

View File

@ -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}

View File

@ -13,6 +13,7 @@ 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 .firefly_user_sync import trigger as trigger_firefly_user_sync
from .wger_user_sync import trigger as trigger_wger_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" MAILU_APP_PASSWORD_ATTR = "mailu_app_password"
WGER_PASSWORD_ATTR = "wger_password" WGER_PASSWORD_ATTR = "wger_password"
WGER_PASSWORD_UPDATED_ATTR = "wger_password_updated_at" 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, ...] = ( REQUIRED_PROVISION_TASKS: tuple[str, ...] = (
"keycloak_user", "keycloak_user",
"keycloak_password", "keycloak_password",
@ -28,6 +31,7 @@ REQUIRED_PROVISION_TASKS: tuple[str, ...] = (
"mailu_sync", "mailu_sync",
"nextcloud_mail_sync", "nextcloud_mail_sync",
"wger_account", "wger_account",
"firefly_account",
"vaultwarden_invite", "vaultwarden_invite",
) )
@ -402,6 +406,52 @@ def provision_access_request(request_code: str) -> ProvisionResult:
except Exception as exc: except Exception as exc:
_upsert_task(conn, request_code, "wger_account", "error", _safe_error_detail(exc, "failed to provision wger")) _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) # Task: ensure Vaultwarden account exists (invite flow)
try: try:
if not user_id: if not user_id:

View File

@ -63,6 +63,8 @@ ONBOARDING_STEPS: tuple[str, ...] = (
"vaultwarden_mobile_app", "vaultwarden_mobile_app",
"health_data_notice", "health_data_notice",
"wger_login", "wger_login",
"actual_login",
"firefly_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 ..firefly_user_sync import trigger as trigger_firefly_user_sync
from ..wger_user_sync import trigger as trigger_wger_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_status = "ready"
wger_password = "" wger_password = ""
wger_password_updated_at = "" wger_password_updated_at = ""
firefly_status = "ready"
firefly_password = ""
firefly_password_updated_at = ""
vaultwarden_email = "" vaultwarden_email = ""
vaultwarden_status = "" vaultwarden_status = ""
vaultwarden_synced_at = "" vaultwarden_synced_at = ""
@ -55,6 +59,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" wger_status = "server not configured"
firefly_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"
@ -103,6 +108,16 @@ def register(app) -> None:
wger_password_updated_at = str(raw_wger_updated[0]) wger_password_updated_at = str(raw_wger_updated[0])
elif isinstance(raw_wger_updated, str) and raw_wger_updated: elif isinstance(raw_wger_updated, str) and raw_wger_updated:
wger_password_updated_at = 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") 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])
@ -126,6 +141,8 @@ def register(app) -> None:
or not mailu_app_password or not mailu_app_password
or not wger_password or not wger_password
or not wger_password_updated_at or not wger_password_updated_at
or not firefly_password
or not firefly_password_updated_at
or not vaultwarden_email or not vaultwarden_email
or not vaultwarden_status or not vaultwarden_status
or not vaultwarden_synced_at or not vaultwarden_synced_at
@ -178,6 +195,18 @@ def register(app) -> None:
wger_password_updated_at = str(raw_wger_updated[0]) wger_password_updated_at = str(raw_wger_updated[0])
elif isinstance(raw_wger_updated, str) and raw_wger_updated: elif isinstance(raw_wger_updated, str) and raw_wger_updated:
wger_password_updated_at = 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: 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:
@ -200,6 +229,7 @@ def register(app) -> None:
mailu_status = "unavailable" mailu_status = "unavailable"
nextcloud_mail_status = "unavailable" nextcloud_mail_status = "unavailable"
wger_status = "unavailable" wger_status = "unavailable"
firefly_status = "unavailable"
vaultwarden_status = "unavailable" vaultwarden_status = "unavailable"
jellyfin_status = "unavailable" jellyfin_status = "unavailable"
jellyfin_sync_status = "unknown" jellyfin_sync_status = "unknown"
@ -214,6 +244,9 @@ def register(app) -> None:
if not wger_password and wger_status == "ready": if not wger_password and wger_status == "ready":
wger_status = "needs provisioning" wger_status = "needs provisioning"
if not firefly_password and firefly_status == "ready":
firefly_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
@ -259,6 +292,12 @@ def register(app) -> None:
"password": wger_password, "password": wger_password,
"password_updated_at": wger_password_updated_at, "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": { "vaultwarden": {
"status": vaultwarden_status, "status": vaultwarden_status,
"username": vaultwarden_username, "username": vaultwarden_username,
@ -375,6 +414,57 @@ def register(app) -> None:
return jsonify({"status": "ok", "password": password}) 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"]) @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

@ -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_NAMESPACE = os.getenv("WGER_NAMESPACE", "health").strip()
WGER_USER_SYNC_CRONJOB = os.getenv("WGER_USER_SYNC_CRONJOB", "wger-user-sync").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")) 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_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"))

View File

@ -149,6 +149,20 @@ export function fallbackServices() {
summary: "Workout + nutrition tracking with the wger mobile app.", summary: "Workout + nutrition tracking with the wger mobile app.",
link: "https://health.bstein.dev", 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", name: "Grafana",
icon: "📈", icon: "📈",

View File

@ -231,6 +231,69 @@
</div> </div>
</div> </div>
<div class="card module">
<div class="module-head">
<h2>Firefly III</h2>
<span
class="pill mono"
:class="
firefly.status === 'ready'
? 'pill-ok'
: firefly.status === 'needs provisioning' || firefly.status === 'login required'
? 'pill-warn'
: firefly.status === 'unavailable' || firefly.status === 'error'
? 'pill-bad'
: ''
"
>
{{ firefly.status }}
</span>
</div>
<p class="muted">
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.
</p>
<div class="kv">
<div class="row">
<span class="k mono">URL</span>
<a class="v mono link" href="https://money.bstein.dev" target="_blank" rel="noreferrer">money.bstein.dev</a>
</div>
<div class="row">
<span class="k mono">Username</span>
<span class="v mono">{{ firefly.username || auth.email || auth.username }}</span>
</div>
<div class="row">
<span class="k mono">Password updated</span>
<span class="v mono">{{ firefly.passwordUpdatedAt || "unknown" }}</span>
</div>
</div>
<div class="actions">
<button class="primary" type="button" :disabled="firefly.resetting" @click="resetFirefly">
{{ firefly.resetting ? "Resetting..." : "Reset Firefly password" }}
</button>
</div>
<div v-if="firefly.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('firefly-password', firefly.password)">
copy
<span v-if="copied['firefly-password']" class="copied">copied</span>
</button>
<button class="copy mono" type="button" @click="firefly.revealPassword = !firefly.revealPassword">
{{ firefly.revealPassword ? "hide" : "show" }}
</button>
</div>
</div>
<div class="mono secret">{{ firefly.revealPassword ? firefly.password : "••••••••••••••••" }}</div>
<div class="hint mono">Use this in Firefly III and the Abacus app.</div>
</div>
<div v-else class="hint mono">No password available yet. Try resetting or check back later.</div>
<div v-if="firefly.error" class="error-box">
<div class="mono">{{ firefly.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>
@ -366,6 +429,16 @@ const wger = reactive({
error: "", error: "",
}); });
const firefly = 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,
@ -388,6 +461,7 @@ onMounted(() => {
jellyfin.status = "login required"; jellyfin.status = "login required";
vaultwarden.status = "login required"; vaultwarden.status = "login required";
wger.status = "login required"; wger.status = "login required";
firefly.status = "login required";
} }
}); });
@ -401,6 +475,7 @@ watch(
jellyfin.status = "login required"; jellyfin.status = "login required";
vaultwarden.status = "login required"; vaultwarden.status = "login required";
wger.status = "login required"; wger.status = "login required";
firefly.status = "login required";
admin.enabled = false; admin.enabled = false;
admin.requests = []; admin.requests = [];
return; return;
@ -417,6 +492,7 @@ async function refreshOverview() {
vaultwarden.error = ""; vaultwarden.error = "";
nextcloudMail.error = ""; nextcloudMail.error = "";
wger.error = ""; wger.error = "";
firefly.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" },
@ -438,6 +514,10 @@ async function refreshOverview() {
wger.username = data.wger?.username || auth.username; wger.username = data.wger?.username || auth.username;
wger.password = data.wger?.password || ""; wger.password = data.wger?.password || "";
wger.passwordUpdatedAt = data.wger?.password_updated_at || ""; 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.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 || "";
@ -449,6 +529,7 @@ async function refreshOverview() {
mailu.status = "unavailable"; mailu.status = "unavailable";
nextcloudMail.status = "unavailable"; nextcloudMail.status = "unavailable";
wger.status = "unavailable"; wger.status = "unavailable";
firefly.status = "unavailable";
vaultwarden.status = "unavailable"; vaultwarden.status = "unavailable";
jellyfin.status = "unavailable"; jellyfin.status = "unavailable";
jellyfin.syncStatus = ""; jellyfin.syncStatus = "";
@ -457,6 +538,7 @@ async function refreshOverview() {
mailu.error = message; mailu.error = message;
nextcloudMail.error = message; nextcloudMail.error = message;
wger.error = message; wger.error = message;
firefly.error = message;
vaultwarden.error = message; vaultwarden.error = message;
jellyfin.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() { async function syncNextcloudMail() {
nextcloudMail.error = ""; nextcloudMail.error = "";
nextcloudMail.syncing = true; nextcloudMail.syncing = true;

View File

@ -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", title: "Security",
description: "Passwords for humans, secrets for infrastructure.", description: "Passwords for humans, secrets for infrastructure.",

View File

@ -244,6 +244,45 @@
</p> </p>
</li> </li>
<li class="check-item" :class="checkItemClass('actual_login')">
<label>
<input
type="checkbox"
:checked="isStepDone('actual_login')"
:disabled="!auth.authenticated || loading || isStepBlocked('actual_login')"
@change="toggleStep('actual_login', $event)"
/>
<span>Sign in to Actual Budget</span>
<span class="pill mono auto-pill" :class="stepPillClass('actual_login')">
{{ stepPillLabel("actual_login") }}
</span>
</label>
<p class="muted">
Open <a href="https://budget.bstein.dev" target="_blank" rel="noreferrer">budget.bstein.dev</a> and sign in
with your Keycloak account.
</p>
</li>
<li class="check-item" :class="checkItemClass('firefly_login')">
<label>
<input
type="checkbox"
:checked="isStepDone('firefly_login')"
:disabled="!auth.authenticated || loading || isStepBlocked('firefly_login')"
@change="toggleStep('firefly_login', $event)"
/>
<span>Sign in to Firefly III</span>
<span class="pill mono auto-pill" :class="stepPillClass('firefly_login')">
{{ stepPillLabel("firefly_login") }}
</span>
</label>
<p class="muted">
Open <a href="https://money.bstein.dev" target="_blank" rel="noreferrer">money.bstein.dev</a> and sign in
with the credentials from your <a href="/account">Account</a> page. In the Abacus app, set the server URL
to money.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 />
@ -536,6 +575,8 @@ function requiredStepOrder() {
"vaultwarden_mobile_app", "vaultwarden_mobile_app",
"health_data_notice", "health_data_notice",
"wger_login", "wger_login",
"actual_login",
"firefly_login",
"keycloak_password_rotated", "keycloak_password_rotated",
"element_recovery_key", "element_recovery_key",
"element_recovery_key_stored", "element_recovery_key_stored",