Family calendar: a colour per person, invitations that land on the invited person's calendar
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
b2b772941b
commit
d761020869
8 files changed
+47
-10
No files matched your search
@@ -231,6 +231,8 @@ const DEFAULT_COLOR = '#4f7cff', COLORS = [DEFAULT_COLOR, '#2f9e6e', '#e0742b',
|
|||||||
const FAM = 'family', FAMCAL = { id: 'local', name: 'Family', color: '#e0742b', shares: [] }; // the calendar the whole household shares
|
const FAM = 'family', FAMCAL = { id: 'local', name: 'Family', color: '#e0742b', shares: [] }; // the calendar the whole household shares
|
||||||
const dirFor = id => { if (id !== FAM) return users.dirOf(id); const d = path.join(process.env.DATA_DIR || path.join(__dirname, 'data'), 'family'); fs.mkdirSync(d, { recursive: true }); return d; };
|
const dirFor = id => { if (id !== FAM) return users.dirOf(id); const d = path.join(process.env.DATA_DIR || path.join(__dirname, 'data'), 'family'); fs.mkdirSync(d, { recursive: true }); return d; };
|
||||||
const myId = () => users.current().id;
|
const myId = () => users.current().id;
|
||||||
|
/** The colour of whoever added an event (the family calendar shows each person's own colour). */
|
||||||
|
const byCol = ev => ev && ev.by && users.get(ev.by) ? users.colorOf(users.get(ev.by)) : '';
|
||||||
|
|
||||||
/** A person's own NE Hub calendars. The first one is the original "NE Hub" calendar (id "local"), so older events stay in it. */
|
/** A person's own NE Hub calendars. The first one is the original "NE Hub" calendar (id "local"), so older events stay in it. */
|
||||||
function calsIn(dir) {
|
function calsIn(dir) {
|
||||||
@@ -326,18 +328,31 @@ async function collectEvents(win) {
|
|||||||
const mine = dirFor(myId()), evs = eventsIn(mine);
|
const mine = dirFor(myId()), evs = eventsIn(mine);
|
||||||
const add = (dir, evs, c, id, extra) => {
|
const add = (dir, evs, c, id, extra) => {
|
||||||
const ics_ = ics.buildCalendar(evs.filter(e => evCal(e) === c.id).map(e => ({ uid: e.uid, fields: e.fields })));
|
const ics_ = ics.buildCalendar(evs.filter(e => evCal(e) === c.id).map(e => ({ uid: e.uid, fields: e.fields })));
|
||||||
for (const e of expand(parseEvents(ics_), win)) all.push({ ...e, cal: c.name, calId: id, color: over[id] || c.color || DEFAULT_COLOR, by: (evs.find(x => x.uid === e.uid) || {}).byName || '', ...extra });
|
for (const e of expand(parseEvents(ics_), win)) all.push({ ...e, cal: c.name, calId: id, color: (extra.family && byCol(evs.find(x => x.uid === e.uid))) || over[id] || c.color || DEFAULT_COLOR, by: (evs.find(x => x.uid === e.uid) || {}).byName || '', ...extra });
|
||||||
};
|
};
|
||||||
for (const c of calsIn(mine)) add(mine, evs, c, c.id, { editable: true });
|
for (const c of calsIn(mine)) add(mine, evs, c, c.id, { editable: true });
|
||||||
for (const sh of sharedWithMe()) add(dirFor(sh.owner), eventsIn(dirFor(sh.owner)), sh.cal, sh.id, { editable: sh.mode === 'edit', shared: true, owner: sh.ownerName });
|
for (const sh of sharedWithMe()) add(dirFor(sh.owner), eventsIn(dirFor(sh.owner)), sh.cal, sh.id, { editable: sh.mode === 'edit', shared: true, owner: sh.ownerName, family: sh.owner === FAM });
|
||||||
} catch (e) { errors.push('NE Hub: ' + e.message); }
|
} catch (e) { errors.push('NE Hub: ' + e.message); }
|
||||||
try { all.push(...require('./contacts').birthdayEvents(win)); } catch {} // birthdays from Contacts
|
try { all.push(...require('./contacts').birthdayEvents(win)); } catch {} // birthdays from Contacts
|
||||||
return { events: all, errors };
|
return { events: all, errors };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** An invitation inside the family: the event is put on each invited person's own calendar and they are told. (They can delete it if they cannot come.) */
|
||||||
|
function invite(ids, uid, fields, ok) {
|
||||||
|
if (!ok || !Array.isArray(ids) || !ids.length) return 0; const me = users.current(); let n = 0;
|
||||||
|
for (const id of new Set(ids.map(String))) {
|
||||||
|
const who = users.get(id); if (!who || who.disabled || who.id === me.id || !users.can(who, 'calendar')) continue;
|
||||||
|
const dir = dirFor(who.id), all = eventsIn(dir), cal = calsIn(dir)[0].id; if (all.some(x => x.uid === uid && evCal(x) === cal)) continue;
|
||||||
|
all.push({ uid, cal, fields, created: Date.now(), updated: Date.now(), by: me.id, byName: me.name, invitedBy: me.name }); saveEvents(dir, all); n++;
|
||||||
|
try { require('./inbox').add(who.id, { kind: 'info', title: `${me.name} invited you: ${String(fields.title || '').slice(0, 60)}`, body: String(fields.start || '').replace('T', ' ').slice(0, 16), url: '#/calendar', tag: 'invite:' + uid }); } catch { /* the event is on their calendar anyway */ }
|
||||||
|
}
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
|
||||||
async function handle(req, res) {
|
async function handle(req, res) {
|
||||||
const u = new URL(req.url, 'http://x');
|
const u = new URL(req.url, 'http://x');
|
||||||
try {
|
try {
|
||||||
|
if (u.pathname === '/api/cal/people') return json(res, 200, users.all().filter(x => !x.disabled && x.id !== myId() && users.can(x, 'calendar')).map(x => ({ id: x.id, name: x.name, color: users.colorOf(x) })));
|
||||||
if (u.pathname === '/api/cal/list') {
|
if (u.pathname === '/api/cal/list') {
|
||||||
const over = colorOverrides();
|
const over = colorOverrides();
|
||||||
return json(res, 200, [
|
return json(res, 200, [
|
||||||
@@ -438,7 +453,8 @@ async function handle(req, res) {
|
|||||||
const rec = { uid, cal: r.cid, fields: b.fields, updated: Date.now() };
|
const rec = { uid, cal: r.cid, fields: b.fields, updated: Date.now() };
|
||||||
if (i >= 0) all[i] = { ...all[i], ...rec }; else all.push({ ...rec, created: Date.now(), by: users.current().id, byName: users.current().name }); // (who added it)
|
if (i >= 0) all[i] = { ...all[i], ...rec }; else all.push({ ...rec, created: Date.now(), by: users.current().id, byName: users.current().name }); // (who added it)
|
||||||
saveEvents(r.dir, all);
|
saveEvents(r.dir, all);
|
||||||
return json(res, 200, { ok: true, uid });
|
const invited = invite(b.invite, uid, b.fields, r.dir === dirFor(myId()) || r.dir === dirFor(FAM));
|
||||||
|
return json(res, 200, { ok: true, uid, invited });
|
||||||
}
|
}
|
||||||
if (caldav.isCal(b.cal)) {
|
if (caldav.isCal(b.cal)) {
|
||||||
ics.validate(b.fields);
|
ics.validate(b.fields);
|
||||||
@@ -480,6 +496,6 @@ async function handle(req, res) {
|
|||||||
/** The Family calendar's events in a window, for the family board (no signed-in person needed). */
|
/** The Family calendar's events in a window, for the family board (no signed-in person needed). */
|
||||||
function familyEvents(win) {
|
function familyEvents(win) {
|
||||||
const evs = eventsIn(dirFor(FAM)), ics_ = ics.buildCalendar(evs.map(e => ({ uid: e.uid, fields: e.fields })));
|
const evs = eventsIn(dirFor(FAM)), ics_ = ics.buildCalendar(evs.map(e => ({ uid: e.uid, fields: e.fields })));
|
||||||
return expand(parseEvents(ics_), win).map(e => ({ ...e, cal: 'Family', color: FAMCAL.color, by: (evs.find(x => x.uid === e.uid) || {}).byName || '' }));
|
return expand(parseEvents(ics_), win).map(e => ({ ...e, cal: 'Family', color: byCol(evs.find(x => x.uid === e.uid)) || FAMCAL.color, by: (evs.find(x => x.uid === e.uid) || {}).byName || '' }));
|
||||||
}
|
}
|
||||||
module.exports = { familyEvents, collectEvents, unfold, parseLine, parseDate, unesc, handle, feed, parseEvents, expand, zonedToUtc, assertPublic, localIcs };
|
module.exports = { familyEvents, collectEvents, unfold, parseLine, parseDate, unesc, handle, feed, parseEvents, expand, zonedToUtc, assertPublic, localIcs };
|
||||||
+6
-2
@@ -126,6 +126,8 @@ function openEvent(e) {
|
|||||||
}
|
}
|
||||||
if (e.contactId) { // a birthday from Contacts
|
if (e.contactId) { // a birthday from Contacts
|
||||||
const el = openSheet(e.title, 'Birthdays', `<div class="bar"><button class="btn ghost" id="sx" style="flex:1;justify-content:center">Close</button>${allowed('contacts') ? '<a class="btn" id="bco" style="flex:1;justify-content:center">Open contact</a>' : ''}</div>`);
|
const el = openSheet(e.title, 'Birthdays', `<div class="bar"><button class="btn ghost" id="sx" style="flex:1;justify-content:center">Close</button>${allowed('contacts') ? '<a class="btn" id="bco" style="flex:1;justify-content:center">Open contact</a>' : ''}</div>`);
|
||||||
|
const inv = el.querySelector('#finv'), invOk = () => { const c = (o.cals || []).find(x => x.id === el.querySelector('#fc').value); return !!c && !c.icloud && !(c.shared && !String(c.id).startsWith('family~')); };
|
||||||
|
if (inv) jfetch('/api/cal/people').then(ps => { if (!ps.length || !el.isConnected) return; inv.innerHTML = `<label>Invite</label><div class="invlist">${ps.map(p => `<label class="chk"><input type="checkbox" data-inv="${esc(p.id)}"><i class="invdot" style="background:${esc(p.color)}"></i>${esc(p.name)}</label>`).join('')}</div><p class="sub" id="invnote" style="margin:4px 0 0">The event is added to their calendar and they are told.</p>`; const sync = () => { inv.hidden = !invOk(); }; sync(); el.querySelector('#fc').addEventListener('change', sync); }).catch(() => {});
|
||||||
el.querySelector('#sx').onclick = closeSheet; const a = el.querySelector('#bco'); if (a) a.onclick = () => { closeSheet(); location.hash = '#/contacts/c/' + e.contactId; };
|
el.querySelector('#sx').onclick = closeSheet; const a = el.querySelector('#bco'); if (a) a.onclick = () => { closeSheet(); location.hash = '#/contacts/c/' + e.contactId; };
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -169,6 +171,7 @@ function eventForm(o) {
|
|||||||
<label for="fl">Location</label><input type="text" id="fl" value="${esc(f.location)}" autocomplete="off">
|
<label for="fl">Location</label><input type="text" id="fl" value="${esc(f.location)}" autocomplete="off">
|
||||||
<label for="fn">Notes</label><textarea id="fn" rows="3">${esc(f.notes)}</textarea>
|
<label for="fn">Notes</label><textarea id="fn" rows="3">${esc(f.notes)}</textarea>
|
||||||
${calPick}
|
${calPick}
|
||||||
|
${editing ? '' : '<div id="finv"></div>'}
|
||||||
${editing && o.series ? '<p class="sub" style="margin:8px 0 0">This repeats. Changes apply to the whole series.</p>' : ''}
|
${editing && o.series ? '<p class="sub" style="margin:8px 0 0">This repeats. Changes apply to the whole series.</p>' : ''}
|
||||||
<div class="bar"><button class="btn ghost" type="button" id="sx" style="flex:1;justify-content:center">Cancel</button><button class="btn" style="flex:1;justify-content:center">${editing ? 'Save' : 'Add'}</button></div>
|
<div class="bar"><button class="btn ghost" type="button" id="sx" style="flex:1;justify-content:center">Cancel</button><button class="btn" style="flex:1;justify-content:center">${editing ? 'Save' : 'Add'}</button></div>
|
||||||
${editing ? '<div class="bar" style="margin-top:0"><button class="btn danger" type="button" id="fd" style="flex:1;justify-content:center">Delete event</button></div>' : ''}
|
${editing ? '<div class="bar" style="margin-top:0"><button class="btn danger" type="button" id="fd" style="flex:1;justify-content:center">Delete event</button></div>' : ''}
|
||||||
@@ -200,9 +203,10 @@ function eventForm(o) {
|
|||||||
start: el.querySelector('#fs').value, end: el.querySelector('#fe2').value, tz: f.tz, repeat: el.querySelector('#fr').value };
|
start: el.querySelector('#fs').value, end: el.querySelector('#fe2').value, tz: f.tz, repeat: el.querySelector('#fr').value };
|
||||||
const cal = editing ? o.calId : el.querySelector('#fc').value;
|
const cal = editing ? o.calId : el.querySelector('#fc').value;
|
||||||
try {
|
try {
|
||||||
await jfetch('/api/cal/event', { method: 'POST', body: JSON.stringify({ cal, uid: o.uid, href: o.href, fields }) });
|
const invite = editing || (inv && inv.hidden) ? [] : [...el.querySelectorAll('[data-inv]:checked')].map(c => c.dataset.inv);
|
||||||
|
const sent = await jfetch('/api/cal/event', { method: 'POST', body: JSON.stringify({ cal, uid: o.uid, href: o.href, fields, invite }) });
|
||||||
if (!editing) store.set('cal_default', cal);
|
if (!editing) store.set('cal_default', cal);
|
||||||
closeSheet(); toast(editing ? 'Event saved' : 'Event added'); viewCalendar();
|
closeSheet(); toast(editing ? 'Event saved' : sent && sent.invited ? `Event added, ${sent.invited} invited` : 'Event added'); viewCalendar();
|
||||||
} catch (err) { btn.disabled = false; el.querySelector('#fe').textContent = err.message; }
|
} catch (err) { btn.disabled = false; el.querySelector('#fe').textContent = err.message; }
|
||||||
};
|
};
|
||||||
const del = el.querySelector('#fd');
|
const del = el.querySelector('#fd');
|
||||||
|
|||||||
@@ -1101,3 +1101,4 @@ body:has(#sheet) #simplehome, body:has(#sheet) #kidreq, body:has(#lyr) #simpleho
|
|||||||
.lsrow { display: flex; gap: 16px; } .lsrow span { flex: 1; min-width: 0; } .lsrow b { display: block; font-size: 22px; letter-spacing: -.01em; } .lsrow small { color: var(--muted); }
|
.lsrow { display: flex; gap: 16px; } .lsrow span { flex: 1; min-width: 0; } .lsrow b { display: block; font-size: 22px; letter-spacing: -.01em; } .lsrow small { color: var(--muted); }
|
||||||
.lsbars { display: flex; align-items: flex-end; gap: 4px; height: 44px; margin: 14px 0 4px; } .lsbars i { flex: 1; background: var(--accent); border-radius: 3px; opacity: .85; }
|
.lsbars { display: flex; align-items: flex-end; gap: 4px; height: 44px; margin: 14px 0 4px; } .lsbars i { flex: 1; background: var(--accent); border-radius: 3px; opacity: .85; }
|
||||||
.lsgoal { height: 6px; background: var(--panel2); border-radius: 3px; overflow: hidden; margin-top: 8px; } .lsgoal i { display: block; height: 100%; background: var(--accent); }
|
.lsgoal { height: 6px; background: var(--panel2); border-radius: 3px; overflow: hidden; margin-top: 8px; } .lsgoal i { display: block; height: 100%; background: var(--accent); }
|
||||||
|
.invlist { display: flex; flex-wrap: wrap; gap: 4px 16px; margin: 6px 0; } .invdot { display: inline-block; width: 10px; height: 10px; border-radius: 50%; margin: 0 6px 0 2px; }
|
||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
// App-shell cache only; Seafile/API traffic always goes to the network.
|
// App-shell cache only; Seafile/API traffic always goes to the network.
|
||||||
const CACHE = 'hub-v104';
|
const CACHE = 'hub-v105';
|
||||||
const SHARE = 'hub-share'; // files shared into the app from other apps, held until the person picks a folder
|
const SHARE = 'hub-share'; // files shared into the app from other apps, held until the person picks a folder
|
||||||
const SHELL = ['/icon.svg', '/manifest.webmanifest', '/icons/icon-192.png', '/icons/apple-touch-icon.png'];
|
const SHELL = ['/icon.svg', '/manifest.webmanifest', '/icons/icon-192.png', '/icons/apple-touch-icon.png'];
|
||||||
|
|
||||||
|
|||||||
+4
-1
@@ -1,6 +1,7 @@
|
|||||||
'use strict';
|
'use strict';
|
||||||
// ---- Themes: each person picks an accent colour and Auto, Light, Dark or AMOLED black (true black, easy on phone batteries). Kept in their synced settings, so it follows them between devices.
|
// ---- Themes: each person picks an accent colour and Auto, Light, Dark or AMOLED black (true black, easy on phone batteries). Kept in their synced settings, so it follows them between devices.
|
||||||
const THEME_ACCENTS = [['blue', 'Blue', '#3d6bf0', '#6a8cff'], ['teal', 'Teal', '#0f8f8a', '#3cc7c0'], ['green', 'Green', '#2f9e6e', '#4cc98d'], ['orange', 'Orange', '#d9741a', '#f09a45'], ['red', 'Red', '#d4392f', '#ff6b60'], ['pink', 'Pink', '#c9407d', '#f26aa4'], ['purple', 'Purple', '#7b4fe0', '#a688ff']];
|
const THEME_ACCENTS = [['blue', 'Blue', '#3d6bf0', '#6a8cff'], ['teal', 'Teal', '#0f8f8a', '#3cc7c0'], ['green', 'Green', '#2f9e6e', '#4cc98d'], ['orange', 'Orange', '#d9741a', '#f09a45'], ['red', 'Red', '#d4392f', '#ff6b60'], ['pink', 'Pink', '#c9407d', '#f26aa4'], ['purple', 'Purple', '#7b4fe0', '#a688ff']];
|
||||||
|
const THEME_PERSON = ['#4f7cff', '#2f9e6e', '#e0742b', '#d9453a', '#8a5cf0', '#e5a00d', '#0f8f8a', '#c9407d'];
|
||||||
const THEME_MODES = [['auto', 'Auto'], ['light', 'Light'], ['dark', 'Dark'], ['amoled', 'AMOLED']];
|
const THEME_MODES = [['auto', 'Auto'], ['light', 'Light'], ['dark', 'Dark'], ['amoled', 'AMOLED']];
|
||||||
const THEME_VARS = {
|
const THEME_VARS = {
|
||||||
light: { '--bg': '#f5f6f8', '--panel': '#fff', '--panel2': '#eef0f4', '--text': '#14171c', '--muted': '#6a7280', '--line': '#e0e3e9' },
|
light: { '--bg': '#f5f6f8', '--panel': '#fff', '--panel2': '#eef0f4', '--text': '#14171c', '--muted': '#6a7280', '--line': '#e0e3e9' },
|
||||||
@@ -24,7 +25,9 @@ function themeSettings(el) {
|
|||||||
const mode = store.get('theme_mode', 'auto'), acc = store.get('theme_accent', 'blue');
|
const mode = store.get('theme_mode', 'auto'), acc = store.get('theme_accent', 'blue');
|
||||||
el.innerHTML = `<h2>Look</h2><p class="sub">Saved for you and used on every device you sign in on.</p>
|
el.innerHTML = `<h2>Look</h2><p class="sub">Saved for you and used on every device you sign in on.</p>
|
||||||
<div class="seg" role="radiogroup" aria-label="Brightness">${THEME_MODES.map(([k, l]) => `<a href="#" role="radio" aria-checked="${mode === k}" data-m="${k}" class="${mode === k ? 'on' : ''}">${l}</a>`).join('')}</div>
|
<div class="seg" role="radiogroup" aria-label="Brightness">${THEME_MODES.map(([k, l]) => `<a href="#" role="radio" aria-checked="${mode === k}" data-m="${k}" class="${mode === k ? 'on' : ''}">${l}</a>`).join('')}</div>
|
||||||
<div class="swatches" role="radiogroup" aria-label="Accent colour">${THEME_ACCENTS.map(([k, l, c]) => `<button type="button" role="radio" aria-checked="${acc === k}" aria-label="${l}" title="${l}" data-a="${k}" class="${acc === k ? 'on' : ''}" style="background:${c}"></button>`).join('')}</div>`;
|
<div class="swatches" role="radiogroup" aria-label="Accent colour">${THEME_ACCENTS.map(([k, l, c]) => `<button type="button" role="radio" aria-checked="${acc === k}" aria-label="${l}" title="${l}" data-a="${k}" class="${acc === k ? 'on' : ''}" style="background:${c}"></button>`).join('')}</div>
|
||||||
|
<h3 class="sub" style="margin:14px 0 0">My colour on shared calendars</h3><div class="swatches" role="radiogroup" aria-label="My colour on shared calendars">${(typeof ME !== 'undefined' && ME ? THEME_PERSON : []).map(c => `<button type="button" role="radio" aria-checked="${ME.color === c}" aria-label="${c}" data-pc="${c}" class="${ME.color === c ? 'on' : ''}" style="background:${c}"></button>`).join('')}</div>`;
|
||||||
|
el.querySelectorAll('[data-pc]').forEach(b => b.onclick = async () => { try { const r = await jfetch('/api/me/color', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ color: b.dataset.pc }) }); ME.color = r.color; try { localStorage.setItem('me_cache', JSON.stringify(ME)); } catch {} draw(); } catch (e) { toast(e.message); } });
|
||||||
el.querySelectorAll('[data-m]').forEach(a => a.onclick = e => { e.preventDefault(); store.set('theme_mode', a.dataset.m); themeApply(); draw(); });
|
el.querySelectorAll('[data-m]').forEach(a => a.onclick = e => { e.preventDefault(); store.set('theme_mode', a.dataset.m); themeApply(); draw(); });
|
||||||
el.querySelectorAll('[data-a]').forEach(b => b.onclick = () => { store.set('theme_accent', b.dataset.a); themeApply(); draw(); });
|
el.querySelectorAll('[data-a]').forEach(b => b.onclick = () => { store.set('theme_accent', b.dataset.a); themeApply(); draw(); });
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -651,6 +651,7 @@ async function handleRequest(req, res) {
|
|||||||
const rule = ACCESS.find(([re]) => re.test(pathname));
|
const rule = ACCESS.find(([re]) => re.test(pathname));
|
||||||
if (rule && !rule[1].some(t => users.can(user, t))) return forbidden(res);
|
if (rule && !rule[1].some(t => users.can(user, t))) return forbidden(res);
|
||||||
if ((pathname === '/api/system' || pathname === '/api/health' || pathname === '/api/audit' || pathname.startsWith('/api/users')) && user.role !== 'admin') return forbidden(res, 'Only an admin can do this');
|
if ((pathname === '/api/system' || pathname === '/api/health' || pathname === '/api/audit' || pathname.startsWith('/api/users')) && user.role !== 'admin') return forbidden(res, 'Only an admin can do this');
|
||||||
|
if (pathname === '/api/me/color' && req.method === 'POST') { const b = JSON.parse(await require('./body').readBody(req, 1e3) || '{}'); users.update(user.id, { color: b.color || null }); res.writeHead(200, { 'content-type': 'application/json' }); return res.end(JSON.stringify({ color: users.colorOf(users.get(user.id)) })); }
|
||||||
if (pathname === '/api/me') { res.writeHead(200, { 'content-type': 'application/json' }); return res.end(JSON.stringify({ ...users.publicView(user), passkeys: auth.passkeyCount(user.id), people: user.role === 'admin' ? users.all().length : undefined })); }
|
if (pathname === '/api/me') { res.writeHead(200, { 'content-type': 'application/json' }); return res.end(JSON.stringify({ ...users.publicView(user), passkeys: auth.passkeyCount(user.id), people: user.role === 'admin' ? users.all().length : undefined })); }
|
||||||
if (pathname.startsWith('/api/users')) return admin.handle(req, res, user);
|
if (pathname.startsWith('/api/users')) return admin.handle(req, res, user);
|
||||||
if (req.url === '/api/config') { // only reveal the addresses of things this person may use
|
if (req.url === '/api/config') { // only reveal the addresses of things this person may use
|
||||||
|
|||||||
@@ -102,5 +102,13 @@ const titles = async u => (await call(u, win)).json.events.map(e => e.title).sor
|
|||||||
ok('a calendar link can be recolored', (await call(bob, '/api/cal/list')).json.find(c => c.id === 'lnk').color === '#112233');
|
ok('a calendar link can be recolored', (await call(bob, '/api/cal/list')).json.find(c => c.id === 'lnk').color === '#112233');
|
||||||
r = await call(bob, '/api/cal/color', { id: 'lnk', color: 'blue' }); ok('with a real color only', r.status === 400);
|
r = await call(bob, '/api/cal/color', { id: 'lnk', color: 'blue' }); ok('with a real color only', r.status === 400);
|
||||||
|
|
||||||
|
// invitations and each person's colour
|
||||||
|
r = await call(ann, '/api/cal/people'); ok('the calendar lists who can be invited (not yourself)', r.status === 200 && r.json.map(x => x.name).sort().join() === 'Bob,Cy' && /^#[0-9a-f]{6}$/.test(r.json[0].color), r.json);
|
||||||
|
r = await call(ann, '/api/cal/event', { cal: 'local', fields: F('Movie night', '2026-11-05'), invite: [bob.id, ann.id, 'nobody'] }); ok('inviting adds the event for the invited people only', r.status === 200 && r.json.invited === 1, r.json);
|
||||||
|
ok('and it is on their calendar', (await titles(bob)).includes('Movie night') && !(await titles(cy)).includes('Movie night'));
|
||||||
|
r = await call(ann, '/api/cal/event', { cal: 'local', fields: F('Movie night 2', '2026-11-05'), invite: [bob.id] }); r = await call(bob, '/api/cal/event', { cal: 'local', fields: F('Bob only', '2026-11-05'), invite: [] }); ok('no invite list, nobody invited', r.json.invited === 0);
|
||||||
|
{ const fe = await call(bob, '/api/cal/event', { cal: 'family~local', fields: F('Family dinner', '2026-11-04') }); const ev = (await call(cy, win)).json.events.find(e => e.title === 'Family dinner'); ok('a family event shows in the colour of whoever added it', ev && ev.color === users.colorOf(users.get(bob.id)), ev); }
|
||||||
|
r = await call(bob, '/api/cal/people'); ok('and nobody sees a disabled or calendar-less person', !r.json.some(x => x.id === bob.id));
|
||||||
|
|
||||||
console.log(failed ? `\n${failed} FAILED` : '\nall passed'); process.exit(failed ? 1 : 0);
|
console.log(failed ? `\n${failed} FAILED` : '\nall passed'); process.exit(failed ? 1 : 0);
|
||||||
})();
|
})();
|
||||||
@@ -63,6 +63,7 @@ function update(id, patch) {
|
|||||||
if (patch.kid !== undefined) { const k = require('./kidlimits').cleanKid(patch.kid); if (u.role === 'admin' && k && k.on) throw new Error('An admin cannot be a child account'); if (k) u.kid = k; else delete u.kid; }
|
if (patch.kid !== undefined) { const k = require('./kidlimits').cleanKid(patch.kid); if (u.role === 'admin' && k && k.on) throw new Error('An admin cannot be a child account'); if (k) u.kid = k; else delete u.kid; }
|
||||||
if (patch.board !== undefined) { if (patch.board === false) u.board = false; else delete u.board; } // whether they are on the family board and in family chore progress
|
if (patch.board !== undefined) { if (patch.board === false) u.board = false; else delete u.board; } // whether they are on the family board and in family chore progress
|
||||||
if (patch.simple !== undefined) { if (patch.simple) u.simple = true; else delete u.simple; } /* simple mode: a big-button layout with few choices */
|
if (patch.simple !== undefined) { if (patch.simple) u.simple = true; else delete u.simple; } /* simple mode: a big-button layout with few choices */
|
||||||
|
if (patch.color !== undefined) { if (/^#[0-9a-f]{6}$/i.test(patch.color)) u.color = patch.color.toLowerCase(); else delete u.color; } // the person's own colour on shared calendars
|
||||||
if (patch.signOutEverywhere) u.epoch++;
|
if (patch.signOutEverywhere) u.epoch++;
|
||||||
save(); return u;
|
save(); return u;
|
||||||
}
|
}
|
||||||
@@ -76,7 +77,10 @@ function remove(id) {
|
|||||||
|
|
||||||
const can = (u, tab) => !!u && (ALWAYS.includes(tab) || u.role === 'admin' || (u.tabs || []).includes(tab));
|
const can = (u, tab) => !!u && (ALWAYS.includes(tab) || u.role === 'admin' || (u.tabs || []).includes(tab));
|
||||||
const allowedTabs = u => !u ? [] : [...ALWAYS, ...(u.role === 'admin' ? TABS : (u.tabs || []).filter(t => TABS.includes(t)))];
|
const allowedTabs = u => !u ? [] : [...ALWAYS, ...(u.role === 'admin' ? TABS : (u.tabs || []).filter(t => TABS.includes(t)))];
|
||||||
const publicView = u => ({ id: u.id, name: u.name, role: u.role, tabs: allowedTabs(u), disabled: !!u.disabled, created: u.created, ...(u.kid && u.kid.on ? { kid: u.kid } : {}), board: u.board !== false, simple: !!u.simple });
|
/** The colour a person's events and name show in on shared calendars: their own pick, or one of a set by the order accounts were made. */
|
||||||
|
const PERSON_COLORS = ['#4f7cff', '#2f9e6e', '#e0742b', '#d9453a', '#8a5cf0', '#e5a00d', '#0f8f8a', '#c9407d'];
|
||||||
|
const colorOf = u => !u ? PERSON_COLORS[0] : u.color || PERSON_COLORS[Math.max(0, all().findIndex(x => x.id === u.id)) % PERSON_COLORS.length];
|
||||||
|
const publicView = u => ({ id: u.id, color: colorOf(u), name: u.name, role: u.role, tabs: allowedTabs(u), disabled: !!u.disabled, created: u.created, ...(u.kid && u.kid.on ? { kid: u.kid } : {}), board: u.board !== false, simple: !!u.simple });
|
||||||
|
|
||||||
// ---- A single-user install becomes "the admin" without anyone doing anything: their passkey, tasks, events, settings,
|
// ---- A single-user install becomes "the admin" without anyone doing anything: their passkey, tasks, events, settings,
|
||||||
// iCloud connection and calendar links move into their own folder. (Copied, so the originals stay as a backup.)
|
// iCloud connection and calendar links move into their own folder. (Copied, so the originals stay as a backup.)
|
||||||
@@ -93,4 +97,4 @@ function migrateLegacy() {
|
|||||||
}
|
}
|
||||||
migrateLegacy();
|
migrateLegacy();
|
||||||
|
|
||||||
module.exports = { TABS, TAB_LABELS, ALWAYS, runAs, current, dir, dirOf, all, get, firstAdmin, adminCount, create, update, remove, can, allowedTabs, publicView, cleanTabs };
|
module.exports = { PERSON_COLORS, colorOf, TABS, TAB_LABELS, ALWAYS, runAs, current, dir, dirOf, all, get, firstAdmin, adminCount, create, update, remove, can, allowedTabs, publicView, cleanTabs };
|
||||||
Reference in new issue
Block a user