mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2026-11-09 04:06:42 +00:00
feat(web-app-mobilizon): converge the design and an administrator into Mobilizon
Mobilizon keeps its brand colors and media in its admin settings, so
tasks/01_design.yml, included on the stack host after config.exs is in
place, starts the container, waits until the node answers rpc and runs
two scripts through `mobilizon rpc`. files/elixir/design.exs sets the
primary and secondary color from the light palette and uploads logo and
favicon from the slots meta/services.yml declares; it records what it
set under the setting group infinito_design and removes only its own
values when design is off. files/elixir/administrator.exs ensures the
platform administrator: it finds the account by e-mail, realigns the
password of a local account, sets the administrator role, registers a
confirmed administrator when none exists and creates a profile, so the
admin pages and the gallery have an account with rights. The identity
comes from lookup('users', 'platform_administrator') and meta/users.yml
declares the user, as on feature/store-i18n.
templates/style.css.j2 and templates/design.js.j2 carry the tokens into
the web client; files/playwright/test-design.js checks tokens, public
pages, the footer frame and its focus stops, the provisioned
administrator reaching the administration, the instance colors, the
native theme switch, logo, favicon and title, and captures the gallery,
signing in through Keycloak with sso on and with ADMIN_EMAIL otherwise.
README.md and the persona note in templates/playwright.env.j2 describe
the account and why the shared persona flow still cannot use it with
sso off; TODO.md records the database login that stays open when the
email service is off.
Checked statically only: administrator.exs changed after the last green
design run, and the identity switch was never run live; the first live
run is the verification on the merged state.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
c9c792a365
commit
faee47c6b7
14 files changed
+3097
-5
No files matched your search
@@ -104,10 +104,11 @@ docker run --rm -it \
|
||||
## Further Resources
|
||||
|
||||
- [Mobilizon Official Website](https://mobilizon.org)
|
||||
- [Corporate design review: screenshots in light, dark, desktop and mobile](https://claude.ai/artifact/ES1gkz6jbb9ieuPaaAL27x)
|
||||
|
||||
## Persona contract opt-outs
|
||||
|
||||
[`templates/config.exs.j2`](./templates/config.exs.j2) wires Keycloak as the only login provider this role configures, and [`tasks/main.yml`](./tasks/main.yml) creates no Mobilizon account; `MOBILIZON_INSTANCE_DISABLE_DATABASE_LOGIN` ([`templates/env.j2`](./templates/env.j2)) additionally closes the database login whenever LDAP is on. In the `services.sso.enabled: false` matrix variants neither persona has credentials, so [`templates/playwright.env.j2`](./templates/playwright.env.j2) renders `PERSONA_BIBER_BLOCKED=true` and `PERSONA_ADMINISTRATOR_BLOCKED=true`. The `guest` persona and the baseline assertions run unconditionally.
|
||||
[`templates/config.exs.j2`](./templates/config.exs.j2) wires Keycloak as the only login provider this role configures, and the only Mobilizon account the role creates is the platform administrator in [`tasks/01_design.yml`](./tasks/01_design.yml), which the design spec signs in as; `MOBILIZON_INSTANCE_DISABLE_DATABASE_LOGIN` ([`templates/env.j2`](./templates/env.j2)) additionally closes the database login whenever LDAP is on. In the `services.sso.enabled: false` matrix variants biber has no account and the shared persona flow types `ADMIN_USERNAME` where Mobilizon's form takes the e-mail, so [`templates/playwright.env.j2`](./templates/playwright.env.j2) renders `PERSONA_BIBER_BLOCKED=true` and `PERSONA_ADMINISTRATOR_BLOCKED=true`. The `guest` persona and the baseline assertions run unconditionally.
|
||||
|
||||
## Credits
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
# TODO
|
||||
|
||||
- Drop the custom image build once the pinned Mobilizon release ships the PostgreSQL 18 migration fix (upstream merge request 1683, milestone 5.3.0). The build of `files/Dockerfile` fails on that release and names the migration that no longer needs the patch. Then delete `files/Dockerfile`, the `build` block in `templates/compose.yml.j2`, `MOBILIZON_IMAGE` and `MOBILIZON_VERSION` in `vars/main.yml`, and `custom: true` in `meta/services.yml`.
|
||||
- `templates/env.j2` renders `MOBILIZON_INSTANCE_DISABLE_DATABASE_LOGIN` inside the `{% if email.enabled %}` block, so a deploy with `sso` on and `email` off leaves the database login form open next to Keycloak (`config.exs.j2` defaults the key to `false`). Options: move the line out of the email block, or derive it in `config.exs.j2` from the SSO and LDAP flags.
|
||||
@@ -0,0 +1,48 @@
|
||||
Logger.put_process_level(self(), :error)
|
||||
%{"email" => email, "password" => password, "username" => username} = Jason.decode!(IO.read(:stdio, :eof))
|
||||
|
||||
{user, changes} =
|
||||
case Mobilizon.Users.get_user_by_email(email) do
|
||||
{:ok, user} ->
|
||||
stale_password =
|
||||
user.provider == nil and
|
||||
not (is_binary(user.password_hash) and Argon2.verify_pass(password, user.password_hash))
|
||||
|
||||
attrs =
|
||||
Map.merge(
|
||||
if(user.role == :administrator, do: %{}, else: %{role: :administrator}),
|
||||
if(stale_password, do: %{password: password}, else: %{})
|
||||
)
|
||||
|
||||
if attrs == %{} do
|
||||
{user, []}
|
||||
else
|
||||
{:ok, user} = Mobilizon.Users.update_user(user, attrs)
|
||||
{user, Map.keys(attrs)}
|
||||
end
|
||||
|
||||
{:error, :user_not_found} ->
|
||||
{:ok, user} =
|
||||
Mobilizon.Users.register(%{
|
||||
email: email,
|
||||
password: password,
|
||||
role: :administrator,
|
||||
confirmed_at: DateTime.utc_now(),
|
||||
confirmation_sent_at: nil,
|
||||
confirmation_token: nil
|
||||
})
|
||||
|
||||
{user, [:user]}
|
||||
end
|
||||
|
||||
changes =
|
||||
case Mobilizon.Users.get_actors_for_user(user) do
|
||||
[] ->
|
||||
Mix.Tasks.Mobilizon.Actors.Utils.create_profile(user, username, username)
|
||||
[:profile | changes]
|
||||
|
||||
_ ->
|
||||
changes
|
||||
end
|
||||
|
||||
IO.puts("MOBILIZON_ADMINISTRATOR=" <> if(changes == [], do: "unchanged", else: "changed"))
|
||||
@@ -0,0 +1,86 @@
|
||||
Logger.put_process_level(self(), :error)
|
||||
alias Mobilizon.Admin
|
||||
alias Mobilizon.Admin.SettingMedia
|
||||
alias Mobilizon.Storage.Repo
|
||||
|
||||
%{"colors" => colors, "medias" => medias} = Jason.decode!(IO.read(:stdio, :eof))
|
||||
mark_group = "infinito_design"
|
||||
|
||||
color_changes =
|
||||
for {name, wanted} <- colors, reduce: [] do
|
||||
acc ->
|
||||
mark = Admin.get_admin_setting_value(mark_group, name)
|
||||
current = Admin.get_admin_setting_value("instance", name)
|
||||
|
||||
cond do
|
||||
wanted != nil and current == wanted and mark == wanted ->
|
||||
acc
|
||||
|
||||
wanted != nil and current in [nil, mark, wanted] ->
|
||||
{:ok, _} = Admin.save_settings("instance", %{String.to_atom(name) => wanted})
|
||||
{:ok, _} = Admin.save_settings(mark_group, %{String.to_atom(name) => wanted})
|
||||
[name | acc]
|
||||
|
||||
wanted == nil and mark != nil and current == mark ->
|
||||
{:ok, _} = Admin.save_settings("instance", %{String.to_atom(name) => ""})
|
||||
{:ok, _} = Admin.save_settings(mark_group, %{String.to_atom(name) => ""})
|
||||
[name | acc]
|
||||
|
||||
wanted == nil and mark != nil ->
|
||||
{:ok, _} = Admin.save_settings(mark_group, %{String.to_atom(name) => ""})
|
||||
[name | acc]
|
||||
|
||||
true ->
|
||||
acc
|
||||
end
|
||||
end
|
||||
|
||||
{:ok, owner} = Mobilizon.Actors.get_or_create_internal_actor("relay")
|
||||
|
||||
media_changes =
|
||||
for {name, wanted} <- medias, reduce: [] do
|
||||
acc ->
|
||||
mark_name = String.to_atom(name <> "_url")
|
||||
mark = Admin.get_admin_setting_value(mark_group, Atom.to_string(mark_name))
|
||||
current = Admin.get_admin_setting_media("instance", name)
|
||||
current_url = current && current.file.url
|
||||
|
||||
cond do
|
||||
wanted != nil and current_url == wanted["url"] and mark == wanted["url"] ->
|
||||
acc
|
||||
|
||||
wanted != nil and (current == nil or current_url in [mark, wanted["url"]]) ->
|
||||
{:ok, media} =
|
||||
Mobilizon.Medias.create_media(%{
|
||||
file: %{url: wanted["url"], name: wanted["name"], content_type: wanted["content_type"]},
|
||||
metadata: %{width: wanted["width"], height: wanted["height"]},
|
||||
actor_id: owner.id
|
||||
})
|
||||
|
||||
Repo.insert!(%SettingMedia{group: "instance", name: name, media_id: media.id},
|
||||
on_conflict: {:replace, [:media_id, :updated_at]},
|
||||
conflict_target: [:group, :name]
|
||||
)
|
||||
|
||||
if current != nil, do: Repo.delete!(current)
|
||||
{:ok, _} = Admin.save_settings(mark_group, %{mark_name => wanted["url"]})
|
||||
[name | acc]
|
||||
|
||||
wanted == nil and mark != nil and current_url == mark ->
|
||||
Repo.delete!(Repo.get_by!(SettingMedia, group: "instance", name: name))
|
||||
Repo.delete!(current)
|
||||
{:ok, _} = Admin.save_settings(mark_group, %{mark_name => ""})
|
||||
[name | acc]
|
||||
|
||||
wanted == nil and mark != nil ->
|
||||
{:ok, _} = Admin.save_settings(mark_group, %{mark_name => ""})
|
||||
[name | acc]
|
||||
|
||||
true ->
|
||||
acc
|
||||
end
|
||||
end
|
||||
|
||||
changes = color_changes ++ media_changes
|
||||
if changes != [], do: Mobilizon.Config.clear_config_cache()
|
||||
IO.puts("MOBILIZON_DESIGN=" <> if(changes == [], do: "unchanged", else: "changed"))
|
||||
@@ -65,3 +65,5 @@ test("administrator: app → universal logout", async ({ page }) => {
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
require("./test-design");
|
||||
@@ -0,0 +1,406 @@
|
||||
const { test, expect } = require("@playwright/test");
|
||||
|
||||
const {
|
||||
assertDesignTokens,
|
||||
assertLightAndDark,
|
||||
assertReadable,
|
||||
assertToken,
|
||||
captureDesignGallery,
|
||||
galleryEnabled,
|
||||
tokenValue,
|
||||
} = require("./design");
|
||||
const { decodeDotenvQuotedValue, gotoOnion, normalizeBaseUrl } = require("./personas");
|
||||
const { performKeycloakLogin } = require("./personas/utils/keycloak");
|
||||
const { skipUnlessServiceEnabled } = require("./service-gating");
|
||||
const { resolveTimeout } = require("./timeouts");
|
||||
|
||||
const base = normalizeBaseUrl(process.env.APP_BASE_URL || "");
|
||||
const canonicalDomain = decodeDotenvQuotedValue(process.env.CANONICAL_DOMAIN || "");
|
||||
const adminEmail = decodeDotenvQuotedValue(process.env.ADMIN_EMAIL || "");
|
||||
const adminPassword = decodeDotenvQuotedValue(process.env.ADMIN_PASSWORD || "");
|
||||
const adminUsername = decodeDotenvQuotedValue(process.env.ADMIN_USERNAME || "");
|
||||
const faviconUrl = decodeDotenvQuotedValue(process.env.DESIGN_FAVICON_URL || "");
|
||||
const logoUrl = decodeDotenvQuotedValue(process.env.DESIGN_LOGO_URL || "");
|
||||
const title = decodeDotenvQuotedValue(process.env.DESIGN_TITLE || "");
|
||||
const ssoEnabled = process.env.SSO_SERVICE_ENABLED === "true";
|
||||
|
||||
const MODES = ["light", "dark"];
|
||||
const MAIN = "main#main";
|
||||
const NAVBAR = "#navbar";
|
||||
const FOOTER = "footer.bg-violet-2";
|
||||
const LOGIN_EMAIL = "#email";
|
||||
const LOGIN_PASSWORD = "#password";
|
||||
const LOGIN_SUBMIT = `${MAIN} form button[type=submit]`;
|
||||
const USER_MENU = "#user-menu-button";
|
||||
const TABLE_ROW = `${MAIN} table tbody tr`;
|
||||
const SHOWCASE_GROUP = "design_showcase";
|
||||
const SHOWCASE_EVENTS = ["Design showcase meetup", "Design showcase workshop"];
|
||||
|
||||
test.use({ ignoreHTTPSErrors: true, serviceWorkers: "block" });
|
||||
|
||||
/**
|
||||
* Args:
|
||||
* selector: element whose visibility marks the view as ready.
|
||||
*/
|
||||
function shown(selector) {
|
||||
return async (page) => {
|
||||
await expect(page.locator(selector).first()).toBeVisible({ timeout: resolveTimeout(10_000) });
|
||||
await expect(page.locator(".o-loading__overlay")).toHaveCount(0, { timeout: resolveTimeout(10_000) });
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Args:
|
||||
* page: Playwright page that ends signed in as the platform administrator, through Keycloak when sso is on.
|
||||
*/
|
||||
async function signIn(page) {
|
||||
await gotoOnion(page, `${base}/login`);
|
||||
if (ssoEnabled) {
|
||||
await page.locator(`${MAIN} a[href*='/auth/']`).first().click({ timeout: resolveTimeout(30_000) });
|
||||
await performKeycloakLogin(page, adminUsername, adminPassword, canonicalDomain);
|
||||
await gotoOnion(page, `${base}/`);
|
||||
} else {
|
||||
const email = page.locator(LOGIN_EMAIL);
|
||||
const password = page.locator(LOGIN_PASSWORD);
|
||||
await expect(email).toBeEditable({ timeout: resolveTimeout(30_000) });
|
||||
await email.fill(adminEmail);
|
||||
await expect(password).toBeEditable({ timeout: resolveTimeout(10_000) });
|
||||
await password.fill(adminPassword);
|
||||
await page.locator(LOGIN_SUBMIT).click();
|
||||
}
|
||||
await expect(page.locator(USER_MENU)).toBeVisible({ timeout: resolveTimeout(30_000) });
|
||||
}
|
||||
|
||||
/**
|
||||
* Args:
|
||||
* page: signed-in page whose access token authorizes the call.
|
||||
* query: GraphQL document.
|
||||
* variables: GraphQL variables.
|
||||
*/
|
||||
async function graphql(page, query, variables = {}) {
|
||||
const body = await page.evaluate(
|
||||
async ([document, values]) => {
|
||||
const response = await fetch("/api", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
authorization: `Bearer ${localStorage.getItem("auth-access-token")}`,
|
||||
},
|
||||
body: JSON.stringify({ query: document, variables: values }),
|
||||
});
|
||||
return response.json();
|
||||
},
|
||||
[query, variables],
|
||||
);
|
||||
expect(body.errors, JSON.stringify(body.errors)).toBeUndefined();
|
||||
return body.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Args:
|
||||
* page: page whose document receives a throwaway element carrying the given classes.
|
||||
* classes: class names of the app's compiled utilities to measure.
|
||||
* property: computed CSS property to read.
|
||||
*/
|
||||
async function utilityValue(page, classes, property) {
|
||||
return page.evaluate(
|
||||
([names, cssProperty]) => {
|
||||
const probe = document.createElement("span");
|
||||
probe.className = names;
|
||||
document.body.appendChild(probe);
|
||||
const value = getComputedStyle(probe).getPropertyValue(cssProperty);
|
||||
probe.remove();
|
||||
return value;
|
||||
},
|
||||
[classes, property],
|
||||
);
|
||||
}
|
||||
|
||||
test("design: corporate tokens apply in light and dark mode", async ({ page }) => {
|
||||
skipUnlessServiceEnabled("design");
|
||||
await gotoOnion(page, `${base}/about/instance`);
|
||||
await shown(`${MAIN} h1`)(page);
|
||||
await assertDesignTokens(page, "mobilizon");
|
||||
});
|
||||
|
||||
test("design: public pages take page, panel, text, field, primary action and footer frame from the palette", async ({
|
||||
page,
|
||||
}) => {
|
||||
skipUnlessServiceEnabled("design");
|
||||
const field = `${MAIN} input[type=email]`;
|
||||
const action = `${MAIN} form .btn-primary`;
|
||||
for (const mode of MODES) {
|
||||
await page.emulateMedia({ colorScheme: mode });
|
||||
await gotoOnion(page, `${base}/password-reset/send`);
|
||||
await shown(field)(page);
|
||||
await assertToken(page, "body", "background-color", "--design-surface-1", `page ${mode}`);
|
||||
await assertToken(page, NAVBAR, "background-color", "--design-surface-2", `navbar ${mode}`);
|
||||
await assertToken(page, `${MAIN} h1`, "color", "--design-text", `heading ${mode}`);
|
||||
await assertToken(page, field, "background-color", "--design-surface-2", `field ${mode}`);
|
||||
await assertToken(page, field, "border-top-color", "--design-border-strong", `field border ${mode}`);
|
||||
await assertToken(page, action, "background-color", "--design-primary", `primary action ${mode}`);
|
||||
await assertToken(page, action, "color", "--design-on-primary", `primary action text ${mode}`);
|
||||
await page.locator(action).hover();
|
||||
await assertToken(page, action, "background-color", "--design-primary-hover", `hovered primary action ${mode}`);
|
||||
await assertToken(page, FOOTER, "background-color", "--design-frame", `footer ${mode}`);
|
||||
await assertToken(page, `${FOOTER} a`, "color", "--design-on-frame", `footer link ${mode}`);
|
||||
await assertToken(page, FOOTER, "--design-focus", "--design-on-frame", `footer focus ${mode}`);
|
||||
}
|
||||
await page.emulateMedia({ colorScheme: null });
|
||||
await assertLightAndDark(page, `${MAIN} h1`, "mobilizon password reset");
|
||||
await assertReadable(page, [`${MAIN} h1`, `${MAIN} label`, action, `${FOOTER} a`], "mobilizon password reset");
|
||||
});
|
||||
|
||||
test("design: the about hero that upstream fills with the primary color sits on the frame", async ({ page }) => {
|
||||
skipUnlessServiceEnabled("design");
|
||||
const hero = `${MAIN} section.bg-primary`;
|
||||
for (const mode of MODES) {
|
||||
await page.emulateMedia({ colorScheme: mode });
|
||||
await gotoOnion(page, `${base}/about/instance`);
|
||||
await shown(`${hero} h1`)(page);
|
||||
await assertToken(page, hero, "background-color", "--design-frame", `about hero ${mode}`);
|
||||
await assertToken(page, `${hero} h1`, "color", "--design-on-frame", `about hero heading ${mode}`);
|
||||
}
|
||||
await page.emulateMedia({ colorScheme: null });
|
||||
await assertReadable(page, [`${hero} h1`], "mobilizon about hero");
|
||||
});
|
||||
|
||||
test("design: every focus stop in the footer frame draws its outline in the frame text color", async ({ page }) => {
|
||||
skipUnlessServiceEnabled("design");
|
||||
await gotoOnion(page, `${base}/about/instance`);
|
||||
await shown(FOOTER)(page);
|
||||
const expected = await tokenValue(page, "--design-on-frame", "outline-color");
|
||||
const inFooter = () => page.evaluate((footer) => !!document.activeElement?.closest(footer), FOOTER);
|
||||
for (let step = 0; step < 80 && !(await inFooter()); step += 1) {
|
||||
await page.keyboard.press("Tab");
|
||||
}
|
||||
let stops = 0;
|
||||
for (let step = 0; step < 30; step += 1) {
|
||||
const state = await page.evaluate((footer) => {
|
||||
const active = document.activeElement;
|
||||
if (!active || !active.closest(footer)) return null;
|
||||
const style = getComputedStyle(active);
|
||||
return { color: style.outlineColor, style: style.outlineStyle };
|
||||
}, FOOTER);
|
||||
if (state === null) break;
|
||||
stops += 1;
|
||||
expect(state.style, `footer focus stop ${stops} draws an outline`).not.toBe("none");
|
||||
expect(state.color, `footer focus stop ${stops} outline color`).toBe(expected);
|
||||
await page.keyboard.press("Tab");
|
||||
}
|
||||
expect(stops, "the footer frame holds focus stops").toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test("design: the provisioned administrator signs in and reaches the administration", async ({ page }) => {
|
||||
skipUnlessServiceEnabled("design");
|
||||
await signIn(page);
|
||||
await gotoOnion(page, `${base}/settings/admin/dashboard`);
|
||||
await shown(`${MAIN} h1`)(page);
|
||||
await expect(page.locator(`${MAIN} h1`).first()).not.toHaveText(/not found|404/i);
|
||||
});
|
||||
|
||||
test("design: the instance settings carry the light primary and the role stylesheet carries dark mode", async ({
|
||||
page,
|
||||
}) => {
|
||||
skipUnlessServiceEnabled("design");
|
||||
await signIn(page);
|
||||
for (const mode of MODES) {
|
||||
await page.emulateMedia({ colorScheme: mode });
|
||||
await gotoOnion(page, `${base}/settings/admin/users`);
|
||||
await shown(TABLE_ROW)(page);
|
||||
await expect(page.locator("html")).toHaveAttribute("style", /--custom-primary/, { timeout: resolveTimeout(10_000) });
|
||||
const primary = await tokenValue(page, "--design-primary", "background-color");
|
||||
if (mode === "light") {
|
||||
const custom = await page.evaluate(() => {
|
||||
const probe = document.createElement("span");
|
||||
probe.style.backgroundColor = getComputedStyle(document.documentElement).getPropertyValue("--custom-primary");
|
||||
document.body.appendChild(probe);
|
||||
const value = getComputedStyle(probe).backgroundColor;
|
||||
probe.remove();
|
||||
return value;
|
||||
});
|
||||
expect(custom, "the instance primary color equals the light --design-primary").toBe(primary);
|
||||
}
|
||||
for (const classes of ["bg-primary", "pagination-link-current", "switch-check-checked", "btn"]) {
|
||||
expect(await utilityValue(page, classes, "background-color"), `${classes} ${mode}`).toBe(primary);
|
||||
}
|
||||
expect(await utilityValue(page, "btn", "color"), `btn text ${mode}`).toBe(
|
||||
await tokenValue(page, "--design-on-primary", "color"),
|
||||
);
|
||||
expect(await utilityValue(page, "text-primary", "color"), `text-primary ${mode}`).toBe(
|
||||
await tokenValue(page, "--design-link", "color"),
|
||||
);
|
||||
await assertToken(page, `${TABLE_ROW}:nth-child(odd)`, "background-color", "--design-surface-2", `table ${mode}`);
|
||||
await assertToken(page, `${TABLE_ROW} td`, "border-bottom-color", "--design-border", `divider ${mode}`);
|
||||
await assertToken(page, `${MAIN} h1`, "color", "--design-text", `heading ${mode}`);
|
||||
}
|
||||
await page.emulateMedia({ colorScheme: null });
|
||||
await assertLightAndDark(page, TABLE_ROW, "mobilizon admin table");
|
||||
await assertReadable(page, [`${MAIN} h1`, `${TABLE_ROW} td`], "mobilizon admin table");
|
||||
});
|
||||
|
||||
test("design: the native theme switch drives the palette", async ({ page }) => {
|
||||
skipUnlessServiceEnabled("design");
|
||||
await signIn(page);
|
||||
await page.emulateMedia({ colorScheme: "light" });
|
||||
await gotoOnion(page, `${base}/settings/preferences`);
|
||||
await shown(`${MAIN} input[name=theme]`)(page);
|
||||
const html = page.locator("html");
|
||||
await expect(html).not.toHaveAttribute("data-design-theme", /.+/);
|
||||
const system = page.locator(`${MAIN} input[type=checkbox]`).first();
|
||||
await system.uncheck({ force: true });
|
||||
await page.locator(`${MAIN} input[name=theme][value=dark]`).check({ force: true });
|
||||
await expect(html).toHaveClass(/\bdark\b/);
|
||||
await expect(html).toHaveAttribute("data-design-theme", "dark");
|
||||
await gotoOnion(page, `${base}/settings/preferences`);
|
||||
await shown(`${MAIN} input[name=theme]`)(page);
|
||||
await expect(html).toHaveAttribute("data-design-theme", "dark");
|
||||
const surface = await tokenValue(page, "--design-surface-1", "background-color");
|
||||
await page.emulateMedia({ colorScheme: "dark" });
|
||||
expect(await tokenValue(page, "--design-surface-1", "background-color")).toBe(surface);
|
||||
await page.emulateMedia({ colorScheme: "light" });
|
||||
await assertToken(page, "body", "background-color", "--design-surface-1", "forced dark");
|
||||
await system.check({ force: true });
|
||||
await expect(html).not.toHaveAttribute("data-design-theme", /.+/);
|
||||
});
|
||||
|
||||
test("design: the navbar shows the generated logo and the instance carries the configured title and favicon", async ({
|
||||
page,
|
||||
}) => {
|
||||
skipUnlessServiceEnabled("design");
|
||||
await gotoOnion(page, `${base}/`);
|
||||
await shown(NAVBAR)(page);
|
||||
await expect(page).toHaveTitle(title, { timeout: resolveTimeout(10_000) });
|
||||
test.skip(!logoUrl, "logo replacement is disabled for this role");
|
||||
const logo = page.locator(`${NAVBAR} a[href='/'] img`);
|
||||
await expect(logo).toBeVisible({ timeout: resolveTimeout(10_000) });
|
||||
await expect(logo).toHaveAttribute("src", logoUrl);
|
||||
await expect.poll(() => logo.evaluate((image) => image.complete && image.naturalWidth > 0)).toBe(true);
|
||||
const box = await logo.boundingBox();
|
||||
expect(box.width, "the navbar lockup must be wider than high").toBeGreaterThan(box.height * 2);
|
||||
await expect(page.locator("link[rel=icon]").first()).toHaveAttribute("href", faviconUrl);
|
||||
});
|
||||
|
||||
/**
|
||||
* Args:
|
||||
* name: view name.
|
||||
* path: route below the app base URL.
|
||||
* selector: element whose visibility marks the view as ready.
|
||||
*/
|
||||
function view(name, path, selector) {
|
||||
return { name, url: `${base}${path}`, prepare: shown(selector) };
|
||||
}
|
||||
|
||||
function visitorViews() {
|
||||
const views = [
|
||||
view("home", "/", `${MAIN} h1, ${MAIN} h2`),
|
||||
view("search", "/search", `${MAIN} a[href*='/events/']`),
|
||||
view("categories", "/categories", `${MAIN} h1`),
|
||||
view("about-instance", "/about/instance", `${MAIN} h1`),
|
||||
view("rules", "/rules", `${MAIN} h1`),
|
||||
view("login", "/login", `${MAIN} h1`),
|
||||
view("password-reset", "/password-reset/send", `${MAIN} input[type=email]`),
|
||||
view("not-found", "/404", `${MAIN} h1`),
|
||||
];
|
||||
if (!ssoEnabled) {
|
||||
views.splice(6, 0, {
|
||||
name: "login-error",
|
||||
url: `${base}/login`,
|
||||
prepare: async (page) => {
|
||||
await shown(LOGIN_EMAIL)(page);
|
||||
await page.locator(LOGIN_EMAIL).fill("nobody@example.invalid");
|
||||
await page.locator(LOGIN_PASSWORD).fill(`wrong-${Date.now()}`);
|
||||
await page.locator(LOGIN_SUBMIT).click();
|
||||
await shown(`${MAIN} .notification-danger`)(page);
|
||||
},
|
||||
});
|
||||
}
|
||||
return views;
|
||||
}
|
||||
|
||||
/**
|
||||
* Args:
|
||||
* eventUuid: uuid of the seeded showcase event.
|
||||
*/
|
||||
function signedInViews(eventUuid) {
|
||||
return [
|
||||
view("event", `/events/${eventUuid}`, `${MAIN} h1`),
|
||||
view("my-events", "/events/me", `${MAIN} h1`),
|
||||
view("event-create", "/events/create", `${MAIN} form`),
|
||||
view("my-groups", "/groups/me", `${MAIN} h1`),
|
||||
view("group", `/@${SHOWCASE_GROUP}`, `${MAIN} h1`),
|
||||
view("group-create", "/groups/create", `${MAIN} form input`),
|
||||
view("settings-account", "/settings/account/general", `${MAIN} input[type=email]`),
|
||||
view("settings-preferences", "/settings/preferences", `${MAIN} input[name=theme]`),
|
||||
view("settings-notifications", "/settings/notifications", `${MAIN} input[type=checkbox]`),
|
||||
view("admin-dashboard", "/settings/admin/dashboard", `${MAIN} h1`),
|
||||
view("admin-settings", "/settings/admin/settings", `${MAIN} form`),
|
||||
view("admin-users", "/settings/admin/users", TABLE_ROW),
|
||||
view("admin-profiles", "/settings/admin/profiles", `${MAIN} table`),
|
||||
view("admin-groups", "/settings/admin/groups", `${MAIN} table`),
|
||||
view("moderation-reports", "/moderation/reports", `${MAIN} h1`),
|
||||
{
|
||||
name: "user-menu",
|
||||
url: `${base}/events/me`,
|
||||
prepare: async (page) => {
|
||||
await shown(USER_MENU)(page);
|
||||
await page.locator(USER_MENU).click();
|
||||
const panel = page.locator(".dropdown-menu").first();
|
||||
await expect(panel).toBeVisible({ timeout: resolveTimeout(10_000) });
|
||||
await expect
|
||||
.poll(() => panel.evaluate((element) => getComputedStyle(element).opacity === "1" && element.getAnimations({ subtree: true }).length === 0), {
|
||||
timeout: resolveTimeout(10_000),
|
||||
})
|
||||
.toBe(true);
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Args:
|
||||
* page: page signed in as the platform administrator; returns the uuid of the first showcase event.
|
||||
*/
|
||||
async function seedShowcase(page) {
|
||||
const me = await graphql(page, "{ loggedPerson { id } }");
|
||||
const actorId = me.loggedPerson.id;
|
||||
const group = await graphql(page, "query($name: String!) { group(preferredUsername: $name) { id } }", {
|
||||
name: SHOWCASE_GROUP,
|
||||
}).catch(() => ({ group: null }));
|
||||
if (!group.group) {
|
||||
await graphql(
|
||||
page,
|
||||
"mutation($name: String!, $title: String!) { createGroup(preferredUsername: $name, name: $title, summary: $title) { id } }",
|
||||
{ name: SHOWCASE_GROUP, title: "Design showcase" },
|
||||
);
|
||||
}
|
||||
const own = await graphql(page, "{ loggedPerson { organizedEvents(limit: 50) { elements { uuid title } } } }");
|
||||
const known = own.loggedPerson.organizedEvents.elements;
|
||||
for (const [index, eventTitle] of SHOWCASE_EVENTS.entries()) {
|
||||
if (known.some((event) => event.title === eventTitle)) continue;
|
||||
const beginsOn = new Date(Date.now() + (index + 3) * 86_400_000).toISOString();
|
||||
await graphql(
|
||||
page,
|
||||
"mutation($actor: ID!, $title: String!, $beginsOn: DateTime!, $description: String!) { createEvent(organizerActorId: $actor, title: $title, beginsOn: $beginsOn, description: $description) { uuid } }",
|
||||
{ actor: actorId, title: eventTitle, beginsOn, description: `<p>${eventTitle} of the corporate design review.</p>` },
|
||||
);
|
||||
}
|
||||
const seeded = await graphql(page, "{ loggedPerson { organizedEvents(limit: 50) { elements { uuid title } } } }");
|
||||
return seeded.loggedPerson.organizedEvents.elements.find((event) => event.title === SHOWCASE_EVENTS[0]).uuid;
|
||||
}
|
||||
|
||||
test("design: gallery of public pages, account, settings and administration", async ({ page, browser }) => {
|
||||
skipUnlessServiceEnabled("design");
|
||||
test.skip(!galleryEnabled(), "INFINITO_PLAYWRIGHT_KEEP is not true");
|
||||
test.setTimeout(resolveTimeout(840_000));
|
||||
const failures = [];
|
||||
const seeder = await browser.newContext({ ignoreHTTPSErrors: true, serviceWorkers: "block" });
|
||||
const seedPage = await seeder.newPage();
|
||||
await signIn(seedPage);
|
||||
const eventUuid = await seedShowcase(seedPage);
|
||||
await seeder.close();
|
||||
await captureDesignGallery(page, visitorViews()).catch((error) => failures.push(error.message));
|
||||
await signIn(page);
|
||||
await captureDesignGallery(page, signedInViews(eventUuid)).catch((error) => failures.push(error.message));
|
||||
expect(failures, failures.join("\n")).toEqual([]);
|
||||
});
|
||||
@@ -65,7 +65,13 @@ design:
|
||||
bond: 1
|
||||
enabled: "{{ 'web-svc-design' in group_names }}"
|
||||
shared: "{{ 'web-svc-design' in group_names }}"
|
||||
title: Mobilizon on {{ DOMAIN_PRIMARY | upper }}
|
||||
slots:
|
||||
logo:
|
||||
width: 160
|
||||
height: 48
|
||||
favicon:
|
||||
width: 152
|
||||
height: 152
|
||||
prometheus:
|
||||
bond: 1
|
||||
enabled: "{{ 'web-app-prometheus' in group_names }}"
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
---
|
||||
platform_administrator: {}
|
||||
@@ -0,0 +1,62 @@
|
||||
---
|
||||
- name: "▶️ MOBILIZON | Start the container with the rendered configuration"
|
||||
ansible.builtin.meta: flush_handlers
|
||||
|
||||
- name: "🔍 MOBILIZON | Resolve hosting node + container id"
|
||||
ansible.builtin.include_tasks: "{{ lookup('path_absolute', 'roles/sys-svc-compose/tasks/utils/swarm/resolve_host_cid.yml') }}"
|
||||
vars:
|
||||
resolve_service_key: mobilizon
|
||||
resolve_output_prefix: MOBILIZON
|
||||
resolve_compose_cid: "{{ mobilizon_container }}"
|
||||
|
||||
- name: "⏳ MOBILIZON | Wait until the running node answers rpc"
|
||||
ansible.builtin.command:
|
||||
argv:
|
||||
- container
|
||||
- exec
|
||||
- "{{ MOBILIZON_LOCAL_CID }}" # nocheck: container-exec-resolver MOBILIZON_LOCAL_CID resolved at delegate_to runtime
|
||||
- /bin/mobilizon
|
||||
- rpc
|
||||
- IO.puts(:ok)
|
||||
register: mobilizon_rpc_probe
|
||||
until: mobilizon_rpc_probe.rc == 0
|
||||
retries: 60
|
||||
delay: 5
|
||||
changed_when: false
|
||||
delegate_to: "{{ MOBILIZON_HOST_NODE }}"
|
||||
|
||||
- name: "👤 MOBILIZON | Ensure the platform administrator with a profile"
|
||||
ansible.builtin.command:
|
||||
argv:
|
||||
- container
|
||||
- exec
|
||||
- -i
|
||||
- "{{ MOBILIZON_LOCAL_CID }}" # nocheck: container-exec-resolver MOBILIZON_LOCAL_CID resolved at delegate_to runtime
|
||||
- /bin/mobilizon
|
||||
- rpc
|
||||
- "{{ lookup('file', 'elixir/administrator.exs') }}"
|
||||
expand_argument_vars: false
|
||||
stdin: "{{ {'email': lookup('users', 'platform_administrator').email, 'password': lookup('users', 'platform_administrator').password, 'username':
|
||||
lookup('users', 'platform_administrator').username} | to_json }}"
|
||||
register: mobilizon_administrator
|
||||
changed_when: "'MOBILIZON_ADMINISTRATOR=changed' in mobilizon_administrator.stdout"
|
||||
failed_when: mobilizon_administrator.rc != 0 or 'MOBILIZON_ADMINISTRATOR=' not in mobilizon_administrator.stdout
|
||||
no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}"
|
||||
delegate_to: "{{ MOBILIZON_HOST_NODE }}"
|
||||
|
||||
- name: "🎨 MOBILIZON | Converge palette colors, logo and favicon settings"
|
||||
ansible.builtin.command:
|
||||
argv:
|
||||
- container
|
||||
- exec
|
||||
- -i
|
||||
- "{{ MOBILIZON_LOCAL_CID }}" # nocheck: container-exec-resolver MOBILIZON_LOCAL_CID resolved at delegate_to runtime
|
||||
- /bin/mobilizon
|
||||
- rpc
|
||||
- "{{ lookup('file', 'elixir/design.exs') }}"
|
||||
expand_argument_vars: false
|
||||
stdin: "{{ {'colors': MOBILIZON_DESIGN_COLORS, 'medias': MOBILIZON_DESIGN_MEDIAS} | to_json }}"
|
||||
register: mobilizon_design
|
||||
changed_when: "'MOBILIZON_DESIGN=changed' in mobilizon_design.stdout"
|
||||
failed_when: mobilizon_design.rc != 0 or 'MOBILIZON_DESIGN=' not in mobilizon_design.stdout
|
||||
delegate_to: "{{ MOBILIZON_HOST_NODE }}"
|
||||
@@ -11,3 +11,7 @@
|
||||
dest: "{{ mobilizon_host_conf_exs_file }}"
|
||||
notify: compose-up
|
||||
when: IS_STACK_HOST | bool
|
||||
|
||||
- name: "🎨 Mobilizon administrator and corporate design"
|
||||
ansible.builtin.include_tasks: 01_design.yml
|
||||
when: IS_STACK_HOST | bool
|
||||
@@ -0,0 +1,29 @@
|
||||
(function () {
|
||||
var root = document.documentElement;
|
||||
function stored() {
|
||||
try {
|
||||
return window.localStorage.getItem("theme");
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
function mirror() {
|
||||
var theme = stored();
|
||||
if (theme === "dark" || theme === "light") {
|
||||
var wanted = root.classList.contains("dark") ? "dark" : "light";
|
||||
if (root.getAttribute("data-design-theme") !== wanted) root.setAttribute("data-design-theme", wanted);
|
||||
} else if (root.hasAttribute("data-design-theme")) {
|
||||
root.removeAttribute("data-design-theme");
|
||||
}
|
||||
}
|
||||
mirror();
|
||||
new MutationObserver(mirror).observe(root, { attributes: true, attributeFilter: ["class"] });
|
||||
window.addEventListener("storage", mirror);
|
||||
document.addEventListener(
|
||||
"change",
|
||||
function () {
|
||||
setTimeout(mirror, 0);
|
||||
},
|
||||
true,
|
||||
);
|
||||
})();
|
||||
@@ -1,6 +1,8 @@
|
||||
ADMIN_PASSWORD={{ lookup('users', 'administrator').password | dotenv_quote }}
|
||||
ADMIN_EMAIL={{ lookup('users', 'platform_administrator').email | dotenv_quote }}
|
||||
|
||||
ADMIN_USERNAME={{ lookup('users', 'administrator').username | dotenv_quote }}
|
||||
ADMIN_PASSWORD={{ lookup('users', 'platform_administrator').password | dotenv_quote }}
|
||||
|
||||
ADMIN_USERNAME={{ lookup('users', 'platform_administrator').username | dotenv_quote }}
|
||||
|
||||
APP_BASE_URL={{ lookup('tls', application_id, 'url.base') | dotenv_quote }}
|
||||
|
||||
@@ -10,9 +12,15 @@ BIBER_USERNAME={{ lookup('users', 'biber').username | dotenv_quote }}
|
||||
|
||||
CANONICAL_DOMAIN={{ lookup('domain', application_id) | dotenv_quote }}
|
||||
|
||||
DESIGN_FAVICON_URL={{ (lookup('design', application_id).urls.favicon.png if lookup('design', application_id).logo is string else '') | dotenv_quote }}
|
||||
|
||||
DESIGN_LOGO_URL={{ (lookup('design', application_id).urls.logo.svg if lookup('design', application_id).logo is string else '') | dotenv_quote }}
|
||||
|
||||
# nocheck: playwright-service-gate — assertCspInjections resolves this injector through a computed loop variable (personas/utils/csp.js), never a literal gate argument, so this rule's literal-only regex cannot see the gate
|
||||
DESIGN_SERVICE_ENABLED={{ lookup('config', application_id, 'services.design.enabled') | string | lower }}
|
||||
|
||||
DESIGN_TITLE={{ lookup('design', application_id).label | dotenv_quote }}
|
||||
|
||||
# nocheck: playwright-service-gate — the flag only decides whether templates/env.j2 renders the MOBILIZON_SMTP_* block; no scenario in this spec creates an event or a registration, so nothing mails and no browser-visible surface changes with it
|
||||
EMAIL_SERVICE_ENABLED={{ lookup('config', application_id, 'services.email.enabled') | string | lower }}
|
||||
|
||||
@@ -22,7 +30,7 @@ MATOMO_BASE_URL={{ lookup('tls', 'web-app-matomo', 'url.base') | dotenv_quote }}
|
||||
|
||||
MATOMO_SERVICE_ENABLED={{ lookup('config', application_id, 'services.matomo.enabled') | string | lower }}
|
||||
|
||||
# nocheck: persona-block-rationale — templates/config.exs.j2:260-278 wires Keycloak as the only login provider the role configures and tasks/main.yml creates no Mobilizon account, so the sso: false variants leave neither persona with credentials (see README "Persona contract opt-outs").
|
||||
# nocheck: persona-block-rationale — templates/config.exs.j2:260-278 wires Keycloak as the only login provider the role configures; with sso off biber has no account, and the platform administrator that tasks/01_design.yml creates signs in with its e-mail while the shared persona flow types ADMIN_USERNAME (see README "Persona contract opt-outs").
|
||||
PERSONA_ADMINISTRATOR_BLOCKED={{ 'false' if (lookup('config', application_id, 'services.sso.enabled') | bool) else 'true' }}
|
||||
|
||||
# nocheck: persona-block-rationale — see PERSONA_ADMINISTRATOR_BLOCKED above; MOBILIZON_INSTANCE_DISABLE_DATABASE_LOGIN (templates/env.j2:45) additionally closes the database login whenever LDAP is on.
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -7,3 +7,14 @@ mobilizon_container: "{{ lookup('config', application_id, 'services.mobilizon.na
|
||||
MOBILIZON_IMAGE: "{{ lookup('config', application_id, 'services.mobilizon.image') }}"
|
||||
MOBILIZON_VERSION: "{{ lookup('config', application_id, 'services.mobilizon.version') }}"
|
||||
MOBILIZON_OIDC_ENABLED: "{{ lookup('config', application_id, 'services.sso.enabled') }}"
|
||||
MOBILIZON_DESIGN_ENABLED: "{{ lookup('config', application_id, 'services.design.enabled') | bool }}"
|
||||
MOBILIZON_DESIGN: "{{ lookup('design', application_id) }}"
|
||||
MOBILIZON_DESIGN_PALETTE: "{{ lookup('design_palette', lookup('config', 'web-svc-design', 'services.design.colors.base')).light }}"
|
||||
MOBILIZON_DESIGN_COLORS:
|
||||
primary_color: "{{ MOBILIZON_DESIGN_PALETTE['--design-primary'] if MOBILIZON_DESIGN_ENABLED | bool else None }}"
|
||||
secondary_color: "{{ MOBILIZON_DESIGN_PALETTE['--design-primary-100'] if MOBILIZON_DESIGN_ENABLED | bool else None }}"
|
||||
MOBILIZON_DESIGN_MEDIAS:
|
||||
instance_logo: "{{ {'url': MOBILIZON_DESIGN.urls.logo.svg, 'name': 'logo.svg', 'content_type': 'image/svg+xml', 'width': MOBILIZON_DESIGN.slots.logo.width, 'height':
|
||||
MOBILIZON_DESIGN.slots.logo.height} if MOBILIZON_DESIGN_ENABLED | bool and MOBILIZON_DESIGN.logo is string else None }}"
|
||||
instance_favicon: "{{ {'url': MOBILIZON_DESIGN.urls.favicon.png, 'name': 'favicon.png', 'content_type': 'image/png', 'width': MOBILIZON_DESIGN.slots.favicon.width,
|
||||
'height': MOBILIZON_DESIGN.slots.favicon.height} if MOBILIZON_DESIGN_ENABLED | bool and MOBILIZON_DESIGN.logo is string else None }}"
|
||||
Reference in new issue
Block a user