clay-server 5.1.1-beta.1 → 5.2.0-beta.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/account-auth-policy.js +21 -0
- package/lib/account-auth-socket.js +29 -0
- package/lib/account-auth-throttle.js +44 -0
- package/lib/account-auth-transport.js +48 -0
- package/lib/account-login-page.js +28 -0
- package/lib/account-passkey-challenges.js +31 -0
- package/lib/account-passkey-page.js +89 -0
- package/lib/account-passkey-policy.js +39 -0
- package/lib/account-password-page.js +69 -0
- package/lib/account-passwords.js +36 -0
- package/lib/account-remembered.js +77 -0
- package/lib/account-session-page.js +51 -0
- package/lib/account-session-store.js +82 -0
- package/lib/daemon.js +2 -0
- package/lib/migrate-single-user.js +3 -23
- package/lib/project-connection.js +1 -1
- package/lib/project-server-settings.js +1 -0
- package/lib/project-sessions.js +3 -1
- package/lib/project-user-knowledge.js +2 -1
- package/lib/public/app.js +2 -0
- package/lib/public/auth/account-login.css +98 -0
- package/lib/public/auth/account-login.js +104 -0
- package/lib/public/css/command-palette.css +12 -33
- package/lib/public/css/user-settings-sessions.css +42 -0
- package/lib/public/index.html +17 -5
- package/lib/public/modules/account-auth-settings.js +71 -0
- package/lib/public/modules/account-transport-settings.js +51 -0
- package/lib/public/modules/app-header.js +3 -2
- package/lib/public/modules/app-messages.js +16 -4
- package/lib/public/modules/app-rendering.js +4 -3
- package/lib/public/modules/chat-render-runtime.js +8 -0
- package/lib/public/modules/clay-log-links.js +23 -0
- package/lib/public/modules/command-palette.js +94 -5
- package/lib/public/modules/markdown.js +1 -1
- package/lib/public/modules/passkey-browser.js +69 -0
- package/lib/public/modules/passkey-server-settings.js +57 -0
- package/lib/public/modules/project-logs-render.js +3 -2
- package/lib/public/modules/server-settings-controls.js +10 -0
- package/lib/public/modules/server-settings.js +7 -2
- package/lib/public/modules/user-settings-account.js +14 -1
- package/lib/public/modules/user-settings-sessions.js +120 -0
- package/lib/public/modules/user-settings.js +3 -0
- package/lib/public/modules/you-activity.js +3 -1
- package/lib/public/modules/you.js +91 -6
- package/lib/public/style.css +1 -0
- package/lib/server-account-passkeys.js +186 -0
- package/lib/server-account-passwords.js +127 -0
- package/lib/server-account-security.js +53 -0
- package/lib/server-auth.js +107 -79
- package/lib/server-home-chat.js +3 -3
- package/lib/server-home-clay-session-links.js +17 -9
- package/lib/server-settings.js +2 -1
- package/lib/server.js +3 -1
- package/lib/sessions.js +3 -1
- package/lib/user-knowledge-service.js +23 -1
- package/lib/users-auth.js +38 -3
- package/lib/users-create.js +97 -0
- package/lib/users-server-pin.js +37 -0
- package/lib/users.js +11 -84
- package/lib/workspace-query-service.js +13 -2
- package/package.json +2 -1
|
@@ -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) {
|
|
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 === '
|
|
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
|
}
|
package/lib/public/style.css
CHANGED
|
@@ -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");
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
var crypto = require('crypto');
|
|
2
|
+
var webauthn = require('@simplewebauthn/server');
|
|
3
|
+
var passwords = require('./account-passwords');
|
|
4
|
+
var policyStore = require('./account-passkey-policy');
|
|
5
|
+
|
|
6
|
+
function attachAccountPasskeys(ctx) {
|
|
7
|
+
var users = ctx.users;
|
|
8
|
+
var http = ctx.http;
|
|
9
|
+
var challenges = require('./account-passkey-challenges').attachChallenges(ctx);
|
|
10
|
+
function policy() { return policyStore.configured(users.loadUsers()); }
|
|
11
|
+
function eligible(user) { return user && user.passwordHash && !user.mustChangePin; }
|
|
12
|
+
function current(req, original) {
|
|
13
|
+
var user = ctx.getUser(req);
|
|
14
|
+
if (!eligible(user) || original && (user.id !== original.id || user.passwordHash !== original.passwordHash)) {
|
|
15
|
+
throw new Error('Your account credentials changed. Sign in again.');
|
|
16
|
+
}
|
|
17
|
+
return user;
|
|
18
|
+
}
|
|
19
|
+
async function reauthenticate(req, data, admin) {
|
|
20
|
+
var user = current(req);
|
|
21
|
+
if (admin && user.role !== 'admin') throw new Error('Administrator access required.');
|
|
22
|
+
http.throttle(req, user.username);
|
|
23
|
+
if (!await passwords.verify(data.currentPassword, user.passwordHash)) throw new Error('Current password is incorrect.');
|
|
24
|
+
user = current(req, user);
|
|
25
|
+
if (admin && user.role !== 'admin') throw new Error('Administrator access required.');
|
|
26
|
+
http.clearAttempts(req, user.username);
|
|
27
|
+
return user;
|
|
28
|
+
}
|
|
29
|
+
function samePolicy(expected) {
|
|
30
|
+
var now = policy();
|
|
31
|
+
if (!now || now.origin !== expected.origin || now.rpID !== expected.rpID) throw new Error('Passkey settings changed. Please retry.');
|
|
32
|
+
}
|
|
33
|
+
function label(value) {
|
|
34
|
+
if (typeof value !== 'string' || !value.trim() || Array.from(value.trim()).length > 80) throw new Error('Give this passkey a name of 1–80 characters.');
|
|
35
|
+
return value.trim();
|
|
36
|
+
}
|
|
37
|
+
function responseData(data) {
|
|
38
|
+
var response = data.response;
|
|
39
|
+
if (!response || typeof response.id !== 'string' || !response.response || typeof response.response.clientDataJSON !== 'string') throw new Error('Invalid passkey response.');
|
|
40
|
+
var client = JSON.parse(Buffer.from(response.response.clientDataJSON, 'base64url').toString('utf8'));
|
|
41
|
+
if (client.crossOrigin || client.topOrigin) throw new Error('Open Clay directly to use a passkey.');
|
|
42
|
+
return response;
|
|
43
|
+
}
|
|
44
|
+
function list(user) {
|
|
45
|
+
return (user.passkeys || []).map(function (key) { return { id: key.id, name: key.name, createdAt: key.createdAt, lastUsedAt: key.lastUsedAt || null }; });
|
|
46
|
+
}
|
|
47
|
+
async function registerOptions(req, res, data, config) {
|
|
48
|
+
var user = await reauthenticate(req, data);
|
|
49
|
+
var name = label(data.name);
|
|
50
|
+
if ((user.passkeys || []).length >= 10) throw new Error('You can register up to 10 passkeys.');
|
|
51
|
+
var handle = user.passkeyUserHandle || crypto.randomBytes(32).toString('base64url');
|
|
52
|
+
var options = await webauthn.generateRegistrationOptions({
|
|
53
|
+
rpName: 'Clay', rpID: config.rpID, userID: Buffer.from(handle, 'base64url'), userName: user.username,
|
|
54
|
+
attestationType: 'none', timeout: 60000, supportedAlgorithmIDs: [-7, -257],
|
|
55
|
+
authenticatorSelection: { residentKey: 'required', userVerification: 'required' },
|
|
56
|
+
excludeCredentials: (user.passkeys || []).map(function (key) { return { id: key.id, transports: key.transports }; }),
|
|
57
|
+
});
|
|
58
|
+
current(req, user); samePolicy(config);
|
|
59
|
+
var cookie = challenges.issue(req, config, { purpose: 'register', challenge: options.challenge, userId: user.id,
|
|
60
|
+
passwordHash: user.passwordHash, session: ctx.sessionToken(req), handle: handle, name: name });
|
|
61
|
+
http.reply(res, 200, { options: options }, cookie);
|
|
62
|
+
}
|
|
63
|
+
async function registerVerify(req, res, data, config) {
|
|
64
|
+
var proof = challenges.take(req, config, 'register');
|
|
65
|
+
var user = current(req, { id: proof.userId, passwordHash: proof.passwordHash });
|
|
66
|
+
if (ctx.sessionToken(req) !== proof.session) throw new Error('Use the same signed-in session to register this passkey.');
|
|
67
|
+
var response = responseData(data);
|
|
68
|
+
var result;
|
|
69
|
+
try {
|
|
70
|
+
result = await webauthn.verifyRegistrationResponse({ response: response, expectedChallenge: proof.challenge,
|
|
71
|
+
expectedOrigin: config.origin, expectedRPID: config.rpID, requireUserVerification: true, supportedAlgorithmIDs: [-7, -257] });
|
|
72
|
+
} catch (error) { throw new Error('Could not verify this passkey. Please try registering again.'); }
|
|
73
|
+
if (!result.verified) throw new Error('Could not verify this passkey.');
|
|
74
|
+
current(req, user); samePolicy(config);
|
|
75
|
+
if (proof.until <= Date.now()) throw new Error('Registration expired. Please try again.');
|
|
76
|
+
var state = users.loadUsers();
|
|
77
|
+
user = state.users.find(function (entry) { return entry.id === user.id; });
|
|
78
|
+
var info = result.registrationInfo; var key = info.credential;
|
|
79
|
+
if (key.id !== response.id || state.users.some(function (entry) { return (entry.passkeys || []).some(function (saved) { return saved.id === key.id; }); })) {
|
|
80
|
+
throw new Error('This passkey is already registered or has an invalid identifier.');
|
|
81
|
+
}
|
|
82
|
+
if ((user.passkeys || []).length >= 10 || user.passkeyUserHandle && user.passkeyUserHandle !== proof.handle) throw new Error('Passkeys changed. Please register again.');
|
|
83
|
+
user.passkeyUserHandle = proof.handle;
|
|
84
|
+
user.passkeys = (user.passkeys || []).concat([{ id: key.id, publicKey: Buffer.from(key.publicKey).toString('base64url'),
|
|
85
|
+
counter: key.counter, transports: key.transports, name: proof.name, createdAt: new Date().toISOString(),
|
|
86
|
+
deviceType: info.credentialDeviceType, backedUp: info.credentialBackedUp }]);
|
|
87
|
+
users.saveUsers(state);
|
|
88
|
+
http.reply(res, 200, { ok: true, passkeys: list(user) }, challenges.cookie('', config.origin));
|
|
89
|
+
}
|
|
90
|
+
async function loginOptions(req, res, config) {
|
|
91
|
+
http.throttle(req, 'passkey:' + ctx.clientAddress(req));
|
|
92
|
+
var options = await webauthn.generateAuthenticationOptions({ rpID: config.rpID, userVerification: 'required', timeout: 60000 });
|
|
93
|
+
samePolicy(config);
|
|
94
|
+
http.reply(res, 200, { options: options }, challenges.issue(req, config, { purpose: 'login', challenge: options.challenge }));
|
|
95
|
+
}
|
|
96
|
+
async function loginVerify(req, res, data, config) {
|
|
97
|
+
var proof = challenges.take(req, config, 'login');
|
|
98
|
+
var response = responseData(data);
|
|
99
|
+
var state = users.loadUsers();
|
|
100
|
+
var user = state.users.find(function (entry) { return (entry.passkeys || []).some(function (key) { return key.id === response.id; }); });
|
|
101
|
+
if (!eligible(user) || response.response.userHandle !== user.passkeyUserHandle) throw new Error('Could not sign in with this passkey.');
|
|
102
|
+
var key = user.passkeys.find(function (entry) { return entry.id === response.id; });
|
|
103
|
+
var result;
|
|
104
|
+
try {
|
|
105
|
+
result = await webauthn.verifyAuthenticationResponse({ response: response, expectedChallenge: proof.challenge,
|
|
106
|
+
expectedOrigin: config.origin, expectedRPID: config.rpID, requireUserVerification: true,
|
|
107
|
+
credential: { id: key.id, publicKey: new Uint8Array(Buffer.from(key.publicKey, 'base64url')), counter: key.counter, transports: key.transports } });
|
|
108
|
+
} catch (error) { throw new Error('Could not sign in with this passkey. Try again or use your password.'); }
|
|
109
|
+
if (!result.verified || result.authenticationInfo.credentialDeviceType !== key.deviceType) throw new Error('Could not sign in with this passkey.');
|
|
110
|
+
samePolicy(config);
|
|
111
|
+
state = users.loadUsers();
|
|
112
|
+
var latest = state.users.find(function (entry) { return entry.id === user.id; });
|
|
113
|
+
var latestKey = latest && (latest.passkeys || []).find(function (entry) { return entry.id === key.id; });
|
|
114
|
+
if (!eligible(latest) || latest.passwordHash !== user.passwordHash || !latestKey || latestKey.publicKey !== key.publicKey ||
|
|
115
|
+
latestKey.counter !== key.counter || proof.until <= Date.now()) throw new Error('Account credentials changed. Please try again.');
|
|
116
|
+
latestKey.counter = result.authenticationInfo.newCounter;
|
|
117
|
+
latestKey.backedUp = result.authenticationInfo.credentialBackedUp;
|
|
118
|
+
latestKey.lastUsedAt = new Date().toISOString();
|
|
119
|
+
users.saveUsers(state);
|
|
120
|
+
var session = ctx.createSession(latest.id, latest.passwordHash, key.id, config.origin.indexOf('https:') === 0, req);
|
|
121
|
+
http.clearAttempts(req, 'passkey:' + ctx.clientAddress(req));
|
|
122
|
+
http.reply(res, 200, { ok: true }, session.cookies.concat([challenges.cookie('', config.origin)]));
|
|
123
|
+
}
|
|
124
|
+
async function manage(req, res, data, config, remove) {
|
|
125
|
+
var user = await reauthenticate(req, data);
|
|
126
|
+
samePolicy(config);
|
|
127
|
+
var state = users.loadUsers();
|
|
128
|
+
user = state.users.find(function (entry) { return entry.id === user.id; });
|
|
129
|
+
var key = (user.passkeys || []).find(function (entry) { return entry.id === data.id; });
|
|
130
|
+
if (!key) throw new Error('Passkey not found.');
|
|
131
|
+
if (remove) user.passkeys = user.passkeys.filter(function (entry) { return entry.id !== key.id; });
|
|
132
|
+
else key.name = label(data.name);
|
|
133
|
+
users.saveUsers(state);
|
|
134
|
+
// Removing a passkey also invalidates its sessions through the live session gate.
|
|
135
|
+
var cookie = remove ? ctx.createSession(user.id, user.passwordHash, null, config.origin.indexOf('https:') === 0, req).cookies : undefined;
|
|
136
|
+
http.reply(res, 200, { ok: true, passkeys: list(user) }, cookie);
|
|
137
|
+
}
|
|
138
|
+
function handleRequest(req, res, url) {
|
|
139
|
+
if (url === '/auth/passkey-browser.js' && req.method === 'GET') {
|
|
140
|
+
res.writeHead(200, { 'Content-Type': 'text/javascript; charset=utf-8', 'Cache-Control': 'no-cache' });
|
|
141
|
+
res.end(require('fs').readFileSync(require('path').join(__dirname, 'public/modules/passkey-browser.js'), 'utf8')); return true;
|
|
142
|
+
}
|
|
143
|
+
if (url === '/auth/logout' && req.method === 'POST') challenges.clear(req);
|
|
144
|
+
if (url === '/account/passkeys' && req.method === 'GET') {
|
|
145
|
+
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store', 'Content-Security-Policy': "frame-ancestors 'none'" });
|
|
146
|
+
res.end(eligible(ctx.getUser(req)) ? require('./account-passkey-page').render() : require('./account-password-page').render());
|
|
147
|
+
return true;
|
|
148
|
+
}
|
|
149
|
+
if (url === '/auth/passkeys/status' && req.method === 'GET') { http.reply(res, 200, { origin: policy() && policy().origin }); return true; }
|
|
150
|
+
if (url === '/api/passkey-policy') {
|
|
151
|
+
var admin = ctx.getUser(req);
|
|
152
|
+
if (!admin || admin.role !== 'admin') { http.reply(res, 403, { error: 'Administrator access required.' }); return true; }
|
|
153
|
+
if (req.method === 'GET') {
|
|
154
|
+
var state = users.loadUsers();
|
|
155
|
+
http.reply(res, 200, { available: state.accountAuthMethod === 'password', origin: policy() && policy().origin, locked: policyStore.count(state) > 0 }); return true;
|
|
156
|
+
}
|
|
157
|
+
if (req.method !== 'PUT') { http.reply(res, 405, { error: 'Method not allowed.' }); return true; }
|
|
158
|
+
http.read(req, res, async function (data) {
|
|
159
|
+
if (req.headers['sec-fetch-site'] === 'cross-site') throw new Error('Open Clay directly to change passkey settings.');
|
|
160
|
+
await reauthenticate(req, data, true);
|
|
161
|
+
var saved = policyStore.save(users, data.origin);
|
|
162
|
+
http.reply(res, 200, { ok: true, available: true, origin: saved.origin, locked: policyStore.count(users.loadUsers()) > 0 });
|
|
163
|
+
}); return true;
|
|
164
|
+
}
|
|
165
|
+
if (url.indexOf('/api/passkeys') !== 0 && url.indexOf('/auth/passkeys/') !== 0) return false;
|
|
166
|
+
var config = policy();
|
|
167
|
+
if (!config) { http.reply(res, 409, { error: 'Your administrator needs to configure passkeys first.' }); return true; }
|
|
168
|
+
if (url === '/api/passkeys' && req.method === 'GET') {
|
|
169
|
+
var user = ctx.getUser(req);
|
|
170
|
+
http.reply(res, eligible(user) ? 200 : 401, eligible(user) ? { origin: config.origin, passkeys: list(user) } : { error: 'Sign in to manage passkeys.' }); return true;
|
|
171
|
+
}
|
|
172
|
+
if (req.method !== 'POST') { http.reply(res, 405, { error: 'Method not allowed.' }); return true; }
|
|
173
|
+
if (req.headers.origin !== config.origin || req.headers['sec-fetch-site'] === 'cross-site') { http.reply(res, 403, { error: 'Use the configured Clay address to use passkeys.' }); return true; }
|
|
174
|
+
http.read(req, res, async function (data) {
|
|
175
|
+
if (url === '/api/passkeys/register/options') return registerOptions(req, res, data, config);
|
|
176
|
+
if (url === '/api/passkeys/register/verify') return registerVerify(req, res, data, config);
|
|
177
|
+
if (url === '/auth/passkeys/login/options') return loginOptions(req, res, config);
|
|
178
|
+
if (url === '/auth/passkeys/login/verify') return loginVerify(req, res, data, config);
|
|
179
|
+
if (url === '/api/passkeys/remove' || url === '/api/passkeys/rename') return manage(req, res, data, config, url.endsWith('/remove'));
|
|
180
|
+
http.reply(res, 404, { error: 'Passkey endpoint not found.' });
|
|
181
|
+
}, 32768);
|
|
182
|
+
return true;
|
|
183
|
+
}
|
|
184
|
+
return { handleRequest: handleRequest };
|
|
185
|
+
}
|
|
186
|
+
module.exports = { attachAccountPasskeys: attachAccountPasskeys };
|