From e52506477d90142c3b3f14f4036b7bf31068ff4a Mon Sep 17 00:00:00 2001 From: Brad Stein Date: Thu, 22 Jan 2026 01:38:41 -0300 Subject: [PATCH] portal: add onboarding stepper + budget section --- .dockerignore | 1 - Dockerfile.frontend | 1 + .../atlas_portal/routes/access_requests.py | 239 +- frontend/package.json | 1 + frontend/scripts/build_media_manifest.mjs | 42 + frontend/src/views/OnboardingView.vue | 1948 ++++++++--------- .../budget/step1_encrypt_data/.keep | 0 .../onboarding/element/step1_web_access/.keep | 0 .../element/step2_record_recovery_key/.keep | 0 .../step3_mobile_app_and_qr_code_login/.keep | 0 .../onboarding/firefly/step1_web_access/.keep | 0 .../onboarding/firefly/step2_mobile_app/.keep | 0 .../jellyfin/step1_web_access/.keep | 0 .../jellyfin/step2_mobile_app/.keep | 0 .../jellyfin/step3_tv_integrations/.keep | 0 .../step3_tv_integrations/Apple/.keep | 0 .../jellyfin/step3_tv_integrations/LG/.keep | 0 .../jellyfin/step3_tv_integrations/Roku/.keep | 0 .../step3_tv_integrations/Samsung/.keep | 0 .../jellyfin/step3_tv_integrations/Xbox/.keep | 0 media/onboarding/mail/step1_mail_app/.keep | 0 .../nextcloud/step1_web_access/.keep | 0 .../nextcloud/step2_mail_integration/.keep | 0 .../nextcloud/step3_desktop_storage_app/.keep | 0 .../nextcloud/step4_mobile_app/.keep | 0 .../vaultwarden/step1_website/.keep | 0 .../vaultwarden/step2_browser_extension/.keep | 0 .../vaultwarden/step3_mobile_app/.keep | 0 media/onboarding/wger/step1_web_access/.keep | 0 media/onboarding/wger/step2_mobile_app/.keep | 0 30 files changed, 1083 insertions(+), 1149 deletions(-) create mode 100644 frontend/scripts/build_media_manifest.mjs create mode 100644 media/onboarding/budget/step1_encrypt_data/.keep create mode 100644 media/onboarding/element/step1_web_access/.keep create mode 100644 media/onboarding/element/step2_record_recovery_key/.keep create mode 100644 media/onboarding/element/step3_mobile_app_and_qr_code_login/.keep create mode 100644 media/onboarding/firefly/step1_web_access/.keep create mode 100644 media/onboarding/firefly/step2_mobile_app/.keep create mode 100644 media/onboarding/jellyfin/step1_web_access/.keep create mode 100644 media/onboarding/jellyfin/step2_mobile_app/.keep create mode 100644 media/onboarding/jellyfin/step3_tv_integrations/.keep create mode 100644 media/onboarding/jellyfin/step3_tv_integrations/Apple/.keep create mode 100644 media/onboarding/jellyfin/step3_tv_integrations/LG/.keep create mode 100644 media/onboarding/jellyfin/step3_tv_integrations/Roku/.keep create mode 100644 media/onboarding/jellyfin/step3_tv_integrations/Samsung/.keep create mode 100644 media/onboarding/jellyfin/step3_tv_integrations/Xbox/.keep create mode 100644 media/onboarding/mail/step1_mail_app/.keep create mode 100644 media/onboarding/nextcloud/step1_web_access/.keep create mode 100644 media/onboarding/nextcloud/step2_mail_integration/.keep create mode 100644 media/onboarding/nextcloud/step3_desktop_storage_app/.keep create mode 100644 media/onboarding/nextcloud/step4_mobile_app/.keep create mode 100644 media/onboarding/vaultwarden/step1_website/.keep create mode 100644 media/onboarding/vaultwarden/step2_browser_extension/.keep create mode 100644 media/onboarding/vaultwarden/step3_mobile_app/.keep create mode 100644 media/onboarding/wger/step1_web_access/.keep create mode 100644 media/onboarding/wger/step2_mobile_app/.keep diff --git a/.dockerignore b/.dockerignore index 4ab6ed1..3b21c46 100644 --- a/.dockerignore +++ b/.dockerignore @@ -4,7 +4,6 @@ node_modules frontend/node_modules frontend/dist frontend/.vite -media docs __pycache__ .venv diff --git a/Dockerfile.frontend b/Dockerfile.frontend index 9e7c60f..a2610e7 100644 --- a/Dockerfile.frontend +++ b/Dockerfile.frontend @@ -6,6 +6,7 @@ COPY frontend/package*.json ./ RUN npm ci --ignore-scripts COPY frontend/ ./ +COPY media/ ./public/media/ RUN npm run build # Runtime stage diff --git a/backend/atlas_portal/routes/access_requests.py b/backend/atlas_portal/routes/access_requests.py index 4e0b5f5..9740b05 100644 --- a/backend/atlas_portal/routes/access_requests.py +++ b/backend/atlas_portal/routes/access_requests.py @@ -148,66 +148,81 @@ def _verify_request(conn, code: str, token: str) -> str: ONBOARDING_STEPS: tuple[str, ...] = ( "vaultwarden_master_password", + "vaultwarden_browser_extension", + "vaultwarden_mobile_app", "keycloak_password_rotated", "element_recovery_key", "element_recovery_key_stored", - "firefly_login", - "health_data_notice", - "wger_login", - "vaultwarden_browser_extension", - "vaultwarden_desktop_app", - "vaultwarden_mobile_app", - "elementx_setup", - "jellyfin_login", + "element_mobile_app", "mail_client_setup", - "actual_login", - "outline_login", - "planka_login", - "keycloak_mfa_optional", + "nextcloud_web_access", + "nextcloud_mail_integration", + "nextcloud_desktop_app", + "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] = { - "vaultwarden_browser_extension", - "vaultwarden_desktop_app", - "vaultwarden_mobile_app", - "elementx_setup", - "jellyfin_login", - "mail_client_setup", - "actual_login", - "outline_login", - "planka_login", - "keycloak_mfa_optional", + "element_mobile_app", + "nextcloud_desktop_app", + "nextcloud_mobile_app", + "jellyfin_web_access", + "jellyfin_mobile_app", + "jellyfin_tv_setup", } -ONBOARDING_REQUIRED_STEPS: tuple[str, ...] = tuple( - step for step in ONBOARDING_STEPS if step not in ONBOARDING_OPTIONAL_STEPS +ONBOARDING_REQUIRED_STEPS: tuple[str, ...] = ( + "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_MFA_OPTIONAL_STATE_ARTIFACT = "keycloak_mfa_optional_state" -_KEYCLOAK_MFA_OPTIONAL_VALID_STATES = {"done", "skipped"} +KEYCLOAK_MANAGED_STEPS: set[str] = { + "keycloak_password_rotated", + "vaultwarden_master_password", + "nextcloud_mail_integration", + "firefly_password_rotated", + "wger_password_rotated", +} _KEYCLOAK_PASSWORD_ROTATION_REQUESTED_ARTIFACT = "keycloak_password_rotation_requested_at" - -def _sequential_prerequisites( - steps: tuple[str, ...], - optional_steps: set[str], -) -> dict[str, set[str]]: - completed: list[str] = [] - prerequisites: dict[str, set[str]] = {} - for step in steps: - prerequisites[step] = set(completed) - if step not in optional_steps: - completed.append(step) - return prerequisites - - -ONBOARDING_STEP_PREREQUISITES: dict[str, set[str]] = _sequential_prerequisites( - ONBOARDING_STEPS, - ONBOARDING_OPTIONAL_STEPS, -) +ONBOARDING_STEP_PREREQUISITES: dict[str, set[str]] = { + "vaultwarden_master_password": set(), + "vaultwarden_browser_extension": {"vaultwarden_master_password"}, + "vaultwarden_mobile_app": {"vaultwarden_master_password"}, + "keycloak_password_rotated": {"vaultwarden_master_password"}, + "element_recovery_key": {"keycloak_password_rotated"}, + "element_recovery_key_stored": {"element_recovery_key"}, + "element_mobile_app": {"element_recovery_key"}, + "mail_client_setup": {"vaultwarden_master_password"}, + "nextcloud_web_access": {"vaultwarden_master_password"}, + "nextcloud_mail_integration": {"nextcloud_web_access"}, + "nextcloud_desktop_app": {"nextcloud_web_access"}, + "nextcloud_mobile_app": {"nextcloud_web_access"}, + "budget_encryption_ack": {"nextcloud_mail_integration"}, + "firefly_password_rotated": {"element_recovery_key_stored"}, + "wger_password_rotated": {"firefly_password_rotated"}, + "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" _SHA256_HEX_RE = re.compile(r"^[0-9a-f]{64}$") +_VAULTWARDEN_READY_STATUSES = {"already_present", "active", "ready"} def _normalize_status(status: str) -> str: @@ -243,6 +258,45 @@ def _password_rotation_requested(conn, request_code: str) -> bool: 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]: if not username: return set() @@ -264,6 +318,9 @@ def _auto_completed_keycloak_steps(conn, request_code: str, username: str) -> se except Exception: 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") required_actions: set[str] = set() actions_list: list[str] = [] @@ -352,26 +409,6 @@ def _advance_status(conn, request_code: str, username: str, status: str) -> str: 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]: completed_steps = sorted(_completed_onboarding_steps(conn, request_code, username)) 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": { "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({"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}) diff --git a/frontend/package.json b/frontend/package.json index 55050ef..1231c14 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -5,6 +5,7 @@ "type": "module", "scripts": { "dev": "vite", + "prebuild": "node scripts/build_media_manifest.mjs", "build": "vite build", "preview": "vite preview" }, diff --git a/frontend/scripts/build_media_manifest.mjs b/frontend/scripts/build_media_manifest.mjs new file mode 100644 index 0000000..8994835 --- /dev/null +++ b/frontend/scripts/build_media_manifest.mjs @@ -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(); diff --git a/frontend/src/views/OnboardingView.vue b/frontend/src/views/OnboardingView.vue index 95f5039..77a11cf 100644 --- a/frontend/src/views/OnboardingView.vue +++ b/frontend/src/views/OnboardingView.vue @@ -17,7 +17,13 @@
- + @@ -36,7 +42,8 @@

Confirm your email

- Open the verification email from Atlas and click the link to confirm your address. After verification, an admin can approve your request. + Open the verification email from Atlas and click the link to confirm your address. After verification, an admin can + approve your request.

If you did not receive an email, return to @@ -52,9 +59,7 @@

Accounts building

-

- Your request is approved. Atlas is now preparing accounts and credentials. Check back in a minute. -

+

Your request is approved. Atlas is now preparing accounts and credentials. Check back in a minute.

Automation

@@ -75,410 +80,193 @@
-
+
-

Onboarding checklist

+
+

Onboarding

+

+ Onboarding is fully self-service. Work through each section in order; you can pause and return later. Vaultwarden + comes first because it stores every credential that follows. +

+
{{ status === "ready" ? "ready" : "in progress" }}
-

- Some steps are verified automatically from Keycloak (password). Others can't be verified yet — mark them complete once you're done. -

- -
-

Temporary password

-

- Use this password to log in for the first time (Nextcloud, Element). You won't be forced to change it - immediately — you'll rotate it after Vaultwarden is set up. This password is shown once — copy it now. If you - refresh this page, it may disappear. -

-
- Password - + + + +
+
+
+ Username + +
+ +
+ Temporary password +
+ + + +
+

- Log in at - sso.bstein.dev - or go directly to - cloud.bstein.dev. + Use the temporary password to sign in the first time (Nextcloud, Element). You will rotate it after Vaultwarden is + ready. Store the new password in Vaultwarden.

-
    -
  • - -

    - Open Passwords and set a strong master - password you won't forget. Your master password is the one password to rule all passwords: use a long passphrase - (64+ characters is a good target), and never write it down or share it with anyone. If you lose it, Atlas can't - recover your vault. If you can't sign in yet, check your Atlas mailbox in - Nextcloud Mail for the invite link. -

    -
    - Master password guidance -
      -
    • Prefer a multi-word passphrase over a single word.
    • -
    • Never store it in plaintext or share it with anyone.
    • -
    • If you forget it, Vaultwarden can’t decrypt your data.
    • -
    -
    -
  • - -
  • - -
    +
    +
    +
    +

    {{ activeSection.title }}

    +

    {{ activeSection.description }}

    +
    +
    + - Open Element -
    -

    - After Vaultwarden is ready, sign in to Element with the temporary password. Keycloak will prompt you to set - a new password and your name. Store the new password in Vaultwarden. Atlas will mark this step complete once - Keycloak no longer requires a password update. -

    -
  • - -
  • -
    -
    - Optional: enable MFA (TOTP) for Keycloak -

    - Add a second factor with a mobile authenticator app. This is optional and won't block the rest of onboarding. -

    -
    - {{ mfaPillLabel() }} -
    - -
    - -
    - -
    - Show app install QR codes -

    {{ mfaQrError }}

    -
    -
    - Aegis (Android) - Aegis Android app QR code - Open store -
    -
    - FreeOTP (iPhone) - FreeOTP iPhone app QR code - Open store -
    -
    -
    -
  • - -
  • - -
    - - -
    -

    - In Element, create a recovery key so you can restore encrypted history if you lose a device. Atlas stores only a - SHA-256 hash so the recovery key itself is never saved server-side. Open - Element settings → Encryption. -

    -
  • - -
  • - -

    Add the Element recovery key to Vaultwarden so it's stored safely.

    -
  • - -
  • - -

    - Open money.bstein.dev and sign in - with the credentials from your Account page. Change the password immediately and - save it in Vaultwarden. In the Abacus app, set the server URL to money.bstein.dev and log in once. -

    -
  • - -
  • - -

    - Wger 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. -

    -
  • - -
  • - -

    - Open health.bstein.dev and sign in - with the credentials shown on your Account page. Change the password immediately and - store it in Vaultwarden. In the mobile app, set the server to health.bstein.dev and sign in once. -

    -
  • - -
  • - -

    - Install Bitwarden in your browser and point it at vault.bstein.dev (Settings → Account → Environment → Self-hosted). - Bitwarden downloads. -

    -
  • - -
  • - -

    - Install the Bitwarden desktop app and set the server to vault.bstein.dev (Settings → Account → Environment → Self-hosted). - Bitwarden downloads. -

    -
  • - -
  • - -

    - Install the mobile app, set the server to vault.bstein.dev, and enable biometrics for fast unlock. - Bitwarden downloads. -

    -
  • - -
  • - -

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

    -
  • - -
  • - -

    - {{ step.description }} - {{ - step.primaryLink.text - }} - . -

    -
  • -
-
-
-

Mobile app guides

- step-by-step
-

- Each guide expands with screenshots. Set the server URL before logging in so everything points at Atlas. -

-
-
-
-

{{ guide.title }}

- {{ guide.app }} -
-

{{ guide.description }}

-
-
- {{ field.label }} - - - {{ field.value }} - - {{ field.value }} - + +
+
+
+
+ +
+ {{ step.title }} +
+ + {{ stepPillLabel(step) }} +
-
- Open + +

{{ step.description }}

+ +
    +
  • {{ bullet }}
  • +
+ + -
+ +
+ + Open Element +
+ +
+ + +
+ +
Photo guide -
-
- -
{{ shot.label }}
-
+
+
+

{{ group.title }}

+
+
+ +
{{ shot.label }}
+
+
+

Guide coming soon.

-
+
@@ -486,9 +274,7 @@

You're ready

Your Atlas account is provisioned and onboarding is complete. You can log in at - cloud.bstein.dev, - notes.bstein.dev, and - tasks.bstein.dev. + cloud.bstein.dev.

@@ -506,172 +292,331 @@ diff --git a/media/onboarding/budget/step1_encrypt_data/.keep b/media/onboarding/budget/step1_encrypt_data/.keep new file mode 100644 index 0000000..e69de29 diff --git a/media/onboarding/element/step1_web_access/.keep b/media/onboarding/element/step1_web_access/.keep new file mode 100644 index 0000000..e69de29 diff --git a/media/onboarding/element/step2_record_recovery_key/.keep b/media/onboarding/element/step2_record_recovery_key/.keep new file mode 100644 index 0000000..e69de29 diff --git a/media/onboarding/element/step3_mobile_app_and_qr_code_login/.keep b/media/onboarding/element/step3_mobile_app_and_qr_code_login/.keep new file mode 100644 index 0000000..e69de29 diff --git a/media/onboarding/firefly/step1_web_access/.keep b/media/onboarding/firefly/step1_web_access/.keep new file mode 100644 index 0000000..e69de29 diff --git a/media/onboarding/firefly/step2_mobile_app/.keep b/media/onboarding/firefly/step2_mobile_app/.keep new file mode 100644 index 0000000..e69de29 diff --git a/media/onboarding/jellyfin/step1_web_access/.keep b/media/onboarding/jellyfin/step1_web_access/.keep new file mode 100644 index 0000000..e69de29 diff --git a/media/onboarding/jellyfin/step2_mobile_app/.keep b/media/onboarding/jellyfin/step2_mobile_app/.keep new file mode 100644 index 0000000..e69de29 diff --git a/media/onboarding/jellyfin/step3_tv_integrations/.keep b/media/onboarding/jellyfin/step3_tv_integrations/.keep new file mode 100644 index 0000000..e69de29 diff --git a/media/onboarding/jellyfin/step3_tv_integrations/Apple/.keep b/media/onboarding/jellyfin/step3_tv_integrations/Apple/.keep new file mode 100644 index 0000000..e69de29 diff --git a/media/onboarding/jellyfin/step3_tv_integrations/LG/.keep b/media/onboarding/jellyfin/step3_tv_integrations/LG/.keep new file mode 100644 index 0000000..e69de29 diff --git a/media/onboarding/jellyfin/step3_tv_integrations/Roku/.keep b/media/onboarding/jellyfin/step3_tv_integrations/Roku/.keep new file mode 100644 index 0000000..e69de29 diff --git a/media/onboarding/jellyfin/step3_tv_integrations/Samsung/.keep b/media/onboarding/jellyfin/step3_tv_integrations/Samsung/.keep new file mode 100644 index 0000000..e69de29 diff --git a/media/onboarding/jellyfin/step3_tv_integrations/Xbox/.keep b/media/onboarding/jellyfin/step3_tv_integrations/Xbox/.keep new file mode 100644 index 0000000..e69de29 diff --git a/media/onboarding/mail/step1_mail_app/.keep b/media/onboarding/mail/step1_mail_app/.keep new file mode 100644 index 0000000..e69de29 diff --git a/media/onboarding/nextcloud/step1_web_access/.keep b/media/onboarding/nextcloud/step1_web_access/.keep new file mode 100644 index 0000000..e69de29 diff --git a/media/onboarding/nextcloud/step2_mail_integration/.keep b/media/onboarding/nextcloud/step2_mail_integration/.keep new file mode 100644 index 0000000..e69de29 diff --git a/media/onboarding/nextcloud/step3_desktop_storage_app/.keep b/media/onboarding/nextcloud/step3_desktop_storage_app/.keep new file mode 100644 index 0000000..e69de29 diff --git a/media/onboarding/nextcloud/step4_mobile_app/.keep b/media/onboarding/nextcloud/step4_mobile_app/.keep new file mode 100644 index 0000000..e69de29 diff --git a/media/onboarding/vaultwarden/step1_website/.keep b/media/onboarding/vaultwarden/step1_website/.keep new file mode 100644 index 0000000..e69de29 diff --git a/media/onboarding/vaultwarden/step2_browser_extension/.keep b/media/onboarding/vaultwarden/step2_browser_extension/.keep new file mode 100644 index 0000000..e69de29 diff --git a/media/onboarding/vaultwarden/step3_mobile_app/.keep b/media/onboarding/vaultwarden/step3_mobile_app/.keep new file mode 100644 index 0000000..e69de29 diff --git a/media/onboarding/wger/step1_web_access/.keep b/media/onboarding/wger/step1_web_access/.keep new file mode 100644 index 0000000..e69de29 diff --git a/media/onboarding/wger/step2_mobile_app/.keep b/media/onboarding/wger/step2_mobile_app/.keep new file mode 100644 index 0000000..e69de29 -- 2.47.2