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:
Aleksandr BogomiakovandClaude committed 2026-10-07 13:28:43 +01:00
1 parent e37d583179
commit 4764f3545f
68 files changed
+7220 -964

No files matched your search

+8 -6
View File
@@ -1,4 +1,5 @@
const {isolatedUser}=require('./support/browser.cjs');
const assert=require('node:assert/strict');
const fs=require('node:fs');
const base=process.env.MEGURI_BROWSER_URL||'http://127.0.0.1:3000';
@@ -7,19 +8,20 @@ fs.mkdirSync('test-results',{recursive:true});
(async()=>{
const browser=await require('./support/browser.cjs').launchBrowser();
const errors=[];
async function login(name){const page=await browser.newPage({viewport:{width:1440,height:1050},timezoneId:'America/New_York'});page.setDefaultTimeout(10000);page.on('pageerror',e=>errors.push(e.message));await page.goto(base);await page.getByLabel('Username',{exact:true}).fill(name);await page.getByLabel('Password',{exact:true}).fill('meguri-demo-only');await page.getByRole('button',{name:'Sign in',exact:true}).click();await page.locator('.day').first().waitFor();return page;}
async function login(name,secret){const page=await browser.newPage({viewport:{width:1440,height:1050},timezoneId:'America/New_York'});page.setDefaultTimeout(10000);page.on('pageerror',e=>errors.push(e.message));await page.goto(base);await page.getByLabel('Username',{exact:true}).fill(name);await page.getByLabel('Password',{exact:true}).fill(secret);await page.getByRole('button',{name:'Sign in',exact:true}).click();await page.waitForFunction(()=>{const a=Alpine.$data(document.body);return a.ready&&a.user&&!a.busy&&a.workspace;});return page;}
async function refresh(page){await page.reload();await page.locator('.day').first().waitFor();}
const dialog=p=>p.getByRole('dialog');
const post=(page,path,body)=>page.evaluate(async({path,body})=>{const a=Alpine.$data(document.body);return a.api(path,'POST',body);},{path,body});
let alice,bob;
try{
alice=await login('alice');bob=await login('bob');
// Isolate the workflow from the seeded examples and from other browser suites.
const ids=await alice.evaluate(()=>{const a=Alpine.$data(document.body);return {alice:a.user.id,bob:a.members.find(m=>m.user.username==='bob').user.id};});
const team=await post(alice,'/api/workspaces',{name:'Cover UX '+Date.now()});
// Sandbox 6 belongs to this suite: an administrator (Alice Morgan) and one employee (Bob Chen).
const sandbox=await isolatedUser({sandbox:6,prefix:'cover-lead',display:'Alice Morgan',employees:['Bob Chen']});
const [bobAccount]=sandbox.staff,team=sandbox.workspace;
alice=await login(sandbox.username,sandbox.password);bob=await login(bobAccount.username,bobAccount.password);
const ids=await alice.evaluate(()=>{const a=Alpine.$data(document.body);return {alice:a.user.id,bob:a.members.find(m=>m.user.display_name==='Bob Chen').user.id};});
const endpoint=`/api/workspaces/${team.id}`;
const start=await alice.evaluate(()=>{const a=Alpine.$data(document.body);return a.addDays(a.today(),2);});
const configured=await alice.evaluate(async({endpoint,ids,start})=>{const a=Alpine.$data(document.body);let r=await a.api(endpoint+'/members','PUT',{revision:1,username:'bob',permissions:['view','request_overrides']});const doc=r.document;doc.schedules=[{id:crypto.randomUUID(),name:'Weekly duty',type:'rotation',participants:[ids.alice,ids.bob],anchor:start,shift_days:7,handover:'11:00:00'}];return a.api(endpoint+'/config','PUT',{revision:r.revision,document:doc});},{endpoint,ids,start});
const configured=await alice.evaluate(async({endpoint,ids,start})=>{const a=Alpine.$data(document.body);const r=(await a.api(endpoint)).workspace;const doc=r.document;doc.schedules=[{id:crypto.randomUUID(),name:'Weekly duty',type:'rotation',participants:[ids.alice,ids.bob],anchor:start,shift_days:7,handover:'11:00:00'}];return a.api(endpoint+'/config','PUT',{revision:r.revision,document:doc});},{endpoint,ids,start});
const url=base+'/?workspace='+team.id;
await alice.goto(url);await bob.goto(url);await bob.locator('.day').first().waitFor();
assert.equal(await bob.getByRole('button',{name:'Schedules',exact:true}).isVisible(),false,'Setup stays out of the daily flow');