fix: Cloudron enrollment + 2FA for fresh invite accounts
Invite acceptance (verified live on 8 agents): Pankow forms need
click + keyboard.type (fill() silently no-ops), submit button is
<div role="button"> "Set up" which starts disabled until the form
is valid.
2FA enablement: fresh accounts land on setupaccount.html ("Your
account is ready") and hash navigation cannot leave that page --
load the panel root first. Also match "Set up" (forced-enrollment
screen says "Set up passkey", profile page says "Setup").
Verified in the 8-agent run: coo/svp-knel/svp-tctc/vp-investing/
vp-trading/vp-compliance enrolled with TOTP. vp-secops and
vp-techcompliance ran pre-fix and need the --enable-2fa second pass.
This commit is contained in:
@@ -22,3 +22,4 @@ services:
|
||||
- ./provision-discourse-apikey.py:/app/provision-discourse-apikey.py:ro
|
||||
- ./provision-redmine.py:/app/provision-redmine.py:ro
|
||||
- ./merge-invites.py:/app/merge-invites.py:ro
|
||||
- ./dump-invite-page.py:/app/dump-invite-page.py:ro
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
dump-invite-page.py -- DOM dump of a Cloudron invite/setup page (pre-acceptance).
|
||||
|
||||
Usage:
|
||||
docker compose run --rm --entrypoint python3 \
|
||||
-e AGENT=vp-secops provision dump-invite-page.py
|
||||
"""
|
||||
|
||||
import os, sys, time
|
||||
from pathlib import Path
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
STATE_DIR = Path("/app/state")
|
||||
|
||||
AGENT = os.environ.get("AGENT", "vp-secops")
|
||||
|
||||
import yaml
|
||||
with open("/app/agents.yaml") as f:
|
||||
manifest = yaml.safe_load(f)
|
||||
agent = next(a for a in manifest["agents"] if a["name"] == AGENT)
|
||||
invite_url = agent["cloudron_invite"]
|
||||
|
||||
with sync_playwright() as p:
|
||||
browser = p.chromium.launch(headless=True)
|
||||
page = browser.new_context(viewport={"width": 1280, "height": 1024}).new_page()
|
||||
page.goto(invite_url, wait_until="networkidle", timeout=20000)
|
||||
page.wait_for_timeout(3000)
|
||||
|
||||
ts = time.strftime("%H%M%S")
|
||||
page.screenshot(path=str(STATE_DIR / f"invite-{AGENT}-{ts}.png"), full_page=True)
|
||||
elements = page.evaluate("""() => {
|
||||
const out = [];
|
||||
document.querySelectorAll('input, button, [role="button"], label, a, h1, h2, h3, form').forEach(el => {
|
||||
const tag = el.tagName.toLowerCase();
|
||||
const text = (el.textContent || '').trim().substring(0, 60);
|
||||
const id = el.id || '';
|
||||
const type = el.getAttribute('type') || '';
|
||||
const cls = (el.getAttribute('class') || '').substring(0, 50);
|
||||
const role = el.getAttribute('role') || '';
|
||||
const vis = el.offsetParent !== null;
|
||||
out.push(`<${tag}> id=${id} type=${type} role=${role} class=${cls} vis=${vis} text="${text}"`);
|
||||
});
|
||||
return out;
|
||||
}""")
|
||||
body = page.evaluate("() => document.body.innerText.substring(0, 400)")
|
||||
out = f"URL: {page.url}\n\nBODY:\n{body}\n\nELEMENTS:\n" + "\n".join(elements)
|
||||
(STATE_DIR / f"invite-{AGENT}-{ts}.txt").write_text(out)
|
||||
print(out)
|
||||
browser.close()
|
||||
+30
-15
@@ -119,28 +119,33 @@ def enroll_cloudron(
|
||||
log.info(f"[{name}] Generated password ({len(password)} chars)")
|
||||
|
||||
# Navigate to invite link
|
||||
# Proven selectors (session 3 DOM dump of setupaccount.html):
|
||||
# #inputUsername (prefilled from invite), #inputDisplayName,
|
||||
# #inputPassword, #inputPasswordRepeat,
|
||||
# submit = <div role="button"> "Set up" (disabled until form valid)
|
||||
page.goto(invite_url, wait_until="networkidle")
|
||||
page.wait_for_timeout(2000)
|
||||
|
||||
# Fill in the invite acceptance form using real Cloudron selectors
|
||||
page.wait_for_selector('#inputPassword', timeout=15000)
|
||||
page.wait_for_selector("#inputPassword", timeout=15000)
|
||||
|
||||
page.fill('#inputPassword', password)
|
||||
page.fill('#inputPasswordRepeat', password)
|
||||
# Pankow/Vue forms need click + keyboard.type, never fill()
|
||||
page.click("#inputDisplayName")
|
||||
page.keyboard.type(display_name)
|
||||
page.click("#inputPassword")
|
||||
page.keyboard.type(password)
|
||||
page.click("#inputPasswordRepeat")
|
||||
page.keyboard.type(password)
|
||||
|
||||
# Set display name if field exists
|
||||
display_field = page.query_selector('#inputDisplayName')
|
||||
if display_field:
|
||||
display_field.fill(display_name)
|
||||
|
||||
# Submit — Cloudron uses a button with class btn-primary or type submit
|
||||
submit = page.query_selector('button[type="submit"], button.btn-primary, button:has-text("Setup"), button:has-text("Create"), button:has-text("Accept")')
|
||||
if submit:
|
||||
submit.click()
|
||||
# Submit button is a div[role=button]; starts disabled, enables on valid input
|
||||
setup_btn = page.locator('[role="button"]:has-text("Set up")')
|
||||
page.wait_for_timeout(1000)
|
||||
if setup_btn.count() > 0 and setup_btn.first.is_visible():
|
||||
setup_btn.first.click()
|
||||
else:
|
||||
page.keyboard.press('Enter')
|
||||
log.warning(f"[{name}] Set up button not found/clickable, pressing Enter")
|
||||
page.keyboard.press("Enter")
|
||||
|
||||
page.wait_for_load_state("networkidle")
|
||||
page.wait_for_timeout(5000)
|
||||
log.info(f"[{name}] Invite accepted")
|
||||
|
||||
# Enable 2FA
|
||||
@@ -177,6 +182,12 @@ def enable_cloudron_2fa(page: Page, agent: dict, bw: BitwardenHelper) -> str:
|
||||
name = agent["name"]
|
||||
log.info(f"[{name}] Enabling 2FA on Cloudron account")
|
||||
|
||||
# Fresh accounts sit on setupaccount.html ("Your account is ready") --
|
||||
# hash navigation cannot leave that page. Load the panel root first.
|
||||
if "setupaccount" in page.url:
|
||||
page.goto(f"{CLOUDRON_BASE}/", wait_until="networkidle", timeout=20000)
|
||||
page.wait_for_timeout(3000)
|
||||
|
||||
page.evaluate('() => window.location.hash = "#/profile"')
|
||||
page.wait_for_timeout(3000)
|
||||
|
||||
@@ -198,10 +209,14 @@ def enable_cloudron_2fa(page: Page, agent: dict, bw: BitwardenHelper) -> str:
|
||||
return ""
|
||||
|
||||
# Step 1: Click "Setup" to start 2FA enrollment
|
||||
# (forced-enrollment screen says "Set up passkey"; profile says "Setup")
|
||||
setup_clicked = False
|
||||
for selector in [
|
||||
'text=Setup',
|
||||
'text=Set up',
|
||||
'[role="button"]:has-text("Setup")',
|
||||
'[role="button"]:has-text("Set up")',
|
||||
'button:has-text("Setup")',
|
||||
'button:has-text("Set up")',
|
||||
'a:has-text("Setup")',
|
||||
]:
|
||||
|
||||
Reference in New Issue
Block a user