feat(web-app-bigbluebutton): carry the corporate design through Greenlight's site settings

Greenlight 3.8 has no theme option beyond its brand color and branding
image. tasks/03_design.yml sets PrimaryColor, PrimaryColorLight and
BrandingImage through files/ruby/site_design.rb on rails runner's stdin; it
replaces only the upstream default or the role's own earlier value, which
storage/infinito-design.json records. templates/style.css.j2 maps
Bootstrap's root variables and Greenlight's fixed colors onto the design
tokens and forces --brand-color in dark mode, because App.jsx sets it inline
on every render; text in the brand color uses --design-link.
templates/design.js.j2 sets the favicon.

Blocking defects fixed on the way: greenlight_ensure_admin.rb created the
administrator unverified, so the local sign-in rejected it; it now sets
verified and active, assigns the password only when authenticate fails and
saves only on a change. tasks/02_administrator.yml passes name, e-mail and
password through -e NAME and the task environment instead of a base64
password in argv. With SSO on, USE_EMAIL_AS_EXTERNAL_ID_FALLBACK lets the
OIDC sign-in land on that administrator.

That fallback links an OIDC login to a local account by e-mail without a
verification check. Greenlight forbids local sign-up and local password
sign-in while OIDC is active, and Keycloak refuses duplicate e-mails, so a
fresh SSO deployment only exposes the provisioned administrator, whose
address Keycloak already holds. Local accounts created before SSO was
switched on stay claimable by a Keycloak user who sets their address;
linking the administrator by its Keycloak subject would remove the
fallback.

TODO.md records the container limits that never apply, the unthemed meeting
client, three tasks that always report changed and the open registration.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
kevinveenbirkenbachandClaude Opus 5.5 committed 2026-10-12 19:03:26 +02:00
1 parent dda97e2899
commit 90d491b5a7
15 files changed
+928 -20

No files matched your search

+3 -1
View File
@@ -90,7 +90,8 @@ Solid `1:1` edges are fixed relationships; dashed `0..1` edges are conditional (
- 🔐 **SSO with OIDC & LDAP** (optional)
- 🧱 Automatic `.env` templating and domain/NGINX integration
- 🛠 Volume patching and Docker Compose customization
- 📬 SMTP integration and Greenlight admin creation
- 📬 SMTP integration and Greenlight admin creation: the administrator is verified and active, and its password converges on every deploy
- 🎨 Corporate design for Greenlight: brand color and logo through the Greenlight site settings, dark mode through the role stylesheet; the meeting client (`/html5client`) keeps the upstream design
- 🧪 Workarounds for known Docker Compose or Etherpad issues
## Quick Setup
@@ -150,6 +151,7 @@ docker run --rm -it \
- [BigBlueButton Docker Docs](https://docs.bigbluebutton.org/greenlight/v3/install/)
- [Networking Fixes & Issues](https://stackoverflow.com/questions/53347951/web-app-network-not-found)
- [Corporate design review: screenshots in light, dark, desktop and mobile](https://claude.ai/artifact/Q7yW5QtrffnuDSQvmRZSXu)
## Credits
+4
View File
@@ -1,3 +1,7 @@
# Todo
- Propper implement and test the LDAP integration, the configuration values just had been set during refactoring
- The declared `cpus` and `mem_limit` of the `bigbluebutton` and `greenlight` entities never reach a container: `docker inspect` reports `NanoCpus=0` and `Memory=0` for `bigbluebutton-greenlight-1`, `bigbluebutton-bbb-web-1` and `bigbluebutton-apps-akka-1`, because `templates/compose.override.yml.j2` does not include `roles/sys-svc-container/templates/base.yml.j2`. Options: include the base template per service in the override, or render `resource.yml.j2` for the services that carry limits.
- The meeting client under `/html5client` keeps the upstream design: the role proxies it with `proxy_lua_enabled: false`, so the injected stylesheet never reaches it. Option: point `public.app.customStyleUrl` in `conf/bbb-html5.yml` (mounted into `apps-akka` by upstream) at the role stylesheet on the CDN, then verify it on a meeting page with a working media backend.
- Three tasks report `changed` on every role sync of an unchanged tree (measured in two consecutive syncs): `✏️ Write compose.override.yml for BigBlueButton`, `🏷️ Tag dockerize so the upstream script finds it locally` and `📁 create compose.yml for bigbluebutton`, so the role is not idempotent and notifies `compose-up` each run. Options: compare the rendered content before writing, and retag dockerize only when the local tag is missing.
- Greenlight's site setting `RegistrationMethod` is `open` on a fresh deploy (measured through `rails runner`), so any visitor can create an account on `/signup`. Options: set it to `invite` or `approval` from the role through `SiteSetting`, or make it a role variable.
@@ -5,6 +5,8 @@ const { skipUnlessServiceEnabled } = require("./service-gating");
const { assertCspResponseHeader, decodeDotenvQuotedValue, expectNoCspViolations, gotoOnion, installCspViolationObserver, normalizeBaseUrl, performKeycloakLoginForm, runGuestFlow } = require("./personas");
test.use({ ignoreHTTPSErrors: true });
require("./test-design");
function attachDiagnostics(page) {
const consoleErrors = [];
const pageErrors = [];
@@ -0,0 +1,401 @@
const { test, expect } = require("@playwright/test");
const {
assertDesignTokens,
assertLightAndDark,
assertReadable,
assertToken,
captureDesignGallery,
galleryEnabled,
tokenValue,
} = require("./design");
const { decodeDotenvQuotedValue, gotoOnion, normalizeBaseUrl, performKeycloakLoginForm } = require("./personas");
const { skipUnlessServiceEnabled } = require("./service-gating");
const { isOnionTarget, isSplitRealmOidc, resolveTimeout } = require("./timeouts");
const base = normalizeBaseUrl(process.env.BBB_BASE_URL || "").replace(/\/$/, "");
const oidcIssuer = normalizeBaseUrl(process.env.OIDC_ISSUER_URL || "");
const adminEmail = decodeDotenvQuotedValue(process.env.ADMIN_EMAIL || "");
const adminUsername = decodeDotenvQuotedValue(process.env.ADMIN_USERNAME || "");
const adminPassword = decodeDotenvQuotedValue(process.env.ADMIN_PASSWORD || "");
const logoUrl = decodeDotenvQuotedValue(process.env.DESIGN_LOGO_URL || "");
const faviconUrl = decodeDotenvQuotedValue(process.env.DESIGN_FAVICON_URL || "");
const ssoEnabled = process.env.SSO_SERVICE_ENABLED === "true";
const MODES = ["light", "dark"];
const DESKTOP = { width: 1440, height: 900 };
const EMAIL = "#signInFormEmail";
const PASSWORD = "#signInFormPwd";
const SUBMIT = "form button[type='submit'].btn-brand";
const NAVBAR = "#navbar";
const LOGO = "img[alt='CompanyLogo']";
const HERO = "#homepage-hero h1";
const ROOM_CARD = "#room-card";
const NEW_ROOM = "button.btn-brand.ms-auto";
const ADMIN = "#admin-panel";
const USERS_ROW = "#manage-users-table tbody tr";
const SHOWCASE = "Design Showcase";
test.use({ ignoreHTTPSErrors: true });
function shown(selector) {
return async (page) => {
await expect(page.locator(selector).first()).toBeVisible({ timeout: resolveTimeout(10_000) });
};
}
function skipUnreachable() {
test.skip(isOnionTarget(), "BigBlueButton is WebRTC (UDP/ICE); not served over Tor");
test.skip(ssoEnabled && isSplitRealmOidc(), "clearnet app with an onion OIDC issuer: unreachable from one browser");
}
/**
* Args:
* page: Playwright page that ends signed in as the administrator on /rooms, through Keycloak when sso is on, through Greenlight's own form otherwise.
*/
async function signIn(page) {
await page.context().clearCookies();
if (ssoEnabled) {
await gotoOnion(page, `${base}/?sso=true`);
await page.waitForURL((u) => u.toString().startsWith(`${oidcIssuer}/protocol/openid-connect/auth`), {
timeout: resolveTimeout(60_000),
});
await performKeycloakLoginForm(page, adminUsername, adminPassword);
await page.waitForURL(
(u) => u.toString().startsWith(`${base}/`) && !u.toString().includes("/auth/openid_connect") && !u.toString().includes("sso=true"),
{ timeout: resolveTimeout(60_000) },
);
await gotoOnion(page, `${base}/rooms`);
} else {
await gotoOnion(page, `${base}/signin`);
await expect(page.locator(EMAIL)).toBeEditable({ timeout: resolveTimeout(10_000) });
await page.locator(EMAIL).fill(adminEmail);
await expect(page.locator(PASSWORD)).toBeEditable({ timeout: resolveTimeout(10_000) });
await page.locator(PASSWORD).fill(adminPassword);
await page.locator(SUBMIT).click({ timeout: resolveTimeout(10_000) });
}
await expect
.poll(() => page.url(), { timeout: resolveTimeout(30_000), message: "the administrator must land on /rooms" })
.toMatch(/\/rooms(\/|\?|$)/);
await shown(NAVBAR)(page);
}
/**
* Args:
* page: Playwright page signed in as the administrator; creates the showcase room through Greenlight's API when it is missing.
*
* Returns:
* {room: friendly id of the showcase room, user: id of the administrator}
*/
async function seedShowcase(page) {
return page.evaluate(async (name) => {
const token = document.querySelector("meta[name=csrf-token]").getAttribute("content");
const headers = { Accept: "application/json", "Content-Type": "application/json", "X-CSRF-TOKEN": token };
const session = await (await fetch("/api/v1/sessions.json", { headers })).json();
const find = async () => {
const list = await (await fetch(`/api/v1/rooms.json?search=${encodeURIComponent(name)}`, { headers })).json();
return (list.data || []).find((room) => room.name === name);
};
let room = await find();
if (!room) {
await fetch("/api/v1/rooms.json", {
method: "POST",
headers,
body: JSON.stringify({ room: { name, user_id: session.data.id } }),
});
room = await find();
}
return { room: room.friendly_id, user: session.data.id };
}, SHOWCASE);
}
test("design: the administrator signs in with the account the role provisions", async ({ page }) => {
skipUnlessServiceEnabled("design");
skipUnreachable();
await signIn(page);
await gotoOnion(page, `${base}/admin/users`);
await shown(USERS_ROW)(page);
});
test("design: corporate tokens apply in light and dark mode", async ({ page }) => {
skipUnlessServiceEnabled("design");
skipUnreachable();
await gotoOnion(page, `${base}/`);
await shown(HERO)(page);
await assertDesignTokens(page, "bigbluebutton");
});
test("design: the home page takes surface, header, primary and text from the palette", async ({ page }) => {
skipUnlessServiceEnabled("design");
skipUnreachable();
await page.setViewportSize(DESKTOP);
await gotoOnion(page, `${base}/`);
await shown(HERO)(page);
for (const mode of MODES) {
await page.emulateMedia({ colorScheme: mode });
await assertToken(page, "body", "background-color", "--design-surface-1", `body ${mode}`);
await assertToken(page, NAVBAR, "background-color", "--design-surface-2", `navbar ${mode}`);
await assertToken(page, HERO, "color", "--design-text", `hero ${mode}`);
await assertToken(page, ".homepage-card-icon-circle", "background-color", "--design-primary", `feature icon ${mode}`);
await assertToken(page, ".homepage-card-icon-circle svg", "color", "--design-on-primary", `feature icon glyph ${mode}`);
await assertToken(page, "#footer a", "color", "--design-link", `footer link ${mode}`);
await assertToken(page, "#footer-container", "border-top-color", "--design-border", `footer divider ${mode}`);
}
await page.emulateMedia({ colorScheme: null });
await assertLightAndDark(page, "body", "bigbluebutton home");
await assertReadable(page, [HERO, "#footer a"], "bigbluebutton home");
});
test("design: the sign-in button carries the primary with its on-color in both modes", async ({ page }) => {
skipUnlessServiceEnabled("design");
skipUnreachable();
test.skip(ssoEnabled, "the identity provider replaces the native sign-in form");
await gotoOnion(page, `${base}/signin`);
await shown(EMAIL)(page);
await page.locator(EMAIL).evaluate((field) => field.blur());
for (const mode of MODES) {
await page.emulateMedia({ colorScheme: mode });
await assertToken(page, SUBMIT, "background-color", "--design-primary", `sign-in ${mode}`);
await assertToken(page, SUBMIT, "color", "--design-on-primary", `sign-in ${mode}`);
await assertToken(page, EMAIL, "background-color", "--design-surface-2", `email field ${mode}`);
await assertToken(page, EMAIL, "border-top-color", "--design-border-strong", `email field ${mode}`);
await page.locator(SUBMIT).hover();
await assertToken(page, SUBMIT, "background-color", "--design-primary-hover", `hovered sign-in ${mode}`);
await expect(page.locator(SUBMIT), `hovered sign-in ${mode} has no inset shadow`).toHaveCSS("box-shadow", "none");
await page.mouse.move(0, 0);
await page.locator(EMAIL).focus();
await assertToken(page, EMAIL, "border-top-color", "--design-link", `focused email field ${mode}`);
await page.locator(EMAIL).evaluate((field) => field.blur());
}
await page.emulateMedia({ colorScheme: null });
await assertReadable(page, [SUBMIT, ".card-title", "label"], "bigbluebutton sign-in");
});
test("design: the site settings hold the light primary and the generated logo", async ({ page }) => {
skipUnlessServiceEnabled("design");
skipUnreachable();
await page.setViewportSize(DESKTOP);
await signIn(page);
await page.emulateMedia({ colorScheme: "light" });
const stored = await page.evaluate(async () => {
const response = await fetch("/api/v1/site_settings.json?names[]=PrimaryColor&names[]=PrimaryColorLight", {
headers: { Accept: "application/json" },
});
return (await response.json()).data;
});
const probe = (value) =>
page.evaluate((color) => {
const span = document.createElement("span");
span.style.color = color;
document.body.appendChild(span);
const computed = getComputedStyle(span).color;
span.remove();
return computed;
}, value);
expect(await probe(stored.PrimaryColor), "PrimaryColor must be the light primary").toBe(
await tokenValue(page, "--design-primary", "color"),
);
expect(await probe(stored.PrimaryColorLight), "PrimaryColorLight must be the light primary-100").toBe(
await tokenValue(page, "--design-primary-100", "color"),
);
await page.emulateMedia({ colorScheme: null });
test.skip(!logoUrl, "logo replacement is disabled for this role");
const logo = page.locator(`${NAVBAR} ${LOGO}`).first();
await expect(logo).toHaveAttribute("src", logoUrl);
await expect.poll(() => logo.evaluate((img) => img.complete && img.naturalWidth), { timeout: resolveTimeout(10_000) }).toBeGreaterThan(0);
const box = await logo.boundingBox();
expect(box && box.width > box.height * 2, "the header logo is a wide lockup").toBe(true);
});
test("design: the rooms list and its card follow the palette in both modes", async ({ page }) => {
skipUnlessServiceEnabled("design");
skipUnreachable();
await page.setViewportSize(DESKTOP);
await signIn(page);
await seedShowcase(page);
await gotoOnion(page, `${base}/rooms`);
await shown(ROOM_CARD)(page);
for (const mode of MODES) {
await page.emulateMedia({ colorScheme: mode });
await assertToken(page, ROOM_CARD, "background-color", "--design-surface-2", `room card ${mode}`);
await assertToken(page, `${ROOM_CARD} .room-icon`, "background-color", "--design-primary-100", `room icon ${mode}`);
await assertToken(page, `${ROOM_CARD} .btn-brand-outline`, "color", "--design-link", `start button ${mode}`);
await assertToken(page, NEW_ROOM, "background-color", "--design-primary", `new room ${mode}`);
await assertToken(page, NEW_ROOM, "color", "--design-on-primary", `new room ${mode}`);
await assertToken(page, ".wide-white", "background-color", "--design-surface-2", `tab strip ${mode}`);
}
await page.emulateMedia({ colorScheme: null });
await assertLightAndDark(page, ROOM_CARD, "bigbluebutton rooms");
await assertReadable(page, [`${ROOM_CARD} .card-title`, { selector: `${ROOM_CARD} .text-muted`, optional: true }, ".nav-tabs .nav-link"], "bigbluebutton rooms");
});
test("design: admin tables use the border token as divider in both modes", async ({ page }) => {
skipUnlessServiceEnabled("design");
skipUnreachable();
await page.setViewportSize(DESKTOP);
await signIn(page);
await gotoOnion(page, `${base}/admin/users`);
await shown(USERS_ROW)(page);
for (const mode of MODES) {
await page.emulateMedia({ colorScheme: mode });
await assertToken(page, `${USERS_ROW} td`, "border-bottom-color", "--design-border", `user row ${mode}`);
await assertToken(page, "#admin-sidebar", "border-right-color", "--design-border", `sidebar divider ${mode}`);
await assertToken(page, "#admin-sidebar .nav-link.active", "background-color", "--design-surface-active", `active entry ${mode}`);
}
await page.emulateMedia({ colorScheme: null });
await assertReadable(page, [`${USERS_ROW} td`, "#admin-sidebar .nav-link"], "bigbluebutton admin users");
});
test("design: the favicon and the title are the configured ones", async ({ page }) => {
skipUnlessServiceEnabled("design");
skipUnreachable();
test.skip(!faviconUrl, "logo replacement is disabled for this role");
await gotoOnion(page, `${base}/`);
await shown(HERO)(page);
await expect(page.locator("link[rel~='icon']").first()).toHaveAttribute("href", faviconUrl);
await expect(page).toHaveTitle("BigBlueButton");
});
/**
* Args:
* page: Playwright page on `url`; signs in again and reopens `url` when the session is gone.
* url: absolute URL of the view.
*/
async function signedInAt(page, url) {
const signedIn = await page.evaluate(async () => {
const response = await fetch("/api/v1/sessions.json", { headers: { Accept: "application/json" } });
return Boolean((await response.json()).data?.id);
});
if (!signedIn) {
await signIn(page);
await gotoOnion(page, url);
}
}
function view(name, path, ready) {
const url = `${base}${path}`;
return {
name,
url,
prepare: async (page) => {
await signedInAt(page, url);
await shown(ready)(page);
},
};
}
function visitor(name, path, ready) {
const url = `${base}${path}`;
return {
name,
url,
prepare: async (page) => {
await page.context().clearCookies();
await gotoOnion(page, "about:blank");
await gotoOnion(page, url);
await shown(ready)(page);
},
};
}
async function settled(page, selector) {
await shown(selector)(page);
await expect
.poll(
() =>
page
.locator(selector)
.first()
.evaluate((el) => getComputedStyle(el).opacity === "1" && el.getAnimations({ subtree: true }).every((a) => a.playState !== "running")),
{ timeout: resolveTimeout(10_000) },
)
.toBe(true);
}
function tab(name, path, label, ready) {
const url = `${base}${path}`;
return {
name,
url,
prepare: async (page) => {
await signedInAt(page, url);
const target = page.getByRole("tab", { name: label }).first();
await expect(target).toBeVisible({ timeout: resolveTimeout(10_000) });
await target.click({ timeout: resolveTimeout(10_000) });
await settled(page, ready);
},
};
}
function opened(name, path, trigger, ready) {
const url = `${base}${path}`;
return {
name,
url,
prepare: async (page) => {
await signedInAt(page, url);
await shown(trigger)(page);
await page.locator(trigger).first().click({ timeout: resolveTimeout(10_000) });
await settled(page, ready);
},
};
}
function userMenu() {
const url = `${base}/rooms`;
return {
name: "user-menu",
url,
prepare: async (page) => {
await signedInAt(page, url);
const toggler = page.locator(".navbar-toggler");
if (await toggler.isVisible()) {
await toggler.click({ timeout: resolveTimeout(10_000) });
await settled(page, "#navbar-menu.show");
} else {
await page.locator("#nav-user-dropdown").click({ timeout: resolveTimeout(10_000) });
await settled(page, ".dropdown-menu.show");
}
},
};
}
function galleryViews(seed) {
return [
view("rooms", "/rooms", ROOM_CARD),
opened("rooms-new-modal", "/rooms", NEW_ROOM, ".modal.show .modal-content"),
tab("rooms-recordings", "/rooms", /recordings/i, ".tab-pane.active"),
userMenu(),
view("room", `/rooms/${seed.room}`, ".room-header-wrapper h1"),
tab("room-access", `/rooms/${seed.room}`, /access/i, ".tab-pane.active"),
tab("room-settings", `/rooms/${seed.room}`, /settings/i, "#room-settings"),
view("profile", "/profile", "#profile-sidebar"),
tab("profile-password", "/profile", /password/i, ".tab-pane.active form"),
view("admin-users", "/admin/users", USERS_ROW),
opened("admin-users-new-modal", "/admin/users", `${ADMIN} .btn-brand`, ".modal.show form"),
view("admin-user-edit", `/admin/users/edit/${seed.user}`, `${ADMIN} form`),
view("admin-server-rooms", "/admin/server_rooms", "#server-rooms-table"),
view("admin-server-recordings", "/admin/server_recordings", "#server-recordings"),
view("admin-appearance", "/admin/site_settings", `${ADMIN} .btn-brand-light`),
opened("admin-brand-popover", "/admin/site_settings", `${ADMIN} .tab-pane.active .btn-brand`, ".popover.show"),
tab("admin-administration", "/admin/site_settings", /administration/i, `${ADMIN} .tab-pane.active form`),
tab("admin-settings", "/admin/site_settings", /^settings$/i, `${ADMIN} .tab-pane.active .form-switch`),
tab("admin-registration", "/admin/site_settings", /registration/i, `${ADMIN} .tab-pane.active .setting-select`),
view("admin-room-config", "/admin/room_configuration", `${ADMIN} .setting-select`),
opened("admin-room-config-menu", "/admin/room_configuration", `${ADMIN} .setting-select button`, ".setting-select .dropdown-menu.show"),
view("admin-roles", "/admin/roles", `${ADMIN} table tbody tr`),
visitor("home", "/", HERO),
visitor("sign-in", "/signin", EMAIL),
visitor("sign-up", "/signup", ".card input[type=email]"),
visitor("room-join", `/rooms/${seed.room}/join`, ".card input"),
];
}
test("design: gallery of visitor pages, rooms, profile and administration", async ({ page }) => {
skipUnlessServiceEnabled("design");
skipUnreachable();
test.skip(!galleryEnabled(), "INFINITO_PLAYWRIGHT_KEEP is not true");
test.setTimeout(resolveTimeout(900_000));
await signIn(page);
await captureDesignGallery(page, galleryViews(await seedShowcase(page)));
});
@@ -2,16 +2,10 @@
# nocheck: mirrored-unit-test - runs in `rails runner` against Greenlight's User and
# Role models; the constants only exist once the app has booted
require "base64"
new_name = ENV.fetch("NEW_NAME")
new_email = ENV.fetch("NEW_EMAIL").downcase
default_admin_email = ENV.fetch("DEFAULT_ADMIN_EMAIL", "admin@example.com").downcase
new_pw_b64 = ENV.fetch("NEW_PASSWORD_B64")
new_pw = Base64.decode64(new_pw_b64)
new_pw = new_pw.force_encoding("UTF-8")
new_pw = new_pw.encode("UTF-8", invalid: :replace, undef: :replace, replace: "?")
new_pw = ENV.fetch("NEW_PASSWORD")
def blankish?(v)
v.nil? || (v.respond_to?(:empty?) && v.empty?)
@@ -51,8 +45,8 @@ if user
changed = true
end
changed ||= ensure_attr!(user, :provider, "greenlight")
changed ||= ensure_attr!(user, :language, "en")
changed = ensure_attr!(user, :provider, "greenlight") || changed
changed = ensure_attr!(user, :language, "en") || changed
if user.respond_to?(:role=) && user.respond_to?(:role_id)
if user.role_id != admin_role.id
@@ -61,9 +55,21 @@ if user
end
end
user.password = new_pw
user.password_confirmation = new_pw if user.respond_to?(:password_confirmation=)
changed = true
unless user.verified?
user.verified = true
changed = true
end
unless user.active?
user.status = :active
changed = true
end
unless user.authenticate(new_pw)
user.password = new_pw
user.password_confirmation = new_pw if user.respond_to?(:password_confirmation=)
changed = true
end
else
attrs = {
email: new_email,
@@ -71,6 +77,8 @@ else
provider: "greenlight",
language: "en",
role: admin_role,
verified: true,
status: :active,
}
attrs[:name] = new_name if User.new.respond_to?(:name=)
@@ -78,6 +86,6 @@ else
changed = true
end
user.save!
user.save! if changed
puts(changed ? "CHANGED id=#{user.id} email=#{user.email}" : "NOOP id=#{user.id} email=#{user.email}")
@@ -0,0 +1,47 @@
# frozen_string_literal: true
# nocheck: mirrored-unit-test - runs in `rails runner` against Greenlight's SiteSetting model; the constants only exist once the app has booted
require "json"
MARK = Rails.root.join("storage", "infinito-design.json")
PROVIDER = "greenlight"
enabled = ENV.fetch("DESIGN_ENABLED") == "true"
defaults = {
"PrimaryColor" => "#467fcf",
"PrimaryColorLight" => "#e8eff9",
"BrandingImage" => ActionController::Base.helpers.image_path("bbb_logo.png"),
}
wanted = {
"PrimaryColor" => ENV.fetch("DESIGN_PRIMARY"),
"PrimaryColorLight" => ENV.fetch("DESIGN_PRIMARY_LIGHT"),
"BrandingImage" => ENV.fetch("DESIGN_LOGO_URL").presence || defaults["BrandingImage"],
}
mark = File.exist?(MARK) ? JSON.parse(File.read(MARK)) : {}
changed = false
wanted.each_key do |name|
setting = SiteSetting.joins(:setting).find_by(provider: PROVIDER, setting: { name: name })
next unless setting
next if name == "BrandingImage" && setting.image.attached?
next unless setting.value == mark[name] || setting.value == defaults[name]
target = enabled ? wanted[name] : defaults[name]
next if setting.value == target
setting.update!(value: target)
changed = true
end
if enabled
if mark != wanted
File.write(MARK, JSON.generate(wanted))
changed = true
end
elsif File.exist?(MARK)
File.delete(MARK)
changed = true
end
puts(changed ? "CHANGED" : "NOOP")
@@ -97,6 +97,11 @@ design:
bond: 1
enabled: "{{ 'web-svc-design' in group_names }}"
shared: "{{ 'web-svc-design' in group_names }}"
bootstrap: true
slots:
logo:
width: 330
height: 71
prometheus:
bond: 1
enabled: "{{ 'web-app-prometheus' in group_names }}"
@@ -70,6 +70,9 @@
- name: "🔧 Setup administrator"
include_tasks: "../02_administrator.yml"
- name: "🎨 Setup corporate design"
include_tasks: "../03_design.yml"
- name: "🔧 Pin search_path on immutable_lower_unaccent in every database"
ansible.builtin.shell: # nocheck: compose_mode_force role
cmd: >-
@@ -13,25 +13,27 @@
delay: 5
changed_when: false
- name: "✅ Ensure Greenlight admin (B64-safe, idempotent)"
- name: "✅ Ensure Greenlight admin (verified, idempotent)"
ansible.builtin.command:
argv:
- container
- exec
- -i
- -e
- "TARGET_NAME={{ lookup('users', 'administrator').username | upper }}"
- NEW_NAME
- -e
- "NEW_NAME={{ lookup('users', 'administrator').username | upper }}"
- NEW_EMAIL
- -e
- "NEW_EMAIL={{ lookup('users', 'administrator').email }}"
- -e
- "NEW_PASSWORD_B64={{ lookup('users', 'administrator').password | b64encode }}"
- NEW_PASSWORD
- "{{ BBB_GREENLIGHT_CONTAINER_ADDRESS | quote }}" # nocheck: container-address-quote, container-address-needs-shell compose_mode_force role, so the lookup can only yield a bare name
- bash
- -lc
- "bundle exec rails runner -"
stdin: "{{ lookup('ansible.builtin.file', role_path ~ '/files/ruby/greenlight_ensure_admin.rb') }}"
environment:
NEW_NAME: "{{ lookup('users', 'administrator').username | upper }}"
NEW_EMAIL: "{{ lookup('users', 'administrator').email }}"
NEW_PASSWORD: "{{ lookup('users', 'administrator').password }}"
register: greenlight_admin_map
changed_when: "'CHANGED' in greenlight_admin_map.stdout"
failed_when: greenlight_admin_map.rc != 0
@@ -0,0 +1,27 @@
---
- name: "🎨 Converge the Greenlight brand settings of {{ application_id }}"
ansible.builtin.command:
argv:
- container
- exec
- -i
- -e
- DESIGN_ENABLED
- -e
- DESIGN_PRIMARY
- -e
- DESIGN_PRIMARY_LIGHT
- -e
- DESIGN_LOGO_URL
- "{{ BBB_GREENLIGHT_CONTAINER_ADDRESS | quote }}" # nocheck: container-address-quote, container-address-needs-shell compose_mode_force role, so the lookup can only yield a bare name
- bash
- -lc
- "bundle exec rails runner -"
stdin: "{{ lookup('ansible.builtin.file', role_path ~ '/files/ruby/site_design.rb') }}"
environment:
DESIGN_ENABLED: "{{ BBB_DESIGN_ENABLED | bool | lower }}"
DESIGN_PRIMARY: "{{ BBB_DESIGN_PRIMARY }}"
DESIGN_PRIMARY_LIGHT: "{{ BBB_DESIGN_PRIMARY_LIGHT }}"
DESIGN_LOGO_URL: "{{ BBB_DESIGN_LOGO_URL }}"
register: bbb_design_settings
changed_when: "'CHANGED' in bbb_design_settings.stdout"
@@ -0,0 +1,20 @@
{% set design = lookup('design', application_id) %}
{% if design.logo is string %}
(function () {
var favicon = {{ design.urls.favicon_ico | to_json }};
function icon() {
var link = document.querySelector("link[rel~='icon']");
if (!link) {
link = document.createElement("link");
link.setAttribute("rel", "icon");
document.head.appendChild(link);
}
if (link.getAttribute("href") !== favicon) {
link.setAttribute("type", "image/x-icon");
link.setAttribute("href", favicon);
}
}
if (document.head) icon();
document.addEventListener("DOMContentLoaded", icon);
})();
{% endif %}
@@ -141,4 +141,5 @@ OPENID_CONNECT_CLIENT_ID={{ OIDC.CLIENT.ID }}
OPENID_CONNECT_CLIENT_SECRET={{ OIDC.CLIENT.SECRET }}
OPENID_CONNECT_ISSUER={{ OIDC.CLIENT.ISSUER_URL }}
OPENID_CONNECT_REDIRECT={{ lookup('tls', application_id, 'url.base') }}
USE_EMAIL_AS_EXTERNAL_ID_FALLBACK=true
{% endif %}
@@ -1,3 +1,5 @@
ADMIN_EMAIL={{ lookup('users', 'administrator').email | dotenv_quote }}
ADMIN_PASSWORD={{ lookup('users', 'administrator').password | dotenv_quote }}
ADMIN_USERNAME={{ lookup('users', 'administrator').username | dotenv_quote }}
@@ -12,6 +14,12 @@ 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_ico if lookup('design', application_id).logo is string else '') | dotenv_quote }}
DESIGN_LOGO_URL={{ (lookup('design', application_id).urls.logo.png if lookup('design', application_id).logo is string else '') | dotenv_quote }}
DESIGN_SERVICE_ENABLED={{ lookup('config', application_id, 'services.design.enabled') | string | lower }}
# 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 }}
@@ -0,0 +1,371 @@
:root {
--brand-color: var(--design-primary) !important;
--brand-color-light: var(--design-primary-100) !important;
--toastify-color-success: var(--design-success) !important;
--bs-body-bg: var(--design-surface-2);
--bs-body-bg-rgb: var(--design-surface-2-rgb);
--bs-body-color: var(--design-text);
--bs-body-color-rgb: var(--design-text-rgb);
--bs-emphasis-color: var(--design-text);
--bs-emphasis-color-rgb: var(--design-text-rgb);
--bs-secondary-color: var(--design-text-muted);
--bs-secondary-color-rgb: var(--design-text-muted-rgb);
--bs-secondary-bg: var(--design-surface-3);
--bs-tertiary-color: var(--design-text-muted);
--bs-tertiary-bg: var(--design-surface-3);
--bs-border-color: var(--design-border);
--bs-border-color-translucent: var(--design-border);
--bs-link-color: var(--design-link);
--bs-link-color-rgb: var(--design-link-rgb);
--bs-link-hover-color: var(--design-link);
--bs-link-hover-color-rgb: var(--design-link-rgb);
--bs-white-rgb: var(--design-surface-2-rgb);
--bs-dark-rgb: var(--design-text-rgb);
--bs-black-rgb: var(--design-text-rgb);
--bs-secondary-rgb: var(--design-text-muted-rgb);
--bs-success-rgb: var(--design-success-rgb);
--bs-danger-rgb: var(--design-danger-rgb);
--toastify-color-light: var(--design-surface-2);
--toastify-toast-background: var(--design-surface-2);
--toastify-text-color-light: var(--design-text);
--toastify-color-info: var(--design-info);
--toastify-color-warning: var(--design-warning);
--toastify-color-error: var(--design-danger);
--toastify-color-progress-light: var(--design-primary);
--toastify-spinner-color: var(--design-text-muted);
--toastify-spinner-color-empty-area: var(--design-border);
}
body {
background: var(--design-surface-1);
}
#navbar,
.wide-white {
background: var(--design-surface-2);
}
.wide-white {
box-shadow: 0 0 0 100vmax var(--design-surface-2);
}
.background-whitesmoke {
background: var(--design-surface-1) !important;
box-shadow: 0 0 0 100vmax var(--design-surface-1);
}
.border-whitesmoke {
border-color: var(--design-border) !important;
}
#navbar-menu a,
.dropdown a {
color: var(--design-text) !important;
}
.dropdown a:hover {
color: var(--design-link) !important;
}
.dropdown a:active,
.dropdown a:focus,
#admin-panel .dropdown-item:active,
#user-recordings .dropdown-item:active,
#room-recordings .dropdown-item:active {
background-color: var(--design-surface-active) !important;
}
#nav-user-dropdown:hover,
#nav-user-dropdown:active,
#nav-user-dropdown:focus {
background-color: var(--design-surface-hover) !important;
color: var(--design-text) !important;
}
#footer #footer-container {
border-top-color: var(--design-border);
}
#footer a,
.text-link {
color: var(--design-link) !important;
}
.text-brand {
color: var(--design-link);
}
.nav-link,
#admin-panel .dropdown-item,
#user-recordings .dropdown-item,
#room-recordings .dropdown-item,
#role-breadcrumb a,
.bg-placeholder {
color: var(--design-text-muted);
}
.nav-link:hover,
.nav-tabs .nav-link.active,
#admin-panel .dropdown-item:hover,
#user-recordings .dropdown-item:hover,
#room-recordings .dropdown-item:hover {
color: var(--design-link);
}
.back-button:hover,
.tenants-icons svg:hover,
.recordings-icons:first-child svg:hover,
#room-card .card-footer svg:hover {
color: var(--design-link) !important;
}
.nav-pills .nav-link.active {
background-color: var(--design-surface-active);
color: var(--design-text) !important;
}
.nav-pills .nav-link.active svg {
color: var(--design-link) !important;
}
#profile #profile-sidebar,
#admin-panel #admin-sidebar {
border-right-color: var(--design-border);
}
#admin-panel .admin-table table,
#admin-panel .admin-table td,
#admin-panel .admin-table tr,
#user-recordings tr,
#room-recordings tr {
border-color: var(--design-border) !important;
}
#user-recordings tbody tr:nth-child(10):not(.no-recordings-found),
#room-recordings tbody tr:nth-child(10):not(.no-recordings-found) {
border-bottom-color: var(--design-border) !important;
}
.btn-brand {
background-color: var(--design-primary);
border-color: var(--design-primary);
color: var(--design-on-primary);
}
.btn-brand:hover {
background-color: var(--design-primary-hover);
border-color: var(--design-primary-hover);
color: var(--design-on-primary);
box-shadow: none;
}
.btn-brand:active {
background-color: var(--design-primary-active) !important;
border-color: var(--design-primary-active) !important;
color: var(--design-on-primary) !important;
}
.btn-brand:focus,
.btn-brand-outline:focus,
.btn-brand-outline-color:focus,
.btn-brand-light:focus,
.btn-dropdown:focus {
box-shadow: var(--design-focus-ring);
}
.btn-brand-outline,
.btn-brand-outline-color,
.btn-neutral,
.btn-delete,
.danger-light-button,
.badge-brand-outline {
background-color: var(--design-surface-2) !important;
}
.btn-brand-outline,
.btn-neutral,
.btn-dropdown,
.badge-brand-outline {
border-color: var(--design-border-strong);
}
.btn-dropdown:hover {
border-color: var(--design-border-strong) !important;
}
.btn-brand-outline,
.btn-brand-outline-color,
.badge-brand-outline {
color: var(--design-link);
}
.btn-brand-outline-color,
.btn-brand-outline:hover {
border-color: var(--design-link);
}
.btn-brand-outline:hover,
.btn-brand-outline-color:hover,
.btn-neutral:hover,
.btn-delete:hover {
background-color: var(--design-surface-hover) !important;
box-shadow: none;
}
.btn-brand-outline:hover,
.btn-brand-outline-color:hover {
color: var(--design-link);
}
.btn-brand-outline:active {
background-color: var(--design-surface-active) !important;
border-color: var(--design-link) !important;
color: var(--design-link) !important;
}
.btn-brand-outline:disabled {
color: var(--design-text-muted) !important;
}
.btn-neutral,
.btn-neutral:hover {
color: var(--design-text-muted);
}
.btn-delete,
.btn-delete:hover,
.danger-light-button {
color: var(--design-danger);
border-color: var(--design-danger);
}
.btn-brand-light {
background-color: var(--design-primary-100);
border-color: var(--design-primary-100);
color: var(--design-primary-900);
}
.btn-brand-light:hover {
background-color: var(--design-primary-200);
border-color: var(--design-primary-200);
color: var(--design-primary-900);
box-shadow: none;
}
.btn-link {
color: var(--design-link);
}
.btn-link:hover {
color: var(--design-link) !important;
}
.btn-link:disabled,
.btn-link.disabled {
color: var(--design-text-muted);
}
.setting-select button,
.simple-select button,
.setting-select button:hover,
.simple-select button:hover,
.setting-select button:active,
.simple-select button:active,
.setting-select button:focus,
.simple-select button:focus {
background: var(--design-surface-2) !important;
color: var(--design-text) !important;
border-color: var(--design-border-strong) !important;
}
.setting-select button:focus,
.simple-select button:focus {
border-color: var(--design-link) !important;
box-shadow: var(--design-focus-ring) !important;
}
.select .dropdown-item.active,
.select .dropdown-item:active,
.custom-select .select-brand-option:active {
background-color: var(--design-surface-active) !important;
color: var(--design-text) !important;
}
.custom-select .select-brand-option {
background-color: var(--design-surface-hover);
color: var(--design-link) !important;
}
.form-control:focus,
input[type="text"]:focus,
input.search-bar:focus {
border-color: var(--design-link) !important;
box-shadow: var(--design-focus-ring-inset) !important;
}
input.search-bar {
background-color: var(--design-surface-2);
}
.form-check-input {
background-color: var(--design-surface-2);
border-color: var(--design-border-strong);
}
.form-check-input:checked {
background-color: var(--design-primary);
border-color: var(--design-primary);
}
input[type="checkbox"]:focus {
border-color: var(--design-link) !important;
box-shadow: var(--design-focus-ring) !important;
}
.badge.bg-success {
--bs-badge-color: var(--design-on-success);
}
.badge.bg-danger {
--bs-badge-color: var(--design-on-danger);
}
.participants-badge,
.online-badge,
.shared-badge,
.role-badge,
.recordings-count-badge {
background-color: var(--design-surface-3) !important;
color: var(--design-text-muted);
}
#profile-avatar .avatar-icon-circle {
background-color: var(--design-surface-3);
color: var(--design-text-muted);
box-shadow: 0 0 0 2px var(--design-border);
}
.blinking-green-dot {
border-color: var(--design-success);
background-color: var(--design-success);
}
.homepage-card-icon-circle {
box-shadow: 0 0 0 8px var(--design-surface-1);
}
.homepage-card-icon-circle svg {
color: var(--design-on-primary) !important;
}
#room-card:hover {
box-shadow: var(--design-shadow-2) !important;
}
.card-shadow {
box-shadow: var(--design-shadow-1) !important;
}
::-webkit-scrollbar-thumb {
background: var(--design-neutral-300);
}
@@ -50,3 +50,10 @@ BBB_GREENLIGHT_CONTAINER_ADDRESS: "{{ lookup('container_address', application_id
BBB_LDAP_ENABLED: "{{ lookup('config', application_id, 'services.ldap.enabled') }}"
BBB_OIDC_ENABLED: "{{ lookup('config', application_id, 'services.sso.enabled') }}"
BBB_DESIGN_ENABLED: "{{ lookup('config', application_id, 'services.design.enabled') | bool }}"
BBB_DESIGN_PALETTE: "{{ lookup('design_palette', lookup('config', 'web-svc-design', 'services.design.colors.base')).light }}"
BBB_DESIGN_PRIMARY: "{{ BBB_DESIGN_PALETTE['--design-primary'] if BBB_DESIGN_ENABLED | bool else '' }}"
BBB_DESIGN_PRIMARY_LIGHT: "{{ BBB_DESIGN_PALETTE['--design-primary-100'] if BBB_DESIGN_ENABLED | bool else '' }}"
BBB_DESIGN_LOGO_URL: "{{ lookup('design', application_id).urls.logo.png if (BBB_DESIGN_ENABLED | bool and lookup('design', application_id).logo is string) else ''
}}"