diff --git a/php/public/desec-done.js b/php/public/desec-done.js new file mode 100644 index 00000000..d2b1d92b --- /dev/null +++ b/php/public/desec-done.js @@ -0,0 +1,12 @@ +"use strict"; + +// Rendered into the deSEC modal view (desec.twig) once a deSEC domain has been fully +// registered. The view lives inside an iframe opened by the containers page; the whole +// process is now done, so reload the parent window to show the updated containers page. +// When opened directly (not embedded), window.top === window, so this just reloads here. +(function () { + // Give the success message a brief moment so the user sees that it worked. + setTimeout(function () { + window.top.location.reload(); + }, 1500); +})(); diff --git a/php/public/desec-modal.js b/php/public/desec-modal.js new file mode 100644 index 00000000..5e575878 --- /dev/null +++ b/php/public/desec-modal.js @@ -0,0 +1,50 @@ +"use strict"; + +// Opens the deSEC registration flow (the /desec view) inside a modal iframe so the user can +// run the multi-step register -> verify -> domain process without leaving the containers +// page. The iframe re-navigates itself between steps; once a domain is registered, the +// /desec view reloads the parent window via desec-done.js, so this script only has to deal +// with opening and closing the modal. +(function () { + const modal = document.getElementById('desec-modal'); + const frame = document.getElementById('desec-frame'); + if (!modal || !frame) { + return; + } + + function openModal() { + // Load (or reload) the flow each time the modal is opened so it always reflects the + // current registration state on the server. + frame.src = 'desec'; + modal.hidden = false; + document.body.classList.add('modal-open'); + } + + function closeModal() { + modal.hidden = true; + document.body.classList.remove('modal-open'); + // Drop the iframe content so credentials are not left rendered in the background. + frame.src = 'about:blank'; + } + + document.querySelectorAll('[data-desec-open]').forEach((el) => { + el.addEventListener('click', openModal); + }); + document.querySelectorAll('[data-desec-close]').forEach((el) => { + el.addEventListener('click', closeModal); + }); + + // Close when clicking the dimmed backdrop (but not when clicking inside the dialog). + modal.addEventListener('click', (event) => { + if (event.target === modal) { + closeModal(); + } + }); + + // Close on Escape for keyboard users. + document.addEventListener('keydown', (event) => { + if (event.key === 'Escape' && !modal.hidden) { + closeModal(); + } + }); +})(); diff --git a/php/public/index.php b/php/public/index.php index 9c1a4e72..95f815fb 100644 --- a/php/public/index.php +++ b/php/public/index.php @@ -193,6 +193,25 @@ $app->get('/containers', function (Request $request, Response $response, array $ ])->withHeader('Cache-Control', 'no-store'); })->setName('profile'); +// Renders only the deSEC registration flow. The containers page opens this in a modal +// iframe so the user can run the multi-step register -> verify -> domain process (adjusting +// the inputs and re-submitting as needed) without reloading the whole page each time. Once a +// deSEC domain is configured the view tells the parent window to reload (see desec-done.js). +$app->get('/desec', function (Request $request, Response $response, array $args) use ($container) { + $view = Twig::fromRequest($request); + /** @var \AIO\Data\ConfigurationManager $configurationManager */ + $configurationManager = $container->get(\AIO\Data\ConfigurationManager::class); + return $view->render($response, 'desec.twig', [ + 'domain' => $configurationManager->domain, + 'desec_email' => $configurationManager->desecEmail, + 'desec_password' => $configurationManager->desecPassword, + 'is_desec_domain' => $configurationManager->isDesecDomain(), + 'desec_account_registered' => $configurationManager->isDesecAccountRegistered(), + 'desec_awaiting_verification' => $configurationManager->isDesecAwaitingVerification(), + // Do not cache the page as it shows credentials + ])->withHeader('Cache-Control', 'no-store'); +})->setName('desec'); + $app->get('/login', function (Request $request, Response $response, array $args) use ($container) { $view = Twig::fromRequest($request); /** @var \AIO\Docker\DockerActionManager $dockerActionManager */ diff --git a/php/public/style.css b/php/public/style.css index 840cab88..df817210 100644 --- a/php/public/style.css +++ b/php/public/style.css @@ -84,6 +84,7 @@ a:hover { } a.button, +button.button, input[type="submit"] { padding: 8px 16px; width: auto; @@ -100,11 +101,13 @@ input[type="submit"] { } a.button:focus, +button.button:focus, input[type="submit"]:focus { outline: 2px solid var(--color-main-border); } a.button:hover, +button.button:hover, input[type="submit"]:hover { background-color: var(--color-primary-element-hover); } @@ -490,6 +493,63 @@ input[type="checkbox"]:disabled:not(:checked) + label { background-color: var(--color-main-background); } +/* deSEC registration modal (containers page) */ +.modal { + position: fixed; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + background-color: rgba(0, 0, 0, 0.5); + z-index: 3; + padding: 1rem; +} + +.modal[hidden] { + display: none; +} + +.modal .modal-content { + position: relative; + width: min(640px, 100%); + max-height: calc(100vh - 4rem); + background-color: var(--color-main-background); + border-radius: var(--border-radius-large); + border: solid thin rgb(192, 192, 192); + box-shadow: 0 10px 40px rgba(0, 0, 0, 0.35); + overflow: hidden; +} + +.modal .modal-content iframe { + display: block; + width: 100%; + height: min(640px, calc(100vh - 4rem)); + border: 0; +} + +.modal .modal-close { + position: absolute; + top: 0.25rem; + right: 0.5rem; + z-index: 1; + background: transparent; + border: none; + font-size: 1.8rem; + line-height: 1; + cursor: pointer; + color: inherit; +} + +body.modal-open { + overflow: hidden; +} + +/* deSEC modal view body (desec.twig) */ +.desec-modal-body, +.desec-modal-done { + padding: 1.5rem; +} + .overlay-iframe { padding: 1rem; font-family: monospace, system-ui, -apple-system, 'Segoe UI', Roboto, Oxygen-Sans, Cantarell, Ubuntu, 'Helvetica Neue', 'Noto Sans', 'Liberation Sans', Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji'; diff --git a/php/src/Controller/DesecController.php b/php/src/Controller/DesecController.php index 192e5a68..5843a1fb 100644 --- a/php/src/Controller/DesecController.php +++ b/php/src/Controller/DesecController.php @@ -19,11 +19,14 @@ readonly class DesecController { $slug = (string)($request->getParsedBody()['desec_slug'] ?? ''); $password = (string)($request->getParsedBody()['desec_password'] ?? ''); // register() returns false when a new account was created and we are now awaiting - // the user's email verification. That is a normal state transition, not an error: - // reload the page (201 + Location) so the awaiting-verification UI renders and - // explains the next step, exactly like the fully-registered success path. + // the user's email verification. That is a normal state transition, not an error. + // The form is submitted from the /desec modal view (an iframe), so redirect back to + // that same view (201 + Location): the iframe re-renders the next step of the flow + // (awaiting verification, or the fully-registered success page, which then reloads + // the parent containers page). Re-rendering /desec — rather than the whole + // containers page — is what keeps the multi-step flow inside the modal. $this->desecManager->register($email, $slug, $password); - return $response->withStatus(201)->withHeader('Location', '.'); + return $response->withStatus(201)->withHeader('Location', 'desec'); } catch (\Exception $ex) { $response->getBody()->write($ex->getMessage()); return $response->withStatus(422); diff --git a/php/templates/containers.twig b/php/templates/containers.twig index 35465020..1f492499 100644 --- a/php/templates/containers.twig +++ b/php/templates/containers.twig @@ -132,7 +132,23 @@ {% endif %}
Hint: If the domain validation fails but you are completely sure that you've configured everything correctly, you may skip the domain validation by following this documentation.
- {% include 'includes/desec-register.twig' %} +deSEC offers free dynamic DNS subdomains under dedyn.io. AIO can register an account and a subdomain for you automatically. Click the button below to start; the registration runs in a small window so you can adjust the details and complete the email verification without losing this page. Once it's done, this page reloads with your new domain configured.
+ {% if desec_awaiting_verification or desec_account_registered %} +⚠️ A deSEC registration for {{ desec_email }} is already in progress but not finished yet. Click the button below to continue it.
+ + {% else %} + + {% endif %} +Your free domain {{ domain }} was registered successfully. Finishing up…
+ + {% else %} +deSEC offers free dynamic DNS subdomains under dedyn.io. AIO can register an account and a subdomain for you automatically. The caddy community container will be enabled as a reverse proxy, the dnsmasq container will be enabled for local DNS resolution, and the mastercontainer will keep your DNS record up to date automatically.
- {% if desec_awaiting_verification %} -⚠️ A deSEC account for {{ desec_email }} was requested but it could not be used yet. Please check your inbox: if deSEC sent you a verification email, click the link inside, then click the button below to finish setting up your domain.
-If this email address already had a deSEC account, no new account was created and no verification email was sent. In that case, enter your existing deSEC password below and try again to log in with it.
-Your deSEC login credentials (for desec.io): Email: {{ desec_email }}. Reveal deSEC password
{{ desec_password }}
Your deSEC account ({{ desec_email }}) was registered successfully but the domain could not be registered. Please enter a desired subdomain slug (the part before .dedyn.io) and try again, or leave it blank for a random one.
Your deSEC login credentials (for desec.io): Email: {{ desec_email }}. Reveal deSEC password
{{ desec_password }}
Please enter your email address. You can also enter a desired subdomain slug (the part before .dedyn.io); leave it blank for a random one.
If you already have a deSEC account for this email address, enter your deSEC password in the optional password field below to log in with it instead of creating a new account. When creating a new account, deSEC will email you a verification link that you must click before the domain can be set up.
- -Note: By submitting this form you agree to the deSEC terms of service. The registered domain and your deSEC account credentials are stored in the AIO configuration. After registration, set your router's DHCP DNS server to this machine's local IP address so LAN devices resolve the domain locally (see the dnsmasq documentation). Alternatively adjust the hosts files on your clients so that they can reach the server using the local ip-address.
- {% endif %} -deSEC offers free dynamic DNS subdomains under dedyn.io. AIO can register an account and a subdomain for you automatically. The caddy community container will be enabled as a reverse proxy, the dnsmasq container will be enabled for local DNS resolution, and the mastercontainer will keep your DNS record up to date automatically.
+{% if desec_awaiting_verification %} +⚠️ A deSEC account for {{ desec_email }} was requested but it could not be used yet. Please check your inbox: if deSEC sent you a verification email, click the link inside, then click the button below to finish setting up your domain.
+If this email address already had a deSEC account, no new account was created and no verification email was sent. In that case, enter your existing deSEC password below and try again to log in with it.
+Your deSEC login credentials (for desec.io): Email: {{ desec_email }}. Reveal deSEC password
{{ desec_password }}
Your deSEC account ({{ desec_email }}) was registered successfully but the domain could not be registered. Please enter a desired subdomain slug (the part before .dedyn.io) and try again, or leave it blank for a random one.
Your deSEC login credentials (for desec.io): Email: {{ desec_email }}. Reveal deSEC password
{{ desec_password }}
Please enter your email address. You can also enter a desired subdomain slug (the part before .dedyn.io); leave it blank for a random one.
If you already have a deSEC account for this email address, enter your deSEC password in the optional password field below to log in with it instead of creating a new account. When creating a new account, deSEC will email you a verification link that you must click before the domain can be set up.
+ +Note: By submitting this form you agree to the deSEC terms of service. The registered domain and your deSEC account credentials are stored in the AIO configuration. After registration, set your router's DHCP DNS server to this machine's local IP address so LAN devices resolve the domain locally (see the dnsmasq documentation). Alternatively adjust the hosts files on your clients so that they can reach the server using the local ip-address.
+{% endif %} diff --git a/php/tests/tests/desec-existing.spec.js b/php/tests/tests/desec-existing.spec.js index 22a05075..7c5af394 100644 --- a/php/tests/tests/desec-existing.spec.js +++ b/php/tests/tests/desec-existing.spec.js @@ -5,6 +5,9 @@ import { DESEC_MOCK_URL, logInToContainersPage } from './desec-helpers.js'; // password logs straight in and registers the domain in one step (no email-verification // round-trip). See desec-register.spec.js for the full setup notes. // +// As in the register flow, the form is driven inside the modal iframe (the /desec view); +// on success the iframe reloads the parent containers page. +// // This flow also ends by registering a domain, so it is run as its own CI step with the // deSEC state re-seeded beforehand (configuration.json must have no domain set for the // registration UI to render). @@ -28,13 +31,18 @@ test('deSEC existing-account login flow', async ({ page: setupPage }) => { const slug = `aio-existing-${Math.floor(Math.random() * 2147483647)}`; + // Open the deSEC registration entry point and launch the modal. await containersPage.getByText("Don't have a domain? Get a free one from deSEC").click(); - await containersPage.locator('input[name="desec_email"]').fill(email); - await containersPage.locator('input[name="desec_password"]').fill(password); - await containersPage.locator('input[name="desec_slug"]').first().fill(slug); await containersPage.getByRole('button', { name: 'Register free domain via deSEC' }).click(); - // Supplying a valid password logs straight in and registers the domain in one step. + const frame = containersPage.frameLocator('#desec-frame'); + await frame.locator('input[name="desec_email"]').fill(email); + await frame.locator('input[name="desec_password"]').fill(password); + await frame.locator('input[name="desec_slug"]').first().fill(slug); + await frame.getByRole('button', { name: 'Register free domain via deSEC' }).click(); + + // Supplying a valid password logs straight in and registers the domain in one step; the + // modal view then reloads the parent containers page. await expect(containersPage.getByRole('button', { name: 'Download and start containers' })).toBeVisible({ timeout: 60 * 1000 }); await expect( containersPage.getByText("Don't have a domain? Get a free one from deSEC"), diff --git a/php/tests/tests/desec-register.spec.js b/php/tests/tests/desec-register.spec.js index a9d760a7..8c4816b0 100644 --- a/php/tests/tests/desec-register.spec.js +++ b/php/tests/tests/desec-register.spec.js @@ -3,11 +3,16 @@ import { DESEC_MOCK_URL, logInToContainersPage } from './desec-helpers.js'; // Drives the real AIO interface through the full deSEC "register a free domain" flow // against the local mock (php/tests/desec-mock.mjs). The mastercontainer must be started -// with SKIP_DOMAIN_VALIDATION=false (so the deSEC registration form is rendered) and its -// configuration.json must point desec_api_base / desec_update_url at the mock (see the +// with SKIP_DOMAIN_VALIDATION=false (so the deSEC registration entry point is rendered) and +// its configuration.json must point desec_api_base / desec_update_url at the mock (see the // Playwright CI workflow). The mock's control endpoint is reachable from the test runner // on the host at DESEC_MOCK_URL (default http://localhost:8090). // +// The registration runs inside a modal iframe (the /desec view): the multi-step +// register -> verify -> domain flow re-renders inside the iframe so the user can adjust the +// details and complete email verification without reloading the whole containers page. Only +// once the domain is fully registered does the iframe reload the parent page. +// // This flow ends by registering a domain, which persists in configuration.json. It is // therefore run as its own CI step; the deSEC state is reset (re-seeded) before the // separate existing-account flow runs. @@ -23,33 +28,37 @@ test('deSEC register -> verify -> domain flow', async ({ page: setupPage }) => { const email = `e2e-${Math.floor(Math.random() * 2147483647)}@example.com`; const slug = `aio-e2e-${Math.floor(Math.random() * 2147483647)}`; - // Open the deSEC registration section. + // Open the deSEC registration entry point and launch the modal. await containersPage.getByText("Don't have a domain? Get a free one from deSEC").click(); + await containersPage.getByRole('button', { name: 'Register free domain via deSEC' }).click(); + + // The flow lives inside the modal iframe (the /desec view). + const frame = containersPage.frameLocator('#desec-frame'); // 1) Submit email only -> a new account is "created" (mock 202) and AIO asks the user - // to verify their email. This is a normal (non-error) state transition: the page - // reloads into the awaiting-verification UI, which renders inside