feat(web-app-xwiki): carry the palette through an own color theme

XWiki compiles its skin on the server from a color theme, so the
palette goes where the app reads it:

- A role task keeps a theme page "Infinito" under FlamingoThemes. Its
  object is derived from the stock theme plus the light palette and is
  written only when it differs. The lockup for the navbar is attached
  to that page, the theme becomes the wiki default and the wiki title
  takes the configured one.
- The preferences the wiki had before are stored on the theme page. A
  deploy with the design service off puts them back.
- A value less4j cannot type breaks the whole skin stylesheet, so the
  task asserts six-digit hex colors before it writes.
- The compiled theme knows one mode. The role stylesheet maps the
  compiled skin colors onto the design tokens and so supplies the dark
  mode. Its image URLs carry the origin of the wiki, because the sheet
  is served from the CDN and a relative target would resolve there.

Without SSO and LDAP the administrator had no password and the member
object of the admin group had a shape XWiki ignored, so nobody could
administer the wiki and the signed-in interface could not be verified.
The role now sets the password when the sign-in fails and writes the
member once. XWiki answers a wrong basic auth as guest with 200, so
the probe reads the XWiki-User response header.

The spec asserts tokens, contrast, logo and title in both modes, the
theme without the stylesheet, the administrator's sign-in, and that
every image the stylesheet references is served. Findings outside the
design are listed in TODO.md.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
kevinveenbirkenbachandClaude Opus 5.5 committed 2026-10-08 09:20:59 +02:00
1 parent 9a48dbfd83
commit b1dfff370a
20 files changed
+6479 -3

No files matched your search

+1
View File
@@ -135,6 +135,7 @@ The OIDC/LDAP runtime configuration (provider URLs, bind DN, secrets) lives in t
- [XWiki Official Website](https://www.xwiki.org/)
- [XWiki Documentation](https://www.xwiki.org/xwiki/bin/view/Documentation/)
- [XWiki GitHub Repository](https://github.com/xwiki/xwiki-platform)
- [Corporate design review: before/after screenshots in light, dark, desktop and mobile](https://claude.ai/artifact/F8XKp2u85ENaT8NVoVTmg6)
## Persona contract opt-outs
+7
View File
@@ -0,0 +1,7 @@
# TODO
- `tasks/02_manager_ops.yml:30` passes `XWIKI_SUPERADMIN_PASSWORD` as an argument of `shell/debug_superadmin.sh`, so the secret is in the process list of the stack host and in the deploy log while that task runs. Options: feed it through stdin, hand the script a file path, or let the script read it from the container environment.
- `templates/compose.yml.j2:28` puts `XWIKI_LDAP_BIND_PASS` into `JAVA_OPTS` as a `-D` system property, so it is visible in the process list of the container. Options: write it into `xwiki.cfg` or `xwiki.properties` from the entrypoint, or mount it as a file the configuration reads.
- `tasks/03_administrator.yml` posted the group member object on every deploy with a body XWiki ignored (`<properties>` wrapper, text outside `<value>`), so an existing installation carries one empty `XWiki.XWikiGroups` object on `XWiki.XWikiAdminGroup` per earlier deploy (measured: two). The post now carries the member and runs once. Options: delete the empty member objects through REST, or leave them (they grant nothing).
- `templates/xml/user/xwikiusers_object.xml.j2` has the same shape, so first name, last name, email and `active` of the administrator are not stored (measured: `first_name` empty). Options: write the properties as `<property name="..."><value>...</value></property>` directly below `<object>`, and update an existing object with a `PUT` on the object.
- `templates/playwright.env.j2` blocks the administrator persona with the reason that the account has no password. Without SSO and LDAP the role now sets one. Options: unblock the persona for that case and teach the admin flow the native sign-in form, or keep the block and reword its reason.
@@ -4,6 +4,8 @@ const { resolveTimeout } = require("./timeouts");
const { decodeDotenvQuotedValue, normalizeBaseUrl, runAdminFlow, runBiberFlow, runGuestFlow , expectHstsWhenTls, gotoOnion } = require("./personas");
test.use({ ignoreHTTPSErrors: true });
require("./test-design").register();
const appBaseUrl = normalizeBaseUrl(process.env.APP_BASE_URL || "");
const canonicalDomain = decodeDotenvQuotedValue(process.env.CANONICAL_DOMAIN || "");
@@ -0,0 +1,341 @@
const { test, expect } = require("@playwright/test");
const {
assertDesignTokens,
assertLightAndDark,
assertReadable,
assertToken,
captureDesignGallery,
galleryEnabled,
tokenValue,
} = require("./design");
const { apiFetchOnion, apiGetOnion, decodeDotenvQuotedValue, gotoOnion, normalizeBaseUrl } = require("./personas");
const { skipUnlessServiceEnabled } = require("./service-gating");
const { resolveTimeout } = require("./timeouts");
const base = normalizeBaseUrl(process.env.APP_BASE_URL);
const adminUsername = decodeDotenvQuotedValue(process.env.ADMIN_USERNAME);
const adminPassword = decodeDotenvQuotedValue(process.env.ADMIN_PASSWORD);
const SHOWCASE = "Sandbox/DesignShowcase";
const SHOWCASE_REST = `${base}/rest/wikis/xwiki/spaces/Sandbox/pages/DesignShowcase`;
const ADMIN = "/bin/admin/XWiki/XWikiPreferences?editor=globaladmin&section=";
const SEARCH = "/bin/view/Main/Search?f_type=DOCUMENT&f_locale=en&f_locale=&r=1&text=";
const INJECTED_SNIPPETS = /<!--infinito-inj-->[\s\S]*?<!--\/infinito-inj-->/g;
const NAVBAR = ".navbar";
function adminAuth() {
return `Basic ${Buffer.from(`${adminUsername}:${adminPassword}`).toString("base64")}`;
}
async function open(page, path, selector) {
await gotoOnion(page, `${base}${path}`);
await expect(page.locator(selector).first()).toBeVisible({ timeout: resolveTimeout(120_000) });
}
async function openHome(page) {
await open(page, "/bin/view/Main/", "#xwikicontent");
}
async function signIn(page) {
test.skip(
process.env.SSO_SERVICE_ENABLED === "true" || process.env.LDAP_SERVICE_ENABLED === "true",
"the local administrator exists only without SSO and LDAP",
);
await open(page, "/bin/login/XWiki/XWikiLogin", "#j_password");
await page.locator("#j_username").fill(adminUsername);
await page.locator("#j_password").fill(adminPassword);
await page.locator("#loginForm input[type='submit']").click();
await expect(page.locator("html[data-xwiki-user-reference]")).toHaveCount(1, { timeout: resolveTimeout(120_000) });
}
async function seedShowcase(request) {
const headers = { Authorization: adminAuth(), "Content-Type": "application/xml", Accept: "application/json" };
const page = (text) =>
`<page xmlns="http://www.xwiki.org"><title>Design Showcase</title><syntax>xwiki/2.1</syntax><content><![CDATA[${text}]]></content></page>`;
const first = "== Showcase ==\n\nA paragraph with a [[link to the home page>>Main.WebHome]].";
const second = [
first,
"{{info}}An information box.{{/info}}",
"{{success}}A success box.{{/success}}",
"{{warning}}A warning box.{{/warning}}",
"{{error}}An error box.{{/error}}",
"|=Name|=Value\n|One|1\n|Two|2\n|Three|3",
'{{code language="js"}}const answer = 42;{{/code}}',
"----",
"* first item\n* second item",
].join("\n\n");
const existing = await apiGetOnion(request, SHOWCASE_REST, { headers });
if (existing.status() === 404) {
for (const text of [first, second]) {
const saved = await apiFetchOnion(request, SHOWCASE_REST, { method: "PUT", headers, data: page(text) });
expect(saved.ok(), `showcase page save answers ${saved.status()}`).toBe(true);
}
}
const attachments = await (await apiGetOnion(request, `${SHOWCASE_REST}/attachments`, { headers })).json();
if (attachments.attachments.length === 0) {
const attached = await apiFetchOnion(request, `${SHOWCASE_REST}/attachments/showcase.txt`, {
method: "PUT",
headers: { Authorization: adminAuth(), "Content-Type": "text/plain" },
data: "A showcase attachment.",
});
expect(attached.ok(), `showcase attachment answers ${attached.status()}`).toBe(true);
}
const comments = await (await apiGetOnion(request, `${SHOWCASE_REST}/comments`, { headers })).json();
if (comments.comments.length === 0) {
const commented = await apiFetchOnion(request, `${SHOWCASE_REST}/comments`, {
method: "POST",
headers,
data: '<comment xmlns="http://www.xwiki.org"><text>A showcase comment.</text></comment>',
});
expect(commented.ok(), `showcase comment answers ${commented.status()}`).toBe(true);
}
}
async function waitForPanel(panel) {
await expect(panel).toBeVisible({ timeout: resolveTimeout(15_000) });
await expect
.poll(
() =>
panel.evaluate(
(el) =>
el.getAnimations({ subtree: true }).filter((animation) => animation.playState === "running").length === 0 &&
getComputedStyle(el).opacity === "1",
),
{ timeout: resolveTimeout(15_000) },
)
.toBe(true);
}
async function dismissTour(page) {
const close = page.locator(".popover.tour a.btn-default").first();
await close
.waitFor({ state: "visible", timeout: resolveTimeout(5_000) })
.then(() => close.click())
.catch(() => {});
await expect(page.locator(".popover.tour")).toHaveCount(0);
}
async function stripInjected(route) {
if (route.request().resourceType() !== "document") return route.continue();
const response = await route.fetch();
return route.fulfill({ response, body: (await response.text()).replace(INJECTED_SNIPPETS, "") });
}
function hex(rgb) {
return `#${rgb.match(/\d+/g).slice(0, 3).map((channel) => Number(channel).toString(16).padStart(2, "0")).join("")}`;
}
async function currentThemePath(page) {
const href = await page.locator("link[href*='/skins/flamingo/style']").first().getAttribute("href");
return new URL(href, base).searchParams.get("colorTheme").replace(/^xwiki:/, "").replace(".", "/");
}
exports.register = function () {
test("design: corporate tokens apply in light and dark mode", async ({ page }) => {
skipUnlessServiceEnabled("design");
await openHome(page);
await assertDesignTokens(page, "xwiki");
});
test("design: page, navbar, text and links take the palette", async ({ page }) => {
skipUnlessServiceEnabled("design");
await openHome(page);
for (const colorScheme of ["light", "dark"]) {
await page.emulateMedia({ colorScheme });
await assertToken(page, "body", "background-color", "--design-surface-1", `xwiki page ${colorScheme}`);
await assertToken(page, "#mainContentArea", "background-color", "--design-surface-2", `xwiki content ${colorScheme}`);
await assertToken(page, "#xwikicontent", "color", "--design-text", `xwiki body text ${colorScheme}`);
await assertToken(page, "#xwikicontent a", "color", "--design-link", `xwiki link ${colorScheme}`);
await assertToken(page, NAVBAR, "background-color", "--design-frame", `xwiki navbar ${colorScheme}`);
await assertToken(page, "#tmDrawerActivator", "color", "--design-on-frame", `xwiki navbar text ${colorScheme}`);
}
await page.emulateMedia({ colorScheme: null });
await assertLightAndDark(page, "#xwikicontent", "xwiki");
await assertReadable(page, ["#xwikicontent", "#xwikicontent a", "#tmDrawerActivator"], "xwiki");
});
test("design: every image the role stylesheet references is served", async ({ page }) => {
skipUnlessServiceEnabled("design");
await openHome(page);
const sheet = await page.locator("link[href*='/roles/web-app-xwiki/'][href*='style.css']").first().getAttribute("href");
const css = await (await apiGetOnion(page.request, sheet)).text();
const targets = [...new Set([...css.matchAll(/url\(\s*["']?([^"')]+)["']?\s*\)/g)].map((match) => match[1]))]
.filter((target) => !target.startsWith("data:"))
.map((target) => new URL(target, sheet).href);
expect(targets.length, "the role stylesheet references images").toBeGreaterThan(0);
for (const target of targets) {
const served = await apiGetOnion(page.request, target);
expect(
`${served.status()} ${served.headers()["content-type"]}`,
`${target} must be served as an image; a relative url() resolves against the stylesheet on the CDN`,
).toMatch(/^200 image\//);
}
});
test("design: the color theme carries the light palette without the role stylesheet", async ({ page }) => {
skipUnlessServiceEnabled("design");
await page.emulateMedia({ colorScheme: "light" });
await openHome(page);
const expected = {
frame: await tokenValue(page, "--design-frame", "background-color"),
surface: await tokenValue(page, "--design-surface-1", "background-color"),
text: await tokenValue(page, "--design-text", "color"),
};
await page.route("**/*", stripInjected);
await openHome(page);
await expect(page.locator("link[href*='_shared/css/default.css']"), "the injected sheets are stripped").toHaveCount(0);
expect(await currentThemePath(page), "the skin sheet is compiled for the corporate theme").toBe(
decodeDotenvQuotedValue(process.env.DESIGN_THEME_REFERENCE).replace(".", "/"),
);
const style = (selector, property) =>
page.locator(selector).first().evaluate((el, prop) => getComputedStyle(el).getPropertyValue(prop), property);
expect(await style(NAVBAR, "background-color"), "the theme paints the navbar in the frame color").toBe(expected.frame);
expect(await style("body", "background-color"), "the theme paints the page in surface-1").toBe(expected.surface);
expect(await style("#xwikicontent", "color"), "the theme sets the body text color").toBe(expected.text);
});
test("design: primary action reads on-primary, fields and dividers read the border tokens", async ({ page }) => {
skipUnlessServiceEnabled("design");
await open(page, `${SEARCH}xwiki`, ".search-results");
for (const colorScheme of ["light", "dark"]) {
await page.emulateMedia({ colorScheme });
await assertToken(page, ".btn-primary", "background-color", "--design-primary", `xwiki primary ${colorScheme}`);
await assertToken(page, ".btn-primary", "color", "--design-on-primary", `xwiki on-primary ${colorScheme}`);
await assertToken(page, "input.form-control", "border-top-color", "--design-border-strong", `xwiki field ${colorScheme}`);
}
await open(page, "/bin/view/Main/", ".xwikitabbar");
for (const colorScheme of ["light", "dark"]) {
await page.emulateMedia({ colorScheme });
await assertToken(page, ".xwikitabbar", "border-bottom-color", "--design-border", `xwiki tab divider ${colorScheme}`);
}
});
test("design: search facets take their surface and text from the tokens", async ({ page }) => {
skipUnlessServiceEnabled("design");
await open(page, `${SEARCH}xwiki`, ".search-facet-header label");
for (const colorScheme of ["light", "dark"]) {
await page.emulateMedia({ colorScheme });
await assertToken(page, ".search-facet-header", "background-color", "--design-surface-3", `xwiki facet header ${colorScheme}`);
await assertToken(page, ".search-facet-header label", "color", "--design-text", `xwiki facet label ${colorScheme}`);
}
await page.emulateMedia({ colorScheme: null });
await assertReadable(page, [".search-facet-header label"], "xwiki search facets");
});
test("design: focus stops inside the navbar draw in the frame text color", async ({ page }) => {
skipUnlessServiceEnabled("design");
await open(page, `${SEARCH}xwiki`, ".search-results");
const onFrame = await tokenValue(page, "--design-on-frame", "outline-color");
let stops = 0;
for (let step = 0; step < 25; step += 1) {
await page.keyboard.press("Tab");
const outline = await page.evaluate((navbar) => {
const el = document.activeElement;
if (!el || !el.closest(navbar)) return null;
const style = getComputedStyle(el);
return { color: style.outlineColor, style: style.outlineStyle };
}, NAVBAR);
if (!outline) continue;
stops += 1;
expect(outline.style, "a focus stop inside the navbar draws an outline").not.toBe("none");
expect(outline.color, "focus outline inside the navbar").toBe(onFrame);
}
expect(stops, "the Tab walk reaches the navbar").toBeGreaterThan(0);
});
test("design: logo and title are the configured ones", async ({ page }) => {
skipUnlessServiceEnabled("design");
await openHome(page);
const title = decodeDotenvQuotedValue(process.env.DESIGN_TITLE);
if (title) {
expect((await page.title()).endsWith(` - ${title}`), `the page title '${await page.title()}' ends with the design title`).toBe(true);
}
const logoUrl = decodeDotenvQuotedValue(process.env.DESIGN_LOGO_URL);
test.skip(!logoUrl, "the logo replacement is disabled");
const logo = page.locator("#companylogo img");
await expect(logo).toBeVisible();
const box = await logo.boundingBox();
expect(box.width, "the navbar logo is a lockup, wider than high").toBeGreaterThan(box.height * 1.5);
const src = await logo.evaluate((img) => img.src);
expect(decodeURIComponent(src), "the logo is the attachment of the corporate theme page").toContain(
`/${decodeDotenvQuotedValue(process.env.DESIGN_THEME_REFERENCE).replace(".", "/")}/`,
);
const served = await apiGetOnion(page.request, src);
const generated = await apiGetOnion(page.request, logoUrl);
expect(generated.ok(), `the generated logo answers ${generated.status()}`).toBe(true);
expect(Buffer.compare(await served.body(), await generated.body()), "XWiki serves the generated lockup").toBe(0);
const onFrame = hex(await tokenValue(page, "--design-on-frame", "color"));
expect((await generated.text()).toLowerCase(), "the lockup text uses the frame text color").toContain(`fill="${onFrame}"`);
});
test("design: the administrator signs in through the form and administers the wiki", async ({ page }) => {
skipUnlessServiceEnabled("design");
await signIn(page);
await expect(page.locator("html")).toHaveAttribute("data-xwiki-user-reference", `xwiki:XWiki.${adminUsername}`);
await open(page, `${ADMIN}Themes`, "#admin-page-content form");
await expect(page.locator("#admin-page-content select[name$='_colorTheme']")).toHaveValue(
decodeDotenvQuotedValue(process.env.DESIGN_THEME_REFERENCE),
);
await assertReadable(page, ["#admin-page-content dt label", "#admin-page-content .xHint"], "xwiki administration form");
await seedShowcase(page.request);
await open(page, `/bin/view/${SHOWCASE}?viewer=history`, "#historycontent table td");
for (const colorScheme of ["light", "dark"]) {
await page.emulateMedia({ colorScheme });
await assertToken(page, "#historycontent table td", "border-top-color", "--design-border", `xwiki divider ${colorScheme}`);
}
});
test("design: gallery of wiki and administration views", async ({ page }) => {
skipUnlessServiceEnabled("design");
test.skip(!galleryEnabled(), "INFINITO_PLAYWRIGHT_KEEP is not true");
test.setTimeout(resolveTimeout(2_400_000));
const view = (name, path, selector, prepare) => ({
name,
url: `${base}${path}`,
prepare: async (p) => {
await expect(p.locator(selector).first()).toBeVisible({ timeout: resolveTimeout(120_000) });
if (prepare) await prepare(p);
},
});
const failures = [];
await captureDesignGallery(page, [
view("home", "/bin/view/Main/", "#xwikicontent", dismissTour),
view("sign-in", "/bin/login/XWiki/XWikiLogin", "#loginForm #j_password"),
]).catch((error) => failures.push(error.message));
await signIn(page);
await seedShowcase(page.request);
const theme = await currentThemePath(page);
await captureDesignGallery(page, [
view("page-view", `/bin/view/${SHOWCASE}`, "#xwikicontent h2"),
view("page-edit-wiki", `/bin/edit/${SHOWCASE}?editor=wiki`, ".buttons"),
view("page-edit-wysiwyg", `/bin/edit/${SHOWCASE}?editor=wysiwyg`, ".cke_top"),
view("page-history", `/bin/view/${SHOWCASE}?viewer=history`, "#historycontent table"),
view("page-diff", `/bin/view/${SHOWCASE}?viewer=changes&rev1=1.1&rev2=2.1`, "#changescontent"),
view("page-comments", `/bin/view/${SHOWCASE}?viewer=comments`, ".xwikicomment"),
view("page-attachments", `/bin/view/${SHOWCASE}?viewer=attachments`, "#attachmentscontent"),
view("page-rights", `/bin/edit/${SHOWCASE}?editor=rights`, '#usersandgroupstable :text("XWikiAllGroup")'),
view("actions-menu-open", `/bin/view/${SHOWCASE}`, "#tmMoreActions .dropdown-toggle", async (p) => {
await p.locator("#tmMoreActions .dropdown-toggle").click();
await waitForPanel(p.locator("#tmMoreActions .dropdown-menu"));
}),
view("drawer-open", `/bin/view/${SHOWCASE}`, "#tmDrawerActivator", async (p) => {
await p.locator("#tmDrawerActivator").click();
await waitForPanel(p.locator("#tmDrawer"));
}),
view("search", `${SEARCH}showcase`, ".search-results"),
view("page-index", "/bin/view/Main/AllDocs", ".liveData table tbody tr"),
view("user-profile", `/bin/view/XWiki/${adminUsername}`, ".userInfo"),
view("user-preferences", `/bin/view/XWiki/${adminUsername}?category=preferences`, "#preferencesPane"),
view("admin-home", "/bin/admin/XWiki/XWikiPreferences", ".admin-menu"),
view("admin-themes", `${ADMIN}Themes`, "#admin-page-content form"),
view("admin-presentation", `${ADMIN}Presentation`, "#admin-page-content form"),
view("admin-users", `${ADMIN}Users`, "#admin-page-content table tbody tr"),
view("admin-groups", `${ADMIN}Groups`, "#admin-page-content table tbody tr"),
view("admin-rights", `${ADMIN}Rights`, '#usersandgroupstable :text("XWikiAllGroup")'),
view("admin-extensions", `${ADMIN}XWiki.Extensions`, "#admin-page-content form"),
view("theme-editor", `/bin/edit/${theme}?editor=inline`, "#inline"),
view("not-found", "/bin/view/Sandbox/DesignShowcaseMissing", ".xwikimessage"),
]).catch((error) => failures.push(error.message));
expect(failures, "every gallery view is captured").toEqual([]);
});
};
+5
View File
@@ -84,6 +84,11 @@ design:
bond: 1
enabled: "{{ 'web-svc-design' in group_names }}"
shared: "{{ 'web-svc-design' in group_names }}"
slots:
logo:
width: 160
height: 50
frame: true
prometheus:
bond: 1
enabled: "{{ 'web-app-prometheus' in group_names }}"
@@ -59,6 +59,9 @@
- name: "✅ XWIKI | Ensure Main.WebHome exists"
include_tasks: 05_main_webhome.yml
- name: "🎨 XWIKI | Reconcile the corporate color theme"
include_tasks: 06_design.yml
- block:
- name: "📁 Create Final Docker Compose File"
stack_host_template:
@@ -39,6 +39,35 @@
when: _users_obj_list.status == 404 or ('<object' not in (_users_obj_list.content | default('')))
register: _user_obj_created
- name: "🔎 XWIKI | Probe the sign-in of {{ XWIKI_ADMIN_USER }}"
no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}"
uri_retry:
url: "{{ [XWIKI_REST_XWIKI, 'spaces'] | url_join }}"
method: GET
url_username: "{{ XWIKI_ADMIN_USER }}"
url_password: "{{ XWIKI_ADMIN_PASSWORD }}"
force_basic_auth: true
status_code: [200]
return_content: false
register: _xwiki_admin_signin
changed_when: false
- name: "🔑 XWIKI | Set the password of {{ XWIKI_ADMIN_USER }}"
no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}"
uri_retry:
url: "{{ [XWIKI_REST_XWIKI_PAGES, XWIKI_ADMIN_USER | urlencode, 'objects/XWiki.XWikiUsers/0/properties/password'] | url_join }}"
method: PUT
url_username: "{{ XWIKI_SUPERADMIN_USERNAME }}"
url_password: "{{ XWIKI_SUPERADMIN_PASSWORD }}"
force_basic_auth: true
status_code: [202]
headers: "{{ xwiki_rest_headers_write }}"
body: "{{ lookup('template', 'xml/property.xml.j2') }}"
vars:
xwiki_property_value: "{{ XWIKI_ADMIN_PASSWORD }}"
when: _xwiki_admin_signin.xwiki_user is not defined
changed_when: true
- block:
- name: "✅ XWIKI | Ensure {{ XWIKI_ADMIN_GROUP_PAGE }} page exists"
uri_retry:
@@ -50,6 +79,19 @@
status_code: [201, 202, 204, 200]
headers: "{{ xwiki_rest_headers_write }}"
body: "{{ lookup('template', 'xml/xwiki_admin_group/page.xml.j2') }}"
- name: "🔎 XWIKI | Read the members of {{ XWIKI_ADMIN_GROUP_PAGE }}"
uri_retry:
url: "{{ [XWIKI_REST_XWIKI_PAGES, XWIKI_ADMIN_GROUP_PAGE, 'objects/XWiki.XWikiGroups'] | url_join }}"
method: GET
url_username: "{{ XWIKI_SUPERADMIN_USERNAME }}"
url_password: "{{ XWIKI_SUPERADMIN_PASSWORD }}"
force_basic_auth: true
status_code: [200]
headers:
Accept: application/json
return_content: true
register: _xwiki_admin_group_members
changed_when: false
- name: "✅ XWIKI | Ensure user is in {{ XWIKI_ADMIN_GROUP_PAGE }}"
uri_retry:
url: "{{ [XWIKI_REST_XWIKI_PAGES, XWIKI_ADMIN_GROUP_PAGE, 'objects'] | url_join }}"
@@ -60,4 +102,8 @@
status_code: [201, 200]
headers: "{{ xwiki_rest_headers_write }}"
body: "{{ lookup('template', 'xml/xwiki_admin_group/member_object.xml.j2') }}"
when: >-
XWIKI_ADMIN_REFERENCE not in
(_xwiki_admin_group_members.json.objectSummaries | map(attribute='headline') | list)
changed_when: true
when: not (XWIKI_SSO_ENABLED | bool)
+69
View File
@@ -0,0 +1,69 @@
---
- name: "⬇️ XWIKI | Fetch REST session + form token (design write ops)"
include_tasks: utils/rest_session.yml
- name: "🔎 XWIKI | Read the wiki color theme preference"
uri_retry:
url: "{{ [XWIKI_REST_PREFERENCES, 'colorTheme'] | url_join }}"
method: GET
url_username: "{{ XWIKI_SUPERADMIN_USERNAME }}"
url_password: "{{ XWIKI_SUPERADMIN_PASSWORD }}"
force_basic_auth: true
status_code: [200]
headers:
Accept: application/json
return_content: true
register: _xwiki_design_color_theme
changed_when: false
- name: "🔎 XWIKI | Read the wiki title preference"
uri_retry:
url: "{{ [XWIKI_REST_PREFERENCES, 'title'] | url_join }}"
method: GET
url_username: "{{ XWIKI_SUPERADMIN_USERNAME }}"
url_password: "{{ XWIKI_SUPERADMIN_PASSWORD }}"
force_basic_auth: true
status_code: [200]
headers:
Accept: application/json
return_content: true
register: _xwiki_design_title
changed_when: false
- name: "🔎 XWIKI | Look for the corporate theme page"
uri_retry:
url: "{{ XWIKI_REST_THEME_PAGE }}"
method: GET
url_username: "{{ XWIKI_SUPERADMIN_USERNAME }}"
url_password: "{{ XWIKI_SUPERADMIN_PASSWORD }}"
force_basic_auth: true
status_code: [200, 404]
return_content: false
register: _xwiki_design_page
changed_when: false
- name: "🔎 XWIKI | Read the preferences the wiki had before the corporate theme"
uri_retry:
url: "{{ [XWIKI_REST_THEME_PAGE, 'objects/XWiki.XWikiPreferences/0'] | url_join }}"
method: GET
url_username: "{{ XWIKI_SUPERADMIN_USERNAME }}"
url_password: "{{ XWIKI_SUPERADMIN_PASSWORD }}"
force_basic_auth: true
status_code: [200, 404]
headers:
Accept: application/json
return_content: true
register: _xwiki_design_previous
changed_when: false
- name: "🎨 XWIKI | Apply the corporate color theme"
include_tasks: 06_design/apply.yml
when: XWIKI_DESIGN_ENABLED | bool
- name: "🧹 XWIKI | Remove the corporate color theme"
include_tasks: 06_design/remove.yml
vars:
xwiki_design_former: "{{ _xwiki_design_previous.json.properties | items2dict(key_name='name', value_name='value') }}"
when:
- not (XWIKI_DESIGN_ENABLED | bool)
- _xwiki_design_previous.status == 200
@@ -0,0 +1,93 @@
---
- name: "🛑 XWIKI | Refuse a theme page this role did not create"
ansible.builtin.assert:
that:
- _xwiki_design_page.status == 404 or _xwiki_design_previous.status == 200
fail_msg: >-
The page {{ XWIKI_DESIGN_THEME_REFERENCE }} exists without the XWiki.XWikiPreferences object
this role stores on it. Rename or delete that page, then deploy again.
- name: "✅ XWIKI | Assert the theme values are six-digit hex colors"
ansible.builtin.assert:
that:
- XWIKI_DESIGN_THEME_COLORS.values() | reject('match', '^#[0-9a-fA-F]{6}$') | list | length == 0
- XWIKI_DESIGN_LESS_COLORS.values() | reject('match', '^#[0-9a-fA-F]{6}$') | list | length == 0
fail_msg: "A value less4j cannot type breaks the whole skin stylesheet."
- name: "📁 XWIKI | Ensure the design directory exists"
ansible.builtin.file:
path: "{{ XWIKI_DESIGN_DIR }}"
state: directory
mode: "0755"
- name: "🖼️ XWIKI | Render the corporate logo set"
ansible.builtin.include_role:
name: sys-front-inj-design
tasks_from: assets.yml
vars:
design_assets_dest: "{{ XWIKI_DESIGN_DIR }}"
- name: "📄 XWIKI | Create the corporate theme page"
uri_retry:
url: "{{ XWIKI_REST_THEME_PAGE }}"
method: PUT
url_username: "{{ XWIKI_SUPERADMIN_USERNAME }}"
url_password: "{{ XWIKI_SUPERADMIN_PASSWORD }}"
force_basic_auth: true
status_code: [201]
headers: "{{ xwiki_rest_headers_write }}"
body: "{{ lookup('template', 'xml/design/theme/page.xml.j2') }}"
when: _xwiki_design_page.status == 404
changed_when: true
- name: "💾 XWIKI | Remember the preferences the wiki had"
uri_retry:
url: "{{ [XWIKI_REST_THEME_PAGE, 'objects'] | url_join }}"
method: POST
url_username: "{{ XWIKI_SUPERADMIN_USERNAME }}"
url_password: "{{ XWIKI_SUPERADMIN_PASSWORD }}"
force_basic_auth: true
status_code: [201]
headers: "{{ xwiki_rest_headers_write }}"
body: "{{ lookup('template', 'xml/design/previous_preferences_object.xml.j2') }}"
when: _xwiki_design_previous.status == 404
changed_when: true
- name: "📂 XWIKI | Write the theme object"
include_tasks: theme.yml
- name: "📂 XWIKI | Attach the navbar lockup"
include_tasks: logo.yml
when: design_branding.logo is string
- name: "📌 XWIKI | Make the corporate theme the wiki default"
uri_retry:
url: "{{ [XWIKI_REST_PREFERENCES, 'colorTheme'] | url_join }}"
method: PUT
url_username: "{{ XWIKI_SUPERADMIN_USERNAME }}"
url_password: "{{ XWIKI_SUPERADMIN_PASSWORD }}"
force_basic_auth: true
status_code: [202]
headers: "{{ xwiki_rest_headers_write }}"
body: "{{ lookup('template', 'xml/property.xml.j2') }}"
vars:
xwiki_property_value: "{{ XWIKI_DESIGN_THEME_REFERENCE }}"
when: _xwiki_design_color_theme.json.value != XWIKI_DESIGN_THEME_REFERENCE
changed_when: true
- name: "🏷️ XWIKI | Set the wiki title"
uri_retry:
url: "{{ [XWIKI_REST_PREFERENCES, 'title'] | url_join }}"
method: PUT
url_username: "{{ XWIKI_SUPERADMIN_USERNAME }}"
url_password: "{{ XWIKI_SUPERADMIN_PASSWORD }}"
force_basic_auth: true
status_code: [202]
headers: "{{ xwiki_rest_headers_write }}"
body: "{{ lookup('template', 'xml/property.xml.j2') }}"
vars:
xwiki_property_value: "{{ XWIKI_DESIGN_TITLE }}"
when:
- design_branding.title is string
- _xwiki_design_title.json.value != XWIKI_DESIGN_TITLE
changed_when: true
@@ -0,0 +1,32 @@
---
- name: "📥 XWIKI | Read the generated navbar lockup"
ansible.builtin.slurp:
src: "{{ [XWIKI_DESIGN_DIR, XWIKI_DESIGN_LOGO_FILE] | path_join }}"
register: _xwiki_design_logo
- name: "🔎 XWIKI | Read the logo attachment of the theme page"
uri_retry:
url: "{{ [XWIKI_REST_THEME_PAGE, 'attachments', XWIKI_DESIGN_LOGO_FILE] | url_join }}"
method: GET
url_username: "{{ XWIKI_SUPERADMIN_USERNAME }}"
url_password: "{{ XWIKI_SUPERADMIN_PASSWORD }}"
force_basic_auth: true
status_code: [200, 404]
return_content: true
register: _xwiki_design_logo_attachment
changed_when: false
- name: "🖼️ XWIKI | Attach the navbar lockup to the theme page"
uri_retry:
url: "{{ [XWIKI_REST_THEME_PAGE, 'attachments', XWIKI_DESIGN_LOGO_FILE] | url_join }}"
method: PUT
url_username: "{{ XWIKI_SUPERADMIN_USERNAME }}"
url_password: "{{ XWIKI_SUPERADMIN_PASSWORD }}"
force_basic_auth: true
status_code: [201, 202]
headers: "{{ xwiki_rest_headers_write | combine({'Content-Type': 'image/svg+xml'}) }}"
body: "{{ _xwiki_design_logo.content | b64decode }}"
when: >-
_xwiki_design_logo_attachment.status == 404
or _xwiki_design_logo_attachment.content != (_xwiki_design_logo.content | b64decode)
changed_when: true
@@ -0,0 +1,48 @@
---
- name: "↩️ XWIKI | Give the wiki its former color theme back"
uri_retry:
url: "{{ [XWIKI_REST_PREFERENCES, 'colorTheme'] | url_join }}"
method: PUT
url_username: "{{ XWIKI_SUPERADMIN_USERNAME }}"
url_password: "{{ XWIKI_SUPERADMIN_PASSWORD }}"
force_basic_auth: true
status_code: [202]
headers: "{{ xwiki_rest_headers_write }}"
body: "{{ lookup('template', 'xml/property.xml.j2') }}"
vars:
xwiki_property_value: "{{ xwiki_design_former.colorTheme }}"
when: _xwiki_design_color_theme.json.value == XWIKI_DESIGN_THEME_REFERENCE
changed_when: true
- name: "↩️ XWIKI | Give the wiki its former title back"
uri_retry:
url: "{{ [XWIKI_REST_PREFERENCES, 'title'] | url_join }}"
method: PUT
url_username: "{{ XWIKI_SUPERADMIN_USERNAME }}"
url_password: "{{ XWIKI_SUPERADMIN_PASSWORD }}"
force_basic_auth: true
status_code: [202]
headers: "{{ xwiki_rest_headers_write }}"
body: "{{ lookup('template', 'xml/property.xml.j2') }}"
vars:
xwiki_property_value: "{{ xwiki_design_former.title }}"
when:
- _xwiki_design_title.json.value == XWIKI_DESIGN_TITLE
- xwiki_design_former.title != XWIKI_DESIGN_TITLE
changed_when: true
- name: "🗑️ XWIKI | Delete the corporate theme page"
uri_retry:
url: "{{ XWIKI_REST_THEME_PAGE }}"
method: DELETE
url_username: "{{ XWIKI_SUPERADMIN_USERNAME }}"
url_password: "{{ XWIKI_SUPERADMIN_PASSWORD }}"
force_basic_auth: true
status_code: [204]
headers: "{{ xwiki_rest_headers_write }}"
changed_when: true
- name: "🗑️ XWIKI | Remove the rendered logo set"
ansible.builtin.file:
path: "{{ XWIKI_DESIGN_DIR }}"
state: absent
@@ -0,0 +1,69 @@
---
- name: "🔎 XWIKI | Read the stock theme the corporate theme derives from"
uri_retry:
url: "{{ [XWIKI_REST_XWIKI, 'spaces/FlamingoThemes/pages', XWIKI_DESIGN_STOCK_THEME_PAGE, XWIKI_DESIGN_THEME_OBJECT] | url_join }}"
method: GET
url_username: "{{ XWIKI_SUPERADMIN_USERNAME }}"
url_password: "{{ XWIKI_SUPERADMIN_PASSWORD }}"
force_basic_auth: true
status_code: [200]
headers:
Accept: application/json
return_content: true
register: _xwiki_design_stock_theme
changed_when: false
- name: "🔎 XWIKI | Read the corporate theme object"
uri_retry:
url: "{{ [XWIKI_REST_THEME_PAGE, XWIKI_DESIGN_THEME_OBJECT] | url_join }}"
method: GET
url_username: "{{ XWIKI_SUPERADMIN_USERNAME }}"
url_password: "{{ XWIKI_SUPERADMIN_PASSWORD }}"
force_basic_auth: true
status_code: [200, 404]
headers:
Accept: application/json
return_content: true
register: _xwiki_design_theme_object
changed_when: false
- name: "🧮 XWIKI | Derive the corporate theme from the stock theme and the palette"
ansible.builtin.set_fact:
xwiki_design_theme_properties: >-
{{ _xwiki_design_stock_theme.json.properties
| items2dict(key_name='name', value_name='value')
| combine(XWIKI_DESIGN_THEME_COLORS, {
'lessCode': XWIKI_DESIGN_LESS_CODE,
'logo': XWIKI_DESIGN_LOGO_FILE if design_branding.logo is string else '',
'logo-description': design_branding.label
}) }}
- name: "➕ XWIKI | Add the corporate theme object"
uri_retry:
url: "{{ [XWIKI_REST_THEME_PAGE, 'objects'] | url_join }}"
method: POST
url_username: "{{ XWIKI_SUPERADMIN_USERNAME }}"
url_password: "{{ XWIKI_SUPERADMIN_PASSWORD }}"
force_basic_auth: true
status_code: [201]
headers: "{{ xwiki_rest_headers_write }}"
body: "{{ lookup('template', 'xml/design/theme/object.xml.j2') }}"
when: _xwiki_design_theme_object.status == 404
changed_when: true
- name: "🎨 XWIKI | Update the corporate theme object"
uri_retry:
url: "{{ [XWIKI_REST_THEME_PAGE, XWIKI_DESIGN_THEME_OBJECT] | url_join }}"
method: PUT
url_username: "{{ XWIKI_SUPERADMIN_USERNAME }}"
url_password: "{{ XWIKI_SUPERADMIN_PASSWORD }}"
force_basic_auth: true
status_code: [202]
headers: "{{ xwiki_rest_headers_write }}"
body: "{{ lookup('template', 'xml/design/theme/object.xml.j2') }}"
when:
- _xwiki_design_theme_object.status == 200
- >-
(_xwiki_design_theme_object.json.properties | items2dict(key_name='name', value_name='value'))
!= xwiki_design_theme_properties
changed_when: true
@@ -12,6 +12,14 @@ BIBER_USERNAME={{ lookup('users', 'biber').username | dotenv_quote }}
CANONICAL_DOMAIN={{ lookup('domain', application_id) | dotenv_quote }}
DESIGN_LOGO_URL={{ (lookup('design', application_id).urls.logo.svg if lookup('design', application_id).logo is string else '') | dotenv_quote }}
DESIGN_SERVICE_ENABLED={{ lookup('config', application_id, 'services.design.enabled') | string | lower }}
DESIGN_THEME_REFERENCE={{ XWIKI_DESIGN_THEME_REFERENCE | dotenv_quote }}
DESIGN_TITLE={{ (lookup('design', application_id).title if lookup('design', application_id).title is string else '') | dotenv_quote }}
# nocheck: playwright-service-gate — covered by sys-svc-mail integration tests, not the persona surface
EMAIL_SERVICE_ENABLED={{ lookup('config', application_id, 'services.email.enabled') | string | lower }}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,6 @@
{% set former_theme = _xwiki_design_color_theme.json.value %}
<object xmlns="http://www.xwiki.org">
<className>XWiki.XWikiPreferences</className>
<property name="colorTheme"><value>{{ ('' if former_theme == XWIKI_DESIGN_THEME_REFERENCE else former_theme) | e }}</value></property>
<property name="title"><value>{{ _xwiki_design_title.json.value | e }}</value></property>
</object>
@@ -0,0 +1,6 @@
<object xmlns="http://www.xwiki.org">
<className>FlamingoThemesCode.ThemeClass</className>
{% for name, value in xwiki_design_theme_properties | dictsort %}
<property name="{{ name }}"><value>{{ value | e }}</value></property>
{% endfor %}
</object>
@@ -0,0 +1,6 @@
<page xmlns="http://www.xwiki.org">
<title>{{ design_branding.label | e }}</title>
<parent>FlamingoThemes.WebHome</parent>
<hidden>true</hidden>
<syntax>xwiki/2.1</syntax>
</page>
@@ -0,0 +1 @@
<property xmlns="http://www.xwiki.org"><value>{{ xwiki_property_value | e }}</value></property>
@@ -1,6 +1,4 @@
<object xmlns="http://www.xwiki.org">
<className>XWiki.XWikiGroups</className>
<properties>
<property name="member">XWiki.{{ XWIKI_ADMIN_USER }}</property>
</properties>
<property name="member"><value>{{ XWIKI_ADMIN_REFERENCE }}</value></property>
</object>
+91
View File
@@ -8,12 +8,14 @@ container_hostname: "{{ lookup('container_hostname', application_id) }}"
XWIKI_HOST_PORT: "{{ lookup('config', application_id, 'services.xwiki.ports.local.http') }}"
XWIKI_HOSTNAME: "{{ container_hostname }}"
XWIKI_URL: "{{ lookup('tls', application_id, 'url.base') | regex_replace('/+$', '') }}"
XWIKI_HOST_PROPERTIES_PATH: "{{ [lookup('container', application_id, 'directories.config'), 'xwiki.properties'] | path_join }}"
XWIKI_DOCK_DATA_DIR: "{{ lookup('volume', 'web-app-xwiki', 'data').target }}"
XWIKI_IMAGE: "{{ lookup('config', application_id, 'services.xwiki.image') }}"
XWIKI_VERSION: "{{ lookup('config', application_id, 'services.xwiki.version') }}"
XWIKI_PLATFORM_VERSION: "{{ XWIKI_VERSION | split('-') | first }}"
XWIKI_CONTAINER: "{{ lookup('config', application_id, 'services.xwiki.name') }}"
XWIKI_CONTAINER_ADDRESS: "{{ lookup('container_address', application_id, entity_name) }}"
@@ -22,6 +24,8 @@ XWIKI_OIDC_ENABLED: "{{ lookup('config', application_id, 'services.sso.enabled')
XWIKI_SSO_ENABLED: "{{ (XWIKI_OIDC_ENABLED | bool) or (XWIKI_LDAP_ENABLED | bool) }}"
XWIKI_ADMIN_USER: "{{ lookup('users', 'administrator').username }}"
XWIKI_ADMIN_PASSWORD: "{{ lookup('users', 'administrator').password }}"
XWIKI_ADMIN_REFERENCE: "XWiki.{{ XWIKI_ADMIN_USER }}"
XWIKI_ADMIN_GROUP: "administrator"
XWIKI_ADMIN_GROUP_PAGE: "XWikiAdminGroup"
@@ -55,3 +59,90 @@ XWIKI_OIDC_GROUPS_CLAIM: "{{ RBAC.GROUP.CLAIM }}"
XWIKI_OIDC_ADMIN_PROVIDER_GROUP: "{{ lookup('rbac_group_path', application_id=application_id, role='administrator') }}"
XWIKI_PLUGINS: "{{ lookup('config', application_id, 'addons') | xwiki_enabled_install_items }}"
XWIKI_DESIGN_ENABLED: "{{ lookup('config', application_id, 'services.design.enabled') | bool }}"
XWIKI_DESIGN_DIR: "{{ [lookup('container', application_id, 'directories.config'), 'design'] | path_join }}"
XWIKI_DESIGN_THEME_PAGE: "Infinito"
XWIKI_DESIGN_THEME_REFERENCE: "FlamingoThemes.{{ XWIKI_DESIGN_THEME_PAGE }}"
XWIKI_DESIGN_STOCK_THEME_PAGE: "Iceberg"
XWIKI_DESIGN_THEME_OBJECT: "objects/FlamingoThemesCode.ThemeClass/0"
XWIKI_DESIGN_LOGO_FILE: "logo.svg"
XWIKI_REST_THEME_PAGE: "{{ [XWIKI_REST_XWIKI, 'spaces/FlamingoThemes/pages', XWIKI_DESIGN_THEME_PAGE] | url_join }}"
XWIKI_REST_PREFERENCES: "{{ [XWIKI_REST_XWIKI_PAGES, 'XWikiPreferences/objects/XWiki.XWikiPreferences/0/properties'] | url_join }}"
XWIKI_DESIGN_TITLE_SUFFIX: " - {{ lookup('design', application_id).label }}"
XWIKI_DESIGN_TITLE: "$tdoc.getRenderedTitle('plain/1.0'){{ XWIKI_DESIGN_TITLE_SUFFIX }}"
XWIKI_DESIGN_PALETTE: "{{ lookup('design_palette', lookup('config', 'web-svc-design', 'services.design.colors.base')).light }}"
XWIKI_DESIGN_THEME_TOKENS:
text-color: text
body-bg: surface-1
xwiki-page-content-bg: surface-2
link-color: link
headings-color: text
brand-primary: primary
brand-success: success
brand-info: info
brand-warning: warning
brand-danger: danger
component-active-color: text
component-active-bg: surface-active
btn-default-color: text
btn-default-bg: surface-2
btn-default-border: border-strong
btn-primary-color: on-primary
btn-primary-bg: primary
btn-success-color: on-success
btn-success-bg: success
btn-info-color: on-info
btn-info-bg: info
btn-warning-color: on-warning
btn-warning-bg: warning
btn-danger-color: on-danger
btn-danger-bg: danger
navbar-default-bg: frame
navbar-default-color: on-frame
navbar-default-link-color: on-frame
navbar-default-link-hover-color: on-frame
navbar-default-link-hover-bg: frame-hover
navbar-default-link-active-color: on-frame
navbar-default-link-active-bg: frame-active
dropdown-bg: surface-2
dropdown-border: border
dropdown-divider-bg: border
dropdown-link-color: text
dropdown-link-hover-color: text
dropdown-link-hover-bg: surface-hover
input-bg: surface-2
input-border: border-strong
input-color: text
input-border-focus: link
input-color-placeholder: text-muted
legend-color: text
legend-border-color: border
panel-bg: surface-2
panel-header-text: text
breadcrumb-bg: surface-3
breadcrumb-color: text-muted
breadcrumb-link-color: link
table-bg: surface-2
table-bg-hover: surface-hover
table-border-color: border
XWIKI_DESIGN_LESS_TOKENS:
gray-light: text-muted
text-muted: text-muted
state-success-bg: success-subtle
state-info-bg: info-subtle
state-warning-bg: warning-subtle
state-danger-bg: danger-subtle
xwiki-border-color: border
xwiki-drawer-menu-item-hover-bg: surface-hover
xwiki-drawer-menu-item-hover-color: text
well-bg: surface-1
XWIKI_DESIGN_THEME_COLORS: >-
{{ dict(XWIKI_DESIGN_THEME_TOKENS.keys()
| zip(XWIKI_DESIGN_THEME_TOKENS.values() | map('regex_replace', '^', '--design-') | map('extract', XWIKI_DESIGN_PALETTE))) }}
XWIKI_DESIGN_LESS_COLORS: >-
{{ dict(XWIKI_DESIGN_LESS_TOKENS.keys()
| zip(XWIKI_DESIGN_LESS_TOKENS.values() | map('regex_replace', '^', '--design-') | map('extract', XWIKI_DESIGN_PALETTE))) }}
XWIKI_DESIGN_LESS_CODE: >-
{% for name, value in XWIKI_DESIGN_LESS_COLORS | dictsort %}@{{ name }}: {{ value }};
{% endfor %}@list-group-active-border: @list-group-border; @headings-font-weight: 500;