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,82 @@
1
+ var fs = require('fs');
2
+ var path = require('path');
3
+ var crypto = require('crypto');
4
+ var DAY = 86400000;
5
+ function digest(value) { return crypto.createHash('sha256').update(value).digest('hex'); }
6
+ function attachSessionStore(ctx) {
7
+ var records = {};
8
+ function save(next) {
9
+ fs.mkdirSync(path.dirname(ctx.file), { recursive: true });
10
+ fs.writeFileSync(ctx.file + '.tmp', JSON.stringify({ version: 2, sessions: next }), { mode: 0o600 });
11
+ fs.renameSync(ctx.file + '.tmp', ctx.file);
12
+ records = next;
13
+ }
14
+ function metadata(userId, managed, req) {
15
+ var now = Date.now();
16
+ return { id: crypto.randomBytes(16).toString('hex'), userId: userId, createdAt: now, lastSeenAt: now,
17
+ expiresAt: managed ? now + 30 * DAY : null, idleMs: managed ? 7 * DAY : null,
18
+ userAgent: req && String(req.headers['user-agent'] || '').slice(0, 256) || '',
19
+ address: req ? ctx.clientAddress(req) : '', observedAt: req ? now : null };
20
+ }
21
+ try {
22
+ var data = JSON.parse(fs.readFileSync(ctx.file, 'utf8'));
23
+ if (data.version === 2 && data.sessions && typeof data.sessions === 'object') records = data.sessions;
24
+ else if (data && typeof data === 'object' && !Array.isArray(data)) {
25
+ var migrated = {};
26
+ Object.keys(data).forEach(function (token) {
27
+ var old = data[token]; var userId = typeof old === 'string' ? old : old && old.userId;
28
+ if (!userId) return;
29
+ var entry = metadata(userId, ctx.managed()); entry.migrated = true;
30
+ if (old.credential) entry.credentialVersion = digest(old.credential);
31
+ if (old.passkeyId) entry.passkeyId = old.passkeyId;
32
+ migrated[digest(token)] = entry;
33
+ });
34
+ save(migrated);
35
+ }
36
+ } catch (error) {
37
+ if (error.code !== 'ENOENT') throw new Error('Could not load or migrate account sessions. Existing session data was not replaced.');
38
+ }
39
+ function live(entry) {
40
+ return !!entry && (!entry.expiresAt || Date.now() < entry.expiresAt) && (!entry.idleMs || Date.now() < entry.lastSeenAt + entry.idleMs);
41
+ }
42
+ function create(userId, credential, passkeyId, req, previousToken) {
43
+ var token = crypto.randomBytes(32).toString('hex');
44
+ var entry = metadata(userId, ctx.managed(), req);
45
+ if (credential) entry.credentialVersion = digest(credential);
46
+ if (passkeyId) entry.passkeyId = passkeyId;
47
+ var next = {};
48
+ var previousKey = previousToken ? digest(previousToken) : '';
49
+ Object.keys(records).forEach(function (key) {
50
+ if (live(records[key]) && !(key === previousKey && records[key].userId === userId)) next[key] = records[key];
51
+ });
52
+ next[digest(token)] = entry; save(next);
53
+ return { token: token, entry: entry };
54
+ }
55
+ function get(token, touch, req) {
56
+ if (typeof token !== 'string' || token.length > 256) return null;
57
+ var key = digest(token); var entry = records[key];
58
+ if (!live(entry)) return null;
59
+ if (touch && (!entry.observedAt || Date.now() - entry.lastSeenAt >= 60000)) {
60
+ var next = Object.assign({}, records); next[key] = Object.assign({}, entry, { lastSeenAt: Date.now() });
61
+ if (req) {
62
+ next[key].observedAt = Date.now();
63
+ next[key].userAgent = String(req.headers['user-agent'] || '').slice(0, 256);
64
+ next[key].address = ctx.clientAddress(req);
65
+ }
66
+ try { save(next); entry = next[key]; } catch (error) { /* Keep the earlier durable idle deadline on write failure. */ }
67
+ }
68
+ return entry;
69
+ }
70
+ function remove(test) {
71
+ var next = {}; var changed = false;
72
+ Object.keys(records).forEach(function (key) { if (test(records[key], key)) changed = true; else next[key] = records[key]; });
73
+ if (changed) save(next);
74
+ }
75
+ function list(userId) { return Object.values(records).filter(function (entry) { return entry.userId === userId && live(entry); }); }
76
+ return { create: create, get: get, list: list,
77
+ revokeUser: function (id) { remove(function (entry) { return entry.userId === id; }); },
78
+ revokeToken: function (token) { if (token) remove(function (entry, key) { return key === digest(token); }); },
79
+ revoke: function (userId, id, except) { remove(function (entry) { return entry.userId === userId && (id === 'others' ? entry.id !== except : entry.id === id); }); },
80
+ };
81
+ }
82
+ module.exports = { attachSessionStore: attachSessionStore, digest: digest };
package/lib/daemon.js CHANGED
@@ -826,6 +826,7 @@ var relay = createServer({
826
826
  return { ok: true, updateChannel: config.updateChannel };
827
827
  },
828
828
  onSetPin: function (pin) {
829
+ if (usersModule.isMultiUser()) return { ok: false, error: 'Server PIN is unavailable while account authentication is enabled.' };
829
830
  saveDaemonSetting(config, "pinHash", pin ? generateAuthToken(pin) : null, saveConfig);
830
831
  relay.setAuthToken(config.pinHash);
831
832
  console.log("[daemon] PIN", pin ? "set" : "removed", "(web)");
@@ -1203,6 +1204,7 @@ var ipc = createIPCServer(socketPath(), function (msg) {
1203
1204
  };
1204
1205
 
1205
1206
  case "set_pin": {
1207
+ if (usersModule.isMultiUser()) return { ok: false, error: 'Server PIN is unavailable while account authentication is enabled.' };
1206
1208
  config.pinHash = msg.pinHash || null;
1207
1209
  relay.setAuthToken(config.pinHash);
1208
1210
  saveConfig(config);
@@ -151,40 +151,20 @@ function migrateSingleUserToMulti() {
151
151
  backupFile(path.join(config.CONFIG_DIR, "users.json"), stamp);
152
152
  backupFile(path.join(config.CONFIG_DIR, "profile.json"), stamp);
153
153
 
154
- // --- Flip to multi-user, then provision the sole admin ---
155
- // Flip first so createUser's built-in mate seeding writes to the per-user
156
- // mates path (CONFIG_DIR/mates/<userId>/), not the flat legacy path.
157
- users.enableMultiUser();
158
-
154
+ // Persist the owner credential and account mode in the same atomic write.
159
155
  var displayName = (profile && profile.name) ? String(profile.name) : "Admin";
160
156
  var created = users.createUserWithoutPin({
161
157
  username: "admin",
162
158
  displayName: displayName,
163
159
  role: "admin",
164
160
  profile: profile || undefined,
165
- });
161
+ }, { pinHash: cfg.pinHash || null });
166
162
  if (!created || !created.user) {
167
163
  console.error("[migrate] Failed to create admin user: " + (created && created.error));
168
- // Roll the flag back so we don't leave a userless multi-user deploy.
169
- try { users.disableMultiUser(); } catch (e) {}
170
164
  return { skipped: "create-failed" };
171
165
  }
172
166
  var userId = created.user.id;
173
167
 
174
- // --- Carry over the PIN hash directly (preserve the existing credential;
175
- // we have the hash, not the raw PIN, so we can't go through updateUserPin) ---
176
- if (hasPin) {
177
- try {
178
- var data = users.loadUsers();
179
- for (var i = 0; i < data.users.length; i++) {
180
- if (data.users[i].id === userId) { data.users[i].pinHash = cfg.pinHash; break; }
181
- }
182
- users.saveUsers(data);
183
- } catch (e) {
184
- console.error("[migrate] PIN carry-over failed (user will have no PIN): " + (e.message || e));
185
- }
186
- }
187
-
188
168
  // --- Carry over settings ---
189
169
  try {
190
170
  if (cfg.chatLayout !== undefined && users.setChatLayout) users.setChatLayout(userId, cfg.chatLayout);
@@ -253,7 +233,7 @@ function ensureMultiUser() {
253
233
  // Try the legacy single-user migration first (handles PIN/settings/mates/
254
234
  // session carry-over for existing deploys).
255
235
  var mig = migrateSingleUserToMulti();
256
- if (mig && mig.migrated) return mig;
236
+ if (mig && (mig.migrated || mig.skipped === "error" || mig.skipped === "create-failed")) return mig;
257
237
 
258
238
  // Fresh or empty deploy: provision a default admin so we're multi-user with
259
239
  // one user. No PIN -> the phase-1 solo auto-login skips the login wall.
@@ -13,6 +13,7 @@ function handleServerSettings(ctx, ws, msg) {
13
13
  var id = ws._clayUser && ws._clayUser.id;
14
14
  var user = id && ctx.usersModule.findUserById(id);
15
15
  if (!user || user.role !== 'admin') { reply({ ok: false, error: 'Administrator access required.' }); return true; }
16
+ if (msg.type === 'set_pin') { reply({ ok: false, error: 'Server PIN is unavailable while account authentication is enabled. Manage account credentials in Personal settings or Users.' }); return true; }
16
17
  }
17
18
  var value = msg[entry.field];
18
19
  if (entry.valid ? !entry.valid(value) : typeof value !== 'boolean') { reply({ ok: false, error: 'Invalid setting value.' }); return true; }
@@ -34,7 +34,7 @@ function attachProjectUserKnowledge(ctx) {
34
34
  } catch(error) {return '';}
35
35
  }
36
36
  function handleMessage(ws,msg) {
37
- if(['you_attention','you_seen','you_list','you_read','you_activity','you_report','you_comment','you_retry'].indexOf(msg.type)===-1) return false;
37
+ if(['you_attention','you_seen','you_list','you_read','you_activity','you_report','you_comment','you_retry','you_delete'].indexOf(msg.type)===-1) return false;
38
38
  var reply={type:'you_result',operation:msg.type,requestId:msg.requestId,ok:false};
39
39
  try {
40
40
  var user=ws._clayUser;
@@ -44,6 +44,7 @@ function attachProjectUserKnowledge(ctx) {
44
44
  if(msg.type==='you_seen') reply.result=ctx.service.acknowledge(id,msg);
45
45
  if(msg.type==='you_list') reply.result=ctx.service.list(id,msg);
46
46
  if(msg.type==='you_read') reply.result=ctx.service.read(id,msg.ref);
47
+ if(msg.type==='you_delete') reply.result=ctx.service.remove(id,{ref:msg.ref,expectedRevision:msg.expectedRevision});
47
48
  if(msg.type==='you_activity') reply.result=ctx.service.activity(id,msg);
48
49
  if(msg.type==='you_comment') reply.result=ctx.service.comment(id,msg,{project:ctx.slug,session:'human',mateId:null});
49
50
  if(msg.type==='you_retry') { ctx.schedule(id); reply.result={pending:ctx.service.pendingCount(id)}; }
@@ -0,0 +1,98 @@
1
+ @font-face {
2
+ font-family: Geist;
3
+ src: url('/auth/login-font.woff2') format('woff2');
4
+ font-weight: 100 900;
5
+ font-display: swap;
6
+ }
7
+ :root {
8
+ color-scheme: light dark;
9
+ --canvas: #f2f2ef;
10
+ --surface: #fafaf7;
11
+ --ink: #262720;
12
+ --muted: #696d61;
13
+ --edge: #dedfd5;
14
+ --field: #fff;
15
+ --accent: #5857fc;
16
+ --danger: #a12c2c;
17
+ }
18
+ * { box-sizing: border-box; }
19
+ [hidden] { display: none !important; }
20
+ body {
21
+ margin: 0;
22
+ min-height: 100svh;
23
+ padding: clamp(48px, 18svh, 160px) 20px 40px;
24
+ background: var(--canvas);
25
+ color: var(--ink);
26
+ font: 14px/1.5 Geist, system-ui, sans-serif;
27
+ }
28
+ main { width: 100%; max-width: 400px; margin: 0 auto; }
29
+ .brand { text-align: center; margin: 0 0 32px; }
30
+ .brand-lockup { display: flex; flex-direction: column; align-items: center; gap: clamp(.65rem, 1.2vw, .9rem); }
31
+ .symbol { width: clamp(58px, 6vw, 82px); height: auto; object-fit: contain; filter: drop-shadow(0 12px 28px rgb(88 87 252 / .12)); }
32
+ .wordmark { display: block; width: clamp(142px, 15vw, 184px); height: auto; filter: brightness(0); }
33
+ .panel { padding: 26px; border: 1px solid var(--edge); border-radius: 14px; background: var(--surface); box-shadow: 0 3px 12px #00000003; }
34
+ [data-stage="welcome"] .panel { width: calc(100% - 54px); margin: 0 auto; padding: 0; border: 0; border-radius: 0; background: transparent; box-shadow: none; }
35
+ .panel-header { display: flex; align-items: center; gap: 10px; margin-bottom: 10px; }
36
+ h1 { font-size: 19px; font-weight: 550; letter-spacing: -.35px; line-height: 1.3; margin: 0; overflow-wrap: anywhere; }
37
+ [data-stage="welcome"] .panel-header, [data-stage="welcome"] #description { display: none; }
38
+ #description { font-size: 13px; color: var(--muted); margin: 0 0 22px; }
39
+ button, input { font: inherit; }
40
+ button { cursor: pointer; min-height: 36px; border: 1px solid var(--edge); border-radius: 8px; background: var(--surface); color: var(--ink); padding: 7px 12px; font-size: 13px; font-weight: 500; transition: background-color 120ms, border-color 120ms, transform 100ms; }
41
+ button:disabled { cursor: wait; opacity: .6; }
42
+ button:hover:not(:disabled) { filter: brightness(.96); }
43
+ button:active:not(:disabled) { transform: translateY(1px); }
44
+ button:focus-visible, input:focus-visible, summary:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; }
45
+ .primary { display: block; width: 100%; background: var(--accent); border-color: var(--accent); color: #fff; margin-top: 18px; }
46
+ .method { min-height: 42px; margin-top: 10px; }
47
+ #welcome > .method:first-of-type { margin-top: 0; }
48
+ #accounts:not(:empty) { margin-bottom: 12px; }
49
+ .secondary { background: transparent; border-color: var(--edge); color: var(--ink); }
50
+ .quiet { background: transparent; border: 0; color: var(--muted); font-size: 12px; display: block; margin: 10px auto 0; padding: 6px 4px; }
51
+ .back { display: grid; place-items: center; flex-shrink: 0; width: 28px; min-height: 28px; padding: 0; margin-left: -5px; border: 0; background: transparent; color: var(--muted); }
52
+ label { display: block; font-size: 12px; font-weight: 500; margin: 16px 0 6px; }
53
+ form > label:first-child { margin-top: 0; }
54
+ input { width: 100%; height: 40px; border: 1px solid var(--edge); border-radius: 8px; background: var(--field); color: var(--ink); padding: 8px 11px; font-size: 14px; }
55
+ .secret-field { position: relative; }
56
+ .secret-field input { padding-right: 60px; }
57
+ .secret-field button { position: absolute; right: 4px; top: 3px; min-height: 34px; border: 0; background: transparent; padding: 5px 8px; font-size: 12px; color: var(--muted); }
58
+ .account-row { display: flex; border: 1px solid var(--edge); border-radius: 10px; margin-bottom: 8px; background: var(--field); }
59
+ .account-choice { flex: 1; min-width: 0; display: flex; align-items: center; gap: 12px; text-align: left; border: 0; background: transparent; padding: 12px; border-radius: 10px 0 0 10px; }
60
+ .account-copy, .identity-chip > div { min-width: 0; }
61
+ .account-copy strong, .identity-chip strong { display: block; font-size: 13px; font-weight: 550; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
62
+ .account-copy small, .identity-chip small { display: block; font-size: 12px; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
63
+ .forget { border: 0; background: transparent; font-size: 12px; color: var(--muted); padding: 8px 12px; border-radius: 0 10px 10px 0; }
64
+ .avatar { width: 34px; height: 34px; flex-shrink: 0; display: grid; place-items: center; border-radius: 9px; border: 1px solid var(--edge); background: var(--canvas); font-size: 12px; font-weight: 550; }
65
+ .identity-chip { display: flex; align-items: center; gap: 10px; margin: 0 0 20px; }
66
+ #status, #error { font-size: 12px; margin: 14px 0 0; }
67
+ #status:empty, #error:empty { display: none; }
68
+ #status { color: var(--muted); }
69
+ #error { color: var(--danger); }
70
+ footer { display: flex; flex-direction: column; align-items: center; gap: 8px; margin: 22px 0 0; color: var(--muted); font-size: 12px; text-align: center; }
71
+ footer details { max-width: 320px; }
72
+ footer summary { cursor: pointer; padding: 3px 6px; }
73
+ footer p { line-height: 1.6; margin: 8px 0; }
74
+ #server-address { overflow-wrap: anywhere; font-size: 11px; }
75
+ .offscreen { position: absolute; width: 1px; height: 1px; clip-path: inset(50%); overflow: hidden; }
76
+ .view:not([hidden]) { animation: arrive 140ms ease-out; }
77
+ @keyframes arrive { from { transform: translateY(3px); } to { transform: translateY(0); } }
78
+ @media (prefers-color-scheme: dark) {
79
+ :root { --canvas: #181a17; --surface: #20231e; --ink: #eeeee7; --muted: #a6ab9d; --edge: #393e34; --field: #181b17; --danger: #ffaaaa; }
80
+ .wordmark { filter: brightness(0) invert(1); }
81
+ }
82
+ @media (max-width: 440px) {
83
+ body { padding: 64px 16px 28px; }
84
+ .brand { margin-bottom: 28px; }
85
+ .panel { padding: 22px; }
86
+ [data-stage="welcome"] .panel { width: calc(100% - 46px); }
87
+ input { font-size: 16px; }
88
+ }
89
+ @media (max-height: 600px) { body { padding-top: 24px; } }
90
+ @media (pointer: coarse) {
91
+ .primary { min-height: 42px; }
92
+ .back { width: 36px; min-height: 36px; }
93
+ .quiet { min-height: 40px; }
94
+ }
95
+ @media (prefers-reduced-motion: reduce) {
96
+ .view:not([hidden]) { animation: none; }
97
+ button { transition: none; }
98
+ }
@@ -0,0 +1,104 @@
1
+ import { supported, getPasskey, passkeyError, request } from '/auth/passkey-browser.js';
2
+ var root = document.getElementById('login');
3
+ var password = root.dataset.password === 'true';
4
+ var selected = null;
5
+ var pin = !password;
6
+ var busy = false;
7
+ var stage = 'welcome';
8
+ var accounts = [];
9
+ var credentialBack = 'welcome';
10
+ function el(id) { return document.getElementById(id); }
11
+ function initials(name) {
12
+ var words = (name || '?').trim().split(/\s+/);
13
+ return (Array.from(words[0])[0] + (words.length > 1 ? Array.from(words[words.length - 1])[0] : '')).toUpperCase();
14
+ }
15
+ function show(next, moveFocus) {
16
+ stage = next;
17
+ root.dataset.stage = next;
18
+ document.querySelectorAll('.view').forEach(function (view) { view.hidden = view.id !== next; });
19
+ el('error').textContent = ''; el('status').textContent = ''; el('back').hidden = next === 'welcome';
20
+ el('secret').value = ''; el('secret').type = 'password'; el('reveal').textContent = 'Show'; el('reveal').setAttribute('aria-pressed', 'false');
21
+ var copy = {
22
+ welcome: ['Sign in', 'Choose how to sign in.'],
23
+ identity: ['Sign in', 'Enter your username to continue.'],
24
+ credential: ['Sign in', pin ? 'Enter your account PIN to continue.' : 'Enter your password to continue.'],
25
+ enroll: ['Set your password.', 'Your administrator has enabled passwords. Use 15–128 characters. Your account PIN is replaced only after saving.'],
26
+ email: ['Sign in with email', 'We’ll send a sign-in code to your account’s email address.'],
27
+ code: ['Check your inbox.', 'Enter the six-digit code we sent you.'],
28
+ };
29
+ el('heading').textContent = copy[next][0]; el('description').textContent = copy[next][1];
30
+ if (next === 'credential') {
31
+ el('selected-name').textContent = selected.name; el('selected-username').textContent = selected.username;
32
+ el('selected-username').hidden = selected.name.toLowerCase() === selected.username.toLowerCase();
33
+ el('selected-avatar').textContent = initials(selected.name); el('credential-username').value = selected.username;
34
+ el('secret-label').textContent = pin ? 'Account PIN' : 'Password';
35
+ el('secret').inputMode = pin ? 'numeric' : 'text'; el('secret').maxLength = pin ? 6 : 256;
36
+ if (pin) el('secret').setAttribute('pattern', '[0-9]{6}'); else el('secret').removeAttribute('pattern');
37
+ el('use-pin').hidden = !password; el('use-pin').textContent = pin ? 'Use my password' : 'I still use an account PIN';
38
+ }
39
+ var focus = { identity: 'username', credential: 'secret', enroll: 'new-password', email: 'email-address', code: 'otp' };
40
+ if (moveFocus !== false) {
41
+ if (focus[next]) el(focus[next]).focus({ preventScroll: true });
42
+ else el('account').focus({ preventScroll: true });
43
+ }
44
+ }
45
+ function renderAccounts() {
46
+ el('accounts').replaceChildren();
47
+ accounts.forEach(function (account) {
48
+ var row = document.createElement('div'); row.className = 'account-row';
49
+ var button = document.createElement('button'); button.className = 'account-choice';
50
+ var avatar = document.createElement('span'); avatar.className = 'avatar'; avatar.textContent = initials(account.name);
51
+ var copy = document.createElement('span'); copy.className = 'account-copy';
52
+ var name = document.createElement('strong'); name.textContent = account.name;
53
+ var username = document.createElement('small'); username.textContent = account.username;
54
+ username.hidden = account.name.toLowerCase() === account.username.toLowerCase();
55
+ copy.append(name, username); button.append(avatar, copy);
56
+ button.onclick = function () { selected = account; credentialBack = 'welcome'; pin = !password; show('credential'); };
57
+ var forget = document.createElement('button'); forget.className = 'forget'; forget.textContent = 'Forget';
58
+ forget.setAttribute('aria-label', 'Forget ' + account.name + ' on this browser');
59
+ forget.onclick = function () { run(async function () { var result = await request('/auth/accounts', { forget: account.id }); accounts = result.accounts; renderAccounts(); show('welcome'); }); };
60
+ row.append(button, forget); el('accounts').append(row);
61
+ });
62
+ el('account').textContent = accounts.length ? 'Use another account' : 'Sign in with an account';
63
+ el('account').classList.toggle('secondary', accounts.length > 0 || !el('passkey').hidden);
64
+ }
65
+ async function run(action) {
66
+ if (busy) return;
67
+ busy = true; el('error').textContent = ''; el('status').textContent = 'Just a moment…';
68
+ document.querySelectorAll('button').forEach(function (button) { button.disabled = true; });
69
+ try { await action(); } catch (error) { el('error').textContent = passkeyError(error); }
70
+ finally { busy = false; el('status').textContent = ''; document.querySelectorAll('button').forEach(function (button) { button.disabled = false; }); }
71
+ }
72
+ function enter() { location.assign('/'); }
73
+ el('server-address').textContent = location.host;
74
+ el('account').onclick = function () { show('identity'); };
75
+ el('back').onclick = function () { el('new-password').value = ''; el('confirm-password').value = ''; el('otp').value = ''; show(stage === 'code' ? 'email' : stage === 'credential' ? credentialBack : 'welcome'); };
76
+ el('identity').onsubmit = function (event) { event.preventDefault(); var username = el('username').value.trim(); if (!username) return; selected = { username: username, name: username }; credentialBack = 'identity'; pin = !password; show('credential'); };
77
+ el('use-pin').onclick = function () { pin = !pin; show('credential'); };
78
+ el('reveal').onclick = function () { var visible = el('secret').type === 'password'; el('secret').type = visible ? 'text' : 'password'; el('reveal').textContent = visible ? 'Hide' : 'Show'; el('reveal').setAttribute('aria-pressed', String(visible)); };
79
+ el('credential').onsubmit = function (event) {
80
+ event.preventDefault(); run(async function () {
81
+ var data = { username: selected.username }; data[pin ? 'pin' : 'password'] = el('secret').value;
82
+ var result = await request('/auth/login', data); if (result.passwordRequired) show('enroll'); else enter();
83
+ });
84
+ };
85
+ el('enroll').onsubmit = function (event) {
86
+ event.preventDefault(); run(async function () {
87
+ var value = el('new-password').value; var confirmation = el('confirm-password').value;
88
+ if (value !== confirmation) throw new Error('Passwords do not match.');
89
+ await request('/auth/password-transition', { password: value, confirmPassword: confirmation }); enter();
90
+ });
91
+ };
92
+ async function sendCode() { await request('/auth/request-otp', { email: el('email-address').value.trim() }); el('otp').value = ''; show('code'); }
93
+ el('email-method').hidden = root.dataset.email !== 'true';
94
+ el('email-method').onclick = function () { show('email'); };
95
+ el('email').onsubmit = function (event) { event.preventDefault(); run(sendCode); };
96
+ el('resend').onclick = function () { run(sendCode); };
97
+ el('code').onsubmit = function (event) { event.preventDefault(); run(async function () { await request('/auth/verify-otp', { email: el('email-address').value.trim(), code: el('otp').value }); enter(); }); };
98
+ el('passkey').onclick = function () { run(async function () { var options = await request('/auth/passkeys/login/options', {}); var response = await getPasskey(options.options); await request('/auth/passkeys/login/verify', { response: response }); enter(); }); };
99
+ async function initialize() {
100
+ try { var result = await request('/auth/accounts'); accounts = result.accounts; renderAccounts(); if (stage === 'welcome') show('welcome', false); } catch (error) { /* Account sign-in remains available without remembered names. */ }
101
+ if (!password || !supported()) return;
102
+ try { var status = await request('/auth/passkeys/status'); if (status.origin === location.origin) { el('passkey').hidden = false; renderAccounts(); } } catch (error) { /* Password sign-in remains available without passkeys. */ }
103
+ }
104
+ initialize();
@@ -0,0 +1,42 @@
1
+ /* A contained collection within Account settings, distinct from setting rows. */
2
+ #user-settings #us-sessions-panel {
3
+ margin: 8px 0 16px;
4
+ padding: 12px 14px;
5
+ border: 1px solid var(--border);
6
+ border-radius: 8px;
7
+ background: rgba(var(--overlay-rgb), 0.025);
8
+ }
9
+ #user-settings #us-sessions-panel > .settings-hint { margin: 0 0 8px; }
10
+ #user-settings #us-sessions-panel .settings-field-row {
11
+ padding: 12px 0;
12
+ gap: 12px;
13
+ }
14
+ #user-settings #us-sessions-panel .settings-field-row > div {
15
+ min-width: 0;
16
+ overflow-wrap: anywhere;
17
+ }
18
+ #user-settings #us-sessions-panel [role="list"] > .settings-field-row:last-child { border-bottom: 0; }
19
+ #user-settings #us-sessions-panel [data-earlier] {
20
+ margin-top: 4px;
21
+ padding-top: 10px;
22
+ border-top: 1px solid var(--border-subtle);
23
+ }
24
+ #user-settings #us-sessions-panel summary {
25
+ display: list-item;
26
+ cursor: pointer;
27
+ padding: 2px 0;
28
+ list-style: disclosure-closed inside;
29
+ }
30
+ #user-settings #us-sessions-panel details[open] > summary { list-style-type: disclosure-open; }
31
+ #user-settings #us-sessions-panel summary:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; }
32
+ #user-settings #us-sessions-panel [data-confirmation] {
33
+ margin-top: 12px;
34
+ padding-top: 10px;
35
+ border-top: 1px solid var(--border-subtle);
36
+ }
37
+ #user-settings #us-sessions-panel .us-session-row-confirm { flex: 0 0 190px; }
38
+ #user-settings #us-sessions-panel .us-session-row-confirm .settings-form-actions { margin-top: 6px; gap: 6px; }
39
+ #user-settings #us-sessions-panel .us-session-row-confirm .settings-hint { margin: 0; }
40
+ @media (max-width: 640px) {
41
+ #user-settings #us-sessions-panel .us-session-row-confirm { flex: none; width: 100%; }
42
+ }
@@ -821,6 +821,7 @@
821
821
  <!-- Account section -->
822
822
  <div class="us-section" data-section="us-account">
823
823
  <h2>Account</h2>
824
+ <p class="settings-page-description">Your profile, sign-in credentials and signed-in devices on this server.</p>
824
825
  <div class="settings-card">
825
826
  <div class="us-account-hero">
826
827
  <div class="us-account-avatar" aria-hidden="true">
@@ -834,15 +835,24 @@
834
835
  </div>
835
836
  </div>
836
837
  </div>
837
- <h3>Security</h3>
838
+ <h3>Sign-in &amp; security</h3>
839
+ <p class="settings-hint">Manage your own account here. Server-wide sign-in requirements are set by the administrator.</p>
838
840
  <div class="settings-card">
839
841
  <div class="settings-field-row">
840
842
  <div>
841
- <label class="settings-label">PIN</label>
842
- <div class="settings-hint">Protect the web UI with a 6-digit PIN.</div>
843
+ <label class="settings-label">Account PIN</label>
844
+ <div class="settings-hint">Manage the PIN used to sign in to your account.</div>
843
845
  </div>
844
846
  <button class="settings-btn-sm" id="us-pin-set-btn">Set PIN</button>
845
847
  </div>
848
+ <div class="settings-field-row" id="us-passkey-row" hidden>
849
+ <div><span class="settings-label">Passkeys</span><p class="settings-hint">Add, rename or remove your account's passkeys.</p></div>
850
+ <a class="settings-btn-sm" href="/account/passkeys">Manage passkeys</a>
851
+ </div>
852
+ <div class="settings-field-row" id="us-sessions-row" hidden>
853
+ <div><span class="settings-label">Signed-in devices</span><p class="settings-hint">Review your browser sessions and sign out this or other devices.</p></div>
854
+ <button type="button" class="settings-btn-sm" id="us-sessions-toggle" aria-expanded="false" aria-controls="us-sessions-panel">Manage sessions</button>
855
+ </div>
846
856
  <div class="settings-field hidden" id="us-pin-form">
847
857
  <div class="settings-pin-form-title" id="us-pin-form-title">Change PIN</div>
848
858
  <div class="settings-pin-current-row" id="us-pin-current-row">
@@ -1139,7 +1149,8 @@
1139
1149
  </div>
1140
1150
  </div>
1141
1151
  <div class="server-settings-section" data-section="security">
1142
- <h2 tabindex="-1">Access &amp; security</h2><p class="settings-page-description">Server sign-in policy. Individual account PINs are managed in Personal settings.</p><h3>Server PIN</h3>
1152
+ <h2 tabindex="-1">Access &amp; security</h2><p class="settings-page-description">Sign-in policies and connection security for this server. Manage your own password, passkeys and devices in Personal settings → Account.</p>
1153
+ <section id="settings-server-pin" hidden><h3>Server PIN</h3>
1143
1154
  <div class="settings-card">
1144
1155
  <div class="settings-field-row">
1145
1156
  <div>
@@ -1168,9 +1179,10 @@
1168
1179
  <div class="settings-hint settings-pin-error hidden" id="settings-pin-error">PIN must be exactly 6 digits.</div>
1169
1180
  </div>
1170
1181
  </div>
1182
+ </section>
1171
1183
  </div>
1172
1184
  <div class="server-settings-section settings-admin-only" data-section="admin-users">
1173
- <h2 tabindex="-1">Users</h2><p class="settings-page-description">Manage server accounts, permissions and invitations. Changes apply to the selected user.</p>
1185
+ <h2 tabindex="-1">Users</h2><p class="settings-page-description">Manage server accounts, permissions, account recovery and invitations. Changes apply to the selected user.</p>
1174
1186
  <h3>Accounts</h3><div class="settings-card" id="admin-users-body"></div>
1175
1187
  <section class="settings-section-divider"><h3>Invitations</h3><p class="settings-hint">Create, copy, email or revoke invitation links. Email invitations require server SMTP.</p>
1176
1188
  <div class="settings-card" id="admin-invites-body"></div></section>
@@ -0,0 +1,71 @@
1
+ import { store } from './store.js';
2
+ import { enhanceSettingsSelects } from './settings-select.js';
3
+ import { confirmSettingsAction, setSettingsDraft, clearSettingsDraft } from './server-settings-state.js';
4
+ import { initPasskeyServerSettings, loadPasskeyServerSettings } from './passkey-server-settings.js';
5
+ import { initAccountTransportSettings, loadAccountTransportSettings } from './account-transport-settings.js';
6
+
7
+ function render(state) {
8
+ var section = document.getElementById('account-auth-policy');
9
+ if (!section) return;
10
+ var select = section.querySelector('select');
11
+ var save = section.querySelector('[data-policy-save]');
12
+ section.hidden = state.available === false;
13
+ select.value = state.draft || state.method || 'pin';
14
+ select.disabled = !!state.pending || state.method === 'password' || !state.method;
15
+ select.dispatchEvent(new Event('settings-sync'));
16
+ save.hidden = !state.method || state.method === select.value;
17
+ save.disabled = !!state.pending;
18
+ section.querySelector('[role=status]').textContent = state.error || (state.pending ? 'Saving…' : state.method === 'password' ? 'Password required. Existing account PINs only authorize password setup.' : 'Existing users verify their account PIN once, then set a password before continuing.');
19
+ }
20
+
21
+ export async function loadAccountAuthSettings() {
22
+ loadPasskeyServerSettings();
23
+ var section = document.getElementById('account-auth-policy');
24
+ if (!section) return;
25
+ if ((store.get('accountAuthPolicy') || {}).draft) return;
26
+ try {
27
+ var response = await fetch('/api/account-auth-policy');
28
+ var data = await response.json();
29
+ if (!response.ok) throw new Error(data.error || 'Could not load account policy.');
30
+ store.set({ accountAuthPolicy: data }); render(data); loadAccountTransportSettings();
31
+ } catch (error) {
32
+ var state = Object.assign({}, store.get('accountAuthPolicy'), { error: error.message });
33
+ store.set({ accountAuthPolicy: state }); render(state);
34
+ }
35
+ }
36
+
37
+ export function initAccountAuthSettings() {
38
+ var page = document.querySelector('.server-settings-section[data-section="security"]');
39
+ if (!page || document.getElementById('account-auth-policy')) return;
40
+ var section = document.createElement('section'); section.id = 'account-auth-policy';
41
+ section.className = 'settings-card';
42
+ section.innerHTML = '<h3>Account sign-in</h3><div class="settings-field-row"><div><label class="settings-label" for="account-auth-method">Authentication method</label><p class="settings-hint">Applies to all accounts on this server. Users manage their own credentials in Personal settings.</p></div><select class="settings-select" id="account-auth-method" disabled><option value="pin">Account PIN</option><option value="password">Password / passphrase</option></select></div><p class="settings-hint" role="status">Loading account policy…</p><div class="settings-form-actions"><button class="settings-btn-sm" data-policy-save hidden>Apply password policy</button></div>';
43
+ var description = page.querySelector('.settings-page-description');
44
+ description.textContent = 'Sign-in policies and connection security for this server. Manage your own password, passkeys and devices in Personal settings → Account.';
45
+ description.after(section);
46
+ var select = section.querySelector('select');
47
+ select.onchange = function () {
48
+ var state = Object.assign({}, store.get('accountAuthPolicy'), { draft: select.value, error: '' });
49
+ if (state.draft === state.method) { delete state.draft; clearSettingsDraft('accountAuth'); }
50
+ else setSettingsDraft('accountAuth', true);
51
+ store.set({ accountAuthPolicy: state }); render(state);
52
+ };
53
+ section.querySelector('[data-policy-save]').onclick = function () {
54
+ confirmSettingsAction('Require passwords for account users? Everyone using an account, including you, must verify their account PIN and set a password before continuing. Existing PINs remain valid for setup until each password is saved. This change does not alter solo access or the Server PIN.', 'Require passwords', async function () {
55
+ var state = Object.assign({}, store.get('accountAuthPolicy'), { pending: true, error: '' });
56
+ store.set({ accountAuthPolicy: state }); render(state);
57
+ try {
58
+ var response = await fetch('/api/account-auth-policy', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ method: 'password' }) });
59
+ var data = await response.json();
60
+ if (!response.ok || !data.ok) throw new Error(data.error || 'Could not save the policy.');
61
+ clearSettingsDraft('accountAuth'); window.location.reload();
62
+ } catch (error) {
63
+ state.pending = false; state.error = error.message;
64
+ store.set({ accountAuthPolicy: state }); render(state);
65
+ }
66
+ });
67
+ };
68
+ enhanceSettingsSelects(section);
69
+ initPasskeyServerSettings();
70
+ initAccountTransportSettings();
71
+ }
@@ -0,0 +1,51 @@
1
+ import { store } from './store.js';
2
+ import { setSettingsDraft, clearSettingsDraft } from './server-settings-state.js';
3
+ function render(state) {
4
+ var section = document.getElementById('account-transport'); if (!section) return;
5
+ section.hidden = !state.available;
6
+ if (!state.dirty) {
7
+ section.querySelector('[name=origin]').value = state.origin || '';
8
+ section.querySelector('[name=proxies]').value = (state.trustedProxies || []).join('\n');
9
+ }
10
+ section.querySelectorAll('input,textarea,button').forEach(function (element) { element.disabled = !!state.pending; });
11
+ section.querySelector('[role=status]').textContent = state.error || (state.pending ? 'Saving…' : state.origin ? 'Saved proxy HTTPS address: ' + state.origin : 'Direct HTTPS or localhost access. No proxy headers are trusted.');
12
+ }
13
+ export async function loadAccountTransportSettings() {
14
+ if ((store.get('accountTransport') || {}).dirty) return;
15
+ try {
16
+ var response = await fetch('/api/account-transport'); var data = await response.json();
17
+ if (!response.ok) throw new Error(data.error || 'Could not load connection settings.');
18
+ var state = Object.assign({}, data, { available: (store.get('accountAuthPolicy') || {}).method === 'password' });
19
+ store.set({ accountTransport: state }); render(state);
20
+ } catch (error) {
21
+ var state = Object.assign({}, store.get('accountTransport'), { error: error.message });
22
+ store.set({ accountTransport: state }); render(state);
23
+ }
24
+ }
25
+ export function initAccountTransportSettings() {
26
+ var anchor = document.getElementById('passkey-policy'); if (!anchor || document.getElementById('account-transport')) return;
27
+ var section = document.createElement('section'); section.id = 'account-transport'; section.className = 'settings-card'; section.hidden = true;
28
+ section.innerHTML = '<h3>HTTPS &amp; trusted proxies</h3><p class="settings-hint">Connection security for account users on this server. Account passwords require HTTPS for remote access. Localhost is available for setup and recovery.</p>' +
29
+ '<form><label class="settings-label" for="account-external-origin">Proxy HTTPS address</label><input class="us-text-input" id="account-external-origin" name="origin" type="url" placeholder="https://clay.example.com">' +
30
+ '<label class="settings-label" for="account-trusted-proxies">Trusted proxy IP addresses</label><textarea class="settings-network-origins" id="account-trusted-proxies" name="proxies" rows="2" placeholder="One exact IP address per line"></textarea>' +
31
+ '<p class="settings-hint">Leave empty for direct HTTPS. Each trusted proxy must overwrite X-Forwarded-Proto and X-Real-IP, prevent direct public access to the backend, and serve this HTTPS address. Forwarded host headers are ignored.</p>' +
32
+ '<label class="settings-label" for="account-transport-password">Administrator password</label><input class="us-text-input" id="account-transport-password" name="password" type="password" autocomplete="current-password" required>' +
33
+ '<div class="settings-form-actions"><button class="settings-btn-sm">Save connection settings</button></div></form><p class="settings-hint" role="status"></p>';
34
+ anchor.after(section);
35
+ section.querySelectorAll('[name=origin],[name=proxies]').forEach(function (input) { input.oninput = function () {
36
+ var state = Object.assign({}, store.get('accountTransport'), { dirty: true, error: '' });
37
+ setSettingsDraft('accountTransport', true); store.set({ accountTransport: state }); render(state);
38
+ }; });
39
+ section.querySelector('form').onsubmit = async function (event) {
40
+ event.preventDefault(); var password = section.querySelector('[name=password]');
41
+ var state = Object.assign({}, store.get('accountTransport'), { pending: true, error: '' }); store.set({ accountTransport: state }); render(state);
42
+ try {
43
+ var response = await fetch('/api/account-transport', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({
44
+ origin: section.querySelector('[name=origin]').value.trim(), trustedProxies: section.querySelector('[name=proxies]').value.split(/\s+/).filter(Boolean), currentPassword: password.value,
45
+ }) });
46
+ var data = await response.json(); if (!response.ok) throw new Error(data.error || 'Could not save connection settings.');
47
+ state = Object.assign({}, data, { available: true }); clearSettingsDraft('accountTransport');
48
+ } catch (error) { state.error = error.message; state.pending = false; }
49
+ finally { password.value = ''; store.set({ accountTransport: state }); render(state); }
50
+ };
51
+ }
@@ -1483,6 +1483,10 @@ export function processMessage(msg) {
1483
1483
  addContextOverflowMessage(msg);
1484
1484
  break;
1485
1485
 
1486
+ case "account_auth_required":
1487
+ window.location.reload();
1488
+ break;
1489
+
1486
1490
  case "auth_required":
1487
1491
  removeMatePreThinking();
1488
1492
  setActivity(null);