portal: refine onboarding vaultwarden flow
This commit is contained in:
parent
882a9ae513
commit
f973b64ac6
@ -148,6 +148,7 @@ def _verify_request(conn, code: str, token: str) -> str:
|
|||||||
|
|
||||||
ONBOARDING_STEPS: tuple[str, ...] = (
|
ONBOARDING_STEPS: tuple[str, ...] = (
|
||||||
"vaultwarden_master_password",
|
"vaultwarden_master_password",
|
||||||
|
"vaultwarden_store_temp_password",
|
||||||
"vaultwarden_browser_extension",
|
"vaultwarden_browser_extension",
|
||||||
"vaultwarden_mobile_app",
|
"vaultwarden_mobile_app",
|
||||||
"keycloak_password_rotated",
|
"keycloak_password_rotated",
|
||||||
@ -199,6 +200,7 @@ _KEYCLOAK_PASSWORD_ROTATION_REQUESTED_ARTIFACT = "keycloak_password_rotation_req
|
|||||||
|
|
||||||
ONBOARDING_STEP_PREREQUISITES: dict[str, set[str]] = {
|
ONBOARDING_STEP_PREREQUISITES: dict[str, set[str]] = {
|
||||||
"vaultwarden_master_password": set(),
|
"vaultwarden_master_password": set(),
|
||||||
|
"vaultwarden_store_temp_password": {"vaultwarden_master_password"},
|
||||||
"vaultwarden_browser_extension": {"vaultwarden_master_password"},
|
"vaultwarden_browser_extension": {"vaultwarden_master_password"},
|
||||||
"vaultwarden_mobile_app": {"vaultwarden_master_password"},
|
"vaultwarden_mobile_app": {"vaultwarden_master_password"},
|
||||||
"keycloak_password_rotated": {"vaultwarden_master_password"},
|
"keycloak_password_rotated": {"vaultwarden_master_password"},
|
||||||
@ -359,6 +361,23 @@ def _extract_attr(attrs: Any, key: str) -> str:
|
|||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def _vaultwarden_status_for_user(username: str) -> str:
|
||||||
|
if not username:
|
||||||
|
return ""
|
||||||
|
if not admin_client().ready():
|
||||||
|
return ""
|
||||||
|
try:
|
||||||
|
user = admin_client().find_user(username) or {}
|
||||||
|
user_id = user.get("id") if isinstance(user, dict) else None
|
||||||
|
if not isinstance(user_id, str) or not user_id:
|
||||||
|
return ""
|
||||||
|
full = admin_client().get_user(user_id)
|
||||||
|
attrs = full.get("attributes") if isinstance(full, dict) else {}
|
||||||
|
return _extract_attr(attrs, "vaultwarden_status")
|
||||||
|
except Exception:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
def _auto_completed_service_steps(attrs: Any) -> set[str]:
|
def _auto_completed_service_steps(attrs: Any) -> set[str]:
|
||||||
completed: set[str] = set()
|
completed: set[str] = set()
|
||||||
if not isinstance(attrs, dict):
|
if not isinstance(attrs, dict):
|
||||||
@ -486,7 +505,12 @@ def _advance_status(conn, request_code: str, username: str, status: str) -> str:
|
|||||||
|
|
||||||
if status == "awaiting_onboarding":
|
if status == "awaiting_onboarding":
|
||||||
completed = _completed_onboarding_steps(conn, request_code, username)
|
completed = _completed_onboarding_steps(conn, request_code, username)
|
||||||
if set(ONBOARDING_REQUIRED_STEPS).issubset(completed):
|
required_steps = set(ONBOARDING_REQUIRED_STEPS)
|
||||||
|
grandfathered, _ = _vaultwarden_grandfathered(conn, request_code, username)
|
||||||
|
vaultwarden_status = _vaultwarden_status_for_user(username)
|
||||||
|
if grandfathered and vaultwarden_status == "grandfathered":
|
||||||
|
required_steps.add("vaultwarden_store_temp_password")
|
||||||
|
if required_steps.issubset(completed):
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"UPDATE access_requests SET status = 'ready' WHERE request_code = %s AND status = 'awaiting_onboarding'",
|
"UPDATE access_requests SET status = 'ready' WHERE request_code = %s AND status = 'awaiting_onboarding'",
|
||||||
(request_code,),
|
(request_code,),
|
||||||
@ -501,8 +525,13 @@ def _onboarding_payload(conn, request_code: str, username: str) -> dict[str, Any
|
|||||||
password_rotation_requested = _password_rotation_requested(conn, request_code)
|
password_rotation_requested = _password_rotation_requested(conn, request_code)
|
||||||
grandfathered, contact_email = _vaultwarden_grandfathered(conn, request_code, username)
|
grandfathered, contact_email = _vaultwarden_grandfathered(conn, request_code, username)
|
||||||
recovery_email = _resolve_recovery_email(username, contact_email) if grandfathered else ""
|
recovery_email = _resolve_recovery_email(username, contact_email) if grandfathered else ""
|
||||||
|
vaultwarden_status = _vaultwarden_status_for_user(username)
|
||||||
|
vaultwarden_matched = grandfathered and vaultwarden_status == "grandfathered"
|
||||||
|
required_steps = list(ONBOARDING_REQUIRED_STEPS)
|
||||||
|
if vaultwarden_matched:
|
||||||
|
required_steps.append("vaultwarden_store_temp_password")
|
||||||
return {
|
return {
|
||||||
"required_steps": list(ONBOARDING_REQUIRED_STEPS),
|
"required_steps": required_steps,
|
||||||
"optional_steps": sorted(ONBOARDING_OPTIONAL_STEPS),
|
"optional_steps": sorted(ONBOARDING_OPTIONAL_STEPS),
|
||||||
"completed_steps": completed_steps,
|
"completed_steps": completed_steps,
|
||||||
"keycloak": {
|
"keycloak": {
|
||||||
@ -511,6 +540,7 @@ def _onboarding_payload(conn, request_code: str, username: str) -> dict[str, Any
|
|||||||
"vaultwarden": {
|
"vaultwarden": {
|
||||||
"grandfathered": grandfathered,
|
"grandfathered": grandfathered,
|
||||||
"recovery_email": recovery_email,
|
"recovery_email": recovery_email,
|
||||||
|
"matched": vaultwarden_matched,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -85,8 +85,8 @@
|
|||||||
<div>
|
<div>
|
||||||
<h3>Onboarding</h3>
|
<h3>Onboarding</h3>
|
||||||
<p class="muted">
|
<p class="muted">
|
||||||
Onboarding is fully self-service. Work through each section in order; you can pause and return later. Vaultwarden
|
Onboarding is fully self-service. There are 8 steps for 8 services below. Each step has smaller tasks; press
|
||||||
comes first because it stores every credential that follows.
|
Confirm when you finish a task. You can pause and return later.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<span v-if="status !== 'ready'" class="pill mono pill-info">active</span>
|
<span v-if="status !== 'ready'" class="pill mono pill-info">active</span>
|
||||||
@ -189,38 +189,24 @@
|
|||||||
<p class="muted" v-if="step.description">{{ step.description }}</p>
|
<p class="muted" v-if="step.description">{{ step.description }}</p>
|
||||||
<p class="muted step-note" v-if="stepNote(step)">{{ stepNote(step) }}</p>
|
<p class="muted step-note" v-if="stepNote(step)">{{ stepNote(step) }}</p>
|
||||||
|
|
||||||
<div
|
|
||||||
v-if="step.id === 'vaultwarden_master_password' && vaultwardenGrandfathered"
|
|
||||||
class="claim-box"
|
|
||||||
>
|
|
||||||
<p class="muted">
|
|
||||||
Already have a Vaultwarden account? Claim it with
|
|
||||||
<span class="mono">{{ vaultwardenRecoveryEmail || "your recovery email" }}</span>.
|
|
||||||
This skips the invite flow and keeps your existing vault.
|
|
||||||
</p>
|
|
||||||
<span class="tooltip-wrap" :title="vaultwardenClaimHint">
|
|
||||||
<button
|
|
||||||
class="secondary"
|
|
||||||
type="button"
|
|
||||||
@click="claimVaultwarden"
|
|
||||||
:disabled="vaultwardenClaimDisabled"
|
|
||||||
>
|
|
||||||
{{ vaultwardenClaiming ? "Claiming..." : "Claim existing account" }}
|
|
||||||
</button>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ul v-if="step.bullets && step.bullets.length" class="step-bullets">
|
<ul v-if="step.bullets && step.bullets.length" class="step-bullets">
|
||||||
<li v-for="bullet in step.bullets" :key="bullet">{{ bullet }}</li>
|
<li v-for="bullet in step.bullets" :key="bullet">{{ bullet }}</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<div v-if="step.links && step.links.length" class="step-links">
|
<div v-if="step.links && step.links.length" class="step-links">
|
||||||
<a v-for="link in step.links" :key="link.href" :href="link.href" target="_blank" rel="noreferrer">
|
<a
|
||||||
|
v-for="link in step.links"
|
||||||
|
:key="link.href"
|
||||||
|
:href="link.href"
|
||||||
|
:title="link.href"
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
>
|
||||||
{{ link.text }}
|
{{ link.text }}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<details v-if="step.guide" class="guide-details">
|
<details v-if="step.guide" class="guide-details" :open="shouldOpenGuide(step, activeSection)">
|
||||||
<summary class="mono">Photo guide</summary>
|
<summary class="mono">Photo guide</summary>
|
||||||
<div v-if="guideGroups(step).length" class="guide-groups">
|
<div v-if="guideGroups(step).length" class="guide-groups">
|
||||||
<div v-for="group in guideGroups(step)" :key="group.id" class="guide-group">
|
<div v-for="group in guideGroups(step)" :key="group.id" class="guide-group">
|
||||||
@ -353,7 +339,6 @@ const guideShots = ref({});
|
|||||||
const guidePage = ref({});
|
const guidePage = ref({});
|
||||||
const lightboxShot = ref(null);
|
const lightboxShot = ref(null);
|
||||||
const confirmingStepId = ref("");
|
const confirmingStepId = ref("");
|
||||||
const vaultwardenClaiming = ref(false);
|
|
||||||
|
|
||||||
const showPasswordCard = computed(() => Boolean(initialPassword.value || initialPasswordRevealedAt.value));
|
const showPasswordCard = computed(() => Boolean(initialPassword.value || initialPasswordRevealedAt.value));
|
||||||
const passwordRevealLocked = computed(() => Boolean(!initialPassword.value && initialPasswordRevealedAt.value));
|
const passwordRevealLocked = computed(() => Boolean(!initialPassword.value && initialPasswordRevealedAt.value));
|
||||||
@ -362,10 +347,10 @@ const passwordRevealHint = computed(() =>
|
|||||||
? "This password was already revealed and cannot be shown again. Ask an admin to reset it if you missed it."
|
? "This password was already revealed and cannot be shown again. Ask an admin to reset it if you missed it."
|
||||||
: "",
|
: "",
|
||||||
);
|
);
|
||||||
const vaultwardenGrandfathered = computed(() => Boolean(onboarding.value?.vaultwarden?.grandfathered));
|
|
||||||
const vaultwardenRecoveryEmail = computed(() => onboarding.value?.vaultwarden?.recovery_email || "");
|
const vaultwardenRecoveryEmail = computed(() => onboarding.value?.vaultwarden?.recovery_email || "");
|
||||||
|
const vaultwardenMatched = computed(() => Boolean(onboarding.value?.vaultwarden?.matched));
|
||||||
const vaultwardenLoginEmail = computed(() => {
|
const vaultwardenLoginEmail = computed(() => {
|
||||||
if (vaultwardenGrandfathered.value) {
|
if (vaultwardenMatched.value) {
|
||||||
return vaultwardenRecoveryEmail.value || "your recovery email";
|
return vaultwardenRecoveryEmail.value || "your recovery email";
|
||||||
}
|
}
|
||||||
if (requestUsername.value) {
|
if (requestUsername.value) {
|
||||||
@ -374,20 +359,10 @@ const vaultwardenLoginEmail = computed(() => {
|
|||||||
return "your @bstein.dev address";
|
return "your @bstein.dev address";
|
||||||
});
|
});
|
||||||
const mailAddress = computed(() => (requestUsername.value ? `${requestUsername.value}@bstein.dev` : "your @bstein.dev address"));
|
const mailAddress = computed(() => (requestUsername.value ? `${requestUsername.value}@bstein.dev` : "your @bstein.dev address"));
|
||||||
const vaultwardenClaimDisabled = computed(
|
|
||||||
() =>
|
|
||||||
loading.value ||
|
|
||||||
vaultwardenClaiming.value ||
|
|
||||||
!auth.authenticated ||
|
|
||||||
isStepDone("vaultwarden_master_password") ||
|
|
||||||
isStepBlocked("vaultwarden_master_password"),
|
|
||||||
);
|
|
||||||
const vaultwardenClaimHint = computed(() =>
|
|
||||||
!auth.authenticated ? "Log in to claim an existing Vaultwarden account." : "",
|
|
||||||
);
|
|
||||||
|
|
||||||
const STEP_PREREQS = {
|
const STEP_PREREQS = {
|
||||||
vaultwarden_master_password: [],
|
vaultwarden_master_password: [],
|
||||||
|
vaultwarden_store_temp_password: ["vaultwarden_master_password"],
|
||||||
vaultwarden_browser_extension: ["vaultwarden_master_password"],
|
vaultwarden_browser_extension: ["vaultwarden_master_password"],
|
||||||
vaultwarden_mobile_app: ["vaultwarden_master_password"],
|
vaultwarden_mobile_app: ["vaultwarden_master_password"],
|
||||||
keycloak_password_rotated: ["vaultwarden_master_password"],
|
keycloak_password_rotated: ["vaultwarden_master_password"],
|
||||||
@ -428,7 +403,7 @@ const SECTION_DEFS = [
|
|||||||
],
|
],
|
||||||
links: [
|
links: [
|
||||||
{ href: "https://cloud.bstein.dev", text: "Nextcloud Mail" },
|
{ href: "https://cloud.bstein.dev", text: "Nextcloud Mail" },
|
||||||
{ href: "https://vault.bstein.dev", text: "vault.bstein.dev" },
|
{ href: "https://vault.bstein.dev", text: "Vaultwarden" },
|
||||||
],
|
],
|
||||||
guide: { service: "vaultwarden", step: "step1_website" },
|
guide: { service: "vaultwarden", step: "step1_website" },
|
||||||
},
|
},
|
||||||
@ -522,7 +497,7 @@ const SECTION_DEFS = [
|
|||||||
action: "checkbox",
|
action: "checkbox",
|
||||||
description:
|
description:
|
||||||
"Open Nextcloud, confirm you can access Files, Calendar, and Mail, and keep the tab handy during onboarding.",
|
"Open Nextcloud, confirm you can access Files, Calendar, and Mail, and keep the tab handy during onboarding.",
|
||||||
links: [{ href: "https://cloud.bstein.dev", text: "cloud.bstein.dev" }],
|
links: [{ href: "https://cloud.bstein.dev", text: "Nextcloud" }],
|
||||||
guide: { service: "nextcloud", step: "step1_web_access" },
|
guide: { service: "nextcloud", step: "step1_web_access" },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -568,7 +543,7 @@ const SECTION_DEFS = [
|
|||||||
"If you lose the key, your budget data cannot be recovered.",
|
"If you lose the key, your budget data cannot be recovered.",
|
||||||
],
|
],
|
||||||
links: [
|
links: [
|
||||||
{ href: "https://budget.bstein.dev", text: "budget.bstein.dev" },
|
{ href: "https://budget.bstein.dev", text: "Actual Budget" },
|
||||||
{ href: "https://vault.bstein.dev", text: "Vaultwarden" },
|
{ href: "https://vault.bstein.dev", text: "Vaultwarden" },
|
||||||
],
|
],
|
||||||
guide: { service: "budget", step: "step1_encrypt_data" },
|
guide: { service: "budget", step: "step1_encrypt_data" },
|
||||||
@ -588,7 +563,7 @@ const SECTION_DEFS = [
|
|||||||
description:
|
description:
|
||||||
"Sign in to money.bstein.dev with the credentials on your Account page, change the password, then confirm here.",
|
"Sign in to money.bstein.dev with the credentials on your Account page, change the password, then confirm here.",
|
||||||
links: [
|
links: [
|
||||||
{ href: "https://money.bstein.dev", text: "money.bstein.dev" },
|
{ href: "https://money.bstein.dev", text: "Firefly III" },
|
||||||
{ href: "/account", text: "Account credentials" },
|
{ href: "/account", text: "Account credentials" },
|
||||||
],
|
],
|
||||||
guide: { service: "firefly", step: "step1_web_access" },
|
guide: { service: "firefly", step: "step1_web_access" },
|
||||||
@ -620,7 +595,7 @@ const SECTION_DEFS = [
|
|||||||
description:
|
description:
|
||||||
"Sign in to health.bstein.dev with the credentials on your Account page, change the password, then confirm here.",
|
"Sign in to health.bstein.dev with the credentials on your Account page, change the password, then confirm here.",
|
||||||
links: [
|
links: [
|
||||||
{ href: "https://health.bstein.dev", text: "health.bstein.dev" },
|
{ href: "https://health.bstein.dev", text: "Wger" },
|
||||||
{ href: "/account", text: "Account credentials" },
|
{ href: "/account", text: "Account credentials" },
|
||||||
],
|
],
|
||||||
guide: { service: "wger", step: "step1_web_access" },
|
guide: { service: "wger", step: "step1_web_access" },
|
||||||
@ -651,7 +626,7 @@ const SECTION_DEFS = [
|
|||||||
action: "checkbox",
|
action: "checkbox",
|
||||||
description:
|
description:
|
||||||
"Sign in with your Atlas username/password (LDAP-backed).",
|
"Sign in with your Atlas username/password (LDAP-backed).",
|
||||||
links: [{ href: "https://stream.bstein.dev", text: "stream.bstein.dev" }],
|
links: [{ href: "https://stream.bstein.dev", text: "Jellyfin" }],
|
||||||
guide: { service: "jellyfin", step: "step1_web_access" },
|
guide: { service: "jellyfin", step: "step1_web_access" },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -674,7 +649,26 @@ const SECTION_DEFS = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const sections = computed(() => SECTION_DEFS);
|
const VAULTWARDEN_TEMP_STEP = {
|
||||||
|
id: "vaultwarden_store_temp_password",
|
||||||
|
title: "Store the temporary Keycloak password",
|
||||||
|
action: "confirm",
|
||||||
|
description:
|
||||||
|
"Save the temporary Keycloak password in Vaultwarden so you can rotate it later without losing access.",
|
||||||
|
links: [{ href: "https://vault.bstein.dev", text: "Vaultwarden" }],
|
||||||
|
guide: { service: "vaultwarden", step: "step1_website", tail: 4 },
|
||||||
|
};
|
||||||
|
|
||||||
|
const sections = computed(() =>
|
||||||
|
SECTION_DEFS.map((section) => {
|
||||||
|
if (section.id !== "vaultwarden") return section;
|
||||||
|
const steps = [...section.steps];
|
||||||
|
if (vaultwardenMatched.value) {
|
||||||
|
steps.splice(1, 0, VAULTWARDEN_TEMP_STEP);
|
||||||
|
}
|
||||||
|
return { ...section, steps };
|
||||||
|
}),
|
||||||
|
);
|
||||||
const activeSection = computed(() => sections.value.find((item) => item.id === activeSectionId.value));
|
const activeSection = computed(() => sections.value.find((item) => item.id === activeSectionId.value));
|
||||||
|
|
||||||
const nextSectionItem = computed(() => {
|
const nextSectionItem = computed(() => {
|
||||||
@ -758,6 +752,9 @@ function stepNote(step) {
|
|||||||
if (step.id === "vaultwarden_master_password") {
|
if (step.id === "vaultwarden_master_password") {
|
||||||
return `Vaultwarden uses an email login. Use ${vaultwardenLoginEmail.value} to sign in.`;
|
return `Vaultwarden uses an email login. Use ${vaultwardenLoginEmail.value} to sign in.`;
|
||||||
}
|
}
|
||||||
|
if (step.id === "vaultwarden_store_temp_password") {
|
||||||
|
return "Store the temporary Keycloak password in Vaultwarden so you can rotate it safely later.";
|
||||||
|
}
|
||||||
if (step.id === "firefly_password_rotated") {
|
if (step.id === "firefly_password_rotated") {
|
||||||
return `Firefly uses an email login. Use ${mailAddress.value} to sign in.`;
|
return `Firefly uses an email login. Use ${mailAddress.value} to sign in.`;
|
||||||
}
|
}
|
||||||
@ -858,7 +855,14 @@ function guideGroups(step) {
|
|||||||
const stepKey = step.guide.step;
|
const stepKey = step.guide.step;
|
||||||
const serviceShots = guideShots.value?.[service] || {};
|
const serviceShots = guideShots.value?.[service] || {};
|
||||||
const stepShots = serviceShots?.[stepKey] || {};
|
const stepShots = serviceShots?.[stepKey] || {};
|
||||||
return Object.values(stepShots);
|
const groups = Object.values(stepShots);
|
||||||
|
const take = step.guide.take || step.guide.tail || 0;
|
||||||
|
if (!take) return groups;
|
||||||
|
const useTail = Boolean(step.guide.tail);
|
||||||
|
return groups.map((group) => {
|
||||||
|
const shots = useTail ? group.shots.slice(-take) : group.shots.slice(0, take);
|
||||||
|
return { ...group, shots };
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function guideKey(step, group) {
|
function guideKey(step, group) {
|
||||||
@ -892,6 +896,14 @@ function guideShot(step, group) {
|
|||||||
return group.shots[guideIndex(step, group)] || {};
|
return group.shots[guideIndex(step, group)] || {};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function shouldOpenGuide(step, section) {
|
||||||
|
if (!step || !step.guide || !section) return false;
|
||||||
|
const first = section.steps.find(
|
||||||
|
(item) => item.guide && !isStepDone(item.id) && !isStepBlocked(item.id),
|
||||||
|
);
|
||||||
|
return Boolean(first && first.id === step.id);
|
||||||
|
}
|
||||||
|
|
||||||
function openLightbox(shot) {
|
function openLightbox(shot) {
|
||||||
if (!shot || !shot.url) return;
|
if (!shot || !shot.url) return;
|
||||||
lightboxShot.value = shot;
|
lightboxShot.value = shot;
|
||||||
@ -1095,18 +1107,6 @@ async function confirmStep(step) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function claimVaultwarden() {
|
|
||||||
if (isStepDone("vaultwarden_master_password") || isStepBlocked("vaultwarden_master_password")) return;
|
|
||||||
vaultwardenClaiming.value = true;
|
|
||||||
try {
|
|
||||||
await setStepCompletion("vaultwarden_master_password", true, { vaultwarden_claim: true });
|
|
||||||
} catch (err) {
|
|
||||||
error.value = err?.message || "Failed to claim Vaultwarden account";
|
|
||||||
} finally {
|
|
||||||
vaultwardenClaiming.value = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function runRotationCheck(service) {
|
async function runRotationCheck(service) {
|
||||||
if (!auth.authenticated) {
|
if (!auth.authenticated) {
|
||||||
throw new Error("Log in to update onboarding steps.");
|
throw new Error("Log in to update onboarding steps.");
|
||||||
@ -1408,8 +1408,8 @@ button.copy:disabled {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: 10px;
|
gap: 6px 10px;
|
||||||
flex-wrap: nowrap;
|
flex-wrap: wrap;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
@ -1592,30 +1592,29 @@ button.copy:disabled {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.step-links {
|
.step-links {
|
||||||
margin-top: 8px;
|
margin-top: 10px;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
justify-content: center;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.step-links a {
|
.step-links a {
|
||||||
color: var(--accent-cyan);
|
color: rgba(92, 214, 167, 0.95);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 6px 14px;
|
||||||
|
border: 1px solid rgba(92, 214, 167, 0.35);
|
||||||
|
background: rgba(92, 214, 167, 0.12);
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.step-links a:hover {
|
.step-links a:hover {
|
||||||
text-decoration: underline;
|
text-decoration: none;
|
||||||
}
|
background: rgba(92, 214, 167, 0.2);
|
||||||
|
|
||||||
.claim-box {
|
|
||||||
margin-top: 12px;
|
|
||||||
padding: 10px 12px;
|
|
||||||
border-radius: 12px;
|
|
||||||
border: 1px dashed rgba(255, 255, 255, 0.18);
|
|
||||||
background: rgba(0, 0, 0, 0.2);
|
|
||||||
display: grid;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.step-actions {
|
.step-actions {
|
||||||
@ -1672,7 +1671,7 @@ button.copy:disabled {
|
|||||||
.guide-shot figcaption {
|
.guide-shot figcaption {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 10px 12px 6px;
|
padding: 10px 12px 6px;
|
||||||
font-size: 17px;
|
font-size: 18px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--text-strong);
|
color: var(--text-strong);
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user