Merge NEXT: planning, generation map and portable configuration on top of the OIDC access model
NEXT predates the access work, so the identity layer follows master: access comes from configured groups, instance administrators replace the site administration, and accounts are created by SSO or by an administrator. Kept from NEXT: manual publication with the Review changes panel and shared generator drafts, portable import/export (meguri/v2), the layered generation map with binding provenance, JSM provider changes, contact/approval semantics and the account-owned Person. Dropped: site administration, local sign-up, and the mandatory unique email with its contact/account migrations. The manual-publication migration is renumbered m_0010 after m_0009_access. Accounts without an email still get a Person in every workspace they reach, a changed sign-in email updates the account's person, and an account that loses access keeps its linked person so schedules stay valid. Instance administrators may use Sync and shared drafts. Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
commit
38105c65c2
57 files changed
+6890
-431
No files matched your search
+14
-10
@@ -18,6 +18,7 @@ document.addEventListener('alpine:init', () => {
|
||||
}));
|
||||
Alpine.data('meguri', () => ({
|
||||
...generationUi(),
|
||||
syncSubmitting:false,workspacePollAt:0,
|
||||
user: null, csrf: '', version: '', ready: false, busy: false, error: '', toast: '', demo: false, demoLifecycle:null, auth:{oidc:false,password:true}, loginError:null, admin:null, events:[], eventsMore:false, eventSearch:'', eventsLoading:false,
|
||||
loginForm: { username: '', password: '' },
|
||||
directoryState: { workspaces: [] }, selected: '', detail: null, calendar: { days: [], assignments: [] },
|
||||
@@ -39,7 +40,7 @@ document.addEventListener('alpine:init', () => {
|
||||
get displayZone() { return this.user?.timezone || browserZone; },
|
||||
get directory() { return this.detail?.directory || []; },
|
||||
get people() {
|
||||
const people = new Map(this.directory.map(u=>[u.id,{id:u.id,user_id:u.id,display_name:u.display_name,email:null,country:u.country || 'CY',username:u.username}]));
|
||||
const people = new Map(this.directory.map(u=>[u.id,{id:u.id,user_id:u.id,display_name:u.display_name,email:u.email,country:u.country || 'CY',username:u.username}]));
|
||||
for(const p of [...(this.doc?.people || []),...this.draftPeople]) people.set(p.id,{...people.get(p.id),...p});
|
||||
return [...people.values()];
|
||||
},
|
||||
@@ -81,7 +82,7 @@ document.addEventListener('alpine:init', () => {
|
||||
useDemoAccount(account){this.loginForm.username=account.username;this.loginForm.password=this.demoLifecycle?.password||'';},
|
||||
async copyDemoPassword(){try{await navigator.clipboard.writeText(this.demoLifecycle?.password||'');this.toast=tr('access_demo_password_copied');setTimeout(()=>this.toast='',4500);}catch(e){this.loginForm.password=this.demoLifecycle?.password||'';}},
|
||||
get setupView() { return ['configuration','settings'].includes(this.view); },
|
||||
get coverPeople() { const q=this.coverQuery.toLowerCase(); return this.people.filter(p=>p.id!==this.form.from_user && (this.members.some(m=>m.user.id===p.id) || this.form.force_mode) && [p.display_name,p.email,p.username].some(v=>v?.toLowerCase().includes(q))); },
|
||||
get coverPeople() { const q=this.coverQuery.toLowerCase(); return this.people.filter(p=>p.id!==this.form.from_user && (this.members.some(m=>m.user.id===p.id) || !p.user_id || this.form.force_mode) && [p.display_name,p.email,p.username].some(v=>v?.toLowerCase().includes(q))); },
|
||||
get nextDuty() { const now=Date.now(); return this.calendar.assignments.filter(a=>a.schedule_id===this.scheduleId && a.user_id===this.user?.id && !a.unavailable && Date.parse(a.end)>now).sort((a,b)=>a.start.localeCompare(b.start))[0]; },
|
||||
get matchingRequest() { return this.needsMe.find(r=>!r.absence_id && r.legs.length===1 && r.legs[0].schedule_id===this.form.schedule_id && r.legs[0].from_user===this.form.from_user && r.legs[0].to_user===this.form.to_user && Date.parse(r.legs[0].start)===Date.parse(this.form.start) && Date.parse(r.legs[0].end)===Date.parse(this.form.end)); },
|
||||
get inbox() { return [...(this.doc?.requests || [])].reverse(); },
|
||||
@@ -119,8 +120,8 @@ document.addEventListener('alpine:init', () => {
|
||||
menuHref(view,workspace=this.selected){const url=new URL(location.href);url.searchParams.set('view',view);if(workspace)url.searchParams.set('workspace',workspace);else url.searchParams.delete('workspace');return url.pathname+url.search+url.hash;},
|
||||
writeNavigation(replace=false){if(!this.ready || this.navigationRestoring)return;const href=this.menuHref(this.view);if(href!==location.pathname+location.search+location.hash)history[replace?'replaceState':'pushState']({},'',href);},
|
||||
menuClick(event,view){if(event.button!==0 || event.ctrlKey || event.metaKey || event.shiftKey || event.altKey)return;event.preventDefault();this.view=view;},
|
||||
async changeWorkspace(){this.modal='';this.scheduleId='';this.startDay='';this.writeNavigation();await this.run(()=>this.loadWorkspace());},
|
||||
async restoreNavigation(){const epoch=++this.navigationEpoch;this.navigationRestoring++;try{const route=this.readNavigation();this.modal='';this.error='';this.view=route.view==='instance' && this.user && !this.user.instance_admin?'calendar':route.view;if(this.user){const selected=this.directoryState.workspaces.some(t=>t.id===route.workspace)?route.workspace:this.directoryState.workspaces[0]?.id||'';if(selected!==this.selected){this.selected=selected;this.scheduleId='';this.startDay='';await this.loadWorkspace();}}else this.selected=route.workspace;await this.$nextTick();}catch(e){if(epoch===this.navigationEpoch)this.error=e.message;}finally{this.navigationRestoring--;if(!this.navigationRestoring)this.writeNavigation(true);}},
|
||||
async changeWorkspace(){await this.run(async()=>{await this.saveSharedGeneration();this.modal='';this.scheduleId='';this.startDay='';this.writeNavigation();await this.loadWorkspace();});if(this.error && this.workspace)this.selected=this.workspace.id;this.writeNavigation();},
|
||||
async restoreNavigation(){const epoch=++this.navigationEpoch;this.navigationRestoring++;try{const route=this.readNavigation();await this.saveSharedGeneration();this.modal='';this.error='';this.view=route.view==='instance' && this.user && !this.user.instance_admin?'calendar':route.view;if(this.user){const selected=this.directoryState.workspaces.some(t=>t.id===route.workspace)?route.workspace:this.directoryState.workspaces[0]?.id||'';if(selected!==this.selected){this.selected=selected;this.scheduleId='';this.startDay='';await this.loadWorkspace();}}else this.selected=route.workspace;await this.$nextTick();}catch(e){if(epoch===this.navigationEpoch)this.error=e.message;}finally{this.navigationRestoring--;if(!this.navigationRestoring)this.writeNavigation(true);}},
|
||||
async init() {
|
||||
this.$watch('user',user=>{Alpine.store('locale').language=user?.language || I18n.browser;});
|
||||
this.$watch('form',()=>{if(this.modal==='absence')this.absenceChanged();if(this.modal==='request')this.requestChanged();if(['variable','generation-rule'].includes(this.modal))this.generationChanged();});
|
||||
@@ -133,13 +134,13 @@ document.addEventListener('alpine:init', () => {
|
||||
const initial=this.readNavigation();this.selected=initial.workspace;this.view=initial.view;window.addEventListener('popstate',()=>this.restoreNavigation());
|
||||
await this.run(async () => { const session = await this.api('/api/session'); this.version = session.version || ''; this.user = session.user; this.csrf = session.csrf || ''; this.auth = session.auth || {oidc:false,password:true}; this.demo = session.demo;this.demoLifecycle=session.demo_lifecycle; if (this.user) { if(this.view==='instance' && !this.user.instance_admin)this.view='calendar'; await this.loadWorkspaces(); if(this.view==='instance')await this.loadAdmin(); } });
|
||||
if(this.setupView)this.setupOpen=true;this.ready = true;this.writeNavigation(true);this.$nextTick(()=>{const update=()=>document.documentElement.style.setProperty('--demo-offset',(this.$refs.demoBanner?.getBoundingClientRect().height||0)+'px');new ResizeObserver(update).observe(this.$refs.demoBanner);update();});if(this.view==='configuration')this.loadGenerationDraft();
|
||||
setInterval(() => { if (this.user && this.workspace && this.view!=='configuration' && !this.modal && !this.busy && !this.draggedRoute) this.run(() => this.loadWorkspace()); }, 20000);
|
||||
setInterval(() => { if (this.user && this.workspace && !this.modal && !this.generationSaving && !this.generationSaveError && !this.busy && !this.draggedRoute && (this.publicationPending() || this.detail?.sync?.publication_busy || Date.now()-this.workspacePollAt>=20000)){this.workspacePollAt=Date.now();this.run(() => this.loadWorkspace());} }, 2000);
|
||||
},
|
||||
consumeLoginError(){const url=new URL(location.href),code=url.searchParams.get('login_error');if(code===null)return;const ref=url.searchParams.get('ref')||'';this.loginError={code:['no_access','failed','unavailable'].includes(code)?code:'failed',ref:ref.slice(0,64)};url.searchParams.delete('login_error');url.searchParams.delete('ref');history.replaceState({},'',url.pathname+url.search+url.hash);},
|
||||
async signIn() { await this.run(async () => { this.loginError=null; const s = await this.api('/api/login', 'POST', this.loginForm); this.user = s.user; this.csrf = s.csrf; this.loginForm.password = ''; await this.loadWorkspaces(); if(this.view==='instance'){if(this.user.instance_admin)await this.loadAdmin();else this.view='calendar';} }); },
|
||||
async signOut() { await this.run(async () => { await this.api('/api/logout', 'POST', {}); this.generationSequence++;clearTimeout(this.generationTimer);this.generationDraft=null;this.generationResult=null;this.generationWorkspace='';this.view='calendar';this.modal='';this.form={};this.user = null; this.admin = null; this.detail = null; this.menu = false; this.selected = '';this.directoryState={workspaces:[]};this.startDay=''; }); },
|
||||
async signOut() { await this.run(async () => { await this.saveSharedGeneration();await this.api('/api/logout', 'POST', {}); this.generationSequence++;clearTimeout(this.generationTimer);this.generationDraft=null;this.generationResult=null;this.generationWorkspace='';this.view='calendar';this.modal='';this.form={};this.user = null; this.admin = null; this.detail = null; this.menu = false; this.selected = '';this.directoryState={workspaces:[]};this.startDay=''; }); },
|
||||
async loadWorkspaces() { this.directoryState = await this.api('/api/workspaces'); if (!this.directoryState.workspaces.some(t => t.id === this.selected)) this.selected = (this.demo && ['alice','bob','carol'].includes(this.user?.username) ? this.directoryState.workspaces.find(t=>t.name===(this.user.username==='carol'?'Customer Support':'Acme Engineering'))?.id : '') || this.directoryState.workspaces[0]?.id || ''; if (this.selected) await this.loadWorkspace(); else this.detail = null; this.writeNavigation(true); },
|
||||
async loadWorkspace() { const selected = this.directoryState.workspaces.find(t => t.id === this.selected); if (!selected) return; const detail = await this.api(`/api/workspaces/${selected.id}`);if(this.selected!==selected.id)return;this.detail=detail; if (!this.doc.schedules.some(s => s.id === this.scheduleId)) this.scheduleId = this.doc.schedules[0]?.id || ''; if (!this.startDay) this.startDay = this.monday(); await this.loadCalendar(); if(this.view==='configuration')this.loadGenerationDraft(); },
|
||||
async loadWorkspace() { const selected = this.directoryState.workspaces.find(t => t.id === this.selected); if (!selected) return; const detail = await this.api(`/api/workspaces/${selected.id}`);if(this.selected!==selected.id)return;this.detail=detail; if (!this.doc.schedules.some(s => s.id === this.scheduleId)) this.scheduleId = this.doc.schedules[0]?.id || ''; if (!this.startDay) this.startDay = this.monday(); await this.loadCalendar(); this.loadGenerationDraft(); },
|
||||
async loadCalendar() { const workspace=this.workspace.id;const calendar=await this.api(this.endpoint(`/calendar?from=${this.startDay}&days=28&schedule=${this.scheduleId}`));if(this.selected===workspace)this.calendar=calendar; },
|
||||
async navigateDays(n) { await this.run(async () => { this.startDay = n === 0 ? this.monday() : this.addDays(this.startDay, n); await this.loadCalendar(); }); },
|
||||
trapDialog(event){const list=[...this.$refs.dialog.querySelectorAll('button,input,select,textarea,summary,[tabindex="0"]')].filter(e=>!e.disabled && e.offsetParent!==null && e.tabIndex>=0);const first=list[0],last=list.at(-1);if(event.shiftKey && document.activeElement===first){event.preventDefault();last?.focus();}else if(!event.shiftKey && document.activeElement===last){event.preventDefault();first?.focus();}},
|
||||
@@ -212,6 +213,7 @@ document.addEventListener('alpine:init', () => {
|
||||
catch(e){if(seq===this.absenceSequence)this.absenceError=e.message;}
|
||||
finally{if(seq===this.absenceSequence)this.absenceLoading=false;}
|
||||
},
|
||||
absenceRowPending(row){return !this.form.force && (this.absenceResult?.approval_groups||[]).some(g=>g.required_approvers.length && g.legs.some(l=>l.schedule_id===row.schedule_id && Date.parse(l.start)<Date.parse(row.end) && Date.parse(row.start)<Date.parse(l.end)));},
|
||||
get canSaveAbsence(){return this.modal==='absence' && !this.absenceLoading && this.absenceResult && !this.absenceResult.plan.errors.length && this.absenceResult.key===JSON.stringify(this.absencePayload());},
|
||||
newAbsenceRange(values={}) { const clock=(this.doc?.working_interval?.start||'11:00:00');return {id:crypto.randomUUID(),start:this.form.start,end:this.addDays(this.form.end,1),start_time:clock,end_time:clock,schedule_id:'',replacement:'',replace_projects:false,...values}; },
|
||||
coverAbsenceRow(row){
|
||||
@@ -258,8 +260,8 @@ document.addEventListener('alpine:init', () => {
|
||||
async sendQuickRequest(){
|
||||
if(this.matchingRequest && !this.form.force_mode && !this.form.custom_range && !this.form.swap){await this.decide(this.matchingRequest,'approve');if(!this.error){this.modal='';this.toast=tr('replacement_accepted_publication_is_queued_afea8f');}return;}
|
||||
if(!this.canSendRequest)return;
|
||||
const force=this.form.force_mode;
|
||||
await this.run(async()=>{await this.api(this.endpoint('/requests'),'POST',this.requestPayload());await this.loadWorkspace();this.modal='';},force?tr('replacement_applied_publication_is_queued_89e283'):tr('request_sent_waiting_for_your_colleague_b70b64'));
|
||||
const immediate=this.form.force_mode || this.requestResult?.applies_immediately;
|
||||
await this.run(async()=>{await this.api(this.endpoint('/requests'),'POST',this.requestPayload());await this.loadWorkspace();this.modal='';},immediate?tr('replacement_applied_publication_is_queued_89e283'):tr('request_sent_waiting_for_your_colleague_b70b64'));
|
||||
},
|
||||
requestTitle(r){const leg=r.legs[0];return leg.to_user===r.requested_by?this.shortName(r.requested_by)+tr('offers_to_cover_935d75')+this.shortName(leg.from_user):this.shortName(r.requested_by)+tr('needs_cover_9e1ff7');},
|
||||
async locateCoverage(gap){this.view='calendar';this.scheduleId=gap.schedule_id;const local=this.dayKey(new Date(gap.start),this.doc.timezone);this.startDay=this.addDays(local,-1);await this.run(()=>this.loadCalendar());const day=this.calendar.days.find(d=>Date.parse(d.start)<=Date.parse(gap.start) && Date.parse(gap.start)<Date.parse(d.end));const assignment=this.calendar.assignments.find(a=>a.schedule_id===gap.schedule_id && a.user_id===gap.user_id && Date.parse(a.start)<=Date.parse(gap.start) && Date.parse(gap.start)<Date.parse(a.end));if(day && assignment)this.openDuty(day,assignment);},
|
||||
@@ -267,6 +269,7 @@ document.addEventListener('alpine:init', () => {
|
||||
moveParticipant(index, delta) { const other = index + delta; if (other < 0 || other >= this.form.participants.length) return; [this.form.participants[index], this.form.participants[other]] = [this.form.participants[other], this.form.participants[index]]; },
|
||||
addParticipant(id) { if (id && !this.form.participants.includes(id)) this.form.participants.push(id); },
|
||||
async save(force = false) {
|
||||
if(this.modal==='generation-transfer'){await this.applyTransfer();return;}
|
||||
if(this.bindingEditor || this.rulePeopleTarget){this.closeRuleEditor();return;}
|
||||
if(this.modal==='request'){await this.sendQuickRequest();return;}
|
||||
if(['variable','generation-rule'].includes(this.modal)){this.saveGenerationModal();return;}
|
||||
@@ -343,7 +346,8 @@ document.addEventListener('alpine:init', () => {
|
||||
await this.run(async()=>{await this.api(this.endpoint('/routes/order'),'PUT',{revision,ids});await this.loadWorkspace();},tr('routing_order_saved_publication_is_queued_cfb972'));
|
||||
},
|
||||
async setDrift(value) { await this.run(async () => { await this.api(this.endpoint(), 'PATCH', {revision:this.workspace.revision, overwrite_drift: value }); await this.loadWorkspaces(); }, tr('drift_policy_updated_be40fd')); },
|
||||
async retry() { await this.run(async () => { await this.api(this.endpoint('/sync'), 'POST', {}); await this.loadWorkspace(); }, tr('publication_queued_8355e1')); },
|
||||
publicationPending(){return this.detail?.sync?.state==='pending' && this.detail.sync.authorized_revision!=null;},
|
||||
async retry() { if(this.busy || this.syncSubmitting || this.publicationPending() || this.detail?.sync?.publication_busy)return;this.syncSubmitting=true;try{await this.run(async () => { await this.stageGeneration();await this.api(this.endpoint('/sync'), 'POST', {revision:this.workspace.revision});this.detail.sync={...this.detail.sync,state:'pending',authorized_revision:this.workspace.revision};await this.loadWorkspace(); }, tr('publication_queued_8355e1'));}finally{this.syncSubmitting=false;} },
|
||||
async preview() { await this.run(async () => { const plan = await this.api(this.endpoint('/preview')); const url = URL.createObjectURL(new Blob([JSON.stringify(plan, null, 2)], { type: 'application/json' })); const link = document.createElement('a'); link.href = url; link.download = 'meguri-publication.json'; link.click(); URL.revokeObjectURL(url); }, tr('publication_preview_downloaded_afd91a')); },
|
||||
auditDetails(details){const localize=(value,key='')=>{if(Array.isArray(value))return value.map(item=>localize(item,key));if(value && typeof value==='object')return Object.fromEntries(Object.entries(value).map(([k,v])=>[k,localize(v,k)]));if(typeof value==='string' && ['start','end','created_at','applied_at','effective_at'].includes(key) && /^\d{4}-\d{2}-\d{2}T/.test(value) && Number.isFinite(Date.parse(value)))return this.formatTime(value);return value;};return JSON.stringify(localize(details),null,2);},
|
||||
describeRecipient(recipient) { return recipient.type === 'schedule' ? this.scheduleName(recipient.id) : recipient.type === 'user' ? this.person(recipient.id) : recipient.type === 'team_all' ? tr('everyone_on_the_team_235b55') : tr('a_random_team_member_8f35f3'); },
|
||||
|
||||
Reference in new issue
Block a user