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.
Files changed (61) 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-connection.js +1 -1
  17. package/lib/project-server-settings.js +1 -0
  18. package/lib/project-sessions.js +3 -1
  19. package/lib/project-user-knowledge.js +2 -1
  20. package/lib/public/app.js +2 -0
  21. package/lib/public/auth/account-login.css +98 -0
  22. package/lib/public/auth/account-login.js +104 -0
  23. package/lib/public/css/command-palette.css +12 -33
  24. package/lib/public/css/user-settings-sessions.css +42 -0
  25. package/lib/public/index.html +17 -5
  26. package/lib/public/modules/account-auth-settings.js +71 -0
  27. package/lib/public/modules/account-transport-settings.js +51 -0
  28. package/lib/public/modules/app-header.js +3 -2
  29. package/lib/public/modules/app-messages.js +16 -4
  30. package/lib/public/modules/app-rendering.js +4 -3
  31. package/lib/public/modules/chat-render-runtime.js +8 -0
  32. package/lib/public/modules/clay-log-links.js +23 -0
  33. package/lib/public/modules/command-palette.js +94 -5
  34. package/lib/public/modules/markdown.js +1 -1
  35. package/lib/public/modules/passkey-browser.js +69 -0
  36. package/lib/public/modules/passkey-server-settings.js +57 -0
  37. package/lib/public/modules/project-logs-render.js +3 -2
  38. package/lib/public/modules/server-settings-controls.js +10 -0
  39. package/lib/public/modules/server-settings.js +7 -2
  40. package/lib/public/modules/user-settings-account.js +14 -1
  41. package/lib/public/modules/user-settings-sessions.js +120 -0
  42. package/lib/public/modules/user-settings.js +3 -0
  43. package/lib/public/modules/you-activity.js +3 -1
  44. package/lib/public/modules/you.js +91 -6
  45. package/lib/public/style.css +1 -0
  46. package/lib/server-account-passkeys.js +186 -0
  47. package/lib/server-account-passwords.js +127 -0
  48. package/lib/server-account-security.js +53 -0
  49. package/lib/server-auth.js +107 -79
  50. package/lib/server-home-chat.js +3 -3
  51. package/lib/server-home-clay-session-links.js +17 -9
  52. package/lib/server-settings.js +2 -1
  53. package/lib/server.js +3 -1
  54. package/lib/sessions.js +3 -1
  55. package/lib/user-knowledge-service.js +23 -1
  56. package/lib/users-auth.js +38 -3
  57. package/lib/users-create.js +97 -0
  58. package/lib/users-server-pin.js +37 -0
  59. package/lib/users.js +11 -84
  60. package/lib/workspace-query-service.js +13 -2
  61. 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.
@@ -301,7 +301,7 @@ function attachConnection(ctx) {
301
301
  }
302
302
  sendTo(ws, { type: "history_meta", total: total, from: fromIndex });
303
303
  for (var i = fromIndex; i < total; i++) {
304
- sendTo(ws, hydrateImageRefs(active.history[i]));
304
+ sendTo(ws, Object.assign({}, hydrateImageRefs(active.history[i]), { _historyIndex: i }));
305
305
  }
306
306
  var _lastUsage = null, _lastModelUsage = null, _lastCost = null, _lastStreamInputTokens = null;
307
307
  for (var _ri = total - 1; _ri >= 0; _ri--) {
@@ -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; }
@@ -588,7 +588,9 @@ function attachSessions(ctx) {
588
588
  var targetFrom = typeof msg.target === "number" ? msg.target : before - sm.HISTORY_PAGE_SIZE;
589
589
  var from = sm.findTurnBoundary(session.history, Math.max(0, targetFrom));
590
590
  var to = before;
591
- var items = session.history.slice(from, to).map(hydrateImageRefs);
591
+ var items = session.history.slice(from, to).map(function (item, index) {
592
+ return Object.assign({}, hydrateImageRefs(item), { _historyIndex: from + index });
593
+ });
592
594
  sendTo(ws, {
593
595
  type: "history_prepend",
594
596
  items: items,
@@ -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)}; }
package/lib/public/app.js CHANGED
@@ -315,6 +315,8 @@ import { initDebate, handleDebatePreparing, handleDebateStarted, handleDebateRes
315
315
  historyTotal: 0,
316
316
  replayingHistory: false,
317
317
  historyReplayBatch: null,
318
+ pendingSessionEntryNavigation: null,
319
+ sessionNavigationRequestId: null,
318
320
  projectName: projectName,
319
321
  cwd: "",
320
322
  currentSlug: currentSlug,
@@ -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();
@@ -550,41 +550,20 @@ body.mate-dm-active .search-clay-transcript .search-clay-message-user .bubble,
550
550
  .clayos-issue-link { --record-color: var(--link, var(--accent)); }
551
551
  .clayos-log-link { --record-color: var(--accent); }
552
552
 
553
- .clayos-session-link {
554
- display: inline-flex;
555
- align-items: center;
556
- gap: 6px;
557
- min-height: 26px;
558
- margin: 2px 1px;
559
- padding: 3px 8px 3px 6px;
560
- border: 1px solid color-mix(in srgb, var(--accent) 28%, var(--border));
561
- border-radius: 7px;
562
- background: color-mix(in srgb, var(--accent) 7%, var(--bg-alt));
563
- color: var(--text);
564
- font: inherit;
565
- font-size: 11px;
566
- line-height: 1;
567
- vertical-align: middle;
568
- cursor: pointer;
553
+ .clayos-session-link { --record-color: var(--accent); }
554
+
555
+ #messages .session-entry-target {
556
+ outline: 2px solid color-mix(in srgb, var(--accent) 72%, transparent);
557
+ outline-offset: 3px;
558
+ border-radius: 8px;
559
+ background: color-mix(in srgb, var(--accent) 7%, transparent);
560
+ animation: sessionEntryTargetFade 1800ms ease-out forwards;
569
561
  }
570
- .clayos-session-link::before {
571
- content: "\2197";
572
- display: grid;
573
- place-items: center;
574
- width: 17px;
575
- height: 17px;
576
- border-radius: 5px;
577
- background: color-mix(in srgb, var(--accent) 14%, transparent);
578
- color: var(--accent);
579
- font-size: 12px;
562
+
563
+ @keyframes sessionEntryTargetFade {
564
+ 0%, 72% { background: color-mix(in srgb, var(--accent) 10%, transparent); }
565
+ 100% { background: transparent; }
580
566
  }
581
- .clayos-session-link > span { font-weight: 620; }
582
- .clayos-session-link > small { color: var(--text-muted); font-size: 10px; }
583
- .clayos-session-link:hover { border-color: color-mix(in srgb, var(--accent) 52%, var(--border)); background: color-mix(in srgb, var(--accent) 11%, var(--bg-alt)); }
584
- .clayos-session-link:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
585
- .clayos-session-link.is-loading { opacity: 0.62; cursor: progress; }
586
- .clayos-session-link.is-loading::before { animation: searchClaySpin 1.2s linear infinite; }
587
- .clayos-session-link.is-error { border-color: color-mix(in srgb, var(--warning) 48%, var(--border)); }
588
567
 
589
568
  .search-clay-pending {
590
569
  align-self: flex-start;
@@ -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>