feat(web-app-pgadmin): bring the corporate design to pgAdmin and converge its administrator

templates/style.css.j2 maps page, panes, object tree, tabs and primary
actions onto the design tokens and makes the menu bar the brand frame;
templates/design.js.j2 follows pgAdmin's native theme switch. The logo
and login-logo slots meta/services.yml declares sit on frame chrome, and
with design on templates/env.j2 sets PGADMIN_CONFIG_APP_NAME to the
configured title.

pgAdmin keeps its internal user in its own database, and
PGADMIN_DEFAULT_PASSWORD only seeds the first start. tasks/main.yml now
flushes the compose handlers and, on the stack host outside server
mode, runs tasks/administrator.yml with files/python/administrator.py:
it creates the internal administrator if missing, otherwise realigns
password, active and locked state, failed login attempts and the
Administrator role. The identity is the platform administrator as on
feature/store-i18n: PGADMIN_ADMIN_EMAIL and PGADMIN_ADMIN_PASSWORD in
vars/main.yml, meta/users.yml declares the user, and meta/secrets.yml,
whose only credential the role no longer reads, is gone.
tests/unit/python/roles/web-app-pgadmin/files/test_administrator.py
covers the script. meta/services.yml names the container pgadmin for
PGADMIN_CONTAINER_ADDRESS.

files/playwright/test-design.js checks tokens, that the administrator
signs in with that password and holds admin rights, panes, tree, tabs,
the menu bar frame and its focus indicator, primary actions, the theme
switch, logo, favicon and title, and captures the gallery against the
role's own database server; templates/playwright.env.j2 hands the
platform administrator, the design URLs and the database to it.
README.md links the gallery; TODO.md records the Gravatar request and
the memory pressure at the 256m limit.

The design was verified green on the compose stack (10 passed); the
switch to the platform administrator is checked statically only, 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:
kevinveenbirkenbachandClaude Opus 5.5 committed 2026-10-13 13:27:27 +02:00
1 parent 9a797a805a
commit 0709de6fa1
18 files changed
+921 -9

No files matched your search

+1
View File
@@ -115,6 +115,7 @@ docker run --rm -it \
- [pgAdmin Official Homepage](https://www.pgadmin.org/)
- [pgAdmin Documentation](https://www.pgadmin.org/docs/)
- [Corporate design review: screenshots in light, dark and desktop](https://claude.ai/artifact/QvGNAugdfpWe5Ed7SqeDRX)
## Persona contract opt-outs
+4
View File
@@ -0,0 +1,4 @@
# Todos
- The user menu loads `https://www.gravatar.com/avatar/<md5 of the administrator e-mail>` on every signed-in page (measured in the browser; the role CSP allows `img-src *`). pgAdmin's default `SHOW_GRAVATAR_IMAGE = True` sends the e-mail hash to a third party. Options: set `PGADMIN_CONFIG_SHOW_GRAVATAR_IMAGE=False` in `templates/env.j2`, or whitelist and accept it explicitly.
- The pgAdmin container runs at its `mem_limit: 256m`: `memory.current` 252 to 265 MiB with `memory.events max` at 1098 to 1774 within minutes of a start, no OOM kill. The administrator convergence script (`/venv/bin/python3` with `create_app`) peaks at 197 MiB RSS and takes about 27 s at `cpus: "0.2"`. Options: raise `mem_limit` to 512m and `cpus` to 0.5 in `meta/services.yml`, or keep the limits and accept the reclaim pressure.
@@ -13,6 +13,8 @@ test.beforeEach(async ({ page }) => {
await page.context().clearCookies();
});
require("./test-design").register();
test("pgAdmin front page is served under canonical domain with TLS", async ({ page }) => {
const response = await gotoOnion(page, `${appBaseUrl}/`);
expect(response, "Expected pgAdmin response").toBeTruthy();
@@ -0,0 +1,347 @@
const { test, expect } = require("@playwright/test");
const {
assertDesignTokens,
assertLightAndDark,
assertReadable,
assertToken,
captureDesignGallery,
galleryEnabled,
tokenValue,
} = require("./design");
const { apiGetOnion, decodeDotenvQuotedValue, gotoOnion, performKeycloakLoginForm } = require("./personas");
const { skipUnlessServiceEnabled } = require("./service-gating");
const { resolveTimeout } = require("./timeouts");
const MODES = ["light", "dark"];
const MENU_BAR = "[data-test='app-menu-bar']";
const MENU_BUTTON = `${MENU_BAR} button[data-label]`;
const USER_MENU = "[data-test='loggedin-username']";
const LOGO = ".AppMenuBar-logo";
const LOGIN_LOGO = ".BasePage-logo";
const LOGIN_CARD = ".BasePage-pageContent";
const LOGIN_BUTTON = "button[name='internal_button']";
const TREE = ".file-tree";
const ENTRY = ".file-entry";
const PANE = ".dock-layout .dock-tabpane-active";
const TAB = ".dock-layout .dock-tab";
const ACTIVE_TAB = ".dock-layout .dock-tab-active";
const OPEN_MENU = ".szh-menu--state-open";
const DIALOG = ".dock-panel.dock-style-dialogs, .MuiDialog-paper";
const SERVER_NAME = "Infinito Postgres";
const READY = 10_000;
const base = decodeDotenvQuotedValue(process.env.APP_BASE_URL).replace(/\/$/, "");
const adminEmail = decodeDotenvQuotedValue(process.env.ADMIN_EMAIL);
const adminPassword = decodeDotenvQuotedValue(process.env.ADMIN_PASSWORD);
const ssoUsername = decodeDotenvQuotedValue(process.env.ADMIN_USERNAME);
const db = {
host: decodeDotenvQuotedValue(process.env.PGADMIN_DB_HOST),
name: decodeDotenvQuotedValue(process.env.PGADMIN_DB_NAME),
password: decodeDotenvQuotedValue(process.env.PGADMIN_DB_PASSWORD),
port: decodeDotenvQuotedValue(process.env.PGADMIN_DB_PORT),
user: decodeDotenvQuotedValue(process.env.PGADMIN_DB_USER),
};
/**
* Args:
* page: Playwright page; opens /login, passing the identity provider first when sso gates the proxy.
*/
async function openLogin(page) {
await gotoOnion(page, `${base}/login`);
if (page.url().includes("openid-connect/auth")) {
await performKeycloakLoginForm(page, ssoUsername, adminPassword);
await expect.poll(() => page.url(), { timeout: resolveTimeout(60_000) }).toContain(base);
await gotoOnion(page, `${base}/login`);
}
}
/**
* Args:
* page: Playwright page; ends signed in on /browser/ with the menu bar and the tree rendered.
*/
async function signIn(page) {
await openLogin(page);
if (page.url().includes("/login")) {
const email = page.locator("input[name='email']");
await expect(email).toBeEditable({ timeout: resolveTimeout(READY) });
await email.fill(adminEmail);
const password = page.locator("input[name='password']");
await expect(password).toBeEditable({ timeout: resolveTimeout(READY) });
await password.fill(adminPassword);
await page.locator(LOGIN_BUTTON).click();
}
await expect(page.locator(MENU_BAR)).toBeVisible({ timeout: resolveTimeout(60_000) });
await expect(page.locator(TREE).first()).toBeVisible({ timeout: resolveTimeout(30_000) });
}
async function onBrowser(page) {
if (page.url().includes("/login") || page.url().includes("openid-connect")) await signIn(page);
await expect(page.locator(TREE).first()).toBeVisible({ timeout: resolveTimeout(30_000) });
}
async function openMenu(page, menu, item) {
await page.locator(`${MENU_BAR} button[data-label='${menu}']`).click({ timeout: resolveTimeout(READY) });
const panel = page.locator(OPEN_MENU).first();
await expect(panel).toBeVisible({ timeout: resolveTimeout(READY) });
if (item) await panel.getByRole("menuitem", { name: item }).first().click({ timeout: resolveTimeout(READY) });
}
async function openSubMenu(page, menu, group, item) {
await openMenu(page, menu);
await page.locator(OPEN_MENU).getByRole("menuitem", { name: group }).first().hover({ timeout: resolveTimeout(READY) });
await page.locator(OPEN_MENU).getByRole("menuitem", { name: item }).first().click({ timeout: resolveTimeout(READY) });
}
function entry(page, text) {
return page.locator(ENTRY).filter({ has: page.locator(".file-name", { hasText: new RegExp(`^${text}$`) }) }).first();
}
async function selectEntry(page, text) {
const node = entry(page, text);
await node.click({ timeout: resolveTimeout(READY) });
await expect(node).toHaveClass(/active/, { timeout: resolveTimeout(READY) });
}
async function openTab(page, title) {
await page.locator(`${TAB}:visible`, { hasText: title }).first().click({ timeout: resolveTimeout(READY) });
await expect(page.locator(ACTIVE_TAB, { hasText: title }).first()).toBeVisible({ timeout: resolveTimeout(READY) });
}
async function openDialog(page) {
const dialog = page.locator(DIALOG).last();
await expect(dialog).toBeVisible({ timeout: resolveTimeout(READY) });
return dialog;
}
/**
* Args:
* page: signed-in Playwright page on /browser/; registers the role's own database with the role's database user once.
*/
async function ensureServer(page) {
await selectEntry(page, "Servers");
const server = entry(page, SERVER_NAME);
if (await server.count()) return;
await page.keyboard.press("ArrowRight");
const listed = await server.waitFor({ state: "visible", timeout: resolveTimeout(5_000) }).then(() => true, () => false);
if (listed) return;
await openSubMenu(page, "Object", /^Register/, "Server...");
const dialog = await openDialog(page);
await expect(dialog.locator("input[name='name']")).toBeEditable({ timeout: resolveTimeout(READY) });
await dialog.locator("input[name='name']").fill(SERVER_NAME);
await dialog.locator("[data-test='Connection']").click();
await dialog.locator("input[name='host']").fill(db.host);
await dialog.locator("input[name='port']").fill(db.port);
await dialog.locator("input[name='db']").fill(db.name);
await dialog.locator("input[name='username']").fill(db.user);
const secret = dialog.locator("input[name='password']");
await expect(secret).toBeEditable({ timeout: resolveTimeout(READY) });
await secret.fill(db.password);
await dialog.locator("input[name='save_password']").check({ force: true });
await dialog.getByRole("button", { name: "Save", exact: true }).click();
await expect(server).toBeVisible({ timeout: resolveTimeout(30_000) });
}
async function onServers(page, tab) {
await onBrowser(page);
await selectEntry(page, "Servers");
if (tab) await openTab(page, tab);
}
async function registerDialog(page, tab) {
await onServers(page);
await openSubMenu(page, "Object", /^Register/, "Server...");
const dialog = await openDialog(page);
if (tab) await dialog.getByRole("tab", { name: tab, exact: true }).click({ timeout: resolveTimeout(READY) });
return dialog;
}
async function userMenu(page, item) {
await onBrowser(page);
await page.locator(USER_MENU).click({ timeout: resolveTimeout(READY) });
const panel = page.locator(OPEN_MENU).first();
await expect(panel).toBeVisible({ timeout: resolveTimeout(READY) });
if (item) await panel.getByRole("menuitem", { name: item }).click({ timeout: resolveTimeout(READY) });
}
async function signedOut(page) {
await page.context().clearCookies();
await gotoOnion(page, `${base}/login`);
await expect(page.locator(LOGIN_CARD)).toBeVisible({ timeout: resolveTimeout(READY) });
}
function views() {
const browser = `${base}/browser/`;
return [
{ name: "login", url: `${base}/login`, prepare: signedOut },
{
name: "forgot-password",
url: `${base}/login`,
prepare: async (page) => {
await signedOut(page);
await page.getByRole("link", { name: /Forgotten your password/ }).click();
await expect(page.locator("input[name='email']")).toBeVisible({ timeout: resolveTimeout(READY) });
},
},
{ name: "welcome", url: browser, prepare: onBrowser },
{ name: "servers-dashboard", url: browser, prepare: (page) => onServers(page, "Dashboard") },
{ name: "servers-properties", url: browser, prepare: (page) => onServers(page, "Properties") },
{ name: "servers-sql", url: browser, prepare: (page) => onServers(page, "SQL") },
{ name: "servers-statistics", url: browser, prepare: (page) => onServers(page, "Statistics") },
{ name: "processes", url: browser, prepare: (page) => onServers(page, "Processes") },
{ name: "register-server", url: browser, prepare: (page) => registerDialog(page) },
{ name: "register-server-connection", url: browser, prepare: async (page) => { const dialog = await registerDialog(page, "Connection"); await dialog.locator("input[name='host']").focus(); } },
{ name: "register-server-ssh", url: browser, prepare: (page) => registerDialog(page, "SSH Tunnel") },
{ name: "register-server-advanced", url: browser, prepare: (page) => registerDialog(page, "Advanced") },
{ name: "create-server-group", url: browser, prepare: async (page) => { await onServers(page); await openSubMenu(page, "Object", /^Create/, "Server Group..."); await openDialog(page); } },
{ name: "preferences", url: browser, prepare: async (page) => { await onBrowser(page); await openMenu(page, "File", "Preferences"); await expect(page.locator(ACTIVE_TAB, { hasText: "Preferences" }).first()).toBeVisible({ timeout: resolveTimeout(READY) }); } },
{ name: "about", url: browser, prepare: async (page) => { await onBrowser(page); await openMenu(page, "Help", /About/); await openDialog(page); } },
{ name: "user-management", url: browser, prepare: async (page) => { await userMenu(page, /User Management/); await expect(page.locator(ACTIVE_TAB, { hasText: "User Management" }).first()).toBeVisible({ timeout: resolveTimeout(READY) }); } },
{ name: "change-password", url: browser, prepare: async (page) => { await userMenu(page, /Change Password/); await openDialog(page); } },
{ name: "user-menu", url: browser, prepare: (page) => userMenu(page) },
{ name: "file-menu", url: browser, prepare: async (page) => { await onBrowser(page); await openMenu(page, "File"); } },
{ name: "tools-menu", url: browser, prepare: async (page) => { await onBrowser(page); await openMenu(page, "Tools"); } },
{ name: "help-menu", url: browser, prepare: async (page) => { await onBrowser(page); await openMenu(page, "Help"); } },
{ name: "object-menu", url: browser, prepare: async (page) => { await onServers(page); await openMenu(page, "Object"); await page.locator(OPEN_MENU).getByRole("menuitem", { name: /^Register/ }).first().hover({ timeout: resolveTimeout(READY) }); } },
{ name: "context-menu", url: browser, prepare: async (page) => { await onServers(page); await entry(page, "Servers").click({ button: "right", timeout: resolveTimeout(READY) }); await expect(page.locator(OPEN_MENU).first()).toBeVisible({ timeout: resolveTimeout(READY) }); } },
];
}
exports.register = function () {
test("design: corporate tokens apply in light and dark mode", async ({ page }) => {
skipUnlessServiceEnabled("design");
await signIn(page);
await assertDesignTokens(page, "pgadmin");
});
test("design: the administrator signs in with the users password and holds admin rights", async ({ page }) => {
skipUnlessServiceEnabled("design");
await signIn(page);
await expect(page.locator(USER_MENU)).toContainText(adminEmail);
await page.locator(USER_MENU).click();
await expect(page.locator(OPEN_MENU).getByRole("menuitem", { name: /User Management/ })).toBeVisible({ timeout: resolveTimeout(READY) });
});
test("design: page, panes, tree and tabs take the palette", async ({ page }) => {
skipUnlessServiceEnabled("design");
await signIn(page);
for (const mode of MODES) {
await page.emulateMedia({ colorScheme: mode });
await assertToken(page, "body", "background-color", "--design-surface-1", `page ${mode}`);
await assertToken(page, PANE, "background-color", "--design-surface-1", `pane ${mode}`);
await assertToken(page, TREE, "color", "--design-text", `tree text ${mode}`);
await assertToken(page, ACTIVE_TAB, "color", "--design-link", `active tab ${mode}`);
}
await page.emulateMedia({ colorScheme: null });
await assertLightAndDark(page, PANE, "pgadmin pane");
await assertReadable(page, [`${ENTRY} .file-name`, TAB, ACTIVE_TAB], "pgadmin browser");
});
test("design: the menu bar is the frame and keeps its focus indicator", async ({ page }) => {
skipUnlessServiceEnabled("design");
await signIn(page);
for (const mode of MODES) {
await page.emulateMedia({ colorScheme: mode });
await assertToken(page, MENU_BAR, "background-color", "--design-frame", `menu bar ${mode}`);
await assertToken(page, MENU_BUTTON, "color", "--design-on-frame", `menu entry ${mode}`);
const ring = await tokenValue(page, "--design-on-frame", "outline-color");
const count = await page.locator(MENU_BUTTON).count();
await page.locator(MENU_BUTTON).nth(1).focus();
await page.keyboard.press("Shift+Tab");
for (let stop = 0; stop < count; stop += 1) {
const drawn = await page.evaluate(() => {
const style = getComputedStyle(document.activeElement);
return { label: document.activeElement.dataset.label, color: style.outlineColor, width: style.outlineWidth, line: style.outlineStyle };
});
expect(drawn.line, `focus on ${drawn.label} ${mode}`).not.toBe("none");
expect(drawn.color, `focus on ${drawn.label} ${mode}`).toBe(ring);
await page.keyboard.press("Tab");
}
}
await page.emulateMedia({ colorScheme: null });
await assertReadable(page, [MENU_BUTTON, USER_MENU], "pgadmin menu bar");
});
test("design: primary actions carry the on-primary text", async ({ page }) => {
skipUnlessServiceEnabled("design");
await openLogin(page);
const email = page.locator("input[name='email']");
await expect(email).toBeEditable({ timeout: resolveTimeout(READY) });
await email.fill(adminEmail);
const password = page.locator("input[name='password']");
await expect(password).toBeEditable({ timeout: resolveTimeout(READY) });
await password.fill(`probe-${Date.now()}`);
for (const mode of MODES) {
await page.emulateMedia({ colorScheme: mode });
await assertToken(page, LOGIN_BUTTON, "background-color", "--design-primary", `login button ${mode}`);
await assertToken(page, LOGIN_BUTTON, "color", "--design-on-primary", `login button text ${mode}`);
await assertToken(page, LOGIN_CARD, "color", "--design-on-frame", `login card text ${mode}`);
}
await page.emulateMedia({ colorScheme: null });
await assertReadable(page, [LOGIN_BUTTON], "pgadmin login");
});
test("design: the native theme switch drives the tokens", async ({ page }) => {
skipUnlessServiceEnabled("design");
await signIn(page);
const html = page.locator("html");
expect(await page.evaluate(() => window.theme), "a fresh account follows the browser").toBe("system");
await expect(html).not.toHaveAttribute("data-design-theme", /./);
await page.emulateMedia({ colorScheme: "dark" });
const dark = await tokenValue(page, "--design-surface-1", "background-color");
await page.emulateMedia({ colorScheme: "light" });
const pick = (value) => page.evaluate((theme) => {
const channel = new BroadcastChannel("preference-change");
channel.postMessage({ data: [{ module: "misc", name: "theme", value: theme }], version: Date.now() });
channel.close();
}, value);
await pick("dark");
await expect(html).toHaveAttribute("data-design-theme", "dark");
await assertToken(page, PANE, "background-color", "--design-surface-1", "pane after picking dark");
expect(await tokenValue(page, "--design-surface-1", "background-color"), "picking dark switches the tokens").toBe(dark);
await pick("high_contrast");
await expect(html).toHaveAttribute("data-pgadmin-theme", "high_contrast");
await expect(html).not.toHaveAttribute("data-design-theme", /./);
await pick("system");
await expect(html).not.toHaveAttribute("data-pgadmin-theme", /./);
await page.emulateMedia({ colorScheme: null });
});
test("design: logo, favicon and title are the configured ones", async ({ page }) => {
skipUnlessServiceEnabled("design");
const logoUrl = decodeDotenvQuotedValue(process.env.DESIGN_LOGO_URL);
const loginLogoUrl = decodeDotenvQuotedValue(process.env.DESIGN_LOGIN_LOGO_URL);
const faviconUrl = decodeDotenvQuotedValue(process.env.DESIGN_FAVICON_URL);
const title = decodeDotenvQuotedValue(process.env.DESIGN_TITLE);
test.skip(!logoUrl && !title, "logo and title replacement are disabled for this role");
await openLogin(page);
if (title) await expect(page).toHaveTitle(title);
if (loginLogoUrl) await expect(page.locator(LOGIN_LOGO)).toHaveCSS("background-image", `url("${loginLogoUrl}")`);
await signIn(page);
if (title) await expect(page).toHaveTitle(title);
if (!logoUrl) return;
await expect(page.locator(LOGO)).toHaveCSS("background-image", `url("${logoUrl}")`);
const box = await page.locator(LOGO).boundingBox();
expect(box.width, "the lockup in the menu bar is wider than high").toBeGreaterThan(box.height * 1.5);
for (const icon of await page.locator("link[rel~='icon']").all()) {
await expect(icon).toHaveAttribute("href", faviconUrl);
}
for (const url of [logoUrl, loginLogoUrl, faviconUrl]) {
expect((await apiGetOnion(page.request, url)).ok(), `${url} is published on the CDN`).toBe(true);
}
const channels = (await tokenValue(page, "--design-on-frame", "color")).match(/\d+/g).slice(0, 3);
const onFrame = `#${channels.map((channel) => Number(channel).toString(16).padStart(2, "0")).join("")}`;
for (const url of [logoUrl, loginLogoUrl]) {
const svg = (await (await apiGetOnion(page.request, url)).text()).toLowerCase();
expect(svg, `${url} renders its text in the frame text color ${onFrame}`).toContain(`fill="${onFrame}"`);
}
});
test("design: gallery of login, object explorer, panels, dialogs, query tool and settings", async ({ page }) => {
skipUnlessServiceEnabled("design");
test.skip(!galleryEnabled(), "INFINITO_PLAYWRIGHT_KEEP is not true");
test.setTimeout(resolveTimeout(2_400_000));
await signIn(page);
await ensureServer(page);
await captureDesignGallery(page, views());
});};
@@ -0,0 +1,78 @@
"""Converge the pgAdmin administrator account on every deploy.
Args:
PGADMIN_DEFAULT_EMAIL: login e-mail of the administrator (container env).
PGADMIN_DEFAULT_PASSWORD: password the account must accept (container env).
Prints ``PGADMIN_ADMINISTRATOR=changed`` when it created or repaired the
account and ``PGADMIN_ADMINISTRATOR=ok`` when the account already matched.
"""
import os
import sys
def converge(email: str, password: str) -> str:
"""Create or repair the internal administrator inside an app context.
Args:
email: login e-mail of the administrator.
password: password the account must accept.
Returns:
``"changed"`` after a create or repair, ``"ok"`` when nothing drifted.
"""
from flask_security.utils import hash_password, verify_password
from pgadmin.model import Role, User, db
from pgadmin.tools.user_management import create_user, normalise_password
from pgadmin.utils.constants import INTERNAL
admin = Role.query.filter_by(name="Administrator").one()
user = User.query.filter_by(email=email, auth_source=INTERNAL).first()
if user is None:
created, message = create_user(
{
"email": email,
"role": admin.id,
"active": True,
"auth_source": INTERNAL,
"newPassword": password,
"confirmPassword": password,
}
)
if not created:
sys.exit(f"creating the administrator failed: {message}")
return "changed"
secret = normalise_password(password)
drift = (
not verify_password(secret, user.password)
or not user.active
or bool(user.locked)
or admin not in user.roles
)
if not drift:
return "ok"
user.password = hash_password(secret)
user.active = True
user.locked = False
user.login_attempts = 0
if admin not in user.roles:
user.roles.append(admin)
db.session.commit()
return "changed"
def main() -> None:
import config
from pgadmin import create_app
app = create_app(config.APP_NAME + "-cli")
with app.test_request_context():
state = converge(
os.environ["PGADMIN_DEFAULT_EMAIL"], os.environ["PGADMIN_DEFAULT_PASSWORD"]
)
print(f"PGADMIN_ADMINISTRATOR={state}")
if __name__ == "__main__":
main()
-6
View File
@@ -1,6 +0,0 @@
---
credentials:
administrator_password:
description: Initial password for the pgAdmin administrator login
algorithm: sha256
validation: ^[a-f0-9]{64}$
+10
View File
@@ -13,6 +13,7 @@ pgadmin:
image: dpage/pgadmin4
version: "9.18"
min_storage: 378MB
name: pgadmin
server_mode: false
master_password_required: true
ports:
@@ -68,6 +69,15 @@ design:
bond: 1
enabled: "{{ 'web-svc-design' in group_names }}"
shared: "{{ 'web-svc-design' in group_names }}"
slots:
logo:
width: 96
height: 30
frame: true
login-logo:
width: 96
height: 40
frame: true
prometheus:
bond: 1
enabled: "{{ 'web-app-prometheus' in group_names }}"
+1 -1
View File
@@ -1,2 +1,2 @@
---
administrator: {}
platform_administrator: {}
@@ -0,0 +1,22 @@
---
- name: "⏳ Wait until pgAdmin is healthy before converging its administrator"
ansible.builtin.shell:
cmd: >-
container inspect --type container --format '{% raw %}{{.State.Health.Status}}{% endraw %}' {{ PGADMIN_CONTAINER_ADDRESS }}
register: pgadmin_health
until: pgadmin_health.stdout | trim == "healthy"
retries: 60
delay: 5
changed_when: false
- name: "🔑 Converge the pgAdmin administrator account"
ansible.builtin.shell:
cmd: >-
container exec -i {{ PGADMIN_CONTAINER_ADDRESS }} /venv/bin/python3 -
stdin: "{{ lookup('ansible.builtin.file', role_path ~ '/files/python/administrator.py') }}"
register: pgadmin_administrator
changed_when: "'PGADMIN_ADMINISTRATOR=changed' in pgadmin_administrator.stdout"
failed_when: >-
pgadmin_administrator.rc != 0
or pgadmin_administrator.stdout_lines | select('match', '^PGADMIN_ADMINISTRATOR=(changed|ok)$') | list | length != 1
timeout: "{{ lookup('timeout', 600) | int }}"
+9
View File
@@ -6,3 +6,12 @@
- name: "🔧 configure pgadmin servers"
include_tasks: configuration.yml
when: PGADMIN_SERVER_MODE | bool
- name: "🚿 Flush compose handlers for {{ application_id }}"
ansible.builtin.meta: flush_handlers
- name: "🔑 converge the pgadmin administrator"
include_tasks: administrator.yml
when:
- IS_STACK_HOST | bool
- not PGADMIN_SERVER_MODE | bool
@@ -0,0 +1,59 @@
{% set design = lookup('design', application_id) %}
(function () {
var root = document.documentElement;
function mirror(theme) {
if (theme === "light" || theme === "dark") {
root.setAttribute("data-design-theme", theme);
} else {
root.removeAttribute("data-design-theme");
}
if (theme === "high_contrast") {
root.setAttribute("data-pgadmin-theme", theme);
} else {
root.removeAttribute("data-pgadmin-theme");
}
}
function picked(message) {
var data = message && message.data;
if (!data || typeof data !== "object") {
return;
}
var entries = Array.isArray(data) ? data : Object.keys(data).map(function (key) { return data[key]; });
for (var index = 0; index < entries.length; index += 1) {
var entry = entries[index];
if (entry && entry.module === "misc" && entry.name === "theme") {
mirror(entry.value);
return;
}
}
}
if (typeof window.theme === "string") {
mirror(window.theme);
}
document.addEventListener("DOMContentLoaded", function () {
if (typeof window.theme === "string") {
mirror(window.theme);
}
});
if (typeof window.BroadcastChannel === "function") {
var channel = new window.BroadcastChannel("preference-change");
channel.onmessage = function (event) {
picked(event.data);
};
channel.postMessage("sync");
}
{% if design.logo is string %}
var favicon = {{ design.urls.favicon_ico | to_json }};
function brandIcon() {
var icons = document.querySelectorAll("link[rel~='icon']");
for (var index = 0; index < icons.length; index += 1) {
if (icons[index].getAttribute("href") !== favicon) {
icons[index].setAttribute("type", "image/x-icon");
icons[index].setAttribute("href", favicon);
}
}
}
new MutationObserver(brandIcon).observe(document.head, { childList: true, subtree: true, attributes: true, attributeFilter: ["href"] });
brandIcon();
{% endif %}
})();
+6 -1
View File
@@ -6,7 +6,7 @@ PGADMIN_DEFAULT_EMAIL={{ PGADMIN_ADMIN_EMAIL }}
PGADMIN_CONFIG_ALLOW_SPECIAL_EMAIL_DOMAINS=['{{ PGADMIN_ADMIN_EMAIL_TLD }}']
PGADMIN_DEFAULT_PASSWORD={{ lookup('config', application_id, 'secrets.credentials.administrator_password') | dotenv_quote }}
PGADMIN_DEFAULT_PASSWORD={{ PGADMIN_ADMIN_PASSWORD | dotenv_quote }}
{% if PGADMIN_SERVER_MODE | bool %}
PGADMIN_SERVER_JSON_FILE={{ pgadmin_docker_server_file }}
@@ -26,3 +26,8 @@ PGADMIN_CONFIG_MAIL_USERNAME={{ email.username | tojson | dotenv_quote }}
PGADMIN_CONFIG_MAIL_PASSWORD={{ email.password | tojson | dotenv_quote }}
PGADMIN_CONFIG_MAIL_DEFAULT_SENDER={{ email['from'] | tojson | dotenv_quote }}
{% endif %}
{% set design = lookup('design', application_id) %}
{% if lookup('config', application_id, 'services.design.enabled') | bool and design.title is string %}
PGADMIN_CONFIG_APP_NAME={{ design.title | tojson | dotenv_quote }}
{% endif %}
@@ -1,10 +1,24 @@
ADMIN_EMAIL={{ lookup('users', 'platform_administrator').email | 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 }}
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_LOGIN_LOGO_URL={{ (lookup('design', application_id).urls['login-logo'].svg 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 — the CSP helper gates design through a computed loop variable (personas/utils/csp.js:87-93), never a literal argument, so test_spec_env_gates.py'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).title if lookup('design', application_id).title is string else '') | dotenv_quote }}
# nocheck: playwright-service-gate — SMTP backs only pgAdmin password-reset mail, and no persona scenario triggers a mail send
EMAIL_SERVICE_ENABLED={{ lookup('config', application_id, 'services.email.enabled') | string | lower }}
@@ -20,6 +34,16 @@ PERSONA_ADMINISTRATOR_BLOCKED=true
# nocheck: persona-block-rationale — meta/services.yml admits only the web-app-pgadmin administrator RBAC group to the oauth2-proxy (sso.oauth2.allowed_groups) and biber carries no roles, so biber is denied at the proxy before pgAdmin is reached (see README "Persona contract opt-outs").
PERSONA_BIBER_BLOCKED=true
PGADMIN_DB_HOST={{ lookup('database', application_id, 'host') | dotenv_quote }}
PGADMIN_DB_NAME={{ lookup('database', application_id, 'name') | dotenv_quote }}
PGADMIN_DB_PASSWORD={{ lookup('database', application_id, 'password') | dotenv_quote }}
PGADMIN_DB_PORT={{ lookup('database', application_id, 'port') | string | dotenv_quote }}
PGADMIN_DB_USER={{ lookup('database', application_id, 'username') | dotenv_quote }}
# nocheck: playwright-service-gate — pgAdmin keeps its own config in embedded SQLite and only reaches the cluster from the server dialog no persona opens
POSTGRES_SERVICE_ENABLED={{ lookup('config', application_id, 'services.postgres.enabled') | string | lower }}
@@ -0,0 +1,209 @@
{% set design = lookup('design', application_id) %}
{% set scope = 'html:not([data-pgadmin-theme="high_contrast"]) body' %}
{{ scope }} {
background-color: var(--design-surface-1);
color: var(--design-text);
}
{{ scope }} .dock-layout .dock-content,
{{ scope }} .dock-layout .dock-bar,
{{ scope }} .dock-layout .dock-panel .dock-tabpane {
background-color: var(--design-surface-1);
color: var(--design-text);
}
{{ scope }} .dock-layout .dock-tab {
color: var(--design-text-muted);
}
{{ scope }} .dock-layout .dock-tab.dock-tab-active {
color: var(--design-link);
}
{{ scope }} .dock-layout .dock-ink-bar {
background-color: var(--design-primary);
}
{{ scope }} .dock-layout .dock-divider::before {
background-color: var(--design-border);
}
{{ scope }} .file-tree {
color: var(--design-text) !important;
background-color: var(--design-surface-1) !important;
}
{{ scope }} .file-entry:hover,
{{ scope }} .file-entry.pseudo-active {
color: var(--design-text) !important;
background-color: var(--design-surface-hover) !important;
}
{{ scope }} .file-entry.active,
{{ scope }} .file-entry.prompt {
color: var(--design-text) !important;
background-color: var(--design-surface-active) !important;
}
{{ scope }} [data-test="app-menu-bar"] {
--design-focus: var(--design-on-frame);
background-color: var(--design-frame);
color: var(--design-on-frame);
}
{{ scope }} [data-test="app-menu-bar"] .MuiButton-root {
background-color: var(--design-frame);
color: var(--design-on-frame);
border-color: var(--design-frame);
}
{{ scope }} [data-test="app-menu-bar"] .MuiButton-root:hover {
background-color: var(--design-frame-hover);
}
{{ scope }} [data-test="app-menu-bar"] .MuiButton-root[aria-expanded="true"] {
background-color: var(--design-frame-active);
}
{{ scope }} .szh-menu {
--design-focus: var(--design-link);
background-color: var(--design-surface-2);
color: var(--design-text);
border-color: var(--design-border);
}
{{ scope }} .MuiPaper-root,
{{ scope }} .MuiDialog-paper {
background-color: var(--design-surface-2);
color: var(--design-text);
}
{{ scope }} .MuiTabs-root,
{{ scope }} .MuiAccordionSummary-root,
{{ scope }} .MuiCardHeader-root {
background-color: var(--design-surface-2);
border-color: var(--design-border);
}
{{ scope }} .MuiTab-root.Mui-selected {
color: var(--design-link);
}
{{ scope }} .MuiTabs-indicator {
background-color: var(--design-primary);
}
{{ scope }} .MuiInputBase-root,
{{ scope }} .MuiInputBase-input {
background-color: var(--design-surface-2);
color: var(--design-text);
}
{{ scope }} .MuiOutlinedInput-notchedOutline {
border-color: var(--design-border-strong);
}
{{ scope }} .MuiInputBase-root.Mui-disabled,
{{ scope }} .MuiInputBase-input[readonly] {
background-color: var(--design-surface-3);
color: var(--design-text-muted);
}
{{ scope }} .MuiFormLabel-root {
color: var(--design-text);
}
{{ scope }} .MuiFormHelperText-root,
{{ scope }} .text-muted {
color: var(--design-text-muted);
}
{{ scope }} .MuiButton-containedPrimary {
background-color: var(--design-primary);
color: var(--design-on-primary);
border-color: var(--design-primary);
}
{{ scope }} .MuiButton-containedPrimary:hover {
background-color: var(--design-primary-hover);
border-color: var(--design-primary-hover);
}
{{ scope }} .MuiButton-outlined {
background-color: var(--design-surface-2);
color: var(--design-text);
border-color: var(--design-border-strong);
}
{{ scope }} .MuiButton-outlined:hover {
background-color: var(--design-surface-hover);
}
{{ scope }} .MuiListItemButton-root.Mui-selected,
{{ scope }} .MuiToggleButton-root.Mui-selected {
background-color: var(--design-surface-active);
color: var(--design-text);
}
{{ scope }} .MuiTooltip-tooltip {
background-color: var(--design-text);
color: var(--design-surface-1);
}
{{ scope }} a {
color: var(--design-link);
}
{{ scope }} .cm-editor {
background-color: var(--design-surface-1);
}
{{ scope }} .cm-editor .cm-gutters {
background-color: var(--design-surface-3);
color: var(--design-text-muted);
border-color: var(--design-border);
}
{{ scope }} .cm-editor .cm-activeLine {
background-color: var(--design-surface-hover);
}
{{ scope }} [data-test="app-menu-bar"] .MuiButton-root:focus-visible,
{{ scope }} [data-test="app-menu-bar"] .MuiButton-root.Mui-focusVisible {
outline: 2px solid var(--design-focus);
outline-offset: -2px;
}
html body #root > div:has(> div > .BasePage-pageContent) {
background-color: var(--design-frame);
color: var(--design-on-frame);
}
html body #root .BasePage-pageContent {
background-color: var(--design-frame-active);
color: var(--design-on-frame);
}
html body #root .BasePage-pageContent a {
color: var(--design-on-frame);
}
html body #root .BasePage-pageContent .MuiButton-root.MuiButton-outlinedPrimary {
background-color: var(--design-primary);
border-color: var(--design-primary);
color: var(--design-on-primary);
}
html body #root .BasePage-pageContent .MuiButton-root.MuiButton-outlinedPrimary:hover {
background-color: var(--design-primary-hover);
}
{% if design.logo is string %}
html body #root [data-test="app-menu-bar"] .AppMenuBar-logo {
background: url("{{ design.urls.logo.svg }}") center / contain no-repeat;
}
html body #root .BasePage-logo {
background: url("{{ design.urls['login-logo'].svg }}") center / contain no-repeat;
}
{% endif %}
+4 -1
View File
@@ -2,8 +2,11 @@
application_id: "web-app-pgadmin"
entity_name: "{{ application_id | get_entity_name }}"
PGADMIN_ADMIN_EMAIL: "{{ lookup('config', application_id, 'users.administrator.email') }}"
PGADMIN_ADMIN_EMAIL: "{{ lookup('config', application_id, 'users.platform_administrator.email') }}"
PGADMIN_ADMIN_EMAIL_TLD: "{{ PGADMIN_ADMIN_EMAIL.split('@')[1].split('.')[-1] }}"
PGADMIN_ADMIN_PASSWORD: "{{ lookup('users', 'platform_administrator').password }}"
PGADMIN_CONTAINER_ADDRESS: "{{ lookup('container_address', application_id, entity_name) }}"
PGADMIN_SERVER_MODE: "{{ lookup('config', application_id, 'services.' ~ entity_name ~ '.server_mode') | bool }}"
PGADMIN_MASTER_PASSWORD_REQUIRED: "{{ lookup('config', application_id, 'services.' ~ entity_name ~ '.master_password_required') | bool }}"
Whitespace-only changes.
@@ -0,0 +1,3 @@
from pathlib import Path
PROJECT_ROOT: Path = Path(__file__).resolve().parents[6]
@@ -0,0 +1,142 @@
from __future__ import annotations
import importlib.util
import types
import unittest
from unittest.mock import patch
from . import PROJECT_ROOT
SCRIPT_PATH = PROJECT_ROOT / "roles/web-app-pgadmin/files/python/administrator.py"
EMAIL = "administrator@example.org"
PASSWORD = "p$ssw0rd-from-the-users-spot"
def load_script() -> object:
spec = importlib.util.spec_from_file_location("administrator", SCRIPT_PATH)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
class FakeQuery:
def __init__(self, rows):
self.rows = rows
self.matched = []
def filter_by(self, **criteria):
self.matched = [
row
for row in self.rows
if all(getattr(row, key) == value for key, value in criteria.items())
]
return self
def first(self):
return self.matched[0] if self.matched else None
def one(self):
assert len(self.matched) == 1
return self.matched[0]
class FakeBackend:
def __init__(self, user=None):
self.admin = types.SimpleNamespace(id=1, name="Administrator")
self.user = user
self.created = []
self.commits = 0
def modules(self) -> dict:
session = types.SimpleNamespace(commit=self._commit)
entries = {
"flask_security": {},
"flask_security.utils": {
"hash_password": lambda plain: f"hashed-{plain}",
"verify_password": lambda plain, hashed: hashed == f"hashed-{plain}",
},
"pgadmin": {},
"pgadmin.model": {
"Role": types.SimpleNamespace(query=FakeQuery([self.admin])),
"User": types.SimpleNamespace(
query=FakeQuery([self.user] if self.user else [])
),
"db": types.SimpleNamespace(session=session),
},
"pgadmin.tools": {},
"pgadmin.tools.user_management": {
"create_user": self._create_user,
"normalise_password": lambda plain: plain,
},
"pgadmin.utils": {},
"pgadmin.utils.constants": {"INTERNAL": "internal"},
}
out = {}
for name, attrs in entries.items():
module = types.ModuleType(name)
for attr, value in attrs.items():
setattr(module, attr, value)
out[name] = module
return out
def _create_user(self, data):
self.created.append(data)
return True, ""
def _commit(self):
self.commits += 1
def account(**overrides) -> types.SimpleNamespace:
fields = {
"email": EMAIL,
"auth_source": "internal",
"password": f"hashed-{PASSWORD}",
"active": True,
"locked": False,
"login_attempts": 0,
"roles": [],
}
fields.update(overrides)
return types.SimpleNamespace(**fields)
class TestConvergeAdministrator(unittest.TestCase):
def run_script(self, backend: FakeBackend) -> str:
module = load_script()
with patch.dict("sys.modules", backend.modules()):
return module.converge(EMAIL, PASSWORD)
def test_a_missing_administrator_is_created_with_the_spot_password(self) -> None:
backend = FakeBackend()
self.assertEqual(self.run_script(backend), "changed")
self.assertEqual(backend.created[0]["email"], EMAIL)
self.assertEqual(backend.created[0]["newPassword"], PASSWORD)
self.assertEqual(backend.created[0]["role"], backend.admin.id)
def test_a_rotated_password_reaches_the_existing_administrator(self) -> None:
backend = FakeBackend()
backend.user = account(password="hashed-an-older-secret", roles=[backend.admin])
self.assertEqual(self.run_script(backend), "changed")
self.assertEqual(backend.user.password, f"hashed-{PASSWORD}")
self.assertEqual(backend.commits, 1)
def test_a_locked_administrator_without_the_role_is_repaired(self) -> None:
backend = FakeBackend()
backend.user = account(locked=True, login_attempts=3, active=False)
self.assertEqual(self.run_script(backend), "changed")
self.assertEqual(
(backend.user.locked, backend.user.login_attempts, backend.user.active),
(False, 0, True),
)
self.assertIn(backend.admin, backend.user.roles)
def test_a_converged_administrator_is_left_alone(self) -> None:
backend = FakeBackend()
backend.user = account(roles=[backend.admin])
self.assertEqual(self.run_script(backend), "ok")
self.assertEqual((backend.created, backend.commits), ([], 0))
if __name__ == "__main__":
unittest.main()