mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-20 09:09:35 +02:00
feat(ui): security notices, shown only to users of the affected CLI
A hand-maintained src/web/public/security-notices.json carries warnings about things outside Codeman that its users should act on. The first: OpenCode's report of unauthorized access to its legacy Console, which may have exposed provider API keys saved there. - Pure core in constants.js (CodemanSecurityNotices): select() keeps the notices that apply now (inclusive local-day dates, not yet seen, https links, malformed entries skipped), and one naming a CLI only when window.__codemanCliAvailable[cli] === true. Unknown means not installed: a warning about software you do not have is noise. The CLI id is data in the JSON; no code branches on it. plan() decides the surface, so a load shows each notice once. - An existing install on a new version gets the notice INSIDE the release's update toast: the highlights as before, a warning section, and the notice's link next to "See what's new". With no notice the update toast is the plain one it always was. - Everyone else (a fresh install, a same-version reload) gets its own card in the toast stack: shield, "Security notice" eyebrow, title, message, the link, Dismiss and the ×; amber-to-red accent bar, theme tokens only (dark and light skins), full width on phones, an opacity/transform entrance and none under prefers-reduced-motion, role=alert. - A notice is recorded as seen only when the user closes it, dismisses it or follows its link, never when it appears. Storage blocked shows nothing. The notice's own text is textContent and carries its own zh-CN, so i18n.js leaves it alone; the card's own copy translates. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
ada2309721
commit
50bae2d371
6 files changed
+677
-26
No files matched your search
@@ -3436,3 +3436,152 @@ if (typeof window !== 'undefined') {
|
||||
TERMINAL_MIN_ROWS,
|
||||
};
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Security notices
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// A security notice is a sticky warning toast about something outside Codeman that
|
||||
// its users should act on (the first: OpenCode's Console breach, October 2026). The
|
||||
// notices are DATA in the hand-maintained src/web/public/security-notices.json, never
|
||||
// in whats-new.json (`npm run version-packages` rewrites that one every release), and
|
||||
// they ship with a release. This is the pure half: which notices this browser shows, and
|
||||
// on which surface. The wiring (fetches, the seen lists) is checkWhatsNew() in settings-ui.js,
|
||||
// the cards are showSecurityNotice() / showUpdateNotice() in panels-ui.js.
|
||||
|
||||
/** `YYYY-MM-DD`, and a real calendar day (no 2026-02-30). */
|
||||
const SECURITY_NOTICE_DATE_PATTERN = /^(\d{4})-(\d{2})-(\d{2})$/;
|
||||
/** An https URL with a plain host: no other scheme, no userinfo, no whitespace. */
|
||||
const SECURITY_NOTICE_URL_PATTERN = /^https:\/\/[A-Za-z0-9.-]+(?::\d{1,5})?(?:[/?#]\S*)?$/;
|
||||
|
||||
/**
|
||||
* The local-time bounds of one `YYYY-MM-DD` day, or null when the string is not one.
|
||||
* Local time on purpose: a notice dated the 18th starts on the viewer's 18th.
|
||||
*/
|
||||
function securityNoticeDay(value) {
|
||||
const match = typeof value === 'string' ? SECURITY_NOTICE_DATE_PATTERN.exec(value) : null;
|
||||
if (!match) return null;
|
||||
const year = Number(match[1]);
|
||||
const month = Number(match[2]) - 1;
|
||||
const day = Number(match[3]);
|
||||
const start = new Date(year, month, day);
|
||||
if (start.getFullYear() !== year || start.getMonth() !== month || start.getDate() !== day) return null;
|
||||
const end = new Date(year, month, day + 1).getTime() - 1;
|
||||
return { start: start.getTime(), end };
|
||||
}
|
||||
|
||||
function securityNoticeText(value) {
|
||||
return typeof value === 'string' && value.trim() ? value.trim() : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* The notices to show, in file order, each as `{ id, title, message, label, url }`
|
||||
* (`title` is '' when the entry has none).
|
||||
*
|
||||
* - A malformed entry is skipped, never repaired: no id, message or link label, a
|
||||
* link that is not https, a date that is not a real `YYYY-MM-DD` day, a `title`
|
||||
* that is present but not a non-blank string, or a `cli` that is not a registry
|
||||
* id. A repeated id shows its first entry only.
|
||||
* - Shown from the start of the `published` day to the END of the `expires` day.
|
||||
* The expiry is what keeps an old notice from alarming a browser that first opens
|
||||
* Codeman months later.
|
||||
* - An id in `seen` is skipped (the user closed it, or followed its link).
|
||||
* - A notice naming a `cli` is shown ONLY when `available[cli] === true`. Absent,
|
||||
* unknown and false all mean "not installed" here, unlike isCliAvailable() in
|
||||
* settings-ui.js (which offers a launch on a doubt): a warning about software you
|
||||
* do not have is noise. A notice with no `cli` is for everyone.
|
||||
* - `lang` 'zh-CN' takes the entry's `i18n['zh-CN']` title, message and label where
|
||||
* they exist, each falling back to the English one on its own.
|
||||
*
|
||||
* The CLI id is data from the JSON, compared as a key; no code here names one.
|
||||
*
|
||||
* @param {Array<object>} notices - `notices` from security-notices.json
|
||||
* @param {{available?: Record<string, boolean>, seen?: Iterable<string>, now?: number, lang?: string}} [opts]
|
||||
* `available` is window.__codemanCliAvailable, `now` epoch milliseconds (default Date.now()).
|
||||
* @returns {Array<{id: string, title: string, message: string, label: string, url: string}>}
|
||||
*/
|
||||
function selectSecurityNotices(notices, opts = {}) {
|
||||
if (!Array.isArray(notices)) return [];
|
||||
const available = opts.available && typeof opts.available === 'object' ? opts.available : {};
|
||||
const seenSource = opts.seen;
|
||||
const seen = new Set(
|
||||
seenSource && typeof seenSource !== 'string' && typeof seenSource[Symbol.iterator] === 'function'
|
||||
? Array.from(seenSource, String)
|
||||
: []
|
||||
);
|
||||
const now = typeof opts.now === 'number' && Number.isFinite(opts.now) ? opts.now : Date.now();
|
||||
const lang = opts.lang === 'zh-CN' ? 'zh-CN' : 'en';
|
||||
const taken = new Set();
|
||||
const shown = [];
|
||||
for (const entry of notices) {
|
||||
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) continue;
|
||||
const id = securityNoticeText(entry.id);
|
||||
const title = securityNoticeText(entry.title);
|
||||
const message = securityNoticeText(entry.message);
|
||||
const link = entry.link && typeof entry.link === 'object' ? entry.link : null;
|
||||
const label = securityNoticeText(link?.label);
|
||||
const url = typeof link?.url === 'string' ? link.url.trim() : '';
|
||||
const published = securityNoticeDay(entry.published);
|
||||
const expires = securityNoticeDay(entry.expires);
|
||||
if (!id || !message || !label || !SECURITY_NOTICE_URL_PATTERN.test(url) || !published || !expires) continue;
|
||||
if (entry.title !== undefined && !title) continue;
|
||||
if (entry.cli !== undefined && (typeof entry.cli !== 'string' || !CLI_ID_PATTERN.test(entry.cli))) continue;
|
||||
if (taken.has(id)) continue;
|
||||
taken.add(id);
|
||||
if (now < published.start || now > expires.end) continue;
|
||||
if (seen.has(id)) continue;
|
||||
if (entry.cli !== undefined && available[entry.cli] !== true) continue;
|
||||
const localized = lang === 'en' ? null : entry.i18n?.[lang];
|
||||
shown.push({
|
||||
id,
|
||||
title: securityNoticeText(localized?.title) || title,
|
||||
message: securityNoticeText(localized?.message) || message,
|
||||
label: securityNoticeText(localized?.label) || label,
|
||||
url,
|
||||
});
|
||||
}
|
||||
return shown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Which surface carries each selected notice on this page load, decided together with
|
||||
* the once-per-release update toast (checkWhatsNew in settings-ui.js) so a browser gets
|
||||
* ONE notification, never a card and a toast for the same notice.
|
||||
*
|
||||
* - The update toast shows exactly as checkWhatsNew always decided: this browser saw an
|
||||
* older version (`seenVersion` set and different), and `notes` are this version's with
|
||||
* at least one highlight. Then every selected notice rides INSIDE it (`updateNotices`)
|
||||
* and no standalone card is shown.
|
||||
* - Otherwise (a fresh install, a reload on the same version, notes missing or empty)
|
||||
* each notice gets its own card (`cards`).
|
||||
* - `recordVersion`: store `runningVersion` as seen, as checkWhatsNew always did: on a
|
||||
* fresh install (so it is not greeted with "what's new") and when the toast shows.
|
||||
* Notes that are missing or empty record nothing, so the next load tries again.
|
||||
*
|
||||
* @param {{seenVersion?: string|null, runningVersion: string, notes?: object|null, notices?: Array<object>}} state
|
||||
* `notices` is the output of selectSecurityNotices().
|
||||
* @returns {{recordVersion: boolean, update: boolean, updateNotices: Array<object>, cards: Array<object>}}
|
||||
*/
|
||||
function planStartupNotices(state = {}) {
|
||||
const seenVersion = typeof state.seenVersion === 'string' && state.seenVersion ? state.seenVersion : null;
|
||||
const runningVersion = state.runningVersion;
|
||||
const notices = Array.isArray(state.notices) ? state.notices : [];
|
||||
const notes = state.notes;
|
||||
const update = Boolean(
|
||||
seenVersion &&
|
||||
seenVersion !== runningVersion &&
|
||||
notes &&
|
||||
notes.version === runningVersion &&
|
||||
Array.isArray(notes.highlights) &&
|
||||
notes.highlights.length > 0
|
||||
);
|
||||
return {
|
||||
recordVersion: !seenVersion || update,
|
||||
update,
|
||||
updateNotices: update ? notices.slice() : [],
|
||||
cards: update ? [] : notices.slice(),
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
window.CodemanSecurityNotices = { select: selectSecurityNotices, plan: planStartupNotices };
|
||||
}
|
||||
@@ -844,6 +844,9 @@
|
||||
'Settings saved': '设置已保存',
|
||||
// The once-per-release toast (settings-ui.js checkWhatsNew); its highlights stay English.
|
||||
"See what's new": '查看更新内容',
|
||||
// The security-notice cards' own eyebrow (panels-ui.js showSecurityNotice/showUpdateNotice);
|
||||
// each notice's title, message and link label carry their own zh-CN text and are skipped.
|
||||
'Security notice': '安全提示',
|
||||
'Settings applied': '设置已应用',
|
||||
'Save and keep Settings open': '保存并保持设置打开',
|
||||
'Settings saved locally': '设置已保存到本机',
|
||||
|
||||
+164
-10
@@ -6249,16 +6249,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
closeBtn.onclick = (e) => { e.stopPropagation(); dismiss(); };
|
||||
toast.appendChild(closeBtn);
|
||||
|
||||
// Cache toast container reference
|
||||
if (!this._toastContainer) {
|
||||
this._toastContainer = document.querySelector('.toast-container');
|
||||
if (!this._toastContainer) {
|
||||
this._toastContainer = document.createElement('div');
|
||||
this._toastContainer.className = 'toast-container';
|
||||
document.body.appendChild(this._toastContainer);
|
||||
}
|
||||
}
|
||||
this._toastContainer.appendChild(toast);
|
||||
this._toastStack().appendChild(toast);
|
||||
|
||||
requestAnimationFrame(() => toast.classList.add('show'));
|
||||
|
||||
@@ -6272,6 +6263,169 @@ Object.assign(CodemanApp.prototype, {
|
||||
return { dismiss, setMessage: (text) => { msgSpan.textContent = text; } };
|
||||
},
|
||||
|
||||
/** The fixed toast stack (`.toast-container`), created on first use and cached. */
|
||||
_toastStack() {
|
||||
if (!this._toastContainer) {
|
||||
this._toastContainer = document.querySelector('.toast-container');
|
||||
if (!this._toastContainer) {
|
||||
this._toastContainer = document.createElement('div');
|
||||
this._toastContainer.className = 'toast-container';
|
||||
document.body.appendChild(this._toastContainer);
|
||||
}
|
||||
}
|
||||
return this._toastContainer;
|
||||
},
|
||||
|
||||
/**
|
||||
* A security notice (security-notices.json) as its own card in the toast stack: shield,
|
||||
* "Security notice" eyebrow, the notice's title and message, its link as the primary
|
||||
* button, a quiet Dismiss and the ×. Same container as every toast, so it never fights
|
||||
* the z-index stack. Sticky: only the user ends it.
|
||||
*
|
||||
* The notice's own text (title, message, link label) is already in the UI language, so it
|
||||
* is set with textContent and carries `data-i18n-skip`; the card's own copy ("Security
|
||||
* notice", "Dismiss", the × label) is application text and translates as usual.
|
||||
*
|
||||
* `opts.onClose(reason)` fires ONCE, and only on a user action: `'link'`, `'dismiss'` or
|
||||
* `'close'`. Never on the handle's own `dismiss()`, which does not mean the user saw it.
|
||||
*
|
||||
* @param {{id: string, title: string, message: string, label: string, url: string}} notice
|
||||
* @param {{onClose?: (reason: string) => void}} [opts]
|
||||
*/
|
||||
showSecurityNotice(notice, opts = {}) {
|
||||
const card = this._noticeCard('security-notice', opts.onClose);
|
||||
const main = this._noticeEl('div', 'security-notice-main');
|
||||
main.append(this._noticeEl('div', 'security-notice-eyebrow', 'Security notice'));
|
||||
main.append(...this._noticeTextBlock(notice));
|
||||
const actions = this._noticeEl('div', 'security-notice-actions');
|
||||
actions.append(
|
||||
this._noticeLinkButton(notice, () => card.close('link')),
|
||||
this._noticeButton('security-notice-quiet', 'Dismiss', () => card.close('dismiss'))
|
||||
);
|
||||
main.append(actions);
|
||||
card.el.append(this._noticeIcon('shield', 'security-notice-icon'), main, card.closeButton);
|
||||
card.mount();
|
||||
return { dismiss: () => card.close(null) };
|
||||
},
|
||||
|
||||
/**
|
||||
* The once-per-release update toast when security notices ride INSIDE it
|
||||
* (CodemanSecurityNotices.plan in constants.js decides that; with no notice the plain
|
||||
* showToast toast is used, unchanged). Styled as the security card: the release text as
|
||||
* before (translated by the same i18n.js pattern), one warning section per notice, then
|
||||
* each notice's link button beside "See what's new".
|
||||
*
|
||||
* Sticky, since it carries a warning. `onNoticeSeen(id)` records a notice: the × records
|
||||
* every notice in the card, a notice's link records that one and closes the card. "See
|
||||
* what's new" opens the release notes and leaves the card up, because the warning in it
|
||||
* has not been answered yet.
|
||||
*
|
||||
* @param {{text: string, releaseUrl: string, notices: Array<object>, onNoticeSeen: (id: string) => void}} opts
|
||||
*/
|
||||
showUpdateNotice({ text, releaseUrl, notices, onNoticeSeen }) {
|
||||
const seen = (id) => { try { onNoticeSeen?.(id); } catch (err) { console.error('[notice] onNoticeSeen failed:', err); } };
|
||||
const card = this._noticeCard('security-notice update-notice', (reason) => {
|
||||
if (reason === 'close') for (const notice of notices) seen(notice.id);
|
||||
});
|
||||
const main = this._noticeEl('div', 'security-notice-main');
|
||||
main.append(this._noticeEl('p', 'update-notice-text', text));
|
||||
const actions = this._noticeEl('div', 'security-notice-actions');
|
||||
for (const notice of notices) {
|
||||
const section = this._noticeEl('section', 'security-notice-section');
|
||||
const head = this._noticeEl('div', 'security-notice-section-head');
|
||||
head.append(this._noticeIcon('shield'), this._noticeEl('span', 'security-notice-eyebrow', 'Security notice'));
|
||||
section.append(head, ...this._noticeTextBlock(notice));
|
||||
main.append(section);
|
||||
actions.append(this._noticeLinkButton(notice, () => { seen(notice.id); card.close('link'); }));
|
||||
}
|
||||
actions.append(
|
||||
this._noticeButton('security-notice-secondary', "See what's new", () => window.open(releaseUrl, '_blank', 'noopener'))
|
||||
);
|
||||
main.append(actions);
|
||||
card.el.append(this._noticeIcon('update', 'security-notice-icon update-notice-icon'), main, card.closeButton);
|
||||
card.mount();
|
||||
return { dismiss: () => card.close(null) };
|
||||
},
|
||||
|
||||
/** The shell both notice cards share: role=alert, the ×, mount and a once-only close. */
|
||||
_noticeCard(className, onUserClose) {
|
||||
const el = this._noticeEl('div', className);
|
||||
el.setAttribute('role', 'alert');
|
||||
el.setAttribute('aria-live', 'assertive');
|
||||
let closed = false;
|
||||
const close = (reason) => {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
el.classList.remove('show');
|
||||
setTimeout(() => el.remove(), 200);
|
||||
if (reason && typeof onUserClose === 'function') {
|
||||
try { onUserClose(reason); } catch (err) { console.error('[notice] onClose failed:', err); }
|
||||
}
|
||||
};
|
||||
const closeButton = this._noticeButton('security-notice-close', '×', () => close('close'));
|
||||
closeButton.setAttribute('aria-label', 'Close');
|
||||
const mount = () => {
|
||||
this._toastStack().appendChild(el);
|
||||
requestAnimationFrame(() => el.classList.add('show'));
|
||||
};
|
||||
return { el, closeButton, close, mount };
|
||||
},
|
||||
|
||||
/** The notice's own title (when it has one) and message: textContent, never translated. */
|
||||
_noticeTextBlock(notice) {
|
||||
const parts = [];
|
||||
if (notice.title) parts.push(this._noticeEl('div', 'security-notice-title', notice.title, true));
|
||||
parts.push(this._noticeEl('p', 'security-notice-body', notice.message, true));
|
||||
return parts;
|
||||
},
|
||||
|
||||
_noticeLinkButton(notice, after) {
|
||||
return this._noticeButton(
|
||||
'security-notice-primary',
|
||||
notice.label,
|
||||
() => { window.open(notice.url, '_blank', 'noopener'); after(); },
|
||||
true
|
||||
);
|
||||
},
|
||||
|
||||
_noticeButton(className, label, onClick, untranslated = false) {
|
||||
const btn = this._noticeEl('button', className, label, untranslated);
|
||||
btn.type = 'button';
|
||||
btn.onclick = (e) => { e.stopPropagation(); onClick(); };
|
||||
return btn;
|
||||
},
|
||||
|
||||
_noticeEl(tag, className, text, untranslated = false) {
|
||||
const el = document.createElement(tag);
|
||||
el.className = className;
|
||||
if (text !== undefined) el.textContent = text;
|
||||
if (untranslated) el.setAttribute('data-i18n-skip', '');
|
||||
return el;
|
||||
},
|
||||
|
||||
/** Inline SVG icons (no asset): `shield` (with an exclamation) or `update` (arrow up). */
|
||||
_noticeIcon(kind, wrapperClass) {
|
||||
const NS = 'http://www.w3.org/2000/svg';
|
||||
const svg = document.createElementNS(NS, 'svg');
|
||||
for (const [k, v] of Object.entries({
|
||||
viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', 'stroke-width': '2',
|
||||
'stroke-linecap': 'round', 'stroke-linejoin': 'round', 'aria-hidden': 'true', focusable: 'false',
|
||||
})) svg.setAttribute(k, v);
|
||||
const shapes = kind === 'shield'
|
||||
? [['path', { d: 'M12 3 4.5 6v5.2c0 4.5 3.1 8.4 7.5 9.8 4.4-1.4 7.5-5.3 7.5-9.8V6L12 3z' }], ['path', { d: 'M12 8.5v4.5' }], ['path', { d: 'M12 16.5h.01' }]]
|
||||
: [['circle', { cx: '12', cy: '12', r: '9' }], ['path', { d: 'M12 16.5v-9' }], ['path', { d: 'm8.5 11 3.5-3.5 3.5 3.5' }]];
|
||||
for (const [tag, attrs] of shapes) {
|
||||
const shape = document.createElementNS(NS, tag);
|
||||
for (const [k, v] of Object.entries(attrs)) shape.setAttribute(k, v);
|
||||
svg.appendChild(shape);
|
||||
}
|
||||
if (!wrapperClass) return svg;
|
||||
const wrap = this._noticeEl('span', wrapperClass);
|
||||
wrap.setAttribute('aria-hidden', 'true');
|
||||
wrap.appendChild(svg);
|
||||
return wrap;
|
||||
},
|
||||
|
||||
/**
|
||||
* A prominent, screen-centred status banner — for the small set of messages that are
|
||||
* genuinely worth interrupting the eye for rather than living in the corner with every
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"notices": [
|
||||
{
|
||||
"id": "opencode-console-breach-2026-10",
|
||||
"severity": "warning",
|
||||
"cli": "opencode",
|
||||
"published": "2026-10-18",
|
||||
"expires": "2026-12-31",
|
||||
"title": "OpenCode Console breach: check your API keys",
|
||||
"message": "Security notice for OpenCode users: on 17 October OpenCode reported unauthorized access to a legacy OpenCode Console system. Provider API keys some users had saved in a Console workspace (OpenAI, Anthropic and others) may have been exposed. OpenCode emailed the affected users: if you got that email, revoke and replace those keys and check their recent usage. The opencode CLI itself is not reported as affected.",
|
||||
"link": { "label": "Read OpenCode's notice", "url": "https://x.com/opencode/status/2111519637049319818" },
|
||||
"i18n": {
|
||||
"zh-CN": {
|
||||
"title": "OpenCode Console 遭入侵:请检查你的 API 密钥",
|
||||
"message": "OpenCode 用户安全提示:OpenCode 于 10 月 17 日报告,其旧版 OpenCode Console 系统遭到未授权访问。部分用户保存在 Console 工作区中的提供商 API 密钥(OpenAI、Anthropic 等)可能已泄露。OpenCode 已向受影响的用户发送邮件:如果你收到了该邮件,请吊销并更换这些密钥,并检查近期用量。目前没有报告称 opencode CLI 本身受到影响。",
|
||||
"label": "查看 OpenCode 公告"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -378,32 +378,108 @@ Object.assign(CodemanApp.prototype, {
|
||||
* The notes come from /whats-new.json, which `npm run version-packages` regenerates from
|
||||
* CHANGELOG.md for each release. A browser that has never seen any version just records the
|
||||
* current one, so a fresh install is not greeted with "what's new".
|
||||
*
|
||||
* The same pass places the security notices (/security-notices.json, see
|
||||
* _loadSecurityNotices), so a load shows ONE notification per notice:
|
||||
* CodemanSecurityNotices.plan() (constants.js) puts every selected notice INSIDE the update
|
||||
* toast when that toast shows (showUpdateNotice), and gives each its own card otherwise, a
|
||||
* fresh install or a reload on the same version (showSecurityNotice). With no notice
|
||||
* selected the update toast is the plain one it always was. Storage blocked shows neither:
|
||||
* nothing could remember a close.
|
||||
*/
|
||||
async checkWhatsNew(runningVersion) {
|
||||
if (this._whatsNewChecked) return;
|
||||
this._whatsNewChecked = true;
|
||||
const KEY = 'codeman-whats-new-seen';
|
||||
let seen = null;
|
||||
try { seen = localStorage.getItem(KEY); } catch { /* storage blocked: skip the announcement */ return; }
|
||||
if (seen === runningVersion) return;
|
||||
try { seen = localStorage.getItem(KEY); } catch { /* storage blocked: skip the announcement and the notices */ return; }
|
||||
const remember = () => { try { localStorage.setItem(KEY, runningVersion); } catch { /* ignore */ } };
|
||||
if (!seen) { remember(); return; }
|
||||
try {
|
||||
const res = await fetch('/whats-new.json', { cache: 'no-cache' });
|
||||
if (!res.ok) return;
|
||||
const notes = await res.json();
|
||||
if (notes?.version !== runningVersion || !Array.isArray(notes.highlights) || !notes.highlights.length) return;
|
||||
const [notes, notices] = await Promise.all([
|
||||
seen && seen !== runningVersion ? this._fetchWhatsNewNotes() : null,
|
||||
this._loadSecurityNotices(),
|
||||
]);
|
||||
const plan = window.CodemanSecurityNotices.plan({ seenVersion: seen, runningVersion, notes, notices });
|
||||
if (plan.recordVersion) remember();
|
||||
if (plan.update) {
|
||||
const shown = notes.highlights.slice(0, 3).join(' · ');
|
||||
const more = notes.highlights.length > 3 ? ` (+${notes.highlights.length - 3} more)` : '';
|
||||
remember();
|
||||
this.showToast(`Codeman updated to v${runningVersion}: ${shown}${more}`, 'info', {
|
||||
duration: 20000,
|
||||
action: {
|
||||
label: "See what's new",
|
||||
onClick: () => window.open(`https://github.com/Ark0N/Codeman/releases/tag/codeman%40${runningVersion}`, '_blank', 'noopener'),
|
||||
},
|
||||
const text = `Codeman updated to v${runningVersion}: ${shown}${more}`;
|
||||
const releaseUrl = `https://github.com/Ark0N/Codeman/releases/tag/codeman%40${runningVersion}`;
|
||||
if (plan.updateNotices.length) {
|
||||
this.showUpdateNotice({
|
||||
text,
|
||||
releaseUrl,
|
||||
notices: plan.updateNotices,
|
||||
onNoticeSeen: (id) => this._rememberSecurityNotice(id),
|
||||
});
|
||||
} else {
|
||||
this.showToast(text, 'info', {
|
||||
duration: 20000,
|
||||
action: {
|
||||
label: "See what's new",
|
||||
onClick: () => window.open(releaseUrl, '_blank', 'noopener'),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
for (const notice of plan.cards) {
|
||||
this.showSecurityNotice(notice, { onClose: () => this._rememberSecurityNotice(notice.id) });
|
||||
}
|
||||
},
|
||||
|
||||
/** This release's notes, or null (offline or malformed: try again next load). */
|
||||
async _fetchWhatsNewNotes() {
|
||||
try {
|
||||
const res = await fetch('/whats-new.json', { cache: 'no-cache' });
|
||||
return res.ok ? await res.json() : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* The security notices this browser should see now, from the HAND-maintained
|
||||
* /security-notices.json (docs/releasing.md says how to add and retire one), selected by
|
||||
* CodemanSecurityNotices.select() in constants.js: dates, the seen list, and for a notice
|
||||
* naming a CLI, `window.__codemanCliAvailable[cli] === true` only (unknown means not
|
||||
* installed). [] on any failure, and when storage is blocked (a close could not be
|
||||
* remembered, so the card would return on every load).
|
||||
*/
|
||||
async _loadSecurityNotices() {
|
||||
let seen;
|
||||
try { seen = this._readSecurityNoticesSeen(); } catch { /* storage blocked: show nothing */ return []; }
|
||||
try {
|
||||
const res = await fetch(CodemanBase.url('/security-notices.json'), { cache: 'no-cache' });
|
||||
if (!res.ok) return [];
|
||||
const data = await res.json();
|
||||
return window.CodemanSecurityNotices.select(data?.notices, {
|
||||
available: window.__codemanCliAvailable,
|
||||
seen,
|
||||
now: Date.now(),
|
||||
lang: window.CodemanI18n?.language || window.__codemanLanguage,
|
||||
});
|
||||
} catch { /* offline or malformed: try again next load */ }
|
||||
} catch { /* offline or malformed: try again next load */ return []; }
|
||||
},
|
||||
|
||||
/** The ids the user closed or followed (`codeman-security-notices-seen`). Throws when storage is blocked. */
|
||||
_readSecurityNoticesSeen() {
|
||||
const raw = localStorage.getItem('codeman-security-notices-seen');
|
||||
let ids = [];
|
||||
try { ids = raw ? JSON.parse(raw) : []; } catch { /* a corrupt value counts as nothing seen */ }
|
||||
return Array.isArray(ids) ? ids.filter((id) => typeof id === 'string') : [];
|
||||
},
|
||||
|
||||
/**
|
||||
* Record a notice as seen. Only a user action calls this (a close, Dismiss, its link), never
|
||||
* the card appearing, so one shown while nobody was looking comes back on the next load.
|
||||
* Re-reads first: another tab may have recorded a notice since this one loaded.
|
||||
*/
|
||||
_rememberSecurityNotice(id) {
|
||||
try {
|
||||
const ids = this._readSecurityNoticesSeen();
|
||||
if (!ids.includes(id)) ids.push(id);
|
||||
localStorage.setItem('codeman-security-notices-seen', JSON.stringify(ids));
|
||||
} catch { /* storage blocked since load: it shows again next load */ }
|
||||
},
|
||||
|
||||
openAppSettings() {
|
||||
|
||||
@@ -9723,6 +9723,254 @@ kbd {
|
||||
.toast-warning { border-color: rgba(234, 179, 8, 0.4); }
|
||||
.toast-info { border-color: rgba(59, 130, 246, 0.4); }
|
||||
|
||||
/* Security notice card (security-notices.json; showSecurityNotice / showUpdateNotice in
|
||||
panels-ui.js). Its own card in the toast stack, never the plain pill, and in the SAME
|
||||
container, so it shares the toasts' position and z-index. Theme tokens only, so every skin
|
||||
reads: the light skins' --yellow is already a dark amber that holds on white, and the
|
||||
primary button's ink is --bg-card (dark ink on the dark skins' light amber, white ink on
|
||||
the light skins' dark amber). The entrance animates opacity and transform only, and
|
||||
nothing moves under prefers-reduced-motion. */
|
||||
.security-notice {
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
column-gap: 0.75rem;
|
||||
align-items: start;
|
||||
box-sizing: border-box;
|
||||
width: min(27rem, calc(100vw - 1rem));
|
||||
padding: 0.85rem 0.55rem 0.9rem 1.05rem;
|
||||
background:
|
||||
linear-gradient(180deg, color-mix(in srgb, var(--yellow) 9%, transparent), transparent 6rem),
|
||||
var(--bg-card);
|
||||
border: 1px solid color-mix(in srgb, var(--yellow) 32%, var(--border));
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.28), 0 2px 6px rgba(0, 0, 0, 0.16);
|
||||
color: var(--text);
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.45;
|
||||
overflow: hidden;
|
||||
pointer-events: auto;
|
||||
opacity: 0;
|
||||
transform: translateY(-6px) scale(0.985);
|
||||
transition: opacity 0.22s ease, transform 0.22s cubic-bezier(0.2, 0.8, 0.2, 1);
|
||||
}
|
||||
|
||||
.security-notice.show {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
/* The accent bar: amber running into red down the left edge. */
|
||||
.security-notice::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 4px;
|
||||
background: linear-gradient(180deg, var(--yellow), var(--red));
|
||||
}
|
||||
|
||||
.security-notice-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border-radius: 50%;
|
||||
color: var(--yellow);
|
||||
background: color-mix(in srgb, var(--yellow) 15%, transparent);
|
||||
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--yellow) 30%, transparent);
|
||||
}
|
||||
|
||||
.security-notice-icon svg {
|
||||
width: 1.1rem;
|
||||
height: 1.1rem;
|
||||
}
|
||||
|
||||
/* The update toast carrying a notice leads with the release, in the accent colour. */
|
||||
.security-notice-icon.update-notice-icon {
|
||||
color: var(--accent);
|
||||
background: color-mix(in srgb, var(--accent) 14%, transparent);
|
||||
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--accent) 28%, transparent);
|
||||
}
|
||||
|
||||
.security-notice-main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.3rem;
|
||||
min-width: 0;
|
||||
padding-top: 0.1rem;
|
||||
}
|
||||
|
||||
.security-notice-eyebrow {
|
||||
color: var(--yellow);
|
||||
font-size: 0.66rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.09em;
|
||||
line-height: 1.3;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.security-notice-title {
|
||||
color: var(--text);
|
||||
font-size: 0.9rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.3;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.security-notice-body,
|
||||
.update-notice-text {
|
||||
margin: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.security-notice-body {
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.update-notice-text {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
/* One warning section per notice inside the update toast. */
|
||||
.security-notice-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
margin-top: 0.4rem;
|
||||
padding: 0.6rem 0.7rem 0.65rem;
|
||||
border: 1px solid color-mix(in srgb, var(--yellow) 26%, transparent);
|
||||
border-radius: 8px;
|
||||
background: color-mix(in srgb, var(--yellow) 7%, transparent);
|
||||
}
|
||||
|
||||
.security-notice-section-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
color: var(--yellow);
|
||||
}
|
||||
|
||||
.security-notice-section-head svg {
|
||||
flex-shrink: 0;
|
||||
width: 0.95rem;
|
||||
height: 0.95rem;
|
||||
}
|
||||
|
||||
.security-notice-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.45rem 0.8rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
/* The update toast's buttons share their row, and when it wraps (a phone, a long label)
|
||||
each fills its own, so the two never sit at odd widths one under the other. */
|
||||
.update-notice .security-notice-actions {
|
||||
column-gap: 0.6rem;
|
||||
}
|
||||
|
||||
.update-notice .security-notice-actions > button {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.security-notice-primary,
|
||||
.security-notice-secondary,
|
||||
.security-notice-quiet {
|
||||
font: inherit;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.security-notice-primary {
|
||||
padding: 0.45rem 0.85rem;
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--btn-radius);
|
||||
background: var(--yellow);
|
||||
color: var(--bg-card);
|
||||
}
|
||||
|
||||
.security-notice-primary:hover {
|
||||
background: color-mix(in srgb, var(--yellow) 86%, var(--text));
|
||||
}
|
||||
|
||||
.security-notice-secondary {
|
||||
padding: 0.45rem 0.85rem;
|
||||
border: 1px solid var(--border-light);
|
||||
border-radius: var(--btn-radius);
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.security-notice-secondary:hover {
|
||||
background: var(--control-bg-hover);
|
||||
}
|
||||
|
||||
.security-notice-quiet {
|
||||
padding: 0.45rem 0.15rem;
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--text-dim);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.security-notice-quiet:hover {
|
||||
color: var(--text);
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 3px;
|
||||
}
|
||||
|
||||
.security-notice-close {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.75rem;
|
||||
height: 1.75rem;
|
||||
margin-top: -0.3rem;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
background: none;
|
||||
color: var(--text-muted);
|
||||
font-size: 1.15rem;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.security-notice-close:hover {
|
||||
background: var(--control-bg-hover);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.security-notice button:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* Phones: the full width minus the stack's 0.5rem gutters, and the notice's own button
|
||||
takes the room left beside Dismiss (wrapping under it when the label is long). */
|
||||
@media (max-width: 599px) {
|
||||
.security-notice {
|
||||
width: calc(100vw - 1rem);
|
||||
}
|
||||
|
||||
.security-notice-primary {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.security-notice {
|
||||
transform: none;
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Notification Bell Button */
|
||||
.btn-icon-header.btn-notifications {
|
||||
position: relative;
|
||||
|
||||
Reference in new issue
Block a user