Restrict workspace settings to administrators, add demo countries, Helm chart and instance event log

Employees keep schedule editing but can no longer change workspace preferences or existing
scheduling-directory entries (enforced by the server, hidden in the UI). Demo accounts live in
Cyprus, the United Kingdom and Armenia with matching bank holidays. A Helm chart deploys the
service with a pre-upgrade migrate hook. Refused SSO sign-ins are stored with their support
reference as instance events and searchable on the instance administration page.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Aleksandr BogomiakovandClaude committed 2026-10-07 15:06:12 +01:00
1 parent 7afdc4be97
commit 548494f12e
11 files changed
+413 -18

No files matched your search

+7
View File
@@ -213,3 +213,10 @@ a.button{display:inline-block;text-decoration:none;border:1px solid #dbe3de;bord
@media(max-width:700px){.sidebar nav .instance-link{margin:0;border:0;border-radius:6px;padding:6px}.sidebar nav .instance-link>span:not(.nav-icon){position:absolute;width:1px;height:1px;clip-path:inset(50%);overflow:hidden;white-space:nowrap}}
.admin-user .badge,.admin-workspace .badge{text-transform:none}.admin-user strong{display:inline}
@media(max-width:700px){.sidebar nav .instance-link{padding:6px 2px}.sidebar nav .instance-link .nav-icon{width:16px}}
.event-search{display:flex;gap:8px;margin:12px 0}.event-search input{flex:1;min-width:0}.event-search button{white-space:nowrap}.event-search .primary{width:auto;margin:0;padding:9px 13px}
.event-list{display:flex;flex-direction:column}.event-row{padding:12px 0;border-top:1px solid var(--line);min-width:0}.event-row:first-child{border-top:0}.event-row.failed{border-left:3px solid #d99a82;padding-left:12px;background:#fff9f6}
.event-head{display:flex;flex-wrap:wrap;align-items:baseline;gap:6px 12px}.event-actor{font-weight:600}.event-time{color:var(--muted);margin-left:auto}
.event-action{font:12px/1.4 Consolas,monospace;background:#eef3f0;color:#3d5a4c;padding:2px 7px;border-radius:5px;overflow-wrap:anywhere}.event-action.failed{background:#fbe3da;color:#a04e34}
.event-details{display:flex;flex-wrap:wrap;gap:4px 18px;margin:6px 0 0}.event-details>div{min-width:0;max-width:100%}.event-details dt{display:inline;color:var(--muted);font-size:11px}.event-details dt::after{content:": "}.event-details dd{display:inline;margin:0;overflow-wrap:anywhere;word-break:break-word;font-size:12px}
.chips{display:inline-flex;flex-wrap:wrap;gap:4px}.chip{font:11px/1.4 Consolas,monospace;background:#eef3f0;padding:1px 6px;border-radius:10px;overflow-wrap:anywhere}
@media(max-width:700px){.event-search{flex-wrap:wrap}.event-search input{flex-basis:100%}.event-time{margin-left:0;flex-basis:100%}}
+8 -2
View File
@@ -18,7 +18,7 @@ document.addEventListener('alpine:init', () => {
}));
Alpine.data('meguri', () => ({
...generationUi(),
user: null, csrf: '', ready: false, busy: false, error: '', toast: '', demo: false, demoLifecycle:null, auth:{oidc:false,password:true}, loginError:null, admin:null,
user: null, csrf: '', 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: [] },
view: 'calendar', scheduleId: '', startDay: '', modal: '', form: {}, menu: false,
@@ -72,7 +72,12 @@ document.addEventListener('alpine:init', () => {
get myRequests() { return this.pending.filter(r=>r.requested_by===this.user?.id); },
get employeeGrantOptions(){return this.grantOptions.filter(g=>!['manage_members','manage_integration'].includes(g.id));},
canEditUserGroups(u){return !u.sso && !u.bootstrap && u.id!==this.user?.id;},
async loadAdmin(){this.admin=await this.api('/api/admin/overview');},
async loadAdmin(){this.admin=await this.api('/api/admin/overview');await this.loadEvents();},
async loadEvents(){const query=this.eventSearch.trim(),sequence=++this.eventSequence;this.eventsLoading=true;try{const result=await this.api('/api/admin/events'+(query?'?search='+encodeURIComponent(query):''));if(sequence===this.eventSequence){this.events=result.events;this.eventsMore=!!result.more;}}finally{if(sequence===this.eventSequence)this.eventsLoading=false;}},
eventSequence:0,
isFailedLogin(event){return event.action==='login.failed';},
eventLabel(key){return MEGURI_MESSAGES['access_event_key_'+key]?tr('access_event_key_'+key):key;},
eventDetails(event){const details=event.details;if(!details || typeof details!=='object')return [];const order=['reference','code','detail','username','email','subject','issuer','groups'];const keys=[...order.filter(k=>k in details),...Object.keys(details).filter(k=>!order.includes(k))];return keys.map(key=>{const value=details[key];return Array.isArray(value)?{key,chips:value.map(String),empty:!value.length}:{key,value:typeof value==='object' && value!==null?JSON.stringify(value):String(value??'')};});},
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); },
@@ -124,6 +129,7 @@ document.addEventListener('alpine:init', () => {
this.$watch('modal',(value,old)=>{if(['variable','generation-rule'].includes(old) && !value){this.bindingEditor=null;this.rulePeopleTarget=null;this.generationChanged();}});
this.$watch('view',v=>{if(v==='instance'){if(this.user?.instance_admin)this.run(()=>this.loadAdmin());}if(v==='configuration')this.loadGenerationDraft();if(this.setupView)this.setupOpen=true;this.writeNavigation();});
this.consumeLoginError();
{const query=new URLSearchParams(location.search);this.eventSearch=(query.get('ref')||query.get('search')||'').slice(0,200);}
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.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();
+23
View File
@@ -161,6 +161,29 @@
</div>
</template>
</div>
<div class="section-line"><h2><span x-text="t('access_events_title')"></span></h2></div>
<article class="panel rule-card spaced" id="instance-events">
<p class="muted"><span x-text="t('access_events_note')"></span></p>
<form class="event-search" @submit.prevent="run(()=>loadEvents())">
<input type="search" x-model="eventSearch" maxlength="200" :aria-label="t('access_events_search_label')" :placeholder="t('access_events_search_placeholder')">
<button class="primary" :disabled="busy"><span x-text="t('access_events_search')"></span></button>
<button type="button" :disabled="busy" @click="run(()=>loadEvents())"><span x-text="t('access_events_refresh')"></span></button>
</form>
<p class="muted padded" data-events-empty x-show="!events.length && !eventsLoading"><span x-text="t('access_events_empty')"></span></p>
<div class="event-list">
<template x-for="e in events" :key="e.id">
<div class="event-row" :class="{failed:isFailedLogin(e)}" :data-event-action="e.action">
<div class="event-head"><code class="event-action" :class="{failed:isFailedLogin(e)}" x-text="e.action"></code><span class="event-actor" x-text="e.actor || t('access_event_system')"></span><small class="event-time" x-text="formatTime(e.created_at)"></small></div>
<dl class="event-details">
<template x-for="d in eventDetails(e)" :key="d.key">
<div><dt x-text="eventLabel(d.key)"></dt><dd><template x-if="d.chips"><span class="chips"><template x-for="chip in d.chips" :key="chip"><code class="chip" x-text="chip"></code></template><small class="muted" x-show="d.empty" x-text="t('access_user_no_groups')"></small></span></template><template x-if="!d.chips"><span x-text="d.value"></span></template></dd></div>
</template>
</dl>
</div>
</template>
</div>
<p class="info-note" data-events-more x-show="eventsMore"><span x-text="t('access_events_more')"></span></p>
</article>
<div class="section-line"><h2><span x-text="t('access_users_title')"></span></h2>
<button class="primary" @click="open('admin-user')"><span x-text="t('access_add_user')"></span></button>
</div>
+95
View File
@@ -6303,5 +6303,100 @@
"en": "This account signs in with SSO and has no password",
"ru": "Эта учётная запись входит через SSO и не имеет пароля",
"fr": "Ce compte se connecte par SSO et n’a pas de mot de passe"
},
"err_the_search_text_is_too_long_7c3d58": {
"en": "The search text is too long",
"ru": "Текст поиска слишком длинный",
"fr": "Le texte de recherche est trop long"
},
"access_events_title": {
"en": "Event log",
"ru": "Журнал событий",
"fr": "Journal des événements"
},
"access_events_search_label": {
"en": "Search events",
"ru": "Поиск событий",
"fr": "Rechercher des événements"
},
"access_events_search_placeholder": {
"en": "Search events, users or a sign-in reference",
"ru": "Событие, пользователь или код входа",
"fr": "Événement, utilisateur ou référence de connexion"
},
"access_events_search": {
"en": "Search",
"ru": "Найти",
"fr": "Rechercher"
},
"access_events_refresh": {
"en": "Refresh",
"ru": "Обновить",
"fr": "Actualiser"
},
"access_events_empty": {
"en": "No events match",
"ru": "Подходящих событий нет",
"fr": "Aucun événement correspondant"
},
"access_events_more": {
"en": "Only the newest 300 events are shown. Narrow the search to find older ones.",
"ru": "Показаны только 300 последних событий. Уточните поиск, чтобы найти более старые.",
"fr": "Seuls les 300 événements les plus récents sont affichés. Affinez la recherche pour trouver les plus anciens."
},
"access_events_note": {
"en": "Sign-in failures, account changes and workspace lifecycle events of the whole instance, newest first.",
"ru": "Ошибки входа, изменения учётных записей и жизненный цикл пространств всего инстанса, сначала новые.",
"fr": "Échecs de connexion, changements de comptes et cycle de vie des espaces de toute l’instance, du plus récent au plus ancien."
},
"access_event_system": {
"en": "System",
"ru": "Система",
"fr": "Système"
},
"access_event_failed_flag": {
"en": "Failed sign-in",
"ru": "Неудачный вход",
"fr": "Connexion échouée"
},
"access_event_key_reference": {
"en": "Reference",
"ru": "Код",
"fr": "Référence"
},
"access_event_key_code": {
"en": "Code",
"ru": "Тип",
"fr": "Code"
},
"access_event_key_detail": {
"en": "Reason",
"ru": "Причина",
"fr": "Motif"
},
"access_event_key_subject": {
"en": "Subject",
"ru": "Субъект",
"fr": "Sujet"
},
"access_event_key_username": {
"en": "Username",
"ru": "Имя пользователя",
"fr": "Identifiant"
},
"access_event_key_email": {
"en": "Email",
"ru": "Email",
"fr": "E-mail"
},
"access_event_key_groups": {
"en": "Groups from provider",
"ru": "Группы от провайдера",
"fr": "Groupes du fournisseur"
},
"access_event_key_issuer": {
"en": "Issuer",
"ru": "Провайдер",
"fr": "Émetteur"
}
}