@acms-subir/core 0.3.12 → 0.3.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +6 -0
- package/cli/migrations.generated.mjs +16 -1
- package/integration.mjs +2 -0
- package/package.json +1 -1
- package/src/actions/index.ts +76 -59
- package/src/components/RolesManager.tsx +276 -0
- package/src/layouts/Admin.astro +8 -3
- package/src/lib/auth/accounts.ts +2 -2
- package/src/lib/auth/store.ts +4 -4
- package/src/lib/content-service.ts +14 -0
- package/src/lib/design-service.ts +11 -3
- package/src/lib/editor/http.ts +7 -0
- package/src/lib/editor/me.ts +12 -8
- package/src/lib/editor/roles.ts +14 -6
- package/src/lib/editor/schedule.ts +12 -12
- package/src/lib/editor/sections.ts +11 -4
- package/src/lib/editor/site-data.ts +4 -2
- package/src/lib/editor/translations.ts +2 -1
- package/src/lib/email/accounts.ts +2 -2
- package/src/lib/email/store.ts +3 -3
- package/src/lib/email/types.ts +1 -1
- package/src/lib/permissions/access.ts +73 -0
- package/src/lib/permissions/catalog.ts +147 -0
- package/src/lib/permissions/migration.ts +17 -0
- package/src/lib/permissions/service.ts +59 -0
- package/src/lib/permissions/store.ts +54 -0
- package/src/lib/repo/migrations.ts +2 -0
- package/src/lib/repo/sql.ts +3 -0
- package/src/lib/repo/types.ts +2 -0
- package/src/lib/roles.ts +9 -0
- package/src/pages/admin/c/[collection]/index.astro +6 -0
- package/src/pages/admin/calendar/bookings.astro +2 -0
- package/src/pages/admin/calendar/events/index.astro +11 -0
- package/src/pages/admin/calendar/resources/[id].astro +2 -0
- package/src/pages/admin/calendar/resources/index.astro +2 -0
- package/src/pages/admin/calendar/resources/link.astro +2 -0
- package/src/pages/admin/calendar/settings.astro +2 -0
- package/src/pages/admin/email.astro +2 -2
- package/src/pages/admin/forms/[id]/share.astro +2 -0
- package/src/pages/admin/forms/[id]/submissions.astro +2 -0
- package/src/pages/admin/forms/index.astro +2 -0
- package/src/pages/admin/import.astro +3 -6
- package/src/pages/admin/integrations.astro +10 -12
- package/src/pages/admin/live.astro +3 -3
- package/src/pages/admin/mobile.astro +3 -3
- package/src/pages/admin/pages/index.astro +8 -1
- package/src/pages/admin/roles.astro +80 -0
- package/src/pages/admin/settings.astro +31 -28
- package/src/pages/admin/tags.astro +2 -0
- package/src/pages/admin/users.astro +87 -41
- package/src/pages/api/ai/clean.ts +3 -2
- package/src/pages/api/ai/draft.ts +3 -2
- package/src/pages/api/ai/settings.ts +4 -3
- package/src/pages/api/ai/test.ts +3 -2
- package/src/pages/api/design/export.ts +2 -1
- package/src/pages/api/design/import.ts +2 -1
- package/src/pages/api/design/keys.ts +3 -2
- package/src/pages/api/email/settings.ts +4 -3
- package/src/pages/api/export.ts +3 -2
- package/src/pages/api/import/asset.ts +3 -2
- package/src/pages/api/import/index.ts +4 -3
- package/src/pages/api/import/media.ts +3 -2
- package/src/pages/api/live/oauth/youtube/callback.ts +4 -4
- package/src/pages/api/live/settings.ts +4 -3
- package/src/pages/api/media/upload.ts +4 -0
- package/src/pages/api/mobile/access.ts +4 -3
- package/src/pages/api/monitoring.ts +4 -3
- package/src/pages/api/push/admin.ts +4 -3
- package/src/pages/api/roles.ts +36 -0
- package/src/pages/api/v1/admin/bookings/[id]/[action].ts +2 -1
- package/src/pages/api/v1/admin/bookings/index.ts +3 -2
- package/src/pages/api/v1/admin/documents/[kind]/[id]/[action].ts +3 -2
- package/src/pages/api/v1/admin/documents/[kind]/[id]/index.ts +3 -2
- package/src/pages/api/v1/admin/documents/[kind]/[id]/translations/[locale]/[action].ts +3 -2
- package/src/pages/api/v1/admin/documents/[kind]/[id]/translations/[locale]/index.ts +3 -2
- package/src/pages/api/v1/admin/documents/index.ts +2 -1
- package/src/pages/api/v1/admin/links.ts +2 -1
- package/src/pages/api/v1/admin/media/index.ts +2 -1
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
import { useEffect, useMemo, useState } from 'react';
|
|
2
|
+
import type { RolesState } from '../lib/permissions/service';
|
|
3
|
+
|
|
4
|
+
// Team → Roles: what each role may do. Every switch edits a draft in this
|
|
5
|
+
// tab; Restore defaults fills the draft too. Nothing changes for anyone until
|
|
6
|
+
// Save changes → Review → Save, which replaces the policy in one write. A
|
|
7
|
+
// save against a newer revision is refused, and the draft is kept.
|
|
8
|
+
|
|
9
|
+
type Grants = RolesState['grants'];
|
|
10
|
+
type RoleKey = keyof Grants;
|
|
11
|
+
type Area = RolesState['areas'][number];
|
|
12
|
+
|
|
13
|
+
const ENDPOINT = '/api/roles';
|
|
14
|
+
async function api(body?: unknown): Promise<RolesState> {
|
|
15
|
+
const res = await fetch(ENDPOINT, {
|
|
16
|
+
method: body ? 'PUT' : 'GET', credentials: 'same-origin',
|
|
17
|
+
headers: body ? { 'content-type': 'application/json', accept: 'application/json' } : { accept: 'application/json' },
|
|
18
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
19
|
+
});
|
|
20
|
+
const data = await res.json().catch(() => null);
|
|
21
|
+
if (!res.ok) throw Object.assign(new Error(data?.issues?.[0]?.message || data?.error || `${res.status} ${res.statusText || 'Request failed'}`), { status: res.status });
|
|
22
|
+
return data as RolesState;
|
|
23
|
+
}
|
|
24
|
+
const errText = (e: unknown) => (e instanceof Error ? e.message : String(e));
|
|
25
|
+
const clone = (g: Grants): Grants => JSON.parse(JSON.stringify(g));
|
|
26
|
+
const editable = (role: string): role is RoleKey => role !== 'owner';
|
|
27
|
+
const when = (ms: number) => new Date(ms).toLocaleString(undefined, { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' });
|
|
28
|
+
|
|
29
|
+
/** differences between two grant sets, per role */
|
|
30
|
+
function diff(a: Grants, b: Grants) {
|
|
31
|
+
const out: Array<{ role: RoleKey; key: string; granted: boolean }> = [];
|
|
32
|
+
for (const role of Object.keys(b) as RoleKey[]) for (const key of Object.keys(b[role])) {
|
|
33
|
+
const k = key as keyof Grants[RoleKey];
|
|
34
|
+
if (a[role][k] !== b[role][k]) out.push({ role, key, granted: b[role][k] });
|
|
35
|
+
}
|
|
36
|
+
return out;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function Switch({ on, disabled, label, onChange }: { on: boolean; disabled?: boolean; label: string; onChange?: () => void }) {
|
|
40
|
+
return (
|
|
41
|
+
<button type="button" role="switch" aria-checked={on} aria-label={label} disabled={disabled}
|
|
42
|
+
className={`rp-switch${on ? ' on' : ''}`} onClick={onChange}><i /></button>
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export default function RolesManager() {
|
|
47
|
+
const [state, setState] = useState<RolesState | null>(null);
|
|
48
|
+
const [draft, setDraft] = useState<Grants | null>(null);
|
|
49
|
+
const [role, setRole] = useState<string>('administrator');
|
|
50
|
+
const [tab, setTab] = useState<'edit' | 'compare'>('edit');
|
|
51
|
+
const [error, setError] = useState('');
|
|
52
|
+
const [conflict, setConflict] = useState(false);
|
|
53
|
+
const [note, setNote] = useState('');
|
|
54
|
+
const [busy, setBusy] = useState(false);
|
|
55
|
+
const [confirm, setConfirm] = useState<null | { kind: 'review' } | { kind: 'restore'; role: RoleKey | 'all' }>(null);
|
|
56
|
+
|
|
57
|
+
useEffect(() => { api().then((s) => { setState(s); setDraft(clone(s.grants)); }, (e) => setError(errText(e))); }, []);
|
|
58
|
+
|
|
59
|
+
const pending = useMemo(() => (state && draft ? diff(state.grants, draft) : []), [state, draft]);
|
|
60
|
+
const dirty = pending.length > 0;
|
|
61
|
+
// unsaved changes live only in this tab: warn before losing them
|
|
62
|
+
useEffect(() => {
|
|
63
|
+
if (!dirty) return;
|
|
64
|
+
const onLeave = (e: BeforeUnloadEvent) => e.preventDefault();
|
|
65
|
+
window.addEventListener('beforeunload', onLeave);
|
|
66
|
+
return () => window.removeEventListener('beforeunload', onLeave);
|
|
67
|
+
}, [dirty]);
|
|
68
|
+
|
|
69
|
+
if (!state || !draft) return error ? <p className="flash error">{error}</p> : <div className="ma-skel" aria-busy="true" aria-label="Loading"><i /><i /></div>;
|
|
70
|
+
|
|
71
|
+
const readOnly = !state.canEdit;
|
|
72
|
+
const labelOf = (r: string) => state.roles.find((x) => x.role === r)?.label ?? r;
|
|
73
|
+
const permOf = (key: string) => state.areas.flatMap((a) => a.permissions).find((p) => p.key === key);
|
|
74
|
+
const customized = (r: RoleKey) => diff(state.defaults, draft).some((d) => d.role === r);
|
|
75
|
+
const unsaved = (r: RoleKey) => pending.some((d) => d.role === r);
|
|
76
|
+
const grant = (r: string, key: string) => r === 'owner' || (editable(r) && (draft[r] as Record<string, boolean>)[key] === true);
|
|
77
|
+
|
|
78
|
+
const toggle = (r: RoleKey, key: string) => {
|
|
79
|
+
if (readOnly) return;
|
|
80
|
+
setNote('');
|
|
81
|
+
setDraft((d) => { const next = clone(d!); (next[r] as Record<string, boolean>)[key] = !(next[r] as Record<string, boolean>)[key]; return next; });
|
|
82
|
+
};
|
|
83
|
+
const restore = (target: RoleKey | 'all') => {
|
|
84
|
+
setDraft((d) => {
|
|
85
|
+
const next = clone(d!);
|
|
86
|
+
for (const r of Object.keys(next) as RoleKey[]) if (target === 'all' || target === r) next[r] = clone(state.defaults)[r];
|
|
87
|
+
return next;
|
|
88
|
+
});
|
|
89
|
+
setConfirm(null);
|
|
90
|
+
setNote(target === 'all' ? 'Every role is back to its defaults in your draft. Save to apply.' : `${labelOf(target)} is back to its defaults in your draft. Save to apply.`);
|
|
91
|
+
};
|
|
92
|
+
const save = async () => {
|
|
93
|
+
setBusy(true); setError(''); setConflict(false);
|
|
94
|
+
try {
|
|
95
|
+
const s = await api({ revision: state.revision, grants: draft });
|
|
96
|
+
setState(s); setDraft(clone(s.grants)); setConfirm(null); setNote('Saved. The new permissions apply from everyone’s next action.');
|
|
97
|
+
} catch (e) {
|
|
98
|
+
setConfirm(null);
|
|
99
|
+
setError(errText(e));
|
|
100
|
+
if ((e as { status?: number }).status === 409) setConflict(true);
|
|
101
|
+
} finally { setBusy(false); }
|
|
102
|
+
};
|
|
103
|
+
// someone else saved: load their version and replay this draft's edits on top
|
|
104
|
+
const reloadLatest = async () => {
|
|
105
|
+
setBusy(true);
|
|
106
|
+
try {
|
|
107
|
+
const s = await api();
|
|
108
|
+
const next = clone(s.grants);
|
|
109
|
+
for (const p of pending) (next[p.role] as Record<string, boolean>)[p.key] = p.granted;
|
|
110
|
+
setState(s); setDraft(next); setError(''); setConflict(false);
|
|
111
|
+
setNote(`Loaded the latest permissions${s.updatedBy ? ` from ${s.updatedBy}` : ''} with your edits on top. Review, then save again.`);
|
|
112
|
+
} catch (e) { setError(errText(e)); }
|
|
113
|
+
finally { setBusy(false); }
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
const areaSummary = (r: string, a: Area) => {
|
|
117
|
+
const n = a.permissions.filter((p) => grant(r, p.key)).length;
|
|
118
|
+
return n === a.permissions.length ? ['Full access', 'published'] : n === 0 ? ['No access', 'archived'] : ['Partial access', 'edited'];
|
|
119
|
+
};
|
|
120
|
+
const selected = state.roles.find((r) => r.role === role)!;
|
|
121
|
+
const rolesChanged = new Set(pending.map((p) => p.role)).size;
|
|
122
|
+
|
|
123
|
+
return (
|
|
124
|
+
<div className="rp">
|
|
125
|
+
{readOnly && <p className="notice">Only owners can change permissions. This is what each role can do today.</p>}
|
|
126
|
+
{error && (
|
|
127
|
+
<div className="flash error rp-flash" role="alert">
|
|
128
|
+
<span>{error}</span>
|
|
129
|
+
{conflict && <button className="btn sm" type="button" onClick={reloadLatest} disabled={busy}>Load their changes</button>}
|
|
130
|
+
{/recent sign-in|last ten minutes/i.test(error) && <form method="post" action="/admin/logout"><button className="btn sm" type="submit">Sign in again</button></form>}
|
|
131
|
+
</div>
|
|
132
|
+
)}
|
|
133
|
+
{note && !error && <p className="flash" role="status">{note}</p>}
|
|
134
|
+
|
|
135
|
+
<div className="rp-bar">
|
|
136
|
+
<div className="seg" role="tablist" aria-label="View">
|
|
137
|
+
<button type="button" role="tab" aria-selected={tab === 'edit'} onClick={() => setTab('edit')}>{readOnly ? 'By role' : 'Edit role'}</button>
|
|
138
|
+
<button type="button" role="tab" aria-selected={tab === 'compare'} onClick={() => setTab('compare')}>Compare roles</button>
|
|
139
|
+
</div>
|
|
140
|
+
{!readOnly && <button className="btn" type="button" onClick={() => setConfirm({ kind: 'restore', role: 'all' })}>Restore all defaults</button>}
|
|
141
|
+
</div>
|
|
142
|
+
|
|
143
|
+
{tab === 'edit' ? (
|
|
144
|
+
<div className="rp-grid">
|
|
145
|
+
<nav className="rp-roles" aria-label="Roles">
|
|
146
|
+
{state.roles.map((r) => (
|
|
147
|
+
<button key={r.role} type="button" className={`rp-role${role === r.role ? ' on' : ''}`} aria-current={role === r.role} onClick={() => setRole(r.role)}>
|
|
148
|
+
<span className="rp-role-name">
|
|
149
|
+
{editable(r.role) && unsaved(r.role) && <span className="rp-dot" title="Unsaved changes" />}
|
|
150
|
+
{r.label}
|
|
151
|
+
{editable(r.role) && customized(r.role) && <span className="pill draft">Customized</span>}
|
|
152
|
+
</span>
|
|
153
|
+
<small>{r.members} {r.members === 1 ? 'person' : 'people'}</small>
|
|
154
|
+
</button>
|
|
155
|
+
))}
|
|
156
|
+
</nav>
|
|
157
|
+
|
|
158
|
+
<section className="card rp-panel" aria-labelledby="rp-role-h">
|
|
159
|
+
<header className="rp-head">
|
|
160
|
+
<div>
|
|
161
|
+
<h2 id="rp-role-h">{selected.label} {editable(role) && <span className={`pill ${customized(role) ? 'draft' : 'archived'}`}>{customized(role) ? 'Customized' : 'Default'}</span>}</h2>
|
|
162
|
+
<p className="hint">{role === 'owner' ? 'Owners always have full access, so the site can never be locked out.' : selected.summary}</p>
|
|
163
|
+
</div>
|
|
164
|
+
{!readOnly && editable(role) && <button className="btn sm" type="button" disabled={!customized(role)} onClick={() => setConfirm({ kind: 'restore', role: role as RoleKey })}>Restore defaults</button>}
|
|
165
|
+
</header>
|
|
166
|
+
{state.areas.map((a) => {
|
|
167
|
+
const [sum, tone] = areaSummary(role, a);
|
|
168
|
+
return (
|
|
169
|
+
<div className="rp-area" key={a.id}>
|
|
170
|
+
<div className="rp-area-head"><b>{a.label}</b>{a.id === 'owner' ? <span className="pill archived">Locked</span> : <span className={`pill ${tone}`}>{sum}</span>}</div>
|
|
171
|
+
{a.permissions.map((p) => {
|
|
172
|
+
const changed = editable(role) && pending.some((d) => d.role === role && d.key === p.key);
|
|
173
|
+
const on = grant(role, p.key);
|
|
174
|
+
return (
|
|
175
|
+
<div className={`rp-row${changed ? ' changed' : ''}`} key={p.key}>
|
|
176
|
+
<div className="rp-text">
|
|
177
|
+
<span className="rp-label">{p.label}{p.sensitive && <span className="pill edited" title="Grants code, credentials or people. Grant with care.">Sensitive</span>}{changed && <span className="pill draft">Changed</span>}</span>
|
|
178
|
+
<small>{p.description}</small>
|
|
179
|
+
</div>
|
|
180
|
+
<Switch on={on} label={`${p.label} for ${selected.label}`} disabled={readOnly || role === 'owner' || p.locked} onChange={() => editable(role) && toggle(role, p.key)} />
|
|
181
|
+
</div>
|
|
182
|
+
);
|
|
183
|
+
})}
|
|
184
|
+
</div>
|
|
185
|
+
);
|
|
186
|
+
})}
|
|
187
|
+
</section>
|
|
188
|
+
</div>
|
|
189
|
+
) : (
|
|
190
|
+
<section className="card rp-compare">
|
|
191
|
+
<table className="list">
|
|
192
|
+
<thead><tr><th>Permission</th>{state.roles.map((r) => <th key={r.role}>{r.label}</th>)}</tr></thead>
|
|
193
|
+
<tbody>
|
|
194
|
+
{state.areas.map((a) => [
|
|
195
|
+
<tr key={a.id} className="rp-compare-area"><td colSpan={state.roles.length + 1}><b>{a.label}</b></td></tr>,
|
|
196
|
+
...a.permissions.map((p) => (
|
|
197
|
+
<tr key={p.key}>
|
|
198
|
+
<td>{p.label}</td>
|
|
199
|
+
{state.roles.map((r) => {
|
|
200
|
+
const on = grant(r.role, p.key);
|
|
201
|
+
const changed = editable(r.role) && pending.some((d) => d.role === r.role && d.key === p.key);
|
|
202
|
+
return <td key={r.role} className={`rp-cell${on ? ' on' : ''}${changed ? ' changed' : ''}`} aria-label={on ? 'Allowed' : 'Not allowed'}>{on ? '✓' : '—'}</td>;
|
|
203
|
+
})}
|
|
204
|
+
</tr>
|
|
205
|
+
)),
|
|
206
|
+
])}
|
|
207
|
+
</tbody>
|
|
208
|
+
</table>
|
|
209
|
+
<p className="hint rp-compare-note">Highlighted cells are unsaved changes. Owners always have every permission.</p>
|
|
210
|
+
</section>
|
|
211
|
+
)}
|
|
212
|
+
|
|
213
|
+
{state.history.length > 0 && (
|
|
214
|
+
<section className="card card-pad rp-history">
|
|
215
|
+
<h3 className="card-title">Recent changes</h3>
|
|
216
|
+
<ul>
|
|
217
|
+
{state.history.map((h) => (
|
|
218
|
+
<li key={h.id}>
|
|
219
|
+
<span><b>{h.by}</b> · {when(h.at)}</span>
|
|
220
|
+
<small>{h.changes.slice(0, 6).map((c) => `${labelOf(c.role)}: ${c.granted ? '+' : '−'} ${permOf(c.permission)?.label ?? c.permission}`).join(' · ')}{h.changes.length > 6 ? ` · and ${h.changes.length - 6} more` : ''}</small>
|
|
221
|
+
</li>
|
|
222
|
+
))}
|
|
223
|
+
</ul>
|
|
224
|
+
</section>
|
|
225
|
+
)}
|
|
226
|
+
|
|
227
|
+
{dirty && !readOnly && (
|
|
228
|
+
<div className="rp-savebar" role="region" aria-label="Unsaved changes">
|
|
229
|
+
<span><span className="rp-dot" />Unsaved changes · {rolesChanged} {rolesChanged === 1 ? 'role' : 'roles'}, {pending.length} {pending.length === 1 ? 'permission' : 'permissions'}</span>
|
|
230
|
+
<span className="rp-savebar-actions">
|
|
231
|
+
<button className="btn" type="button" disabled={busy} onClick={() => { setDraft(clone(state.grants)); setNote('Changes discarded.'); setError(''); setConflict(false); }}>Discard</button>
|
|
232
|
+
<button className="btn primary" type="button" disabled={busy} onClick={() => setConfirm({ kind: 'review' })}>Save changes</button>
|
|
233
|
+
</span>
|
|
234
|
+
</div>
|
|
235
|
+
)}
|
|
236
|
+
|
|
237
|
+
{confirm && (
|
|
238
|
+
<div className="rp-overlay" role="presentation" onClick={(e) => { if (e.target === e.currentTarget && !busy) setConfirm(null); }}>
|
|
239
|
+
<div className="rp-dialog" role="dialog" aria-modal="true" aria-labelledby="rp-dialog-h">
|
|
240
|
+
{confirm.kind === 'review' ? (
|
|
241
|
+
<>
|
|
242
|
+
<h2 id="rp-dialog-h">Review permission changes</h2>
|
|
243
|
+
<div className="rp-dialog-body">
|
|
244
|
+
{[...new Set(pending.map((p) => p.role))].map((r) => (
|
|
245
|
+
<div className="rp-review-role" key={r}>
|
|
246
|
+
<b>{labelOf(r)}</b> <small className="hint">· {state.roles.find((x) => x.role === r)?.members ?? 0} affected</small>
|
|
247
|
+
<ul>{pending.filter((p) => p.role === r).map((p) => (
|
|
248
|
+
<li key={p.key} className={p.granted ? 'plus' : 'minus'}>{p.granted ? '+' : '−'} {permOf(p.key)?.label ?? p.key}</li>
|
|
249
|
+
))}</ul>
|
|
250
|
+
</div>
|
|
251
|
+
))}
|
|
252
|
+
<p className="hint">Changes apply right away to everyone in these roles, including people already signed in on the web or in the editor app.</p>
|
|
253
|
+
</div>
|
|
254
|
+
<div className="modal-foot">
|
|
255
|
+
<button className="btn ghost" type="button" disabled={busy} onClick={() => setConfirm(null)}>Back</button>
|
|
256
|
+
<button className="btn primary" type="button" disabled={busy} onClick={save}>{busy ? 'Saving…' : 'Save changes'}</button>
|
|
257
|
+
</div>
|
|
258
|
+
</>
|
|
259
|
+
) : (
|
|
260
|
+
<>
|
|
261
|
+
<h2 id="rp-dialog-h">{confirm.role === 'all' ? 'Restore every role to its defaults?' : `Restore ${labelOf(confirm.role)} to its defaults?`}</h2>
|
|
262
|
+
<div className="rp-dialog-body">
|
|
263
|
+
<p>This puts {confirm.role === 'all' ? 'every role' : labelOf(confirm.role)} back to the default permissions in your draft. <b>Nothing changes for your team until you save.</b></p>
|
|
264
|
+
</div>
|
|
265
|
+
<div className="modal-foot">
|
|
266
|
+
<button className="btn ghost" type="button" onClick={() => setConfirm(null)}>Cancel</button>
|
|
267
|
+
<button className="btn primary" type="button" onClick={() => restore(confirm.role)}>Restore in draft</button>
|
|
268
|
+
</div>
|
|
269
|
+
</>
|
|
270
|
+
)}
|
|
271
|
+
</div>
|
|
272
|
+
</div>
|
|
273
|
+
)}
|
|
274
|
+
</div>
|
|
275
|
+
);
|
|
276
|
+
}
|
package/src/layouts/Admin.astro
CHANGED
|
@@ -6,6 +6,7 @@ import { CONTENT } from '../lib/content';
|
|
|
6
6
|
import { CALENDAR, bookingsEnabled } from '../lib/calendar/config';
|
|
7
7
|
import { OWNER_ENV } from '../lib/auth';
|
|
8
8
|
import { validateRequestSession } from '../lib/auth/request';
|
|
9
|
+
import { accessFor } from '../lib/permissions/access';
|
|
9
10
|
import { AUTH } from '../lib/auth/config';
|
|
10
11
|
import ClerkSession from '../components/auth/ClerkSession.astro';
|
|
11
12
|
import ThemeScript from '../components/ThemeScript.astro';
|
|
@@ -40,6 +41,9 @@ const me = {
|
|
|
40
41
|
};
|
|
41
42
|
const initials = me.name.split(/\s+/).map((w) => w[0] ?? '').join('').slice(0, 2).toUpperCase();
|
|
42
43
|
const siteName = settings.siteTitle || BRAND.name;
|
|
44
|
+
// Team → Roles decides some of what's listed (pages still check for themselves)
|
|
45
|
+
const access = await accessFor(Astro.request, Astro.cookies, repo);
|
|
46
|
+
const can = (p: Parameters<NonNullable<typeof access>['can']>[0]) => access?.can(p) ?? false;
|
|
43
47
|
|
|
44
48
|
const icons: Record<string, string> = {
|
|
45
49
|
dashboard: 'M3 3h7v9H3zM14 3h7v5h-7zM14 12h7v9h-7zM3 16h7v5H3z',
|
|
@@ -118,7 +122,7 @@ const groups: { id: string; label: string; items: Item[] }[] = [
|
|
|
118
122
|
// people, booking links). Sub-pages highlight their home.
|
|
119
123
|
items: [
|
|
120
124
|
{ key: 'calendar:schedule', label: 'Schedule', href: '/admin/calendar', icon: icons.calendar, count: null },
|
|
121
|
-
...(CALENDAR.enabled
|
|
125
|
+
...(CALENDAR.enabled && can('schedule.bookings')
|
|
122
126
|
? [{ key: 'calendar:bookings', label: 'Bookings', href: '/admin/calendar/bookings', icon: icons.bookings, count: pendingBookings || null, attention: pendingBookings > 0 }]
|
|
123
127
|
: []),
|
|
124
128
|
],
|
|
@@ -131,8 +135,8 @@ const groups: { id: string; label: string; items: Item[] }[] = [
|
|
|
131
135
|
{ key: 'design', label: 'Design', href: '/admin/design', icon: icons.design, count: null },
|
|
132
136
|
{ key: 'navigation', label: 'Navigation', href: '/admin/navigation', icon: icons.navigation, count: null },
|
|
133
137
|
{ key: 'links', label: 'Links page', href: '/admin/links', icon: icons.links, count: links.length },
|
|
134
|
-
// app access switches (+ push notifications when acms({ mobile: { push } }) is on)
|
|
135
|
-
...(
|
|
138
|
+
// app access switches (+ push notifications when acms({ mobile: { push } }) is on)
|
|
139
|
+
...(can('services.manage') ? [{ key: 'mobile', label: 'Mobile app', href: '/admin/mobile', icon: icons.phone, count: null }] : []),
|
|
136
140
|
],
|
|
137
141
|
},
|
|
138
142
|
{
|
|
@@ -140,6 +144,7 @@ const groups: { id: string; label: string; items: Item[] }[] = [
|
|
|
140
144
|
label: 'Team',
|
|
141
145
|
items: [
|
|
142
146
|
{ key: 'users', label: 'Users', href: '/admin/users', icon: icons.users, count: null },
|
|
147
|
+
...(access?.role === 'owner' || access?.role === 'administrator' ? [{ key: 'roles', label: 'Roles', href: '/admin/roles', icon: icons.shield, count: null }] : []),
|
|
143
148
|
{ key: 'ai', label: 'Document import', href: '/admin/ai', icon: icons.posts, count: null },
|
|
144
149
|
{ key: 'integrations', label: 'API & webhooks', href: '/admin/integrations', icon: icons.plug, count: null },
|
|
145
150
|
{ key: 'settings', label: 'Settings', href: '/admin/settings', icon: icons.settings, count: null },
|
package/src/lib/auth/accounts.ts
CHANGED
|
@@ -4,7 +4,7 @@ import { AUTH, clerkConfig } from './config';
|
|
|
4
4
|
import { clerkClient, clerkOperation } from './request';
|
|
5
5
|
import { validAddress } from '../email/config';
|
|
6
6
|
|
|
7
|
-
export async function inviteClerkUser(repo: ContentRepository, data: { email: string; name: string; role: unknown }, locals?: unknown) {
|
|
7
|
+
export async function inviteClerkUser(repo: ContentRepository, data: { email: string; name: string; role: unknown; invitedBy?: string }, locals?: unknown) {
|
|
8
8
|
if (AUTH.provider !== 'clerk') throw new Error('Clerk authentication is not enabled.');
|
|
9
9
|
const config = clerkConfig(locals), email = data.email.trim().toLowerCase(), role = Role.parse(data.role);
|
|
10
10
|
if (!validAddress(email)) throw new Error('Enter a valid email address.');
|
|
@@ -15,7 +15,7 @@ export async function inviteClerkUser(repo: ContentRepository, data: { email: st
|
|
|
15
15
|
const id = crypto.randomUUID();
|
|
16
16
|
// Expired/failed invitations do not prevent a deliberate retry.
|
|
17
17
|
for (const old of await repo.auth.invitations(config.issuer)) if (old.email === email && old.state === 'expired') await repo.auth.invitationState(old.id, 'expired');
|
|
18
|
-
await repo.auth.reserveInvitation(config.issuer, { id, email, name: data.name.trim().slice(0, 120) || email, role });
|
|
18
|
+
await repo.auth.reserveInvitation(config.issuer, { id, email, name: data.name.trim().slice(0, 120) || email, role, invitedBy: data.invitedBy });
|
|
19
19
|
try {
|
|
20
20
|
const invitation = await clerkOperation(() => client.invitations.createInvitation({ emailAddress: email, expiresInDays: 7, ignoreExisting: false,
|
|
21
21
|
redirectUrl: `${config.authorizedParties[0]}/admin/login?signUp=1`, publicMetadata: { acmsInvitationId: id } }));
|
package/src/lib/auth/store.ts
CHANGED
|
@@ -2,9 +2,9 @@ import type { SqlDriver } from '../repo/sql';
|
|
|
2
2
|
import type { Role } from '../schema';
|
|
3
3
|
|
|
4
4
|
export interface ClerkBinding { issuer: string; subject: string; sessionId: string }
|
|
5
|
-
export interface AuthInvitation { id: string; email: string; name: string; role: Role; remoteId: string; state: string; expiresAt: number }
|
|
5
|
+
export interface AuthInvitation { id: string; email: string; name: string; role: Role; remoteId: string; state: string; expiresAt: number; invitedBy: string; createdAt: number }
|
|
6
6
|
function invitation(r: Record<string, unknown>): AuthInvitation {
|
|
7
|
-
return { id: String(r.id), email: String(r.email), name: String(r.name), role: r.role as Role, remoteId: String(r.remote_id), state: r.state === 'pending' && Number(r.expires_at) <= Date.now() ? 'expired' : String(r.state), expiresAt: Number(r.expires_at) };
|
|
7
|
+
return { id: String(r.id), email: String(r.email), name: String(r.name), role: r.role as Role, remoteId: String(r.remote_id), state: r.state === 'pending' && Number(r.expires_at) <= Date.now() ? 'expired' : String(r.state), expiresAt: Number(r.expires_at), invitedBy: String(r.invited_by ?? ''), createdAt: Number(r.created_at) };
|
|
8
8
|
}
|
|
9
9
|
export class AuthStore {
|
|
10
10
|
constructor(private driver: () => Promise<SqlDriver>) {}
|
|
@@ -43,9 +43,9 @@ export class AuthStore {
|
|
|
43
43
|
const rows = await (await this.driver()).all('SELECT * FROM auth_invitations WHERE issuer=? AND id=?', [issuer, id]);
|
|
44
44
|
return rows[0] ? invitation(rows[0]) : null;
|
|
45
45
|
}
|
|
46
|
-
async reserveInvitation(issuer: string, data: { id: string; email: string; name: string; role: Role }) {
|
|
46
|
+
async reserveInvitation(issuer: string, data: { id: string; email: string; name: string; role: Role; invitedBy?: string }) {
|
|
47
47
|
await (await this.driver()).run("UPDATE auth_invitations SET state='expired' WHERE issuer=? AND email=? AND state='pending' AND expires_at<=?", [issuer,data.email,Date.now()]);
|
|
48
|
-
await (await this.driver()).run("INSERT INTO auth_invitations(id,issuer,email,name,role,state,remote_id,expires_at,created_at) VALUES (?,?,?,?,?,'sending',''
|
|
48
|
+
await (await this.driver()).run("INSERT INTO auth_invitations(id,issuer,email,name,role,state,remote_id,expires_at,created_at,invited_by) VALUES (?,?,?,?,?,'sending','',?,?,?)", [data.id, issuer, data.email, data.name, data.role, Date.now() + 7 * 86400000, Date.now(), data.invitedBy ?? '']);
|
|
49
49
|
}
|
|
50
50
|
async invitationState(id: string, state: string, remoteId = '') {
|
|
51
51
|
const guard = state === 'pending' || state === 'failed' ? " AND state='sending'" : " AND state<>'accepted'";
|
|
@@ -3,6 +3,7 @@ import { FormDefinition } from './forms/schema';
|
|
|
3
3
|
import type { CalEvent, EventLocation, Page, Post, WidgetInstance } from './schema';
|
|
4
4
|
import { slugify } from './schema';
|
|
5
5
|
import { ApiError } from './api';
|
|
6
|
+
import { deniedMessage } from './permissions/catalog';
|
|
6
7
|
import { CONTENT, collectionUrl } from './content';
|
|
7
8
|
import { CALENDAR } from './calendar/config';
|
|
8
9
|
import { isValidTimeZone, normalizeEventTimes } from './calendar/events';
|
|
@@ -26,6 +27,8 @@ export interface SaveHooks {
|
|
|
26
27
|
locals?: unknown;
|
|
27
28
|
/** new redirects must fire at once in this isolate */
|
|
28
29
|
onRedirect?: () => void;
|
|
30
|
+
/** false: the person's role edits drafts but can't change what's live (unset: keys and agents, scoped elsewhere) */
|
|
31
|
+
canPublish?: boolean;
|
|
29
32
|
}
|
|
30
33
|
|
|
31
34
|
export type PostSave = Parameters<ContentRepository['updatePost']>[1];
|
|
@@ -38,6 +41,11 @@ export type EventSave = Partial<Omit<CalEvent, 'id' | 'draft' | 'startAt' | 'end
|
|
|
38
41
|
type EventClean = Omit<EventSave, 'location'> & { location?: EventLocation };
|
|
39
42
|
|
|
40
43
|
const live = (status: string) => status === 'published' || status === 'scheduled';
|
|
44
|
+
/** publishing, scheduling, unpublishing or applying staged edits needs Publish content */
|
|
45
|
+
export function assertCanPublish(hooks: Pick<SaveHooks, 'canPublish'>) {
|
|
46
|
+
if (hooks.canPublish === false) throw new ApiError(403, deniedMessage('content.publish'));
|
|
47
|
+
}
|
|
48
|
+
const touchesLive = (before: string, after: string | undefined) => after !== undefined && after !== before && (live(before) || live(after));
|
|
41
49
|
|
|
42
50
|
export async function savePost(repo: ContentRepository, id: string, input: PostSave, hooks: SaveHooks): Promise<Post> {
|
|
43
51
|
const data = { ...input };
|
|
@@ -45,6 +53,7 @@ export async function savePost(repo: ContentRepository, id: string, input: PostS
|
|
|
45
53
|
if (data.collection !== undefined && !CONTENT.collections[data.collection]) throw new ApiError(400, `Unknown collection "${data.collection}".`);
|
|
46
54
|
const before = await repo.getPost(id);
|
|
47
55
|
if (!before) throw new ApiError(404, 'Post not found.');
|
|
56
|
+
if (touchesLive(before.status, data.status)) assertCanPublish(hooks);
|
|
48
57
|
const transitions = data.status !== undefined && data.status !== before.status;
|
|
49
58
|
if (live(before.status) && !transitions) {
|
|
50
59
|
const { status: _status, scheduledAt: _scheduledAt, authorId: _authorId, ...staged } = data;
|
|
@@ -66,6 +75,7 @@ export async function savePost(repo: ContentRepository, id: string, input: PostS
|
|
|
66
75
|
|
|
67
76
|
/** Update: the pending draft goes live (tag registry, slug-change 301 happen now) */
|
|
68
77
|
export async function applyPostUpdate(repo: ContentRepository, id: string, hooks: SaveHooks): Promise<Post> {
|
|
78
|
+
assertCanPublish(hooks);
|
|
69
79
|
const before = await repo.getPost(id);
|
|
70
80
|
if (!before) throw new ApiError(404, 'Post not found.');
|
|
71
81
|
await repo.applyPostDraft(id);
|
|
@@ -85,6 +95,7 @@ export async function savePage(repo: ContentRepository, id: string, input: PageS
|
|
|
85
95
|
if (data.locale !== undefined) data.locale = normalizePageLocale(data.locale);
|
|
86
96
|
const before = await repo.getPage(id);
|
|
87
97
|
if (!before) throw new ApiError(404, 'Page not found.');
|
|
98
|
+
if (touchesLive(before.status, data.status)) assertCanPublish(hooks);
|
|
88
99
|
const transitions = data.status !== undefined && data.status !== before.status;
|
|
89
100
|
if (live(before.status) && !transitions) {
|
|
90
101
|
const { status: _status, ...staged } = data;
|
|
@@ -102,6 +113,7 @@ export async function savePage(repo: ContentRepository, id: string, input: PageS
|
|
|
102
113
|
}
|
|
103
114
|
|
|
104
115
|
export async function applyPageUpdate(repo: ContentRepository, id: string, hooks: SaveHooks): Promise<Page> {
|
|
116
|
+
assertCanPublish(hooks);
|
|
105
117
|
const before = await repo.getPage(id);
|
|
106
118
|
if (!before) throw new ApiError(404, 'Page not found.');
|
|
107
119
|
await repo.applyPageDraft(id);
|
|
@@ -198,6 +210,7 @@ async function eventSideEffects(repo: ContentRepository, hooks: SaveHooks, befor
|
|
|
198
210
|
export async function saveEvent(repo: ContentRepository, id: string, data: EventSave, hooks: SaveHooks): Promise<CalEvent> {
|
|
199
211
|
const before = await repo.getEvent(id);
|
|
200
212
|
if (!before) throw new ApiError(404, 'Event not found.');
|
|
213
|
+
if (touchesLive(before.status, data.status)) assertCanPublish(hooks);
|
|
201
214
|
const clean = await cleanEventInput(repo, data, before);
|
|
202
215
|
// static map images follow the pin (fetched once per pin, never blocking)
|
|
203
216
|
if (clean.location) {
|
|
@@ -219,6 +232,7 @@ export async function saveEvent(repo: ContentRepository, id: string, data: Event
|
|
|
219
232
|
}
|
|
220
233
|
|
|
221
234
|
export async function applyEventUpdate(repo: ContentRepository, id: string, hooks: SaveHooks): Promise<CalEvent> {
|
|
235
|
+
assertCanPublish(hooks);
|
|
222
236
|
const before = await repo.getEvent(id);
|
|
223
237
|
if (!before) throw new ApiError(404, 'Event not found.');
|
|
224
238
|
await repo.applyEventDraft(id);
|
|
@@ -3,6 +3,8 @@ import { SectionPages, sectionIssues } from '../theme-sdk/contract.mjs';
|
|
|
3
3
|
import { migrateSection } from './sections';
|
|
4
4
|
import type { Theme } from './theme';
|
|
5
5
|
import { ApiError, sessionRole } from './api';
|
|
6
|
+
import { grantsFor } from './permissions/access';
|
|
7
|
+
import { allows, deniedMessage } from './permissions/catalog';
|
|
6
8
|
import { hashAgentKey } from './ai-config';
|
|
7
9
|
import { pickDesignDraft } from './drafts';
|
|
8
10
|
import type { ContentRepository } from './repo/types';
|
|
@@ -41,10 +43,16 @@ export async function designIdentity(request: Request, cookies: {get(name:string
|
|
|
41
43
|
if (!required.some(s=>key.scopes.includes(s))) throw new ApiError(403,`Required scope: ${required.join(' or ')}`);
|
|
42
44
|
return { actor:`key:${key.id}`,scopes:key.scopes };
|
|
43
45
|
}
|
|
44
|
-
//
|
|
46
|
+
// Arrange and lock sections: full design control (identity role 'owner');
|
|
47
|
+
// otherwise Edit page sections changes section content (never layout,
|
|
48
|
+
// theme or locked fields). Media writes follow Upload media.
|
|
45
49
|
const who=await sessionRole(request,cookies,repo);
|
|
46
|
-
|
|
47
|
-
|
|
50
|
+
const grants=await grantsFor(repo,request);
|
|
51
|
+
const required=typeof scope==='string'?[scope]:scope;
|
|
52
|
+
if(required.includes('media:write')&&!allows(grants,who.role,'media.upload'))throw new ApiError(403,deniedMessage('media.upload'));
|
|
53
|
+
if(allows(grants,who.role,'site.layout'))return {actor:who.role==='owner'?'owner':`${who.role}:${who.id}`,role:'owner' as const,scopes:[...DESIGN_SCOPES]};
|
|
54
|
+
if(!allows(grants,who.role,'site.sections'))throw new ApiError(403,deniedMessage('site.sections'));
|
|
55
|
+
return {actor:`editor:${who.id}`,role:'editor' as const,scopes:['design:read','design:write-draft','design:publish']};
|
|
48
56
|
}
|
|
49
57
|
export function publicDesign(settings:Settings) { return {...pickDesignDraft(settings), sectionPages:settings.sectionPages}; }
|
|
50
58
|
export function validateSectionDesign(settings:Settings, theme:Pick<Theme,'sections'|'migrations'>) {
|
package/src/lib/editor/http.ts
CHANGED
|
@@ -6,6 +6,8 @@ import { sha256Hex } from '../api-keys';
|
|
|
6
6
|
import { failure, response } from '../mobile/http';
|
|
7
7
|
import { authenticateDevice, type EditorPrincipal } from './auth';
|
|
8
8
|
import { requireEditorApps } from './config';
|
|
9
|
+
import { principalCan, requirePrincipal } from '../permissions/access';
|
|
10
|
+
import type { Permission } from '../permissions/catalog';
|
|
9
11
|
|
|
10
12
|
export interface EditorContext { context: APIContext; repo: ContentRepository; who: EditorPrincipal; now: number }
|
|
11
13
|
|
|
@@ -49,3 +51,8 @@ export function editorRoute(handler: (c: EditorContext) => Promise<unknown>, opt
|
|
|
49
51
|
export async function logWrite(c: EditorContext, action: string, target: string) {
|
|
50
52
|
await c.repo.editor.log({ userId: c.who.user.id, deviceId: c.who.device.id, action, target }, Date.now()).catch(() => {});
|
|
51
53
|
}
|
|
54
|
+
|
|
55
|
+
/** the person's role must grant this permission (Team → Roles; read live per request) */
|
|
56
|
+
export const requireCan = (c: EditorContext, permission: Permission) => requirePrincipal(c.repo, c.who, permission);
|
|
57
|
+
/** whether saves may change what's live (content-service SaveHooks.canPublish) */
|
|
58
|
+
export const canPublish = (c: EditorContext) => principalCan(c.repo, c.who, 'content.publish');
|
package/src/lib/editor/me.ts
CHANGED
|
@@ -6,11 +6,15 @@ import { translationEngine } from '../translate';
|
|
|
6
6
|
import { siteOrigin } from '../seo';
|
|
7
7
|
import type { EditorPrincipal } from './auth';
|
|
8
8
|
import { canManageSchedule, isAdmin } from './roles';
|
|
9
|
+
import { grantsFor } from '../permissions/access';
|
|
10
|
+
import { allows, type Permission } from '../permissions/catalog';
|
|
9
11
|
|
|
10
12
|
/** who is signed in on this device, and what this site lets them do */
|
|
11
13
|
export async function me(repo: ContentRepository, who: EditorPrincipal, url: URL, locals: unknown) {
|
|
12
14
|
const settings = await repo.getSettings();
|
|
13
|
-
const
|
|
15
|
+
const grants = await grantsFor(repo, who);
|
|
16
|
+
const can = (p: Permission) => allows(grants, who.user.role, p);
|
|
17
|
+
const manageSchedule = await canManageSchedule(repo, who, settings);
|
|
14
18
|
return {
|
|
15
19
|
user: who.user,
|
|
16
20
|
device: { id: who.device.id, name: who.device.name, clientId: who.device.clientId },
|
|
@@ -21,14 +25,14 @@ export async function me(repo: ContentRepository, who: EditorPrincipal, url: URL
|
|
|
21
25
|
languages: { default: I18N.enabled ? I18N.defaultLocale : null, extra: extraLocales().map((code) => ({ code, name: localeName(code, true) })) },
|
|
22
26
|
aiTranslation: translationEngine(settings.ai, locals) !== null,
|
|
23
27
|
},
|
|
24
|
-
//
|
|
28
|
+
// what this person's role allows (Team → Roles), plus the app's own admin-only limits
|
|
25
29
|
permissions: {
|
|
26
|
-
documents: { edit:
|
|
27
|
-
sections: { editContent:
|
|
28
|
-
bookings: bookingsEnabled(),
|
|
29
|
-
schedule: { view: CALENDAR.enabled, manage: CALENDAR.enabled &&
|
|
30
|
-
forms: { list: true, readResponses: isAdmin(who) },
|
|
31
|
-
links: { edit:
|
|
30
|
+
documents: { edit: can('content.edit'), publish: can('content.publish') },
|
|
31
|
+
sections: { editContent: can('site.sections') || can('site.layout'), editLocked: can('site.layout'), publishSection: can('site.sections') || can('site.layout'), changeLayout: false },
|
|
32
|
+
bookings: bookingsEnabled() && can('schedule.bookings'),
|
|
33
|
+
schedule: { view: CALENDAR.enabled, manage: CALENDAR.enabled && manageSchedule, ignoreRules: bookingsEnabled() && isAdmin(who) && can('schedule.bookings') },
|
|
34
|
+
forms: { list: true, readResponses: isAdmin(who) && can('forms.responses') },
|
|
35
|
+
links: { edit: can('site.links'), editHtml: isAdmin(who) && can('site.links') },
|
|
32
36
|
},
|
|
33
37
|
};
|
|
34
38
|
}
|
package/src/lib/editor/roles.ts
CHANGED
|
@@ -1,9 +1,14 @@
|
|
|
1
1
|
import type { Settings } from '../schema';
|
|
2
2
|
import { ApiError } from '../api';
|
|
3
|
+
import type { ContentRepository } from '../repo/types';
|
|
4
|
+
import { principalCan, requirePrincipal } from '../permissions/access';
|
|
3
5
|
import type { EditorPrincipal } from './auth';
|
|
4
6
|
|
|
5
|
-
// Role checks for editor apps. The role
|
|
6
|
-
// (authenticateDevice), so a demotion
|
|
7
|
+
// Role checks for editor apps. The role and the saved role permissions are
|
|
8
|
+
// read live on every request (authenticateDevice, grantsFor), so a demotion
|
|
9
|
+
// or a permission change takes effect on the next call. The app keeps a few
|
|
10
|
+
// limits of its own on top of Team → Roles: form responses, HTML link blocks
|
|
11
|
+
// and overriding booking rules stay with administrators and owners.
|
|
7
12
|
|
|
8
13
|
const ADMIN = new Set(['administrator', 'owner']);
|
|
9
14
|
export const isAdmin = (who: EditorPrincipal) => ADMIN.has(who.user.role);
|
|
@@ -13,8 +18,11 @@ export function requireAdmin(who: EditorPrincipal, what = 'do this') {
|
|
|
13
18
|
if (!isAdmin(who)) throw new ApiError(403, `Only administrators and owners can ${what}.`);
|
|
14
19
|
}
|
|
15
20
|
|
|
16
|
-
/** rooms, booking types, hours and calendars
|
|
17
|
-
export
|
|
18
|
-
|
|
19
|
-
|
|
21
|
+
/** rooms, booking types, hours and calendars: the role's permission, and Admin → Mobile app decides who in the app */
|
|
22
|
+
export async function canManageSchedule(repo: ContentRepository, who: EditorPrincipal, settings: Settings) {
|
|
23
|
+
return (settings.mobileAccess.scheduleRoles === 'staff' || isAdmin(who)) && await principalCan(repo, who, 'schedule.manage');
|
|
24
|
+
}
|
|
25
|
+
export async function requireScheduleRole(repo: ContentRepository, who: EditorPrincipal, settings: Settings) {
|
|
26
|
+
if (!(settings.mobileAccess.scheduleRoles === 'staff' || isAdmin(who))) throw new ApiError(403, 'Only administrators and owners can change rooms, booking types, hours and calendars.');
|
|
27
|
+
await requirePrincipal(repo, who, 'schedule.manage');
|
|
20
28
|
}
|