onboarding: relax confirm flow and add element guides
@ -9,13 +9,13 @@ import string
|
||||
from typing import Any
|
||||
from urllib.parse import quote
|
||||
|
||||
from flask import jsonify, request, g, redirect
|
||||
from flask import jsonify, request, redirect
|
||||
|
||||
import psycopg
|
||||
|
||||
from .. import ariadne_client
|
||||
from ..db import connect, configured
|
||||
from ..keycloak import admin_client, oidc_client, require_auth
|
||||
from ..keycloak import admin_client, oidc_client
|
||||
from ..mailer import MailerError, access_request_verification_body, send_text_email
|
||||
from ..rate_limit import rate_limit_allow
|
||||
from ..provisioning import provision_access_request, provision_tasks_complete
|
||||
@ -152,7 +152,6 @@ ONBOARDING_STEPS: tuple[str, ...] = (
|
||||
"vaultwarden_mobile_app",
|
||||
"keycloak_password_rotated",
|
||||
"element_recovery_key",
|
||||
"element_recovery_key_stored",
|
||||
"element_mobile_app",
|
||||
"mail_client_setup",
|
||||
"nextcloud_web_access",
|
||||
@ -181,7 +180,6 @@ ONBOARDING_REQUIRED_STEPS: tuple[str, ...] = (
|
||||
"vaultwarden_mobile_app",
|
||||
"keycloak_password_rotated",
|
||||
"element_recovery_key",
|
||||
"element_recovery_key_stored",
|
||||
"mail_client_setup",
|
||||
"nextcloud_web_access",
|
||||
"nextcloud_mail_integration",
|
||||
@ -204,7 +202,6 @@ ONBOARDING_STEP_PREREQUISITES: dict[str, set[str]] = {
|
||||
"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"},
|
||||
@ -212,15 +209,13 @@ ONBOARDING_STEP_PREREQUISITES: dict[str, set[str]] = {
|
||||
"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"},
|
||||
"firefly_password_rotated": {"element_recovery_key"},
|
||||
"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"}
|
||||
|
||||
|
||||
@ -959,14 +954,9 @@ def register(app) -> None:
|
||||
mark_done = completed
|
||||
|
||||
if mark_done:
|
||||
if step == "element_recovery_key":
|
||||
return (
|
||||
jsonify({"error": "step requires verification"}),
|
||||
400,
|
||||
)
|
||||
prerequisites = ONBOARDING_STEP_PREREQUISITES.get(step, set())
|
||||
if prerequisites:
|
||||
current_completed = _completed_onboarding_steps(conn, code, username)
|
||||
current_completed = _completed_onboarding_steps(conn, code, row.get("username") or "")
|
||||
missing = sorted(prerequisites - current_completed)
|
||||
if missing:
|
||||
return jsonify({"error": "step is blocked", "blocked_by": missing}), 409
|
||||
@ -989,15 +979,11 @@ def register(app) -> None:
|
||||
"DELETE FROM access_request_onboarding_steps WHERE request_code = %s AND step = %s",
|
||||
(code, step),
|
||||
)
|
||||
if step == "element_recovery_key":
|
||||
conn.execute(
|
||||
"DELETE FROM access_request_onboarding_artifacts WHERE request_code = %s AND artifact = %s",
|
||||
(code, _ELEMENT_RECOVERY_ARTIFACT),
|
||||
)
|
||||
|
||||
# Re-evaluate completion to update request status to ready if applicable.
|
||||
status = _advance_status(conn, code, username, status)
|
||||
onboarding_payload = _onboarding_payload(conn, code, username)
|
||||
request_username = row.get("username") or ""
|
||||
status = _advance_status(conn, code, request_username, status)
|
||||
onboarding_payload = _onboarding_payload(conn, code, request_username)
|
||||
except Exception:
|
||||
return jsonify({"error": "failed to update onboarding"}), 502
|
||||
|
||||
@ -1009,83 +995,7 @@ def register(app) -> None:
|
||||
}
|
||||
)
|
||||
|
||||
@app.route("/api/access/request/onboarding/element-recovery", methods=["POST"])
|
||||
@require_auth
|
||||
def request_access_onboarding_element_recovery() -> 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()
|
||||
sha256_hex = (payload.get("sha256") or payload.get("sha256_hex") or "").strip().lower()
|
||||
|
||||
if not code:
|
||||
return jsonify({"error": "request_code is required"}), 400
|
||||
if not sha256_hex:
|
||||
return jsonify({"error": "sha256 is required"}), 400
|
||||
if not _SHA256_HEX_RE.fullmatch(sha256_hex):
|
||||
return jsonify({"error": "invalid sha256"}), 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("element_recovery_key", 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, _ELEMENT_RECOVERY_ARTIFACT, sha256_hex),
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO access_request_onboarding_steps (request_code, step)
|
||||
VALUES (%s, %s)
|
||||
ON CONFLICT (request_code, step) DO NOTHING
|
||||
""",
|
||||
(code, "element_recovery_key"),
|
||||
)
|
||||
|
||||
status = _advance_status(conn, code, username, status)
|
||||
onboarding_payload = _onboarding_payload(conn, code, username)
|
||||
except Exception:
|
||||
return jsonify({"error": "failed to verify element recovery key"}), 502
|
||||
|
||||
return jsonify(
|
||||
{
|
||||
"ok": True,
|
||||
"status": status,
|
||||
"onboarding": onboarding_payload,
|
||||
}
|
||||
)
|
||||
|
||||
@app.route("/api/access/request/onboarding/keycloak-password-rotate", methods=["POST"])
|
||||
@require_auth
|
||||
def request_access_onboarding_keycloak_password_rotate() -> Any:
|
||||
if not configured():
|
||||
return jsonify({"error": "server not configured"}), 503
|
||||
@ -1095,9 +1005,20 @@ def register(app) -> None:
|
||||
if not code:
|
||||
return jsonify({"error": "request_code is required"}), 400
|
||||
|
||||
username = getattr(g, "keycloak_username", "") or ""
|
||||
if not username:
|
||||
return jsonify({"error": "invalid token"}), 401
|
||||
token_username = ""
|
||||
bearer = request.headers.get("Authorization", "")
|
||||
if bearer:
|
||||
parts = bearer.split(None, 1)
|
||||
if len(parts) != 2 or parts[0].lower() != "bearer":
|
||||
return jsonify({"error": "invalid token"}), 401
|
||||
token = parts[1].strip()
|
||||
if not token:
|
||||
return jsonify({"error": "invalid token"}), 401
|
||||
try:
|
||||
claims = oidc_client().verify(token)
|
||||
except Exception:
|
||||
return jsonify({"error": "invalid token"}), 401
|
||||
token_username = claims.get("preferred_username") or ""
|
||||
|
||||
if not admin_client().ready():
|
||||
return jsonify({"error": "keycloak admin unavailable"}), 503
|
||||
@ -1110,7 +1031,8 @@ def register(app) -> None:
|
||||
).fetchone()
|
||||
if not row:
|
||||
return jsonify({"error": "not found"}), 404
|
||||
if (row.get("username") or "") != username:
|
||||
request_username = row.get("username") or ""
|
||||
if token_username and request_username != token_username:
|
||||
return jsonify({"error": "forbidden"}), 403
|
||||
|
||||
status = _normalize_status(row.get("status") or "")
|
||||
@ -1119,12 +1041,12 @@ def register(app) -> None:
|
||||
|
||||
prerequisites = ONBOARDING_STEP_PREREQUISITES.get("keycloak_password_rotated", set())
|
||||
if prerequisites:
|
||||
current_completed = _completed_onboarding_steps(conn, code, username)
|
||||
current_completed = _completed_onboarding_steps(conn, code, request_username)
|
||||
missing = sorted(prerequisites - current_completed)
|
||||
if missing:
|
||||
return jsonify({"error": "step is blocked", "blocked_by": missing}), 409
|
||||
|
||||
user = admin_client().find_user(username) or {}
|
||||
user = admin_client().find_user(request_username) or {}
|
||||
user_id = user.get("id") if isinstance(user, dict) else None
|
||||
if not isinstance(user_id, str) or not user_id:
|
||||
return jsonify({"error": "keycloak user not found"}), 409
|
||||
@ -1147,7 +1069,7 @@ def register(app) -> None:
|
||||
(code, _KEYCLOAK_PASSWORD_ROTATION_REQUESTED_ARTIFACT),
|
||||
)
|
||||
|
||||
onboarding_payload = _onboarding_payload(conn, code, username)
|
||||
onboarding_payload = _onboarding_payload(conn, code, request_username)
|
||||
except Exception:
|
||||
return jsonify({"error": "failed to request password rotation"}), 502
|
||||
|
||||
|
||||
@ -161,7 +161,7 @@
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="isStepDone(step.id)"
|
||||
:disabled="!auth.authenticated || loading || isStepBlocked(step.id)"
|
||||
:disabled="loading || isStepBlocked(step.id)"
|
||||
@change="toggleStep(step.id, $event)"
|
||||
/>
|
||||
<span>{{ step.title }}</span>
|
||||
@ -233,56 +233,7 @@
|
||||
<p v-else class="muted">Guide coming soon.</p>
|
||||
</details>
|
||||
|
||||
<div v-if="step.action === 'element_recovery'" class="recovery-verify">
|
||||
<input
|
||||
v-model="elementRecoveryKey"
|
||||
class="input mono"
|
||||
type="text"
|
||||
placeholder="Paste recovery key (hashed locally)"
|
||||
:disabled="!auth.authenticated || loading || isStepDone(step.id) || isStepBlocked(step.id)"
|
||||
/>
|
||||
<button
|
||||
class="primary verify"
|
||||
type="button"
|
||||
@click="verifyElementRecoveryKey"
|
||||
:disabled="
|
||||
!auth.authenticated ||
|
||||
loading ||
|
||||
isStepDone(step.id) ||
|
||||
isStepBlocked(step.id) ||
|
||||
!elementRecoveryKey.trim()
|
||||
"
|
||||
>
|
||||
Verify
|
||||
</button>
|
||||
<button
|
||||
class="secondary"
|
||||
type="button"
|
||||
@click="confirmStep(step)"
|
||||
:disabled="loading || isStepDone(step.id) || isStepBlocked(step.id) || !elementRecoveryKey.trim()"
|
||||
>
|
||||
{{ confirmLabel(step) }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-else class="step-actions">
|
||||
<template v-if="step.action === 'keycloak_rotate'">
|
||||
<button
|
||||
class="secondary"
|
||||
type="button"
|
||||
@click="requestKeycloakPasswordRotation"
|
||||
:disabled="
|
||||
!auth.authenticated ||
|
||||
loading ||
|
||||
isStepDone('keycloak_password_rotated') ||
|
||||
isStepBlocked('keycloak_password_rotated') ||
|
||||
keycloakPasswordRotationRequested
|
||||
"
|
||||
>
|
||||
Start Keycloak update
|
||||
</button>
|
||||
<a class="mono" href="https://live.bstein.dev" target="_blank" rel="noreferrer">Open Element</a>
|
||||
</template>
|
||||
<div class="step-actions">
|
||||
<button
|
||||
class="secondary"
|
||||
type="button"
|
||||
@ -361,7 +312,6 @@ const passwordCopied = ref(false);
|
||||
const usernameCopied = ref(false);
|
||||
const tasks = ref([]);
|
||||
const blocked = ref(false);
|
||||
const elementRecoveryKey = ref("");
|
||||
const keycloakPasswordRotationRequested = ref(false);
|
||||
const activeSectionId = ref("vaultwarden");
|
||||
const guideShots = ref({});
|
||||
@ -383,7 +333,6 @@ const STEP_PREREQS = {
|
||||
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"],
|
||||
@ -391,7 +340,7 @@ const STEP_PREREQS = {
|
||||
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"],
|
||||
firefly_password_rotated: ["element_recovery_key"],
|
||||
wger_password_rotated: ["firefly_password_rotated"],
|
||||
jellyfin_web_access: ["vaultwarden_master_password"],
|
||||
jellyfin_mobile_app: ["jellyfin_web_access"],
|
||||
@ -448,12 +397,12 @@ const SECTION_DEFS = [
|
||||
{
|
||||
id: "element",
|
||||
title: "Element",
|
||||
description: "Rotate your Keycloak password, create a recovery key, and store it safely.",
|
||||
description: "Secure chat, calls, and video for the lab.",
|
||||
steps: [
|
||||
{
|
||||
id: "keycloak_password_rotated",
|
||||
title: "Rotate your Keycloak password",
|
||||
action: "keycloak_rotate",
|
||||
title: "Connect to Element web",
|
||||
action: "confirm",
|
||||
description:
|
||||
"Sign in to Element with the temporary password. Keycloak will prompt you to set a new password. Store the new password in Vaultwarden.",
|
||||
links: [
|
||||
@ -464,18 +413,12 @@ const SECTION_DEFS = [
|
||||
},
|
||||
{
|
||||
id: "element_recovery_key",
|
||||
title: "Create and verify your recovery key",
|
||||
action: "element_recovery",
|
||||
title: "Create your recovery key",
|
||||
action: "confirm",
|
||||
description:
|
||||
"In Element settings → Encryption, create a recovery key so you can restore encrypted history if you lose a device.",
|
||||
"In Element settings → Encryption, create a recovery key and store it in Vaultwarden.",
|
||||
guide: { service: "element", step: "step2_record_recovery_key" },
|
||||
},
|
||||
{
|
||||
id: "element_recovery_key_stored",
|
||||
title: "Store the recovery key in Vaultwarden",
|
||||
action: "checkbox",
|
||||
description: "Save the recovery key in Vaultwarden so it never gets lost.",
|
||||
},
|
||||
{
|
||||
id: "element_mobile_app",
|
||||
title: "Optional: install Element X on mobile",
|
||||
@ -759,7 +702,8 @@ function stepCardClass(step) {
|
||||
function sectionProgress(section) {
|
||||
const requiredSteps = section.steps.filter((step) => isStepRequired(step.id));
|
||||
if (!requiredSteps.length) return "optional";
|
||||
const doneCount = requiredSteps.filter((step) => isStepDone(step.id)).length;
|
||||
if (isSectionLocked(section)) return `0/${requiredSteps.length} done`;
|
||||
const doneCount = requiredSteps.filter((step) => isStepDone(step.id) && !isStepBlocked(step.id)).length;
|
||||
return `${doneCount}/${requiredSteps.length} done`;
|
||||
}
|
||||
|
||||
@ -951,18 +895,22 @@ async function setStepCompletion(stepId, completed) {
|
||||
if (isStepBlocked(stepId)) {
|
||||
return;
|
||||
}
|
||||
if (stepId === "element_recovery_key") {
|
||||
return;
|
||||
}
|
||||
loading.value = true;
|
||||
error.value = "";
|
||||
try {
|
||||
const requester = auth.authenticated ? authFetch : fetch;
|
||||
const resp = await requester("/api/access/request/onboarding/attest", {
|
||||
let resp = await requester("/api/access/request/onboarding/attest", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ request_code: requestCode.value.trim(), step: stepId, completed }),
|
||||
});
|
||||
if (resp.status === 401 && requester === authFetch) {
|
||||
resp = await fetch("/api/access/request/onboarding/attest", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ request_code: requestCode.value.trim(), step: stepId, completed }),
|
||||
});
|
||||
}
|
||||
const data = await resp.json().catch(() => ({}));
|
||||
if (!resp.ok) throw new Error(data.error || resp.statusText || `status ${resp.status}`);
|
||||
status.value = data.status || status.value;
|
||||
@ -978,18 +926,15 @@ async function confirmStep(step) {
|
||||
if (!step || isStepBlocked(step.id) || isStepDone(step.id)) return;
|
||||
confirmingStepId.value = step.id;
|
||||
try {
|
||||
if (step.id === "keycloak_password_rotated") {
|
||||
await requestKeycloakPasswordRotation();
|
||||
await check();
|
||||
return;
|
||||
}
|
||||
if (step.action === "auto") {
|
||||
await check();
|
||||
return;
|
||||
}
|
||||
if (step.action === "keycloak_rotate") {
|
||||
await check();
|
||||
return;
|
||||
}
|
||||
if (step.action === "element_recovery") {
|
||||
await verifyElementRecoveryKey();
|
||||
return;
|
||||
}
|
||||
if (step.action === "confirm") {
|
||||
await check();
|
||||
if (!isStepDone(step.id)) {
|
||||
@ -1003,42 +948,9 @@ async function confirmStep(step) {
|
||||
}
|
||||
}
|
||||
|
||||
async function verifyElementRecoveryKey() {
|
||||
if (!auth.authenticated) {
|
||||
error.value = "Log in to verify your recovery key.";
|
||||
return;
|
||||
}
|
||||
if (isStepBlocked("element_recovery_key")) {
|
||||
error.value = "Complete earlier onboarding steps first.";
|
||||
return;
|
||||
}
|
||||
const raw = elementRecoveryKey.value.trim();
|
||||
if (!raw) return;
|
||||
|
||||
error.value = "";
|
||||
loading.value = true;
|
||||
try {
|
||||
const sha256 = await sha256Hex(raw);
|
||||
const resp = await authFetch("/api/access/request/onboarding/element-recovery", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ request_code: requestCode.value.trim(), sha256 }),
|
||||
});
|
||||
const data = await resp.json().catch(() => ({}));
|
||||
if (!resp.ok) throw new Error(data.error || resp.statusText || `status ${resp.status}`);
|
||||
status.value = data.status || status.value;
|
||||
onboarding.value = data.onboarding || onboarding.value;
|
||||
elementRecoveryKey.value = "";
|
||||
} catch (err) {
|
||||
error.value = err?.message || "Failed to verify recovery key";
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function requestKeycloakPasswordRotation() {
|
||||
if (!auth.authenticated) {
|
||||
error.value = "Log in to request password rotation.";
|
||||
if (!requestCode.value.trim()) {
|
||||
error.value = "Request code is missing.";
|
||||
return;
|
||||
}
|
||||
if (isStepBlocked("keycloak_password_rotated")) {
|
||||
@ -1050,11 +962,19 @@ async function requestKeycloakPasswordRotation() {
|
||||
loading.value = true;
|
||||
error.value = "";
|
||||
try {
|
||||
const resp = await authFetch("/api/access/request/onboarding/keycloak-password-rotate", {
|
||||
const requester = auth.authenticated ? authFetch : fetch;
|
||||
let resp = await requester("/api/access/request/onboarding/keycloak-password-rotate", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ request_code: requestCode.value.trim() }),
|
||||
});
|
||||
if (resp.status === 401 && requester === authFetch) {
|
||||
resp = await fetch("/api/access/request/onboarding/keycloak-password-rotate", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ request_code: requestCode.value.trim() }),
|
||||
});
|
||||
}
|
||||
const data = await resp.json().catch(() => ({}));
|
||||
if (!resp.ok) throw new Error(data.error || resp.statusText || `status ${resp.status}`);
|
||||
onboarding.value = data.onboarding || onboarding.value;
|
||||
@ -1067,14 +987,6 @@ async function requestKeycloakPasswordRotation() {
|
||||
}
|
||||
}
|
||||
|
||||
async function sha256Hex(value) {
|
||||
const data = new TextEncoder().encode(value);
|
||||
const hash = await crypto.subtle.digest("SHA-256", data);
|
||||
return Array.from(new Uint8Array(hash))
|
||||
.map((byte) => byte.toString(16).padStart(2, "0"))
|
||||
.join("");
|
||||
}
|
||||
|
||||
function parseManifest(files) {
|
||||
const grouped = {};
|
||||
for (const path of files) {
|
||||
@ -1507,7 +1419,13 @@ button.copy:disabled {
|
||||
}
|
||||
|
||||
.step-links a {
|
||||
color: var(--text-link);
|
||||
color: var(--accent-cyan);
|
||||
text-decoration: none;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.step-links a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.step-actions {
|
||||
@ -1554,21 +1472,28 @@ button.copy:disabled {
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
padding: 6px;
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
padding: 0;
|
||||
position: relative;
|
||||
cursor: zoom-in;
|
||||
}
|
||||
|
||||
.guide-shot figcaption {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
left: 8px;
|
||||
right: 8px;
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
margin: 0 4px;
|
||||
color: var(--text-strong);
|
||||
background: rgba(0, 0, 0, 0.65);
|
||||
padding: 4px 8px;
|
||||
border-radius: 8px;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.guide-shot img {
|
||||
width: 100%;
|
||||
border-radius: 8px;
|
||||
display: block;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.guide-pagination {
|
||||
@ -1654,12 +1579,12 @@ button.copy:disabled {
|
||||
}
|
||||
|
||||
.lightbox-card {
|
||||
width: min(1100px, 92vw);
|
||||
max-height: 92vh;
|
||||
width: min(1400px, 96vw);
|
||||
max-height: 94vh;
|
||||
background: rgba(10, 14, 24, 0.96);
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
border-radius: 16px;
|
||||
padding: 14px;
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
@ -1678,7 +1603,7 @@ button.copy:disabled {
|
||||
|
||||
.lightbox-card img {
|
||||
width: 100%;
|
||||
max-height: 74vh;
|
||||
max-height: 82vh;
|
||||
object-fit: contain;
|
||||
border-radius: 12px;
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
|
||||
|
After Width: | Height: | Size: 67 KiB |
|
After Width: | Height: | Size: 55 KiB |
|
After Width: | Height: | Size: 56 KiB |
|
After Width: | Height: | Size: 78 KiB |
|
After Width: | Height: | Size: 92 KiB |
|
After Width: | Height: | Size: 74 KiB |
|
After Width: | Height: | Size: 78 KiB |
|
After Width: | Height: | Size: 100 KiB |
|
After Width: | Height: | Size: 132 KiB |
|
After Width: | Height: | Size: 129 KiB |
|
After Width: | Height: | Size: 106 KiB |
|
After Width: | Height: | Size: 122 KiB |
|
After Width: | Height: | Size: 120 KiB |
|
After Width: | Height: | Size: 97 KiB |
|
After Width: | Height: | Size: 130 KiB |
|
After Width: | Height: | Size: 116 KiB |
|
After Width: | Height: | Size: 126 KiB |
|
Before Width: | Height: | Size: 53 KiB After Width: | Height: | Size: 53 KiB |