Add OIDC sign-in, group-derived roles and declarative instance configuration
Workspaces, group mappings and sign-in methods now come from a configuration file synchronized at startup; workspaces dropped from it are disabled, never deleted. Instance administrators (a new role) see and help every workspace and may delete disabled ones; workspace administrators and employees are derived from the user's groups at every SSO sign-in or administrator edit, and the employee permission set belongs to the workspace. Self-registration is gone: users come from SSO (only when their groups map) or from the administrator. Break-glass bootstrap accounts get an HMAC-derived hourly password that all replicas agree on. First SSO sign-in links a matching email contact to the user. The demo runs on the same model with a generated per-start password. Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
1 parent
e37d583179
commit
4764f3545f
68 files changed
+7220
-964
No files matched your search
@@ -2,14 +2,78 @@ const { chromium } = require('playwright');
|
||||
|
||||
function launchBrowser() {
|
||||
const channel = process.env.MEGURI_BROWSER_CHANNEL || 'chrome';
|
||||
const executablePath = process.env.MEGURI_BROWSER_EXECUTABLE;
|
||||
return chromium.launch({
|
||||
headless: true,
|
||||
...(channel === 'chromium' ? {} : { channel }),
|
||||
...(executablePath ? { executablePath, args: ['--no-sandbox'] } : channel === 'chromium' ? {} : { channel }),
|
||||
});
|
||||
}
|
||||
|
||||
exports.launchBrowser = launchBrowser;
|
||||
|
||||
const baseUrl = () => process.env.MEGURI_BROWSER_URL || 'http://127.0.0.1:3000';
|
||||
exports.baseUrl = baseUrl;
|
||||
|
||||
// The demo generates one password per server start and publishes it to the login screen.
|
||||
let password;
|
||||
exports.demoPassword = () => password ||= fetch(baseUrl() + '/api/session').then(r => r.json()).then(session => {
|
||||
if (!session.demo_lifecycle?.password) throw new Error('Browser tests require a disposable demo instance');
|
||||
return session.demo_lifecycle.password;
|
||||
});
|
||||
|
||||
exports.signIn = async (page, username, secret) => {
|
||||
await page.goto(baseUrl());
|
||||
await page.getByLabel('Username', { exact: true }).fill(username);
|
||||
await page.getByLabel('Password', { exact: true }).fill(secret ?? await exports.demoPassword());
|
||||
await page.getByRole('button', { name: 'Sign in', exact: true }).click();
|
||||
await page.waitForFunction(() => { const a = window.Alpine && Alpine.$data(document.body); return a && a.ready && a.user && !a.busy; });
|
||||
};
|
||||
|
||||
// Minimal cookie-aware JSON client for test setup that must not go through the UI.
|
||||
exports.apiLogin = async (username, secret) => {
|
||||
const base = baseUrl();
|
||||
const login = await fetch(base + '/api/login', { method: 'POST', headers: { Origin: base, 'Content-Type': 'application/json' }, body: JSON.stringify({ username, password: secret ?? await exports.demoPassword() }) });
|
||||
if (!login.ok) throw new Error(`Login as ${username} failed: ${login.status}`);
|
||||
const cookie = login.headers.getSetCookie().map(c => c.split(';')[0]).find(c => c.startsWith('meguri_session='));
|
||||
const { csrf } = await login.json();
|
||||
const call = async (method, path, body) => {
|
||||
const response = await fetch(base + path, { method, headers: { Origin: base, 'Content-Type': 'application/json', Cookie: cookie, 'X-CSRF-Token': csrf }, ...(body === undefined ? {} : { body: JSON.stringify(body) }) });
|
||||
const data = await response.json().catch(() => ({}));
|
||||
return { status: response.status, data };
|
||||
};
|
||||
const ok = async (method, path, body) => {
|
||||
const result = await call(method, path, body);
|
||||
if (result.status >= 300) throw new Error(`${method} ${path} -> ${result.status} ${JSON.stringify(result.data)}`);
|
||||
return result.data;
|
||||
};
|
||||
return { call, ok, get: path => ok('GET', path), post: (path, body) => ok('POST', path, body), put: (path, body) => ok('PUT', path, body), csrf, cookie };
|
||||
};
|
||||
|
||||
exports.adminApi = () => exports.apiLogin('admin');
|
||||
|
||||
// Mutating suites each own one empty sandbox workspace ("Sandbox N", demo started with
|
||||
// MEGURI_DEMO_SANDBOXES) whose groups are sandbox-N-leads (administrators) and sandbox-N-staff (employees).
|
||||
// Nobody is in a sandbox except accounts created here, so suites do not depend on each other or on order.
|
||||
exports.createUser = async (admin, { sandbox, role = 'admin', prefix, display }) => {
|
||||
const overview = await admin.get('/api/admin/overview');
|
||||
const target = overview.workspaces.find(w => w.name === `Sandbox ${sandbox}` && !w.disabled_at);
|
||||
if (!target) throw new Error(`Start the demo with MEGURI_DEMO_SANDBOXES>=${sandbox}: Sandbox ${sandbox} not found`);
|
||||
const groups = role === 'admin' ? target.admin_groups : target.member_groups;
|
||||
const username = `${prefix}-${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`.toLowerCase();
|
||||
const secret = 'local-browser-test-password';
|
||||
await admin.post('/api/admin/users', { username, display_name: display, password: secret, groups });
|
||||
return { username, password: secret, display, workspace: target };
|
||||
};
|
||||
|
||||
// An administrator of exactly one sandbox, plus any employees (display names) created beside them.
|
||||
exports.isolatedUser = async ({ sandbox, prefix, display, employees = [] }) => {
|
||||
const admin = await exports.adminApi();
|
||||
const user = await exports.createUser(admin, { sandbox, role: 'admin', prefix, display });
|
||||
const staff = [];
|
||||
for (const name of employees) staff.push(await exports.createUser(admin, { sandbox, role: 'member', prefix: name.split(' ')[0], display: name }));
|
||||
return { admin, ...user, staff };
|
||||
};
|
||||
|
||||
exports.openSetup = async (page, name) => {
|
||||
await page.waitForFunction(()=>window.Alpine && Alpine.$data(document.body).ready && Alpine.$data(document.body).workspace);
|
||||
const combined=['Schedules','Escalations & routing','Variables & rules'].includes(name);
|
||||
|
||||
Reference in new issue
Block a user