a27ff49171
terdut can now sign people in through any OIDC provider (written against Authentik), and let groups at the provider decide who may sign in, which teams they belong to and whether they administer the install. Password login keeps working alongside it; TERDUT_PASSWORD_LOGIN=false turns it off, and is refused at startup unless SSO is configured. With no TERDUT_OIDC_* setting nothing changes, so every existing install behaves as before. Identity is (issuer, subject), never email or username: those are mutable at the provider and a recycled address must not inherit an account. An existing user is linked by email only when the provider marks it verified, or TERDUT_OIDC_TRUST_EMAIL is set, which Authentik needs. Group grants are marked source='oidc' on team_members and users, and the sync changes only those rows. Hand-made memberships and administrators are left alone, and the sync bypasses the last-owner and last-admin guards because the provider is the source of truth for what it grants. Editing managed access by hand is refused with 409, since the next sign-in would undo it. The web UI badges it as SSO and disables the controls. Groups are read only at sign-in, so an SSO session carries a hard ceiling (sessions.max_expires_at, 12h by default) that sliding never extends. There is no refresh token, which means API keys of somebody removed at the provider stay valid until an administrator disables the user. That is accepted and documented, not fixed. A client with no browser, the TUI over SSH, signs in with a device code run by terdut itself (POST /api/oidc/device and /device/token), so the terminal never talks to the provider and ends up with the ordinary terdut_session cookie. Only a browser session can approve a code; an API key cannot. /device?code= sends a signed-out visitor through sign-in and back, which is what oidc_logins.next is for. oauth2 is pinned to v0.36.0: v0.37 needs Go 1.26 and the Dockerfile builds on 1.25. Migrations 011 and 012 add tables and defaulted columns only.
209 lines
7.0 KiB
JavaScript
209 lines
7.0 KiB
JavaScript
// DOM helpers, the bottom sheet, confirmation and toasts.
|
||
|
||
// h builds an element. attrs: class, text, on<event>, dataset, aria/other
|
||
// attributes; boolean true sets an empty attribute, false/null skips it.
|
||
export function h(tag, attrs = {}, ...children) {
|
||
const el = document.createElement(tag);
|
||
for (const [k, v] of Object.entries(attrs || {})) {
|
||
if (v == null || v === false) continue;
|
||
if (k === 'class') el.className = v;
|
||
else if (k === 'text') el.textContent = v;
|
||
else if (k === 'dataset') Object.assign(el.dataset, v);
|
||
else if (k.startsWith('on') && typeof v === 'function') el.addEventListener(k.slice(2), v);
|
||
else if (k in el && typeof v !== 'string') el[k] = v;
|
||
else el.setAttribute(k, v === true ? '' : v);
|
||
}
|
||
append(el, children);
|
||
return el;
|
||
}
|
||
|
||
function append(el, children) {
|
||
for (const c of children.flat(Infinity)) {
|
||
if (c == null || c === false) continue;
|
||
el.append(c instanceof Node ? c : document.createTextNode(String(c)));
|
||
}
|
||
}
|
||
|
||
export function clear(el, ...children) {
|
||
el.replaceChildren();
|
||
append(el, children);
|
||
return el;
|
||
}
|
||
|
||
// Stroke icons, 24×24. Built as SVG nodes so the CSP needs no inline anything.
|
||
const ICONS = {
|
||
back: ['M15 18l-6-6 6-6'],
|
||
more: ['M5 12h.01M12 12h.01M19 12h.01'],
|
||
queueList: ['M4 6h16M4 12h16M4 18h10'],
|
||
calendar: ['rect:3.5,5,17,15,2', 'M3.5 10h17M8 3v4M16 3v4'],
|
||
team: ['circle:9,8,3', 'circle:17,9,2.5', 'M3 19a6 6 0 0 1 12 0M15 19a5 5 0 0 1 6-4'],
|
||
shield: ['M12 3l7 3v6c0 4-3 7-7 9-4-2-7-5-7-9V6z'],
|
||
check: ['M5 12.5l4.5 4.5L19 7'],
|
||
checkCircle: ['M8 12.5l3 3 5-6', 'circle:12,12,9'],
|
||
undo: ['M9 14L4 9l5-5', 'M4 9h10a6 6 0 0 1 0 12h-3'],
|
||
user: ['circle:12,8,3.5', 'M5 20a7 7 0 0 1 14 0'],
|
||
clock: ['circle:12,12,9', 'M12 7v5l3 2'],
|
||
chart: ['M4 20h16M7 20v-7M12 20V6M17 20v-10'],
|
||
bell: ['M6 16V11a6 6 0 0 1 12 0v5l1.5 2h-15z', 'M10 20.5a2 2 0 0 0 4 0'],
|
||
note: ['M5 4h14v12l-4 4H5z', 'M15 20v-4h4', 'M9 9h6M9 13h4'],
|
||
archive: ['M3.5 5h17v4h-17z', 'M5 9v10h14V9', 'M10 13h4'],
|
||
flag: ['M5 21V4', 'M5 4h11l-2 4 2 4H5'],
|
||
copy: ['rect:9,9,11,11,2', 'M5 15V6a2 2 0 0 1 2-2h9'],
|
||
trash: ['M4 7h16', 'M9 7V4h6v3', 'M6 7l1 13h10l1-13'],
|
||
chevronLeft: ['M15 18l-6-6 6-6'],
|
||
chevronRight: ['M9 6l6 6-6 6'],
|
||
external: ['M14 4h6v6', 'M20 4l-9 9', 'M18 14v6H4V6h6'],
|
||
logout: ['M15 4h4v16h-4', 'M10 17l5-5-5-5', 'M15 12H4'],
|
||
};
|
||
|
||
const SVG = 'http://www.w3.org/2000/svg';
|
||
export function icon(name, cls = 'icon') {
|
||
const svg = document.createElementNS(SVG, 'svg');
|
||
svg.setAttribute('viewBox', '0 0 24 24');
|
||
svg.setAttribute('aria-hidden', 'true');
|
||
svg.setAttribute('class', cls);
|
||
for (const d of ICONS[name] || []) {
|
||
let node;
|
||
if (d.startsWith('circle:')) {
|
||
const [cx, cy, r] = d.slice(7).split(',');
|
||
node = document.createElementNS(SVG, 'circle');
|
||
node.setAttribute('cx', cx);
|
||
node.setAttribute('cy', cy);
|
||
node.setAttribute('r', r);
|
||
} else if (d.startsWith('rect:')) {
|
||
const [x, y, w, hgt, rx] = d.slice(5).split(',');
|
||
node = document.createElementNS(SVG, 'rect');
|
||
node.setAttribute('x', x);
|
||
node.setAttribute('y', y);
|
||
node.setAttribute('width', w);
|
||
node.setAttribute('height', hgt);
|
||
if (rx) node.setAttribute('rx', rx);
|
||
} else {
|
||
node = document.createElementNS(SVG, 'path');
|
||
node.setAttribute('d', d);
|
||
}
|
||
svg.append(node);
|
||
}
|
||
return svg;
|
||
}
|
||
|
||
// ---------- sheet ----------
|
||
|
||
const sheet = () => document.getElementById('sheet');
|
||
let sheetResolve = null;
|
||
|
||
// openSheet shows content in the bottom sheet (a centred dialog on desktop)
|
||
// and resolves with whatever closeSheet is given, or null when dismissed.
|
||
export function openSheet(build) {
|
||
const dlg = sheet();
|
||
if (dlg.open) closeSheet(null);
|
||
const inner = h('div', { class: 'sheet-inner' }, h('div', { class: 'sheet-grab' }));
|
||
append(inner, [build()]);
|
||
clear(dlg, inner);
|
||
dlg.showModal();
|
||
const first = dlg.querySelector('[autofocus]');
|
||
if (first) first.focus();
|
||
return new Promise((resolve) => {
|
||
sheetResolve = resolve;
|
||
});
|
||
}
|
||
|
||
export function closeSheet(value = null) {
|
||
const dlg = sheet();
|
||
const resolve = sheetResolve;
|
||
sheetResolve = null;
|
||
if (dlg.open) dlg.close();
|
||
if (resolve) resolve(value);
|
||
}
|
||
|
||
export function sheetIsOpen() {
|
||
return sheet().open;
|
||
}
|
||
|
||
export function initSheet() {
|
||
const dlg = sheet();
|
||
// A tap on the backdrop lands on the dialog element itself.
|
||
dlg.addEventListener('click', (e) => {
|
||
if (e.target === dlg) closeSheet(null);
|
||
});
|
||
dlg.addEventListener('cancel', (e) => {
|
||
e.preventDefault();
|
||
closeSheet(null);
|
||
});
|
||
}
|
||
|
||
// confirm asks a yes/no question in the sheet.
|
||
export function confirm({ title, text, confirmLabel = 'Confirm', danger = false }) {
|
||
return openSheet(() => [
|
||
h('h2', { class: 'sheet-title', text: title }),
|
||
text && h('p', { class: 'sheet-text', text }),
|
||
h('div', { class: 'sheet-actions' },
|
||
h('button', { class: 'btn', type: 'button', onclick: () => closeSheet(false), text: 'Cancel' }),
|
||
h('button', {
|
||
class: `btn ${danger ? 'btn-danger' : 'btn-primary'}`,
|
||
type: 'button',
|
||
autofocus: true,
|
||
onclick: () => closeSheet(true),
|
||
text: confirmLabel,
|
||
}),
|
||
),
|
||
]).then((v) => v === true);
|
||
}
|
||
|
||
// ---------- toast ----------
|
||
|
||
let toastTimer = 0;
|
||
export function toast(message, kind = '') {
|
||
const el = document.getElementById('toast');
|
||
el.textContent = message;
|
||
el.className = `toast ${kind}`;
|
||
el.hidden = false;
|
||
clearTimeout(toastTimer);
|
||
toastTimer = setTimeout(() => {
|
||
el.hidden = true;
|
||
}, kind === 'error' ? 5000 : 2500);
|
||
}
|
||
|
||
// ---------- misc ----------
|
||
|
||
export function badge(text, cls = '') {
|
||
return h('span', { class: `badge ${cls}`, text });
|
||
}
|
||
|
||
// Access granted by the identity provider's groups. The server refuses to edit
|
||
// it by hand (it would be undone at the next sign-in), so the controls that
|
||
// would try are disabled with this as their reason.
|
||
export const SSO_MANAGED = 'Managed by single sign-on. Change the person’s groups in the identity provider.';
|
||
|
||
export function ssoBadge(text = 'SSO') {
|
||
return h('span', { class: 'row-team sso', title: SSO_MANAGED, text });
|
||
}
|
||
|
||
export function labelChip(k, v) {
|
||
return h('span', { class: 'label', title: `${k}=${v}` }, h('span', { text: k }), h('span', { text: v }));
|
||
}
|
||
|
||
// One entry in a section's overview: a card that is a link, carrying the count
|
||
// only that section can state. Both the Admin tab and the Team tab open on one
|
||
// of these menus, and a menu item is a shape rather than a page's own idea.
|
||
export function menuCard(href, label, count, note) {
|
||
return h('a', { class: 'card overview-item', href },
|
||
h('div', { class: 'overview-head' },
|
||
h('strong', { text: label }),
|
||
count != null && h('span', { class: 'overview-count', text: String(count) })),
|
||
h('p', { class: 'muted small', text: note }),
|
||
);
|
||
}
|
||
|
||
export function emptyState(title, text, iconName) {
|
||
return h('div', { class: 'empty' },
|
||
iconName && icon(iconName),
|
||
h('strong', { text: title }),
|
||
text && h('span', { text }),
|
||
);
|
||
}
|
||
|
||
export function spinner() {
|
||
return h('div', { class: 'empty' }, h('span', { class: 'spinner' }));
|
||
}
|