portal: add onboarding stepper + budget section
This commit is contained in:
parent
dad4a5afd2
commit
e52506477d
@ -4,7 +4,6 @@ node_modules
|
|||||||
frontend/node_modules
|
frontend/node_modules
|
||||||
frontend/dist
|
frontend/dist
|
||||||
frontend/.vite
|
frontend/.vite
|
||||||
media
|
|
||||||
docs
|
docs
|
||||||
__pycache__
|
__pycache__
|
||||||
.venv
|
.venv
|
||||||
|
|||||||
@ -6,6 +6,7 @@ COPY frontend/package*.json ./
|
|||||||
RUN npm ci --ignore-scripts
|
RUN npm ci --ignore-scripts
|
||||||
|
|
||||||
COPY frontend/ ./
|
COPY frontend/ ./
|
||||||
|
COPY media/ ./public/media/
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
# Runtime stage
|
# Runtime stage
|
||||||
|
|||||||
@ -148,66 +148,81 @@ def _verify_request(conn, code: str, token: str) -> str:
|
|||||||
|
|
||||||
ONBOARDING_STEPS: tuple[str, ...] = (
|
ONBOARDING_STEPS: tuple[str, ...] = (
|
||||||
"vaultwarden_master_password",
|
"vaultwarden_master_password",
|
||||||
|
"vaultwarden_browser_extension",
|
||||||
|
"vaultwarden_mobile_app",
|
||||||
"keycloak_password_rotated",
|
"keycloak_password_rotated",
|
||||||
"element_recovery_key",
|
"element_recovery_key",
|
||||||
"element_recovery_key_stored",
|
"element_recovery_key_stored",
|
||||||
"firefly_login",
|
"element_mobile_app",
|
||||||
"health_data_notice",
|
|
||||||
"wger_login",
|
|
||||||
"vaultwarden_browser_extension",
|
|
||||||
"vaultwarden_desktop_app",
|
|
||||||
"vaultwarden_mobile_app",
|
|
||||||
"elementx_setup",
|
|
||||||
"jellyfin_login",
|
|
||||||
"mail_client_setup",
|
"mail_client_setup",
|
||||||
"actual_login",
|
"nextcloud_web_access",
|
||||||
"outline_login",
|
"nextcloud_mail_integration",
|
||||||
"planka_login",
|
"nextcloud_desktop_app",
|
||||||
"keycloak_mfa_optional",
|
"nextcloud_mobile_app",
|
||||||
|
"budget_encryption_ack",
|
||||||
|
"firefly_password_rotated",
|
||||||
|
"wger_password_rotated",
|
||||||
|
"jellyfin_web_access",
|
||||||
|
"jellyfin_mobile_app",
|
||||||
|
"jellyfin_tv_setup",
|
||||||
)
|
)
|
||||||
|
|
||||||
ONBOARDING_OPTIONAL_STEPS: set[str] = {
|
ONBOARDING_OPTIONAL_STEPS: set[str] = {
|
||||||
"vaultwarden_browser_extension",
|
"element_mobile_app",
|
||||||
"vaultwarden_desktop_app",
|
"nextcloud_desktop_app",
|
||||||
"vaultwarden_mobile_app",
|
"nextcloud_mobile_app",
|
||||||
"elementx_setup",
|
"jellyfin_web_access",
|
||||||
"jellyfin_login",
|
"jellyfin_mobile_app",
|
||||||
"mail_client_setup",
|
"jellyfin_tv_setup",
|
||||||
"actual_login",
|
|
||||||
"outline_login",
|
|
||||||
"planka_login",
|
|
||||||
"keycloak_mfa_optional",
|
|
||||||
}
|
}
|
||||||
ONBOARDING_REQUIRED_STEPS: tuple[str, ...] = tuple(
|
ONBOARDING_REQUIRED_STEPS: tuple[str, ...] = (
|
||||||
step for step in ONBOARDING_STEPS if step not in ONBOARDING_OPTIONAL_STEPS
|
"vaultwarden_master_password",
|
||||||
|
"vaultwarden_browser_extension",
|
||||||
|
"vaultwarden_mobile_app",
|
||||||
|
"keycloak_password_rotated",
|
||||||
|
"element_recovery_key",
|
||||||
|
"element_recovery_key_stored",
|
||||||
|
"mail_client_setup",
|
||||||
|
"nextcloud_web_access",
|
||||||
|
"nextcloud_mail_integration",
|
||||||
|
"budget_encryption_ack",
|
||||||
|
"firefly_password_rotated",
|
||||||
|
"wger_password_rotated",
|
||||||
)
|
)
|
||||||
|
|
||||||
KEYCLOAK_MANAGED_STEPS: set[str] = {"keycloak_password_rotated"}
|
KEYCLOAK_MANAGED_STEPS: set[str] = {
|
||||||
_KEYCLOAK_MFA_OPTIONAL_STATE_ARTIFACT = "keycloak_mfa_optional_state"
|
"keycloak_password_rotated",
|
||||||
_KEYCLOAK_MFA_OPTIONAL_VALID_STATES = {"done", "skipped"}
|
"vaultwarden_master_password",
|
||||||
|
"nextcloud_mail_integration",
|
||||||
|
"firefly_password_rotated",
|
||||||
|
"wger_password_rotated",
|
||||||
|
}
|
||||||
_KEYCLOAK_PASSWORD_ROTATION_REQUESTED_ARTIFACT = "keycloak_password_rotation_requested_at"
|
_KEYCLOAK_PASSWORD_ROTATION_REQUESTED_ARTIFACT = "keycloak_password_rotation_requested_at"
|
||||||
|
|
||||||
|
ONBOARDING_STEP_PREREQUISITES: dict[str, set[str]] = {
|
||||||
def _sequential_prerequisites(
|
"vaultwarden_master_password": set(),
|
||||||
steps: tuple[str, ...],
|
"vaultwarden_browser_extension": {"vaultwarden_master_password"},
|
||||||
optional_steps: set[str],
|
"vaultwarden_mobile_app": {"vaultwarden_master_password"},
|
||||||
) -> dict[str, set[str]]:
|
"keycloak_password_rotated": {"vaultwarden_master_password"},
|
||||||
completed: list[str] = []
|
"element_recovery_key": {"keycloak_password_rotated"},
|
||||||
prerequisites: dict[str, set[str]] = {}
|
"element_recovery_key_stored": {"element_recovery_key"},
|
||||||
for step in steps:
|
"element_mobile_app": {"element_recovery_key"},
|
||||||
prerequisites[step] = set(completed)
|
"mail_client_setup": {"vaultwarden_master_password"},
|
||||||
if step not in optional_steps:
|
"nextcloud_web_access": {"vaultwarden_master_password"},
|
||||||
completed.append(step)
|
"nextcloud_mail_integration": {"nextcloud_web_access"},
|
||||||
return prerequisites
|
"nextcloud_desktop_app": {"nextcloud_web_access"},
|
||||||
|
"nextcloud_mobile_app": {"nextcloud_web_access"},
|
||||||
|
"budget_encryption_ack": {"nextcloud_mail_integration"},
|
||||||
ONBOARDING_STEP_PREREQUISITES: dict[str, set[str]] = _sequential_prerequisites(
|
"firefly_password_rotated": {"element_recovery_key_stored"},
|
||||||
ONBOARDING_STEPS,
|
"wger_password_rotated": {"firefly_password_rotated"},
|
||||||
ONBOARDING_OPTIONAL_STEPS,
|
"jellyfin_web_access": {"vaultwarden_master_password"},
|
||||||
)
|
"jellyfin_mobile_app": {"jellyfin_web_access"},
|
||||||
|
"jellyfin_tv_setup": {"jellyfin_web_access"},
|
||||||
|
}
|
||||||
|
|
||||||
_ELEMENT_RECOVERY_ARTIFACT = "element_recovery_key_sha256"
|
_ELEMENT_RECOVERY_ARTIFACT = "element_recovery_key_sha256"
|
||||||
_SHA256_HEX_RE = re.compile(r"^[0-9a-f]{64}$")
|
_SHA256_HEX_RE = re.compile(r"^[0-9a-f]{64}$")
|
||||||
|
_VAULTWARDEN_READY_STATUSES = {"already_present", "active", "ready"}
|
||||||
|
|
||||||
|
|
||||||
def _normalize_status(status: str) -> str:
|
def _normalize_status(status: str) -> str:
|
||||||
@ -243,6 +258,45 @@ def _password_rotation_requested(conn, request_code: str) -> bool:
|
|||||||
return bool(row)
|
return bool(row)
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_attr(attrs: Any, key: str) -> str:
|
||||||
|
if not isinstance(attrs, dict):
|
||||||
|
return ""
|
||||||
|
raw = attrs.get(key)
|
||||||
|
if isinstance(raw, list):
|
||||||
|
for item in raw:
|
||||||
|
if isinstance(item, str) and item.strip():
|
||||||
|
return item.strip()
|
||||||
|
return ""
|
||||||
|
if isinstance(raw, str) and raw.strip():
|
||||||
|
return raw.strip()
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def _auto_completed_service_steps(attrs: Any) -> set[str]:
|
||||||
|
completed: set[str] = set()
|
||||||
|
if not isinstance(attrs, dict):
|
||||||
|
return completed
|
||||||
|
|
||||||
|
vaultwarden_status = _extract_attr(attrs, "vaultwarden_status")
|
||||||
|
vaultwarden_master = _extract_attr(attrs, "vaultwarden_master_password_set_at")
|
||||||
|
if vaultwarden_master or vaultwarden_status in _VAULTWARDEN_READY_STATUSES:
|
||||||
|
completed.add("vaultwarden_master_password")
|
||||||
|
|
||||||
|
nextcloud_synced_at = _extract_attr(attrs, "nextcloud_mail_synced_at")
|
||||||
|
if nextcloud_synced_at:
|
||||||
|
completed.add("nextcloud_mail_integration")
|
||||||
|
|
||||||
|
firefly_rotated_at = _extract_attr(attrs, "firefly_password_rotated_at")
|
||||||
|
if firefly_rotated_at:
|
||||||
|
completed.add("firefly_password_rotated")
|
||||||
|
|
||||||
|
wger_rotated_at = _extract_attr(attrs, "wger_password_rotated_at")
|
||||||
|
if wger_rotated_at:
|
||||||
|
completed.add("wger_password_rotated")
|
||||||
|
|
||||||
|
return completed
|
||||||
|
|
||||||
|
|
||||||
def _auto_completed_keycloak_steps(conn, request_code: str, username: str) -> set[str]:
|
def _auto_completed_keycloak_steps(conn, request_code: str, username: str) -> set[str]:
|
||||||
if not username:
|
if not username:
|
||||||
return set()
|
return set()
|
||||||
@ -264,6 +318,9 @@ def _auto_completed_keycloak_steps(conn, request_code: str, username: str) -> se
|
|||||||
except Exception:
|
except Exception:
|
||||||
full = user if isinstance(user, dict) else {}
|
full = user if isinstance(user, dict) else {}
|
||||||
|
|
||||||
|
attrs = full.get("attributes") if isinstance(full, dict) else {}
|
||||||
|
completed |= _auto_completed_service_steps(attrs)
|
||||||
|
|
||||||
actions = full.get("requiredActions")
|
actions = full.get("requiredActions")
|
||||||
required_actions: set[str] = set()
|
required_actions: set[str] = set()
|
||||||
actions_list: list[str] = []
|
actions_list: list[str] = []
|
||||||
@ -352,26 +409,6 @@ def _advance_status(conn, request_code: str, username: str, status: str) -> str:
|
|||||||
return status
|
return status
|
||||||
|
|
||||||
|
|
||||||
def _fetch_optional_mfa_state(conn, request_code: str) -> str:
|
|
||||||
row = conn.execute(
|
|
||||||
"""
|
|
||||||
SELECT value_hash
|
|
||||||
FROM access_request_onboarding_artifacts
|
|
||||||
WHERE request_code = %s AND artifact = %s
|
|
||||||
""",
|
|
||||||
(request_code, _KEYCLOAK_MFA_OPTIONAL_STATE_ARTIFACT),
|
|
||||||
).fetchone()
|
|
||||||
if not row:
|
|
||||||
return "pending"
|
|
||||||
value = row.get("value_hash") if isinstance(row, dict) else None
|
|
||||||
if not isinstance(value, str):
|
|
||||||
return "pending"
|
|
||||||
cleaned = value.strip().lower()
|
|
||||||
if cleaned in _KEYCLOAK_MFA_OPTIONAL_VALID_STATES:
|
|
||||||
return cleaned
|
|
||||||
return "pending"
|
|
||||||
|
|
||||||
|
|
||||||
def _onboarding_payload(conn, request_code: str, username: str) -> dict[str, Any]:
|
def _onboarding_payload(conn, request_code: str, username: str) -> dict[str, Any]:
|
||||||
completed_steps = sorted(_completed_onboarding_steps(conn, request_code, username))
|
completed_steps = sorted(_completed_onboarding_steps(conn, request_code, username))
|
||||||
password_rotation_requested = _password_rotation_requested(conn, request_code)
|
password_rotation_requested = _password_rotation_requested(conn, request_code)
|
||||||
@ -382,11 +419,6 @@ def _onboarding_payload(conn, request_code: str, username: str) -> dict[str, Any
|
|||||||
"keycloak": {
|
"keycloak": {
|
||||||
"password_rotation_requested": password_rotation_requested,
|
"password_rotation_requested": password_rotation_requested,
|
||||||
},
|
},
|
||||||
"optional": {
|
|
||||||
"keycloak_mfa_optional": {
|
|
||||||
"state": _fetch_optional_mfa_state(conn, request_code),
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -1066,70 +1098,3 @@ def register(app) -> None:
|
|||||||
return jsonify({"error": "failed to request password rotation"}), 502
|
return jsonify({"error": "failed to request password rotation"}), 502
|
||||||
|
|
||||||
return jsonify({"ok": True, "status": status, "onboarding": onboarding_payload})
|
return jsonify({"ok": True, "status": status, "onboarding": onboarding_payload})
|
||||||
|
|
||||||
@app.route("/api/access/request/onboarding/mfa", methods=["POST"])
|
|
||||||
@require_auth
|
|
||||||
def request_access_onboarding_mfa_optional() -> Any:
|
|
||||||
if not configured():
|
|
||||||
return jsonify({"error": "server not configured"}), 503
|
|
||||||
|
|
||||||
payload = request.get_json(silent=True) or {}
|
|
||||||
code = (payload.get("request_code") or payload.get("code") or "").strip()
|
|
||||||
state = (payload.get("state") or "").strip().lower()
|
|
||||||
|
|
||||||
if not code:
|
|
||||||
return jsonify({"error": "request_code is required"}), 400
|
|
||||||
if state not in _KEYCLOAK_MFA_OPTIONAL_VALID_STATES:
|
|
||||||
return jsonify({"error": "invalid state"}), 400
|
|
||||||
|
|
||||||
username = getattr(g, "keycloak_username", "") or ""
|
|
||||||
if not username:
|
|
||||||
return jsonify({"error": "invalid token"}), 401
|
|
||||||
|
|
||||||
try:
|
|
||||||
with connect() as conn:
|
|
||||||
row = conn.execute(
|
|
||||||
"SELECT username, status FROM access_requests WHERE request_code = %s",
|
|
||||||
(code,),
|
|
||||||
).fetchone()
|
|
||||||
if not row:
|
|
||||||
return jsonify({"error": "not found"}), 404
|
|
||||||
if (row.get("username") or "") != username:
|
|
||||||
return jsonify({"error": "forbidden"}), 403
|
|
||||||
|
|
||||||
status = _normalize_status(row.get("status") or "")
|
|
||||||
if status not in {"awaiting_onboarding", "ready"}:
|
|
||||||
return jsonify({"error": "onboarding not available"}), 409
|
|
||||||
|
|
||||||
prerequisites = ONBOARDING_STEP_PREREQUISITES.get("keycloak_mfa_optional", set())
|
|
||||||
if prerequisites:
|
|
||||||
current_completed = _completed_onboarding_steps(conn, code, username)
|
|
||||||
missing = sorted(prerequisites - current_completed)
|
|
||||||
if missing:
|
|
||||||
return jsonify({"error": "step is blocked", "blocked_by": missing}), 409
|
|
||||||
|
|
||||||
conn.execute(
|
|
||||||
"""
|
|
||||||
INSERT INTO access_request_onboarding_artifacts (request_code, artifact, value_hash)
|
|
||||||
VALUES (%s, %s, %s)
|
|
||||||
ON CONFLICT (request_code, artifact) DO UPDATE
|
|
||||||
SET value_hash = EXCLUDED.value_hash,
|
|
||||||
created_at = NOW()
|
|
||||||
""",
|
|
||||||
(code, _KEYCLOAK_MFA_OPTIONAL_STATE_ARTIFACT, state),
|
|
||||||
)
|
|
||||||
conn.execute(
|
|
||||||
"""
|
|
||||||
INSERT INTO access_request_onboarding_steps (request_code, step)
|
|
||||||
VALUES (%s, %s)
|
|
||||||
ON CONFLICT (request_code, step) DO NOTHING
|
|
||||||
""",
|
|
||||||
(code, "keycloak_mfa_optional"),
|
|
||||||
)
|
|
||||||
|
|
||||||
status = _advance_status(conn, code, username, status)
|
|
||||||
onboarding_payload = _onboarding_payload(conn, code, username)
|
|
||||||
except Exception:
|
|
||||||
return jsonify({"error": "failed to update onboarding"}), 502
|
|
||||||
|
|
||||||
return jsonify({"ok": True, "status": status, "onboarding": onboarding_payload})
|
|
||||||
|
|||||||
@ -5,6 +5,7 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
"prebuild": "node scripts/build_media_manifest.mjs",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
|
|||||||
42
frontend/scripts/build_media_manifest.mjs
Normal file
42
frontend/scripts/build_media_manifest.mjs
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
import { promises as fs } from "fs";
|
||||||
|
import path from "path";
|
||||||
|
|
||||||
|
const ROOT = path.resolve("public", "media", "onboarding");
|
||||||
|
const MANIFEST = path.join(ROOT, "manifest.json");
|
||||||
|
const EXTENSIONS = new Set([".png", ".jpg", ".jpeg", ".webp"]);
|
||||||
|
|
||||||
|
async function walk(dir, base = "") {
|
||||||
|
const entries = await fs.readdir(dir, { withFileTypes: true });
|
||||||
|
const files = [];
|
||||||
|
for (const entry of entries) {
|
||||||
|
const full = path.join(dir, entry.name);
|
||||||
|
const rel = path.join(base, entry.name);
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
files.push(...(await walk(full, rel)));
|
||||||
|
} else if (EXTENSIONS.has(path.extname(entry.name).toLowerCase())) {
|
||||||
|
files.push(rel.replace(/\\/g, "/"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return files;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureDir(dir) {
|
||||||
|
await fs.mkdir(dir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
try {
|
||||||
|
await ensureDir(ROOT);
|
||||||
|
const files = await walk(ROOT).catch(() => []);
|
||||||
|
const payload = {
|
||||||
|
generated_at: new Date().toISOString(),
|
||||||
|
files: files.sort(),
|
||||||
|
};
|
||||||
|
await fs.writeFile(MANIFEST, JSON.stringify(payload, null, 2));
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to build onboarding media manifest", err);
|
||||||
|
process.exitCode = 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await main();
|
||||||
File diff suppressed because it is too large
Load Diff
0
media/onboarding/budget/step1_encrypt_data/.keep
Normal file
0
media/onboarding/budget/step1_encrypt_data/.keep
Normal file
0
media/onboarding/element/step1_web_access/.keep
Normal file
0
media/onboarding/element/step1_web_access/.keep
Normal file
0
media/onboarding/firefly/step1_web_access/.keep
Normal file
0
media/onboarding/firefly/step1_web_access/.keep
Normal file
0
media/onboarding/firefly/step2_mobile_app/.keep
Normal file
0
media/onboarding/firefly/step2_mobile_app/.keep
Normal file
0
media/onboarding/jellyfin/step1_web_access/.keep
Normal file
0
media/onboarding/jellyfin/step1_web_access/.keep
Normal file
0
media/onboarding/jellyfin/step2_mobile_app/.keep
Normal file
0
media/onboarding/jellyfin/step2_mobile_app/.keep
Normal file
0
media/onboarding/mail/step1_mail_app/.keep
Normal file
0
media/onboarding/mail/step1_mail_app/.keep
Normal file
0
media/onboarding/nextcloud/step1_web_access/.keep
Normal file
0
media/onboarding/nextcloud/step1_web_access/.keep
Normal file
0
media/onboarding/nextcloud/step4_mobile_app/.keep
Normal file
0
media/onboarding/nextcloud/step4_mobile_app/.keep
Normal file
0
media/onboarding/vaultwarden/step1_website/.keep
Normal file
0
media/onboarding/vaultwarden/step1_website/.keep
Normal file
0
media/onboarding/vaultwarden/step3_mobile_app/.keep
Normal file
0
media/onboarding/vaultwarden/step3_mobile_app/.keep
Normal file
0
media/onboarding/wger/step1_web_access/.keep
Normal file
0
media/onboarding/wger/step1_web_access/.keep
Normal file
0
media/onboarding/wger/step2_mobile_app/.keep
Normal file
0
media/onboarding/wger/step2_mobile_app/.keep
Normal file
Loading…
x
Reference in New Issue
Block a user