clay-server 5.1.1-beta.1 → 5.2.0-beta.1

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.
Files changed (47) hide show
  1. package/lib/account-auth-policy.js +21 -0
  2. package/lib/account-auth-socket.js +29 -0
  3. package/lib/account-auth-throttle.js +44 -0
  4. package/lib/account-auth-transport.js +48 -0
  5. package/lib/account-login-page.js +28 -0
  6. package/lib/account-passkey-challenges.js +31 -0
  7. package/lib/account-passkey-page.js +89 -0
  8. package/lib/account-passkey-policy.js +39 -0
  9. package/lib/account-password-page.js +69 -0
  10. package/lib/account-passwords.js +36 -0
  11. package/lib/account-remembered.js +77 -0
  12. package/lib/account-session-page.js +51 -0
  13. package/lib/account-session-store.js +82 -0
  14. package/lib/daemon.js +2 -0
  15. package/lib/migrate-single-user.js +3 -23
  16. package/lib/project-server-settings.js +1 -0
  17. package/lib/project-user-knowledge.js +2 -1
  18. package/lib/public/auth/account-login.css +98 -0
  19. package/lib/public/auth/account-login.js +104 -0
  20. package/lib/public/css/user-settings-sessions.css +42 -0
  21. package/lib/public/index.html +17 -5
  22. package/lib/public/modules/account-auth-settings.js +71 -0
  23. package/lib/public/modules/account-transport-settings.js +51 -0
  24. package/lib/public/modules/app-messages.js +4 -0
  25. package/lib/public/modules/passkey-browser.js +69 -0
  26. package/lib/public/modules/passkey-server-settings.js +57 -0
  27. package/lib/public/modules/project-logs-render.js +3 -2
  28. package/lib/public/modules/server-settings-controls.js +10 -0
  29. package/lib/public/modules/server-settings.js +7 -2
  30. package/lib/public/modules/user-settings-account.js +14 -1
  31. package/lib/public/modules/user-settings-sessions.js +120 -0
  32. package/lib/public/modules/user-settings.js +3 -0
  33. package/lib/public/modules/you-activity.js +3 -1
  34. package/lib/public/modules/you.js +91 -6
  35. package/lib/public/style.css +1 -0
  36. package/lib/server-account-passkeys.js +186 -0
  37. package/lib/server-account-passwords.js +127 -0
  38. package/lib/server-account-security.js +53 -0
  39. package/lib/server-auth.js +107 -79
  40. package/lib/server-settings.js +2 -1
  41. package/lib/server.js +2 -0
  42. package/lib/user-knowledge-service.js +23 -1
  43. package/lib/users-auth.js +38 -3
  44. package/lib/users-create.js +97 -0
  45. package/lib/users-server-pin.js +37 -0
  46. package/lib/users.js +11 -84
  47. package/package.json +2 -1
@@ -0,0 +1,69 @@
1
+ function decode(value) {
2
+ var base64 = value.replace(/-/g, '+').replace(/_/g, '/');
3
+ return Uint8Array.from(atob(base64), function (character) { return character.charCodeAt(0); });
4
+ }
5
+ function encode(value) {
6
+ var bytes = new Uint8Array(value); var binary = '';
7
+ bytes.forEach(function (byte) { binary += String.fromCharCode(byte); });
8
+ return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
9
+ }
10
+ function serialize(credential) {
11
+ if (!credential) throw new Error('No passkey was selected.');
12
+ var response = { clientDataJSON: encode(credential.response.clientDataJSON) };
13
+ ['attestationObject', 'authenticatorData', 'signature', 'userHandle'].forEach(function (key) {
14
+ if (credential.response[key]) response[key] = encode(credential.response[key]);
15
+ });
16
+ if (credential.response.getTransports) response.transports = credential.response.getTransports();
17
+ return { id: credential.id, rawId: encode(credential.rawId), type: credential.type, response: response,
18
+ clientExtensionResults: credential.getClientExtensionResults(), authenticatorAttachment: credential.authenticatorAttachment };
19
+ }
20
+ function prepare(options) {
21
+ var publicKey = Object.assign({}, options, { challenge: decode(options.challenge) });
22
+ ['allowCredentials', 'excludeCredentials'].forEach(function (field) {
23
+ if (options[field]) publicKey[field] = options[field].map(function (key) { return Object.assign({}, key, { id: decode(key.id) }); });
24
+ });
25
+ if (options.user) publicKey.user = Object.assign({}, options.user, { id: decode(options.user.id) });
26
+ return publicKey;
27
+ }
28
+ export function supported() { return !!(window.isSecureContext && window.PublicKeyCredential && navigator.credentials); }
29
+ export function passkeyError(error) {
30
+ if (error.name === 'NotAllowedError' || error.name === 'AbortError') return 'Passkey request cancelled or timed out. You can try again or use your password.';
31
+ if (error.name === 'InvalidStateError') return 'This passkey is already registered. Choose a different device or passkey.';
32
+ return error.message || 'Could not use this passkey.';
33
+ }
34
+ export async function createPasskey(options) {
35
+ if (!supported()) throw new Error('Passkeys need a supported browser and a secure connection.');
36
+ return serialize(await navigator.credentials.create({ publicKey: prepare(options) }));
37
+ }
38
+ export async function getPasskey(options) {
39
+ if (!supported()) throw new Error('Passkeys need a supported browser and a secure connection.');
40
+ return serialize(await navigator.credentials.get({ publicKey: prepare(options) }));
41
+ }
42
+ export async function request(url, data) {
43
+ var response = await fetch(url, data === undefined ? { credentials: 'same-origin' } : {
44
+ method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data),
45
+ });
46
+ var result = await response.json();
47
+ if (!response.ok) throw new Error(result.error || 'Could not complete this request.');
48
+ return result;
49
+ }
50
+ export async function initPasskeyLogin() {
51
+ var button = document.getElementById('passkey-login');
52
+ if (!button || !supported()) return;
53
+ try {
54
+ var status = await request('/auth/passkeys/status');
55
+ if (status.origin !== location.origin || document.getElementById('mode-row').hidden) return;
56
+ button.hidden = false;
57
+ button.onclick = async function () {
58
+ button.disabled = true;
59
+ var error = document.getElementById('error'); error.textContent = '';
60
+ try {
61
+ var options = await request('/auth/passkeys/login/options', {});
62
+ var response = await getPasskey(options.options);
63
+ await request('/auth/passkeys/login/verify', { response: response });
64
+ location.href = '/';
65
+ } catch (failure) { error.textContent = passkeyError(failure); }
66
+ finally { button.disabled = false; }
67
+ };
68
+ } catch (failure) { /* Password sign-in remains available when passkeys cannot load. */ }
69
+ }
@@ -0,0 +1,57 @@
1
+ import { store } from './store.js';
2
+ import { setSettingsDraft, clearSettingsDraft } from './server-settings-state.js';
3
+
4
+ function render(state) {
5
+ var section = document.getElementById('passkey-policy');
6
+ if (!section) return;
7
+ section.hidden = !state.available;
8
+ var input = section.querySelector('[name=origin]');
9
+ if (state.draft === undefined) input.value = state.origin || '';
10
+ input.disabled = !!state.pending || !!state.locked;
11
+ section.querySelector('[name=currentPassword]').disabled = !!state.pending;
12
+ section.querySelector('button').disabled = !!state.pending || !!state.locked;
13
+ section.querySelector('[data-passkey-editor]').hidden = !!state.locked;
14
+ section.querySelector('[role=status]').textContent = state.error || (state.pending ? 'Saving…' : state.origin ?
15
+ 'Passkeys are available at ' + state.origin + (state.locked ? '. Remove registered passkeys before changing this address.' : '.') : 'Not configured. Password sign-in remains available.');
16
+ }
17
+ export async function loadPasskeyServerSettings() {
18
+ if (!document.getElementById('passkey-policy') || (store.get('passkeyPolicy') || {}).draft !== undefined) return;
19
+ try {
20
+ var response = await fetch('/api/passkey-policy'); var data = await response.json();
21
+ if (!response.ok) throw new Error(data.error || 'Could not load passkey settings.');
22
+ store.set({ passkeyPolicy: data }); render(data);
23
+ } catch (error) {
24
+ var state = Object.assign({}, store.get('passkeyPolicy'), { error: error.message });
25
+ store.set({ passkeyPolicy: state }); render(state);
26
+ }
27
+ }
28
+ export function initPasskeyServerSettings() {
29
+ var anchor = document.getElementById('account-auth-policy');
30
+ if (!anchor || document.getElementById('passkey-policy')) return;
31
+ var section = document.createElement('section'); section.id = 'passkey-policy'; section.className = 'settings-card'; section.hidden = true;
32
+ section.innerHTML = '<h3>Passkey sign-in address</h3><p class="settings-hint">Configure the shared server address for passkey sign-in. Users register and manage their own passkeys in Personal settings → Account. Passwords remain available.</p>' +
33
+ '<form><label class="settings-label" for="passkey-origin">Sign-in address</label><input class="us-text-input" id="passkey-origin" name="origin" type="url" placeholder="https://clay.example.com" required>' +
34
+ '<p class="settings-hint">Use the permanent HTTPS address for this server. Local development can use http://localhost with its port.</p>' +
35
+ '<div data-passkey-editor><label class="settings-label" for="passkey-admin-password">Administrator password</label><input class="us-text-input" id="passkey-admin-password" name="currentPassword" type="password" autocomplete="current-password" required>' +
36
+ '<div class="settings-form-actions"><button class="settings-btn-sm" type="submit">Save passkey address</button></div></div></form><p class="settings-hint" role="status"></p>';
37
+ anchor.after(section);
38
+ var input = section.querySelector('[name=origin]');
39
+ input.oninput = function () {
40
+ var state = Object.assign({}, store.get('passkeyPolicy'), { draft: input.value, error: '' });
41
+ if (input.value === (state.origin || '')) { delete state.draft; clearSettingsDraft('passkeys'); }
42
+ else setSettingsDraft('passkeys', true);
43
+ store.set({ passkeyPolicy: state }); render(state);
44
+ };
45
+ section.querySelector('form').onsubmit = async function (event) {
46
+ event.preventDefault();
47
+ var state = Object.assign({}, store.get('passkeyPolicy'), { pending: true, error: '' });
48
+ store.set({ passkeyPolicy: state }); render(state);
49
+ var password = section.querySelector('[name=currentPassword]');
50
+ try {
51
+ var response = await fetch('/api/passkey-policy', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ origin: input.value, currentPassword: password.value }) });
52
+ var data = await response.json(); if (!response.ok) throw new Error(data.error || 'Could not save passkey settings.');
53
+ state = data; clearSettingsDraft('passkeys');
54
+ } catch (error) { state.pending = false; state.error = error.message; }
55
+ finally { password.value = ''; store.set({ passkeyPolicy: state }); render(state); }
56
+ };
57
+ }
@@ -366,11 +366,12 @@ export function renderDetail(detailEl, entry, handlers) {
366
366
  formatTime(entry.updatedAt || entry.createdAt),
367
367
  ].filter(Boolean).join(" · ");
368
368
  setText(header, ".project-log-byline", byline);
369
- if (handlers && handlers.canDelete && !entry.scheduledResult) {
369
+ if (handlers && handlers.canDelete && !entry.scheduledResult && !entry.deleted) {
370
370
  var deleteButton = document.createElement("button");
371
371
  deleteButton.type = "button";
372
372
  deleteButton.className = "project-log-delete-button";
373
- deleteButton.innerHTML = '<i data-lucide="trash-2"></i><span>Delete log</span>';
373
+ deleteButton.innerHTML = '<i data-lucide="trash-2"></i><span></span>';
374
+ deleteButton.querySelector("span").textContent = handlers.deleteLabel || "Delete log";
374
375
  deleteButton.addEventListener("click", function () { handlers.onDelete(entry); });
375
376
  header.appendChild(deleteButton);
376
377
  }
@@ -10,6 +10,7 @@ function status(id, text) {
10
10
  }
11
11
 
12
12
  function sendSetting(type, values, buttonId, statusId) {
13
+ if (type === 'set_pin' && store.get('serverPinAvailable') !== true) return;
13
14
  var ws = getWs();
14
15
  if (!ws || ws.readyState !== 1) { status(statusId, 'Disconnected. Your change has not been saved.'); return; }
15
16
  var button = document.getElementById(buttonId);
@@ -29,6 +30,15 @@ function sendSetting(type, values, buttonId, statusId) {
29
30
  }, 12000);
30
31
  }
31
32
 
33
+ export function setServerPinAvailability(available) {
34
+ store.set({ serverPinAvailable: !!available });
35
+ var section = document.getElementById('settings-server-pin');
36
+ if (!section) return;
37
+ section.hidden = !available;
38
+ section.querySelectorAll('input,button').forEach(function (element) { element.disabled = !available; });
39
+ if (!available) hidePinForm();
40
+ }
41
+
32
42
  export function handleServerSettingResult(msg) {
33
43
  var type = msg.type.replace(/_result$/, '');
34
44
  var pending = Object.assign({}, store.get('serverSettingsPending') || {});
@@ -1,4 +1,5 @@
1
1
  import { initStorageUsage, loadStorageUsage } from './server-storage-settings.js';
2
+ import { initAccountAuthSettings, loadAccountAuthSettings } from './account-auth-settings.js';
2
3
  // Full-app server administration shell. Page state survives navigation and closing.
3
4
  import { store } from './store.js';
4
5
  import { getWs } from './ws-ref.js';
@@ -7,7 +8,7 @@ import { closeFileViewer } from './filebrowser.js';
7
8
  import { checkAdminAccess, loadAdminSection } from './admin.js';
8
9
  import { openUserSettings } from './user-settings.js';
9
10
  import { initNetworkSettings, loadNetworkSettings } from './server-network-settings.js';
10
- import { initServerControls } from './server-settings-controls.js';
11
+ import { initServerControls, setServerPinAvailability } from './server-settings-controls.js';
11
12
  import { initServerEnvironment, loadSharedEnv } from './server-settings-environment.js';
12
13
  import { renderSettingsNavigation } from './server-settings-navigation.js';
13
14
  import { hasSettingsDrafts, confirmSettingsAction, trapSettingsFocus } from './server-settings-state.js';
@@ -41,6 +42,7 @@ function selectPage(id, focus) {
41
42
  });
42
43
  document.getElementById('settings-nav-dropdown').value = id;
43
44
  if (id === 'network') loadNetworkSettings();
45
+ if (id === 'security') loadAccountAuthSettings();
44
46
  if (id === 'storage') loadStorageUsage();
45
47
  if (id === 'environment') loadSharedEnv();
46
48
  if (id.indexOf('admin-') === 0) {
@@ -64,6 +66,8 @@ async function openSettings() {
64
66
  var status = document.getElementById('settings-access-status');
65
67
  status.textContent = 'Checking administrator access…';
66
68
  store.set({ serverSettingsAuthorized: false });
69
+ var serverPinSection = document.getElementById('settings-server-pin');
70
+ if (serverPinSection) serverPinSection.hidden = true;
67
71
  root().querySelectorAll('.server-settings-section').forEach(function (page) { page.classList.remove('active'); });
68
72
  try {
69
73
  var response = await fetch('/api/me', { credentials: 'same-origin' });
@@ -78,6 +82,7 @@ async function openSettings() {
78
82
  await checkAdminAccess();
79
83
  if (!isSettingsOpen()) return;
80
84
  store.set({ serverSettingsAuthorized: true });
85
+ setServerPinAvailability(!data.multiUser);
81
86
  status.textContent = '';
82
87
  renderSettingsNavigation(!!data.multiUser, selectPage);
83
88
  selectPage(store.get('serverSettingsPage') || 'overview', false);
@@ -111,7 +116,7 @@ function closeSettings() {
111
116
 
112
117
  export function initServerSettings() {
113
118
  if (!root()) return;
114
- initNetworkSettings(); initServerControls(); initServerEnvironment(); initStorageUsage();
119
+ initNetworkSettings(); initServerControls(); initServerEnvironment(); initStorageUsage(); initAccountAuthSettings();
115
120
  document.getElementById('server-settings-btn').addEventListener('click', openSettings);
116
121
  document.getElementById('server-settings-close').onclick = closeSettings;
117
122
  document.getElementById('settings-personal-link').onclick = function () {
@@ -1,4 +1,5 @@
1
1
  import { refreshIcons } from './icons.js';
2
+ import { resetAccountSessions } from './user-settings-sessions.js';
2
3
  import { confirmSettingsAction as showConfirmDialog } from './server-settings-state.js';
3
4
  import { store } from './store.js';
4
5
  import { getWs } from './ws-ref.js';
@@ -10,6 +11,7 @@ import { syncCapsulePreference } from './capsule-preference.js';
10
11
  import { getEmailAccountListCache } from './context-sources.js';
11
12
  function applyProjectMateDmsClass(enabled) { document.body.classList.toggle('project-mate-dms-disabled', !enabled); }
12
13
  export function showPinForm() {
14
+ if (store.get('userPasswordEnabled')) { window.location.href = '/account/password'; return; }
13
15
  var pinForm = document.getElementById('us-pin-form');
14
16
  if (!pinForm) return;
15
17
  if(!pinForm.classList.contains('hidden'))return;
@@ -76,6 +78,7 @@ export function hidePinForm(force) {
76
78
  // --- Account population ---
77
79
 
78
80
  export function populateAccount() {
81
+ resetAccountSessions();
79
82
  fetch('/api/profile').then(function (r) {
80
83
  if (!r.ok) return null;
81
84
  return r.json();
@@ -98,9 +101,19 @@ export function populateAccount() {
98
101
  if (data.role) parts.push(data.role.charAt(0).toUpperCase() + data.role.slice(1));
99
102
  accountSubline.textContent = parts.length ? parts.join(' · ') : 'Local profile';
100
103
  }
101
- store.set({userPinEnabled:!!data.pinEnabled});
104
+ store.set({userPinEnabled:!!data.pinEnabled, userPasswordEnabled:!!data.passwordEnabled});
105
+ var passkeyRow = document.getElementById('us-passkey-row');
106
+ if (passkeyRow) passkeyRow.hidden = !data.passwordEnabled;
107
+ var sessionsRow = document.getElementById('us-sessions-row');
108
+ if (sessionsRow) sessionsRow.hidden = !data.passwordEnabled && !data.pinEnabled;
102
109
  if (pinSetBtnEl) {
103
110
  pinSetBtnEl.textContent = store.get('userPinEnabled') ? 'Change PIN' : 'Set PIN';
111
+ if (data.passwordEnabled) pinSetBtnEl.textContent = 'Change password';
112
+ var row = pinSetBtnEl.closest('.settings-field-row');
113
+ if (row) {
114
+ row.querySelector('.settings-label').textContent = data.passwordEnabled ? 'Password' : 'Account PIN';
115
+ row.querySelector('.settings-hint').textContent = data.passwordEnabled ? 'Change your account password or passphrase.' : 'Manage your account PIN.';
116
+ }
104
117
  }
105
118
  if (pinFormTitle) {
106
119
  pinFormTitle.textContent = store.get('userPinEnabled') ? 'Change PIN' : 'Set PIN';
@@ -0,0 +1,120 @@
1
+ import { store } from './store.js';
2
+
3
+ function describe(value) {
4
+ if (!value || value === 'Earlier session') return 'Unknown device';
5
+ var browser = /Edg(?:e|A|iOS)?\//.test(value) ? 'Edge' : /Firefox\/|FxiOS\//.test(value) ? 'Firefox' : /Chrome\/|CriOS\//.test(value) ? 'Chrome' : /Safari\//.test(value) ? 'Safari' : 'Browser';
6
+ var system = /Android/.test(value) ? 'Android' : /iPhone|iPad/.test(value) ? 'iOS' : /Windows/.test(value) ? 'Windows' : /Macintosh/.test(value) ? 'macOS' : /Linux/.test(value) ? 'Linux' : '';
7
+ return browser + (system ? ' on ' + system : '');
8
+ }
9
+ function state() { return store.get('accountSessionsPanel') || { epoch: 0, open: false, entries: [] }; }
10
+ function update(values) { store.set({ accountSessionsPanel: Object.assign({}, state(), values) }); render(); }
11
+ function render() {
12
+ var panel = document.getElementById('us-sessions-panel'); if (!panel) return;
13
+ var scroll = []; var ancestor = panel.parentElement;
14
+ while (ancestor) { scroll.push({ element: ancestor, top: ancestor.scrollTop }); ancestor = ancestor.parentElement; }
15
+ var current = state(); var toggle = document.getElementById('us-sessions-toggle');
16
+ panel.hidden = !current.open; toggle.setAttribute('aria-expanded', String(!!current.open));
17
+ toggle.textContent = current.open ? 'Hide sessions' : 'Manage sessions'; toggle.disabled = !!current.saving;
18
+ var list = panel.querySelector('[data-list]'); list.replaceChildren();
19
+ var previousGroup = panel.querySelector('[data-earlier]'); if (previousGroup) previousGroup.remove();
20
+ var unknown = current.entries.filter(function (entry) { return entry.unknownDevice && !entry.current; });
21
+ var older = document.createElement('details'); older.dataset.earlier = ''; var summary = document.createElement('summary');
22
+ summary.className = 'settings-label'; summary.textContent = 'Earlier sessions without device details (' + unknown.length + ')'; older.append(summary);
23
+ var explanation = document.createElement('p'); explanation.className = 'settings-hint';
24
+ explanation.textContent = 'These sessions predate device tracking. Their browser and previous activity are unknown; matching labels do not mean they are the same device.';
25
+ older.append(explanation); older.open = !!current.showEarlier;
26
+ older.ontoggle = function () { if (older.isConnected) store.set({ accountSessionsPanel: Object.assign({}, state(), { showEarlier: older.open }) }); };
27
+ var olderList = document.createElement('div'); olderList.setAttribute('role', 'list'); older.append(olderList);
28
+ current.entries.forEach(function (entry) {
29
+ var row = document.createElement('div'); row.className = 'settings-field-row'; row.setAttribute('role', 'listitem');
30
+ var detail = document.createElement('div'); var title = document.createElement('span'); title.className = 'settings-label';
31
+ title.textContent = describe(entry.userAgent) + (entry.current ? ' · This session' : '');
32
+ var hint = document.createElement('p'); hint.className = 'settings-hint';
33
+ hint.textContent = entry.method + (entry.lastSeenAt ? ' · Last active ' + new Date(entry.lastSeenAt).toLocaleString() : ' · Previous activity unknown') +
34
+ (entry.address ? ' · Last observed IP ' + entry.address : '') +
35
+ (entry.expiresAt ? ' · Expires ' + new Date(entry.expiresAt).toLocaleDateString() : ' · Compatibility session');
36
+ detail.append(title, hint);
37
+ var button = document.createElement('button'); button.type = 'button'; button.className = 'settings-btn-sm'; button.textContent = 'Sign out';
38
+ button.dataset.sessionId = entry.id; button.setAttribute('aria-label', 'Sign out ' + describe(entry.userAgent) + (entry.current ? ' current session' : ''));
39
+ button.disabled = !!current.pending;
40
+ button.onclick = function () { choose(entry.id); };
41
+ if (current.target === entry.id) {
42
+ var confirmation = document.createElement('div'); confirmation.className = 'us-session-row-confirm';
43
+ confirmation.setAttribute('role', 'group'); confirmation.setAttribute('aria-label', 'Confirm session sign-out');
44
+ var question = document.createElement('p'); question.className = 'settings-hint';
45
+ question.textContent = entry.current ? 'Sign out this browser? You will need to sign in again.' : 'Sign out this session?';
46
+ var actions = document.createElement('div'); actions.className = 'settings-form-actions';
47
+ var confirm = document.createElement('button'); confirm.type = 'button'; confirm.className = 'settings-btn-sm settings-btn-danger';
48
+ confirm.dataset.rowConfirm = ''; confirm.textContent = current.saving ? 'Signing out…' : 'Sign out'; confirm.disabled = !!current.pending;
49
+ confirm.onclick = function () { request(entry.id); };
50
+ var cancel = document.createElement('button'); cancel.type = 'button'; cancel.className = 'settings-btn-sm';
51
+ cancel.dataset.rowCancel = ''; cancel.textContent = 'Cancel'; cancel.disabled = !!current.pending;
52
+ cancel.onclick = function () { update({ target: null, error: '' }); restoreFocus(entry.id); };
53
+ actions.append(confirm, cancel); confirmation.append(question, actions);
54
+ if (current.error) { var error = document.createElement('p'); error.className = 'settings-status'; error.setAttribute('role', 'alert'); error.textContent = current.error; confirmation.append(error); }
55
+ row.append(detail, confirmation);
56
+ } else row.append(detail, button);
57
+ (entry.unknownDevice && !entry.current ? olderList : list).append(row);
58
+ });
59
+ if (unknown.length) list.after(older);
60
+ panel.querySelector('[data-status]').textContent = current.target && current.target !== 'others' ? '' : current.error || (current.pending ? current.saving ? 'Signing out…' : 'Loading sessions…' : current.message || '');
61
+ panel.querySelector('[data-retry]').hidden = !current.error || !!current.target;
62
+ panel.querySelector('[data-others]').disabled = !!current.pending || !current.entries.some(function (entry) { return !entry.current; });
63
+ panel.querySelector('[data-confirmation]').hidden = current.target !== 'others';
64
+ var selected = current.entries.find(function (entry) { return entry.id === current.target; });
65
+ panel.querySelector('[data-question]').textContent = selected && selected.current ? 'Sign out this browser? Your settings will close and you will need to sign in again.' : 'Sign out the selected sessions? Their open connections will close.';
66
+ panel.querySelector('[data-confirm]').disabled = !!current.pending;
67
+ panel.querySelector('[data-cancel]').disabled = !!current.pending;
68
+ scroll.forEach(function (position) { position.element.scrollTop = position.top; });
69
+ }
70
+ function choose(id) {
71
+ update({ target: id, error: '', message: '' });
72
+ document.querySelector(id === 'others' ? '#us-sessions-panel [data-confirm]' : '#us-sessions-panel [data-row-confirm]').focus({ preventScroll: true });
73
+ }
74
+ function restoreFocus(id) {
75
+ var panel = document.getElementById('us-sessions-panel');
76
+ var button = Array.from(panel.querySelectorAll('[data-session-id]')).find(function (item) { return item.dataset.sessionId === id; });
77
+ (button || (id === 'others' ? panel.querySelector('[data-others]') : document.getElementById('us-sessions-toggle'))).focus({ preventScroll: true });
78
+ }
79
+ async function request(id) {
80
+ var entries = state().entries; var index = entries.findIndex(function (entry) { return entry.id === id; });
81
+ var neighbor = entries[index + 1] || entries[index - 1];
82
+ var epoch = state().epoch + 1;
83
+ update({ epoch: epoch, pending: true, saving: !!id, error: '', message: '' });
84
+ try {
85
+ var response = await fetch('/api/account-sessions', id ? { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id: id }) } : {});
86
+ var data = await response.json();
87
+ if (epoch !== state().epoch) return;
88
+ if (!response.ok) {
89
+ if (response.status === 401 || response.status === 403) update({ entries: [], target: null });
90
+ throw new Error(data.error || 'Could not manage sessions.');
91
+ }
92
+ if (data.signedOut) { window.location.href = '/'; return; }
93
+ update({ entries: data.sessions, target: null, pending: false, saving: false, message: id ? 'Selected sessions signed out.' : '' });
94
+ if (id) restoreFocus(id === 'others' ? id : neighbor && neighbor.id);
95
+ } catch (error) {
96
+ if (epoch === state().epoch) update({ pending: false, saving: false, error: error.message });
97
+ var retry = document.querySelector('#us-sessions-panel [data-row-confirm]');
98
+ if (epoch === state().epoch && id && retry) retry.focus({ preventScroll: true });
99
+ }
100
+ }
101
+ export function resetAccountSessions() {
102
+ update({ epoch: state().epoch + 1, open: false, entries: [], target: null, pending: false, saving: false, error: '', message: '', showEarlier: false });
103
+ }
104
+ export function initAccountSessions() {
105
+ var toggle = document.getElementById('us-sessions-toggle'); if (!toggle || document.getElementById('us-sessions-panel')) return;
106
+ var panel = document.createElement('div'); panel.id = 'us-sessions-panel'; panel.hidden = true;
107
+ panel.innerHTML = '<p class="settings-hint">Each entry represents a browser session. Account sessions expire after 30 days, or after 7 days without use.</p>' +
108
+ '<div data-list role="list" aria-label="Signed-in sessions"></div><div class="settings-form-actions"><button type="button" class="settings-btn-sm" data-others>Sign out other sessions</button></div>' +
109
+ '<div data-confirmation role="group" aria-label="Confirm sign-out" hidden><p class="settings-hint" data-question></p><div class="settings-form-actions"><button type="button" class="settings-btn-sm settings-btn-danger" data-confirm>Sign out</button><button type="button" class="settings-btn-sm" data-cancel>Cancel</button></div></div>' +
110
+ '<p class="settings-status" role="status" data-status></p><button type="button" class="settings-btn-sm" data-retry hidden>Retry</button>';
111
+ toggle.closest('.settings-field-row').after(panel);
112
+ toggle.onclick = function () {
113
+ if (state().open) { resetAccountSessions(); toggle.focus(); }
114
+ else { update({ open: true }); request(); }
115
+ };
116
+ panel.querySelector('[data-others]').onclick = function () { choose('others'); };
117
+ panel.querySelector('[data-retry]').onclick = function () { request(); };
118
+ panel.querySelector('[data-confirm]').onclick = function () { request(state().target); };
119
+ panel.querySelector('[data-cancel]').onclick = function () { var id = state().target; update({ target: null, error: '' }); restoreFocus(id); };
120
+ }
@@ -1,4 +1,5 @@
1
1
  import { initPersonalAppearance } from './user-settings-appearance.js';
2
+ import { initAccountSessions, resetAccountSessions } from './user-settings-sessions.js';
2
3
  import { initPersonalVoice } from './user-settings-voice.js';
3
4
  import { isolateSettingsDialog } from './server-settings-state.js';
4
5
  import { initPersonalProfile } from './user-settings-profile.js';
@@ -29,6 +30,7 @@ var pinCancelBtn = null;
29
30
 
30
31
  export function initUserSettings() {
31
32
  buildUserSettingsLayout();
33
+ initAccountSessions();
32
34
  settingsEl = document.getElementById('user-settings');
33
35
  openBtn = document.getElementById('user-settings-btn');
34
36
  closeBtn = document.getElementById('user-settings-close');
@@ -204,6 +206,7 @@ export function openUserSettings(initialSection) {
204
206
 
205
207
  export function closeUserSettings() {
206
208
  guardUserSettingsLeave(function () {
209
+ resetAccountSessions();
207
210
  finishUserSettingsExtras();
208
211
  settingsEl.classList.add('hidden');
209
212
  var restore=store.get('userSettingsRestore');if(restore)restore();store.set({userSettingsRestore:null});
@@ -7,6 +7,7 @@ function state() { return store.get('youActivity') || {}; }
7
7
  function put(values) { store.set({ youActivity: Object.assign({}, state(), values) }); }
8
8
  function el(id) { return document.getElementById(id); }
9
9
  function describe(item) {
10
+ if (item.action === 'delete') return 'You deleted a memory from Me.';
10
11
  if (item.action === 'archive') return 'Clay stopped using a memory about you.';
11
12
  return (item.action === 'update' ? 'Clay updated: ' : 'Clay remembered: ') + (item.summary || item.title);
12
13
  }
@@ -67,7 +68,7 @@ function render() {
67
68
  var text = document.createElement('span'); text.textContent = describe(item);
68
69
  var time = document.createElement('small'); time.textContent = relativeTime(item.at);
69
70
  button.append(text, time);
70
- button.addEventListener('click', function () { close(); openYou(item.action === 'archive' ? null : item.ref); });
71
+ button.addEventListener('click', function () { close(); openYou(item.action === 'archive' || item.action === 'delete' ? null : item.ref); });
71
72
  list.appendChild(button);
72
73
  });
73
74
  el('you-activity-more').classList.toggle('hidden', state().nextOffset == null);
@@ -76,6 +77,7 @@ export function handleYouActivity(msg) {
76
77
  if (!el('you-activity') || (msg.activity && msg.activity.seen)) return;
77
78
  if (msg.type === 'you_reviewed') { put({ error: '' }); render(); return; }
78
79
  if (msg.type === 'you_changed') {
80
+ if (msg.activity && msg.activity.deleted) { request(); return; }
79
81
  if (msg.activity && msg.activity.error) { put({ error: msg.activity.error }); announce(msg.activity.error); return; }
80
82
  put({ error: '' });
81
83
  if (msg.activity && msg.activity.id && (state().seen || []).indexOf(msg.activity.id) === -1) {
@@ -5,6 +5,8 @@ import { refreshIcons } from './icons.js';
5
5
  import { renderList, renderDetail, categoryLabel } from './project-logs-render.js';
6
6
  import { registerRightWorkbench, claimRightWorkbench, releaseRightWorkbench } from './right-workbench.js';
7
7
 
8
+ var deleteDialog = null;
9
+ var deleteDialogFocus = null;
8
10
  function state() { return store.get('you') || {}; }
9
11
  function put(values) { store.set({ you: Object.assign({}, state(), values) }); }
10
12
  function element(id) { return document.getElementById(id); }
@@ -70,6 +72,37 @@ function requestEntry(ref) {
70
72
  put({ readId: sendYou('you_read', { ref: ref }), selected: ref, scroll: element('you-list').scrollTop });
71
73
  status(state().readId ? 'Loading…' : 'Reconnect to read this memory.');
72
74
  }
75
+ function closeDeleteDialog(force) {
76
+ if (!deleteDialog) return;
77
+ if (!force && state().deleteId) return;
78
+ deleteDialog.classList.add('hidden');
79
+ put({ deleteId: null, deleteEntry: null });
80
+ if (deleteDialogFocus && deleteDialogFocus.isConnected) deleteDialogFocus.focus();
81
+ deleteDialogFocus = null;
82
+ }
83
+ function sendDelete() {
84
+ var entry = state().deleteEntry;
85
+ if (!entry || state().deleteId) return;
86
+ var requestId = sendYou('you_delete', { ref: entry.ref, expectedRevision: entry.revisions });
87
+ put({ deleteId: requestId });
88
+ var confirm = deleteDialog.querySelector('[data-action="delete"]');
89
+ var cancel = deleteDialog.querySelector('[data-action="cancel"]');
90
+ confirm.disabled = true; cancel.disabled = true;
91
+ deleteDialog.querySelector('.you-delete-dialog-status').textContent = requestId ? 'Deleting memory… Stored audit history remains.' : 'Reconnect to delete this memory.';
92
+ if (requestId) { deleteDialog.tabIndex = -1; deleteDialog.focus(); }
93
+ if (!requestId) { put({ deleteId: null }); confirm.disabled = false; cancel.disabled = false; }
94
+ }
95
+ function deleteEntry(entry) {
96
+ if (!entry || state().deleteId || !deleteDialog) return;
97
+ put({ deleteEntry: entry });
98
+ deleteDialogFocus = document.activeElement;
99
+ deleteDialog.querySelector('.you-delete-dialog-title').textContent = entry.title || 'Untitled memory';
100
+ deleteDialog.querySelector('.you-delete-dialog-status').textContent = '';
101
+ deleteDialog.querySelector('[data-action="delete"]').disabled = false;
102
+ deleteDialog.querySelector('[data-action="cancel"]').disabled = false;
103
+ deleteDialog.classList.remove('hidden');
104
+ deleteDialog.querySelector('[data-action="cancel"]').focus();
105
+ }
73
106
  function windowState(wide, fullscreen) {
74
107
  put({ wide: wide, fullscreen: fullscreen });
75
108
  element('you-panel').classList.toggle('project-logs-wide', wide && !fullscreen);
@@ -117,6 +150,25 @@ function ensurePanel() {
117
150
  '<div id="you-list" class="project-logs-list" role="list"></div><main id="you-detail" class="project-logs-detail hidden"></main>' +
118
151
  '<button id="you-more" type="button" class="you-more hidden">Load more</button>';
119
152
  document.getElementById('main-panels').appendChild(panel);
153
+ var dialog = document.createElement('div');
154
+ dialog.className = 'project-log-delete-dialog you-delete-dialog hidden';
155
+ dialog.setAttribute('role', 'dialog'); dialog.setAttribute('aria-modal', 'true'); dialog.setAttribute('aria-labelledby', 'you-delete-heading'); dialog.tabIndex = -1;
156
+ dialog.innerHTML = '<div class="project-log-delete-dialog-card"><h2 id="you-delete-heading">Delete this memory?</h2><p>This removes the memory from Me immediately. Clay’s audit history will retain that it was deleted.</p><p class="project-log-delete-dialog-title you-delete-dialog-title"></p><p class="project-log-delete-dialog-status you-delete-dialog-status" role="status"></p><div class="project-log-delete-dialog-actions"><button type="button" data-action="cancel">Cancel</button><button type="button" class="danger" data-action="delete">Delete memory</button></div></div>';
157
+ panel.appendChild(dialog); deleteDialog = dialog;
158
+ dialog.querySelector('[data-action="cancel"]').addEventListener('click', function () { closeDeleteDialog(false); });
159
+ dialog.querySelector('[data-action="delete"]').addEventListener('click', sendDelete);
160
+ dialog.addEventListener('keydown', function (event) {
161
+ event.stopPropagation();
162
+ if (event.key === 'Escape') { event.preventDefault(); closeDeleteDialog(false); return; }
163
+ if (event.key === 'Tab') {
164
+ var buttons = Array.from(dialog.querySelectorAll('button:not(:disabled)'));
165
+ if (!buttons.length) { event.preventDefault(); dialog.focus(); return; }
166
+ var first = buttons[0]; var last = buttons[buttons.length - 1];
167
+ if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last.focus(); }
168
+ else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first.focus(); }
169
+ }
170
+ });
171
+ dialog.addEventListener('click', function (event) { if (event.target === dialog) closeDeleteDialog(false); });
120
172
  element('you-close').addEventListener('click', closeYou);
121
173
  element('you-back').addEventListener('click', showList);
122
174
  element('you-wide').addEventListener('click', function () { windowState(!state().wide, false); });
@@ -159,6 +211,7 @@ export function openYou(ref) {
159
211
  if (ref) element('you-close').focus(); else element('you-search').focus();
160
212
  }
161
213
  export function closeYou() {
214
+ closeDeleteDialog(true);
162
215
  releaseRightWorkbench('you');
163
216
  if (!state().open) return;
164
217
  element('you-panel').classList.add('hidden');
@@ -172,6 +225,17 @@ export function closeYou() {
172
225
  export function handleYouMessage(msg) {
173
226
  if (msg.type === 'you_reviewed') { if (state().open) { requestList(); if (state().selected) requestEntry(state().selected); } return; }
174
227
  if (msg.type === 'you_changed') {
228
+ if (msg.activity && msg.activity.deleted) {
229
+ requestAttention();
230
+ if (state().selected === msg.activity.ref) {
231
+ if (state().deleteEntry && state().deleteEntry.ref === msg.activity.ref) closeDeleteDialog(true);
232
+ put({ readId: null, renderedRef: null, renderedRevision: null });
233
+ showList();
234
+ element('you-search').focus();
235
+ status('This memory was deleted and is no longer available.');
236
+ } else if (state().open) requestList();
237
+ return;
238
+ }
175
239
  requestAttention();
176
240
  if (msg.activity && msg.activity.seen) { if (state().open && !state().selected) requestList(); return; }
177
241
  if (msg.activity && msg.activity.error) { status(msg.activity.error); return; }
@@ -179,7 +243,7 @@ export function handleYouMessage(msg) {
179
243
  return;
180
244
  }
181
245
  var s = state();
182
- var key = { you_attention: 'attentionId', you_seen: 'seenId', you_list: 'listId', you_read: 'readId', you_comment: 'commentId', you_retry: 'retryId' }[msg.operation];
246
+ var key = { you_attention: 'attentionId', you_seen: 'seenId', you_list: 'listId', you_read: 'readId', you_comment: 'commentId', you_retry: 'retryId', you_delete: 'deleteId' }[msg.operation];
183
247
  if (!key || !s[key] || s[key] !== msg.requestId) return;
184
248
  var cleared = {}; cleared[key] = null; put(cleared);
185
249
  if (msg.operation === 'you_comment' && element('you-comment-input')) element('you-comment-input').form.querySelector('button').disabled = false;
@@ -189,10 +253,25 @@ export function handleYouMessage(msg) {
189
253
  return;
190
254
  }
191
255
  if (!state().open && msg.operation === 'you_read') return;
192
- if (!msg.ok) { if (msg.operation === 'you_read') showList(); status(msg.error || 'Clay could not complete this request.'); return; }
256
+ if (!msg.ok) {
257
+ if (msg.operation === 'you_read') showList();
258
+ if (msg.operation === 'you_delete' && deleteDialog) {
259
+ deleteDialog.querySelector('[data-action="delete"]').disabled = false;
260
+ deleteDialog.querySelector('[data-action="cancel"]').disabled = false;
261
+ deleteDialog.querySelector('.you-delete-dialog-status').textContent = msg.error || 'The memory could not be deleted. Try again.';
262
+ }
263
+ status(msg.error || 'Clay could not complete this request.'); return;
264
+ }
193
265
  status('');
194
266
  var result = msg.result;
195
- if (msg.operation === 'you_list') {
267
+ if (msg.operation === 'you_delete') {
268
+ closeDeleteDialog(true);
269
+ put({ deleteEntry: null });
270
+ showList();
271
+ element('you-search').focus();
272
+ requestAttention();
273
+ status('Memory deleted. Stored audit history remains.');
274
+ } else if (msg.operation === 'you_list') {
196
275
  var entries = s.append ? (s.entries || []).concat(result.items) : result.items;
197
276
  put({ entries: entries, nextOffset: result.nextOffset });
198
277
  var emptyMessage = state().query || state().category
@@ -210,7 +289,7 @@ export function handleYouMessage(msg) {
210
289
  element('you-detail').classList.remove('hidden'); element('you-back').classList.remove('hidden');
211
290
  var previousInput = element('you-comment-input');
212
291
  var draft = previousInput && previousInput.dataset.ref === result.ref ? previousInput.value : '';
213
- renderDetail(element('you-detail'), result, { commentPrefix: 'you', commentReviewer: 'Clay', onComment: function (ref, body, feedback, input) {
292
+ renderDetail(element('you-detail'), result, { commentPrefix: 'you', commentReviewer: 'Clay', canDelete: !result.deleted, deleteLabel: 'Delete memory', onDelete: deleteEntry, onComment: function (ref, body, feedback, input) {
214
293
  if (state().commentId) return;
215
294
  put({ commentId: sendYou('you_comment', { ref: ref, body: body }), commentRef: ref });
216
295
  input.form.querySelector('button').disabled = !!state().commentId;
@@ -236,13 +315,19 @@ export function initYou() {
236
315
  element('you-button').addEventListener('click', function () { openYou(); });
237
316
  store.subscribe(function (next, previous) {
238
317
  if (next.currentSlug !== previous.currentSlug || next.myUserId !== previous.myUserId) {
239
- closeYou(); clearTimeout(state().searchTimer);
318
+ closeDeleteDialog(true); closeYou(); clearTimeout(state().searchTimer);
240
319
  if (next.myUserId !== previous.myUserId) { if (element('you-panel')) element('you-panel').remove(); put({ entries: [], query: '', category: '', attentionVersion: 0, acknowledged: null, attentionId: null, seenId: null }); showAttention({ unread: 0, version: 0 }); }
241
- put({ listId: null, readId: null, commentId: null, retryId: null, seenId: null });
320
+ put({ listId: null, readId: null, commentId: null, retryId: null, seenId: null, deleteId: null, deleteEntry: null });
242
321
  if (element('you-comment-input')) element('you-comment-input').form.querySelector('button').disabled = false;
243
322
  }
244
323
  if (previous.connected && !next.connected) {
245
324
  put({ commentId: null, seenId: null, attentionId: null });
325
+ if (state().deleteId && deleteDialog) {
326
+ put({ deleteId: null });
327
+ deleteDialog.querySelector('[data-action="delete"]').disabled = false;
328
+ deleteDialog.querySelector('[data-action="cancel"]').disabled = false;
329
+ deleteDialog.querySelector('.you-delete-dialog-status').textContent = 'Disconnected. Reconnect before deleting. Stored audit history remains.';
330
+ }
246
331
  if (element('you-comment-input')) element('you-comment-input').form.querySelector('button').disabled = false;
247
332
  if (state().open) status('Disconnected. Reconnect before sending or refreshing.');
248
333
  }
@@ -47,6 +47,7 @@
47
47
  @import url("css/stt.css");
48
48
  @import url("css/profile.css");
49
49
  @import url("css/user-settings.css?v=20260828-type1");
50
+ @import url("css/user-settings-sessions.css");
50
51
  @import url("css/settings-ui.css");
51
52
  @import url("css/admin.css");
52
53
  @import url("css/session-search.css");