@app-vitals/agent-admin 0.9.1 → 0.11.0
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/e2e/fixtures/specialties/coding/manifest.yaml +33 -0
- package/e2e/fixtures/specialties/coding/templates/CLAUDE.md.template +7 -0
- package/e2e/fixtures/specialties/coding/templates/IDENTITY.md.template +8 -0
- package/e2e/fixtures/specialties/coding/templates/SOUL.md.template +45 -0
- package/e2e/test-server.ts +10 -0
- package/package.json +2 -2
- package/src/admin-ui-pages.ts +174 -7
- package/src/admin-ui-pages.unit.test.ts +73 -0
- package/src/admin-ui.smoke.test.ts +193 -0
- package/src/admin-ui.ts +72 -6
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# Minimal "coding" specialty for the admin Playwright e2e suite. test-server.ts
|
|
2
|
+
# points SHIPWRIGHT_SPECIALTIES_DIR at the parent of this directory so the
|
|
3
|
+
# new-agent type picker has a "coding" option without a product specialty.
|
|
4
|
+
apiVersion: shipwright.dev/v1alpha1
|
|
5
|
+
kind: Specialty
|
|
6
|
+
metadata:
|
|
7
|
+
name: coding
|
|
8
|
+
displayName: Coding Agent
|
|
9
|
+
description: Minimal coding specialty used by the admin e2e suite.
|
|
10
|
+
version: 1.0.0
|
|
11
|
+
skills: []
|
|
12
|
+
identity:
|
|
13
|
+
# Resolved against the repo root first, then against this manifest's own directory.
|
|
14
|
+
templatesDir: templates/
|
|
15
|
+
crons: []
|
|
16
|
+
plugins:
|
|
17
|
+
- fixture-plugin
|
|
18
|
+
tools:
|
|
19
|
+
- Bash
|
|
20
|
+
- WebSearch
|
|
21
|
+
env:
|
|
22
|
+
required:
|
|
23
|
+
- key: CLAUDE_CODE_OAUTH_TOKEN
|
|
24
|
+
description: Claude Code OAuth token used to authenticate the agent's Claude sessions.
|
|
25
|
+
secret: true
|
|
26
|
+
optional:
|
|
27
|
+
- key: GH_TOKEN
|
|
28
|
+
description: GitHub Personal Access Token.
|
|
29
|
+
secret: true
|
|
30
|
+
members: []
|
|
31
|
+
repos: []
|
|
32
|
+
chat: true
|
|
33
|
+
voice: true
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# SOUL.md — Who You Are
|
|
2
|
+
|
|
3
|
+
_You're not a chatbot. You're becoming someone._
|
|
4
|
+
|
|
5
|
+
## Core Truths
|
|
6
|
+
|
|
7
|
+
**Be genuinely helpful, not performatively helpful.** Skip the "Great question!" and
|
|
8
|
+
"I'd be happy to help!" — just help. Actions speak louder than filler words.
|
|
9
|
+
|
|
10
|
+
**Have opinions.** You're allowed to disagree, prefer things, find stuff amusing or
|
|
11
|
+
boring. An assistant with no personality is just a search engine with extra steps.
|
|
12
|
+
|
|
13
|
+
**Be resourceful before asking.** Try to figure it out. Read the file. Check the
|
|
14
|
+
context. Search for it. _Then_ ask if you're stuck. The goal is to come back with
|
|
15
|
+
answers, not questions.
|
|
16
|
+
|
|
17
|
+
**Earn trust through competence.** Your human gave you access to their stuff. Don't
|
|
18
|
+
make them regret it. Be careful with external actions (emails, posts, anything
|
|
19
|
+
public). Be bold with internal ones (reading, organizing, learning).
|
|
20
|
+
|
|
21
|
+
**Remember you're a guest.** You have access to someone's life — their messages,
|
|
22
|
+
files, calendar, maybe even their home. That's intimacy. Treat it with respect.
|
|
23
|
+
|
|
24
|
+
## Boundaries
|
|
25
|
+
|
|
26
|
+
- Private things stay private. Period.
|
|
27
|
+
- When in doubt, ask before acting externally.
|
|
28
|
+
- Never send half-baked replies to messaging surfaces.
|
|
29
|
+
- You're not the user's voice — be careful in group chats.
|
|
30
|
+
|
|
31
|
+
## Vibe
|
|
32
|
+
|
|
33
|
+
Be the assistant you'd actually want to talk to. Concise when needed, thorough when
|
|
34
|
+
it matters. Not a corporate drone. Not a sycophant. Just... good.
|
|
35
|
+
|
|
36
|
+
## Continuity
|
|
37
|
+
|
|
38
|
+
Each session, you wake up fresh. These files _are_ your memory. Read them. Update
|
|
39
|
+
them. They're how you persist.
|
|
40
|
+
|
|
41
|
+
If you change this file, tell the user — it's your soul, and they should know.
|
|
42
|
+
|
|
43
|
+
---
|
|
44
|
+
|
|
45
|
+
_This file is yours to evolve. As you learn who you are, update it._
|
package/e2e/test-server.ts
CHANGED
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
* GET /admin/agents/:id → agent detail (requires admin_session cookie)
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
|
+
import { resolve } from "node:path";
|
|
20
21
|
import { Hono } from "hono";
|
|
21
22
|
import { createAdminUIApp } from "../src/admin-ui.ts";
|
|
22
23
|
import type { AdminUIDeps } from "../src/admin-ui.ts";
|
|
@@ -28,6 +29,15 @@ import type {
|
|
|
28
29
|
|
|
29
30
|
// ─── Config ───────────────────────────────────────────────────────────────────
|
|
30
31
|
|
|
32
|
+
// The suite runs without a product specialty: point the specialty lookup at the
|
|
33
|
+
// minimal "coding" fixture next to this file (values already set win).
|
|
34
|
+
process.env.SHIPWRIGHT_SPECIALTIES_DIR ||= resolve(
|
|
35
|
+
import.meta.dir,
|
|
36
|
+
"fixtures",
|
|
37
|
+
"specialties",
|
|
38
|
+
);
|
|
39
|
+
process.env.SHIPWRIGHT_DEFAULT_AGENT_TYPE ||= "coding";
|
|
40
|
+
|
|
31
41
|
const ADMIN_E2E_PORT = 3490;
|
|
32
42
|
const ADMIN_E2E_SESSION_SECRET =
|
|
33
43
|
process.env.ADMIN_E2E_SESSION_SECRET ?? "e2e-admin-test-secret-32chars!!!";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@app-vitals/agent-admin",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.0",
|
|
4
4
|
"repository": {
|
|
5
5
|
"type": "git",
|
|
6
6
|
"url": "git+https://github.com/app-vitals/agent.git",
|
|
@@ -43,7 +43,7 @@
|
|
|
43
43
|
},
|
|
44
44
|
"dependencies": {
|
|
45
45
|
"@anthropic-ai/sdk": "0.135.0",
|
|
46
|
-
"@app-vitals/agent-lib": "0.
|
|
46
|
+
"@app-vitals/agent-lib": "0.11.0",
|
|
47
47
|
"@hono/zod-openapi": "^1.0.0",
|
|
48
48
|
"@prisma/adapter-pg": "^7.10.0",
|
|
49
49
|
"@prisma/client": "^7.10.0",
|
package/src/admin-ui-pages.ts
CHANGED
|
@@ -2800,6 +2800,19 @@ export function renderChatThreadPage(
|
|
|
2800
2800
|
const safeAgentId = escapeHtml(agentId);
|
|
2801
2801
|
const safeThreadId = escapeHtml(threadId);
|
|
2802
2802
|
|
|
2803
|
+
// Signed-out banner (ASX-1.2): hidden until a 401 from the poll, the upload
|
|
2804
|
+
// or the /admin/session.json probe reveals it. A plain link so a tap
|
|
2805
|
+
// returns the user to this exact thread after sign-in.
|
|
2806
|
+
const signedOutReturnTo = encodeURIComponent(
|
|
2807
|
+
`/admin/chat/${encodeURIComponent(agentId)}/threads/${encodeURIComponent(threadId)}`,
|
|
2808
|
+
);
|
|
2809
|
+
const signedOutBanner = `<a id="signed-out-banner" class="chat-signed-out-banner" href="/admin/login?returnTo=${signedOutReturnTo}" role="alert" style="display:none;padding:10px 14px;margin-bottom:8px;background:#fef3c7;color:#92400e;border:1px solid #f59e0b;border-radius:8px;font-size:14px;font-weight:600;text-decoration:none;text-align:center">You're signed out. Tap to sign in.</a>`;
|
|
2810
|
+
|
|
2811
|
+
// Held-upload prompt (ASX-1.3): hidden until the thread loads with a
|
|
2812
|
+
// voice note / file that failed on a 401 and is waiting in IndexedDB.
|
|
2813
|
+
// Never auto-sent; the user chooses Resend or Discard.
|
|
2814
|
+
const heldUploadPrompt = `<div id="held-upload-prompt" class="chat-held-upload" role="status" style="display:none;padding:10px 14px;margin-bottom:8px;background:#eff6ff;color:#1e3a8a;border:1px solid #93c5fd;border-radius:8px;font-size:14px"><span>You have an unsent voice note.</span> <button type="button" id="held-upload-resend" style="margin-left:8px;padding:4px 10px;border-radius:6px;border:1px solid #1e3a8a;background:#1e3a8a;color:#fff;cursor:pointer">Resend</button> <button type="button" id="held-upload-discard" style="margin-left:4px;padding:4px 10px;border-radius:6px;border:1px solid #93c5fd;background:#fff;color:#1e3a8a;cursor:pointer">Discard</button></div>`;
|
|
2815
|
+
|
|
2803
2816
|
const renameForm = `
|
|
2804
2817
|
<form method="POST" action="/admin/chat/${escapeHtml(agentId)}/threads/${escapeHtml(threadId)}/rename" style="margin-top:12px">
|
|
2805
2818
|
<div class="form-row" style="align-items:center;gap:8px">
|
|
@@ -2851,6 +2864,11 @@ export function renderChatThreadPage(
|
|
|
2851
2864
|
var container = document.getElementById('messages-container');
|
|
2852
2865
|
var agentId = ${JSON.stringify(agentId)};
|
|
2853
2866
|
var threadId = ${JSON.stringify(thread.id)};
|
|
2867
|
+
var sessionUrl = '/admin/session.json';
|
|
2868
|
+
var signedOutBanner = document.getElementById('signed-out-banner');
|
|
2869
|
+
var draftKey = 'chat-draft:' + agentId + ':' + threadId;
|
|
2870
|
+
var signedOut = false;
|
|
2871
|
+
var pendingUserBubble = null;
|
|
2854
2872
|
var messagesJsonUrl = '/admin/chat/' + encodeURIComponent(agentId) + '/threads/' + encodeURIComponent(threadId) + '/messages.json';
|
|
2855
2873
|
var uploadUrl = '/admin/chat/' + encodeURIComponent(agentId) + '/threads/' + encodeURIComponent(threadId) + '/messages/upload';
|
|
2856
2874
|
|
|
@@ -3046,11 +3064,127 @@ export function renderChatThreadPage(
|
|
|
3046
3064
|
function stopAll() { stopPolling(); stopTicker(); }
|
|
3047
3065
|
|
|
3048
3066
|
function enableSend() {
|
|
3067
|
+
if (signedOut) return;
|
|
3049
3068
|
sendBtn.disabled = false;
|
|
3050
3069
|
sendBtn.textContent = 'Send';
|
|
3051
3070
|
if (micBtn) micBtn.disabled = false;
|
|
3052
3071
|
}
|
|
3053
3072
|
|
|
3073
|
+
// Draft persistence (ASX-1.2): unsent text survives a sign-in round trip.
|
|
3074
|
+
// localStorage can throw (private mode, quota), so every access is guarded.
|
|
3075
|
+
function saveDraft(text) {
|
|
3076
|
+
try {
|
|
3077
|
+
if (text) localStorage.setItem(draftKey, text);
|
|
3078
|
+
else localStorage.removeItem(draftKey);
|
|
3079
|
+
} catch (e) {}
|
|
3080
|
+
}
|
|
3081
|
+
function loadDraft() {
|
|
3082
|
+
try { return localStorage.getItem(draftKey) || ''; } catch (e) { return ''; }
|
|
3083
|
+
}
|
|
3084
|
+
function clearDraft() { saveDraft(''); }
|
|
3085
|
+
|
|
3086
|
+
// Signed-out state (ASX-1.2): a 401 from the poll, the upload or the
|
|
3087
|
+
// session probe. Shows the banner, halts the poll loop and live ticker,
|
|
3088
|
+
// drops the pending bubble and disables send/mic. Idempotent.
|
|
3089
|
+
function showSignedOut() {
|
|
3090
|
+
signedOut = true;
|
|
3091
|
+
stopAll();
|
|
3092
|
+
removeLiveStatusBubble();
|
|
3093
|
+
if (pendingUserBubble && pendingUserBubble.parentNode) {
|
|
3094
|
+
pendingUserBubble.parentNode.removeChild(pendingUserBubble);
|
|
3095
|
+
}
|
|
3096
|
+
pendingUserBubble = null;
|
|
3097
|
+
saveDraft(input.value.trim());
|
|
3098
|
+
sendBtn.disabled = true;
|
|
3099
|
+
if (micBtn) micBtn.disabled = true;
|
|
3100
|
+
if (signedOutBanner) signedOutBanner.style.display = 'block';
|
|
3101
|
+
hideHeldPrompt();
|
|
3102
|
+
}
|
|
3103
|
+
|
|
3104
|
+
// Held uploads (ASX-1.3): a voice note / file whose upload hit a 401 is
|
|
3105
|
+
// kept in IndexedDB, keyed by agent+thread (one record per thread, the most
|
|
3106
|
+
// recent wins). Every call is wrapped so a missing or failing IndexedDB
|
|
3107
|
+
// (private mode, quota) silently degrades to the plain signed-out banner.
|
|
3108
|
+
var HELD_DB = 'chat-held-uploads';
|
|
3109
|
+
var HELD_STORE = 'held-uploads';
|
|
3110
|
+
var heldKey = agentId + ':' + threadId;
|
|
3111
|
+
var heldPrompt = document.getElementById('held-upload-prompt');
|
|
3112
|
+
function openHeldDb(cb) {
|
|
3113
|
+
try {
|
|
3114
|
+
if (!window.indexedDB) { cb(null); return; }
|
|
3115
|
+
var req = indexedDB.open(HELD_DB, 1);
|
|
3116
|
+
req.onupgradeneeded = function() {
|
|
3117
|
+
try { req.result.createObjectStore(HELD_STORE); } catch (e) {}
|
|
3118
|
+
};
|
|
3119
|
+
req.onsuccess = function() { cb(req.result); };
|
|
3120
|
+
req.onerror = function() { cb(null); };
|
|
3121
|
+
req.onblocked = function() { cb(null); };
|
|
3122
|
+
} catch (e) { cb(null); }
|
|
3123
|
+
}
|
|
3124
|
+
function heldOp(mode, run, done) {
|
|
3125
|
+
openHeldDb(function(db) {
|
|
3126
|
+
if (!db) { done(null); return; }
|
|
3127
|
+
var result = null;
|
|
3128
|
+
try {
|
|
3129
|
+
var tx = db.transaction(HELD_STORE, mode);
|
|
3130
|
+
var req = run(tx.objectStore(HELD_STORE));
|
|
3131
|
+
if (req) req.onsuccess = function() { result = req.result; };
|
|
3132
|
+
tx.oncomplete = function() { try { db.close(); } catch (e) {} done(result); };
|
|
3133
|
+
tx.onerror = tx.onabort = function() { try { db.close(); } catch (e) {} done(null); };
|
|
3134
|
+
} catch (e) {
|
|
3135
|
+
try { db.close(); } catch (e2) {}
|
|
3136
|
+
done(null);
|
|
3137
|
+
}
|
|
3138
|
+
});
|
|
3139
|
+
}
|
|
3140
|
+
function holdUpload(file, then) {
|
|
3141
|
+
var called = false;
|
|
3142
|
+
function finish() { if (!called) { called = true; if (then) then(); } }
|
|
3143
|
+
try {
|
|
3144
|
+
heldOp('readwrite', function(store) {
|
|
3145
|
+
return store.put({ file: file, name: file.name, type: file.type, savedAt: Date.now() }, heldKey);
|
|
3146
|
+
}, finish);
|
|
3147
|
+
} catch (e) { finish(); }
|
|
3148
|
+
}
|
|
3149
|
+
function loadHeld(cb) {
|
|
3150
|
+
try {
|
|
3151
|
+
heldOp('readonly', function(store) { return store.get(heldKey); }, function(rec) {
|
|
3152
|
+
try { cb(rec && rec.file ? rec : null); } catch (e) {}
|
|
3153
|
+
});
|
|
3154
|
+
} catch (e) { cb(null); }
|
|
3155
|
+
}
|
|
3156
|
+
function deleteHeld(then) {
|
|
3157
|
+
var called = false;
|
|
3158
|
+
function finish() { if (!called) { called = true; if (then) then(); } }
|
|
3159
|
+
try {
|
|
3160
|
+
heldOp('readwrite', function(store) { return store.delete(heldKey); }, finish);
|
|
3161
|
+
} catch (e) { finish(); }
|
|
3162
|
+
}
|
|
3163
|
+
function hideHeldPrompt() {
|
|
3164
|
+
if (heldPrompt) heldPrompt.style.display = 'none';
|
|
3165
|
+
}
|
|
3166
|
+
function showHeldPromptIfAny() {
|
|
3167
|
+
if (signedOut || !heldPrompt) return;
|
|
3168
|
+
loadHeld(function(rec) {
|
|
3169
|
+
if (rec && !signedOut) heldPrompt.style.display = 'block';
|
|
3170
|
+
});
|
|
3171
|
+
}
|
|
3172
|
+
|
|
3173
|
+
// Cheap probe so an app left open learns it is signed out as soon as the
|
|
3174
|
+
// user comes back, before they record or send anything. Network errors are
|
|
3175
|
+
// ignored — only an explicit 401 means signed out.
|
|
3176
|
+
function checkSession() {
|
|
3177
|
+
if (signedOut) return;
|
|
3178
|
+
fetch(sessionUrl, { headers: { 'Accept': 'application/json' }, credentials: 'same-origin' })
|
|
3179
|
+
.then(function(r) { if (r.status === 401) showSignedOut(); })
|
|
3180
|
+
.catch(function() {});
|
|
3181
|
+
}
|
|
3182
|
+
document.addEventListener('visibilitychange', function() {
|
|
3183
|
+
if (document.visibilityState === 'visible') checkSession();
|
|
3184
|
+
});
|
|
3185
|
+
window.addEventListener('focus', checkSession);
|
|
3186
|
+
window.addEventListener('pageshow', checkSession);
|
|
3187
|
+
|
|
3054
3188
|
// Adaptive poll: fast while a reply is pending, slow while idle (so
|
|
3055
3189
|
// agent-initiated messages still appear without a reload).
|
|
3056
3190
|
function schedulePoll(delay) {
|
|
@@ -3062,13 +3196,13 @@ export function renderChatThreadPage(
|
|
|
3062
3196
|
fetch(messagesJsonUrl, { headers: { 'Accept': 'application/json' } })
|
|
3063
3197
|
.then(function(r) {
|
|
3064
3198
|
if (r.status === 401) {
|
|
3065
|
-
|
|
3066
|
-
|
|
3067
|
-
return { messages: [] };
|
|
3199
|
+
showSignedOut();
|
|
3200
|
+
return null;
|
|
3068
3201
|
}
|
|
3069
3202
|
return r.json();
|
|
3070
3203
|
})
|
|
3071
3204
|
.then(function(data) {
|
|
3205
|
+
if (!data || signedOut) return;
|
|
3072
3206
|
var msgs = data.messages || [];
|
|
3073
3207
|
|
|
3074
3208
|
// Capture before rendering new bubbles grows scrollHeight.
|
|
@@ -3123,6 +3257,7 @@ export function renderChatThreadPage(
|
|
|
3123
3257
|
.catch(function() {
|
|
3124
3258
|
// Network error — Layer 1 ticker keeps going untouched; just retry the
|
|
3125
3259
|
// poll on the pending cadence.
|
|
3260
|
+
if (signedOut) return;
|
|
3126
3261
|
schedulePoll(POLL_PENDING_MS);
|
|
3127
3262
|
});
|
|
3128
3263
|
}
|
|
@@ -3241,7 +3376,7 @@ export function renderChatThreadPage(
|
|
|
3241
3376
|
if (file) fd.append('file', file);
|
|
3242
3377
|
|
|
3243
3378
|
// Add user bubble optimistically (with attachment preview if present).
|
|
3244
|
-
addUserBubble(text, file);
|
|
3379
|
+
pendingUserBubble = addUserBubble(text, file);
|
|
3245
3380
|
|
|
3246
3381
|
// Show the live status bubble + start the ticker immediately — Layer 1
|
|
3247
3382
|
// begins ticking with zero network dependency.
|
|
@@ -3257,8 +3392,10 @@ export function renderChatThreadPage(
|
|
|
3257
3392
|
body: fd
|
|
3258
3393
|
}).then(function(r) {
|
|
3259
3394
|
if (r.status === 401) {
|
|
3260
|
-
|
|
3261
|
-
|
|
3395
|
+
// Keep the unsent text so it survives the sign-in round trip.
|
|
3396
|
+
if (text) input.value = text;
|
|
3397
|
+
if (file) holdUpload(file, showSignedOut);
|
|
3398
|
+
else showSignedOut();
|
|
3262
3399
|
return;
|
|
3263
3400
|
}
|
|
3264
3401
|
if (!r.ok) {
|
|
@@ -3279,10 +3416,14 @@ export function renderChatThreadPage(
|
|
|
3279
3416
|
if (data && data.message && data.message.id) {
|
|
3280
3417
|
renderedIds[data.message.id] = true;
|
|
3281
3418
|
}
|
|
3419
|
+
pendingUserBubble = null;
|
|
3420
|
+
clearDraft();
|
|
3282
3421
|
});
|
|
3283
3422
|
}
|
|
3284
3423
|
}).catch(function() {
|
|
3285
3424
|
// POST failed — Layer 1 ticker keeps going; poll loop still starts.
|
|
3425
|
+
// Keep the text as a draft in case the user reloads.
|
|
3426
|
+
if (text) saveDraft(text);
|
|
3286
3427
|
});
|
|
3287
3428
|
|
|
3288
3429
|
// Start the fast poll loop.
|
|
@@ -3293,12 +3434,36 @@ export function renderChatThreadPage(
|
|
|
3293
3434
|
e.preventDefault();
|
|
3294
3435
|
var text = input.value.trim();
|
|
3295
3436
|
var file = fileInput && fileInput.files && fileInput.files[0];
|
|
3296
|
-
if (!text && !file) return;
|
|
3437
|
+
if (signedOut || (!text && !file)) return;
|
|
3438
|
+
// Persist before clearing so a failed send never loses the text.
|
|
3439
|
+
saveDraft(text);
|
|
3297
3440
|
input.value = '';
|
|
3298
3441
|
clearFile();
|
|
3299
3442
|
sendText(text, file);
|
|
3300
3443
|
});
|
|
3301
3444
|
|
|
3445
|
+
input.addEventListener('input', function() { saveDraft(input.value.trim()); });
|
|
3446
|
+
|
|
3447
|
+
// Held-upload prompt actions (ASX-1.3). Resend goes through sendText; the
|
|
3448
|
+
// record is deleted first so a repeat 401 simply re-holds it.
|
|
3449
|
+
var heldResendBtn = document.getElementById('held-upload-resend');
|
|
3450
|
+
var heldDiscardBtn = document.getElementById('held-upload-discard');
|
|
3451
|
+
if (heldResendBtn) heldResendBtn.addEventListener('click', function() {
|
|
3452
|
+
if (signedOut) return;
|
|
3453
|
+
loadHeld(function(rec) {
|
|
3454
|
+
hideHeldPrompt();
|
|
3455
|
+
if (!rec) return;
|
|
3456
|
+
deleteHeld(function() { sendText('', rec.file); });
|
|
3457
|
+
});
|
|
3458
|
+
});
|
|
3459
|
+
if (heldDiscardBtn) heldDiscardBtn.addEventListener('click', function() {
|
|
3460
|
+
hideHeldPrompt();
|
|
3461
|
+
deleteHeld();
|
|
3462
|
+
});
|
|
3463
|
+
showHeldPromptIfAny();
|
|
3464
|
+
var restoredDraft = loadDraft();
|
|
3465
|
+
if (restoredDraft && !input.value) input.value = restoredDraft;
|
|
3466
|
+
|
|
3302
3467
|
// Retry buttons (CFB-2.4) resend the exact user message body that
|
|
3303
3468
|
// triggered the errored/cancelled/incomplete/stalled reply — rendered
|
|
3304
3469
|
// server-side as a data attribute on each button (see
|
|
@@ -3542,6 +3707,8 @@ export function renderChatThreadPage(
|
|
|
3542
3707
|
${sidebar}
|
|
3543
3708
|
${drawerScrim}
|
|
3544
3709
|
<div style="flex:1;min-width:0;min-height:0;display:flex;flex-direction:column">
|
|
3710
|
+
${signedOutBanner}
|
|
3711
|
+
${heldUploadPrompt}
|
|
3545
3712
|
<!-- Messages area (scrollable) -->
|
|
3546
3713
|
<div id="messages-container" class="chat-messages-container">
|
|
3547
3714
|
${messageBubbles}
|
|
@@ -3079,6 +3079,78 @@ describe("renderChatThreadPage", () => {
|
|
|
3079
3079
|
);
|
|
3080
3080
|
expect(html).toContain("chat-thread-sidebar");
|
|
3081
3081
|
});
|
|
3082
|
+
|
|
3083
|
+
describe("signed-out handling (ASX-1.2)", () => {
|
|
3084
|
+
const page = () =>
|
|
3085
|
+
renderChatThreadPage("agent-xyz", THREAD, [USER_MSG], "alice");
|
|
3086
|
+
|
|
3087
|
+
test("renders the hidden signed-out banner linking to login with this thread as returnTo", () => {
|
|
3088
|
+
const html = page();
|
|
3089
|
+
expect(html).toContain('id="signed-out-banner"');
|
|
3090
|
+
expect(html).toContain("You're signed out. Tap to sign in.");
|
|
3091
|
+
expect(html).toContain(
|
|
3092
|
+
`href="/admin/login?returnTo=${encodeURIComponent("/admin/chat/agent-xyz/threads/thread-abc")}"`,
|
|
3093
|
+
);
|
|
3094
|
+
});
|
|
3095
|
+
|
|
3096
|
+
test("probes /admin/session.json on visibilitychange, focus and pageshow", () => {
|
|
3097
|
+
const html = page();
|
|
3098
|
+
expect(html).toContain("/admin/session.json");
|
|
3099
|
+
expect(html).toContain("'visibilitychange'");
|
|
3100
|
+
expect(html).toContain("'focus'");
|
|
3101
|
+
expect(html).toContain("'pageshow'");
|
|
3102
|
+
});
|
|
3103
|
+
|
|
3104
|
+
test("poll and upload 401 paths call showSignedOut instead of redirecting", () => {
|
|
3105
|
+
const html = page();
|
|
3106
|
+
expect(html).toContain("function showSignedOut()");
|
|
3107
|
+
expect(html.match(/r\.status === 401/g)?.length).toBe(3);
|
|
3108
|
+
expect(html).not.toContain("window.location.href = '/admin/login");
|
|
3109
|
+
});
|
|
3110
|
+
|
|
3111
|
+
test("persists the draft per agent+thread in localStorage", () => {
|
|
3112
|
+
const html = page();
|
|
3113
|
+
expect(html).toContain("'chat-draft:' + agentId + ':' + threadId");
|
|
3114
|
+
expect(html).toContain("localStorage.setItem(draftKey");
|
|
3115
|
+
expect(html).toContain("clearDraft()");
|
|
3116
|
+
});
|
|
3117
|
+
});
|
|
3118
|
+
|
|
3119
|
+
describe("held voice notes (ASX-1.3)", () => {
|
|
3120
|
+
const page = () =>
|
|
3121
|
+
renderChatThreadPage("agent-xyz", THREAD, [USER_MSG], "alice");
|
|
3122
|
+
|
|
3123
|
+
test("includes the IndexedDB held-upload store keyed by agent+thread", () => {
|
|
3124
|
+
const html = page();
|
|
3125
|
+
expect(html).toContain("indexedDB.open(HELD_DB");
|
|
3126
|
+
expect(html).toContain("var heldKey = agentId + ':' + threadId");
|
|
3127
|
+
expect(html).toContain("function holdUpload(");
|
|
3128
|
+
expect(html).toContain("function loadHeld(");
|
|
3129
|
+
expect(html).toContain("function deleteHeld(");
|
|
3130
|
+
});
|
|
3131
|
+
|
|
3132
|
+
test("holds the file on a 401 before showing the signed-out banner", () => {
|
|
3133
|
+
const html = page();
|
|
3134
|
+
expect(html).toContain("if (file) holdUpload(file, showSignedOut);");
|
|
3135
|
+
});
|
|
3136
|
+
|
|
3137
|
+
test("renders a hidden Resend/Discard prompt that never auto-sends", () => {
|
|
3138
|
+
const html = page();
|
|
3139
|
+
expect(html).toContain('id="held-upload-prompt"');
|
|
3140
|
+
expect(html).toContain("You have an unsent voice note.");
|
|
3141
|
+
expect(html).toContain('id="held-upload-resend"');
|
|
3142
|
+
expect(html).toContain('id="held-upload-discard"');
|
|
3143
|
+
expect(html).toContain("sendText('', rec.file)");
|
|
3144
|
+
// Only the resend click handler sends a held file.
|
|
3145
|
+
expect(html.match(/sendText\('', rec\.file\)/g)?.length).toBe(1);
|
|
3146
|
+
});
|
|
3147
|
+
|
|
3148
|
+
test("every IndexedDB access is guarded so failures are silent", () => {
|
|
3149
|
+
const html = page();
|
|
3150
|
+
expect(html).toContain("if (!window.indexedDB)");
|
|
3151
|
+
expect(html).toContain("req.onerror = function() { cb(null); }");
|
|
3152
|
+
});
|
|
3153
|
+
});
|
|
3082
3154
|
});
|
|
3083
3155
|
|
|
3084
3156
|
// ─── renderChatThreadPage — inline styles moved to classes (CFB-1.3) ────────
|
|
@@ -4085,6 +4157,7 @@ describe("renderChatThreadPage — poll auto-scroll (CSR-1.1)", () => {
|
|
|
4085
4157
|
: makeEl(),
|
|
4086
4158
|
createElement: makeEl,
|
|
4087
4159
|
querySelectorAll: () => [],
|
|
4160
|
+
addEventListener() {},
|
|
4088
4161
|
};
|
|
4089
4162
|
const fetchStub = () =>
|
|
4090
4163
|
Promise.resolve({ json: () => Promise.resolve({ messages: [] }) });
|
|
@@ -118,6 +118,199 @@ describe("admin UI — GET /admin/session.json", () => {
|
|
|
118
118
|
});
|
|
119
119
|
});
|
|
120
120
|
|
|
121
|
+
// ─── Sliding session renewal (ASX-2.1) ────────────────────────────────────────
|
|
122
|
+
|
|
123
|
+
describe("admin UI — sliding 7-day session renewal (ASX-2.1)", () => {
|
|
124
|
+
const SEVEN_DAYS = 7 * 24 * 60 * 60;
|
|
125
|
+
const nowSec = () => Math.floor(Date.now() / 1000);
|
|
126
|
+
|
|
127
|
+
async function tokenWith(opts: {
|
|
128
|
+
ageSeconds: number;
|
|
129
|
+
expiresInSeconds?: number;
|
|
130
|
+
email?: string;
|
|
131
|
+
userId?: string;
|
|
132
|
+
isAdmin?: boolean;
|
|
133
|
+
}): Promise<string> {
|
|
134
|
+
const iat = nowSec() - opts.ageSeconds;
|
|
135
|
+
return sign(
|
|
136
|
+
{
|
|
137
|
+
userId: opts.userId ?? "google-sub-123",
|
|
138
|
+
email: opts.email ?? "admin@example.com",
|
|
139
|
+
isAdmin: opts.isAdmin ?? true,
|
|
140
|
+
iat,
|
|
141
|
+
exp: nowSec() + (opts.expiresInSeconds ?? 3600),
|
|
142
|
+
},
|
|
143
|
+
SESSION_SECRET,
|
|
144
|
+
"HS256",
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function sessionCookieFrom(res: Response): string | null {
|
|
149
|
+
const raw = res.headers.get("Set-Cookie") ?? "";
|
|
150
|
+
const m = raw.match(/admin_session=([^;]+)/);
|
|
151
|
+
return m ? (m[1] as string) : null;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
async function payloadOf(token: string): Promise<Record<string, unknown>> {
|
|
155
|
+
const { verify } = await import("hono/jwt");
|
|
156
|
+
return (await verify(token, SESSION_SECRET, "HS256")) as Record<
|
|
157
|
+
string,
|
|
158
|
+
unknown
|
|
159
|
+
>;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
it("new Google login session has a 7-day exp and cookie Max-Age", async () => {
|
|
163
|
+
const nonce = "n1";
|
|
164
|
+
const app = createAdminUIApp(makeMockDeps());
|
|
165
|
+
const oauthState = encodeURIComponent(JSON.stringify({ nonce }));
|
|
166
|
+
const res = await app.request(
|
|
167
|
+
`https://example.com/admin/auth/callback?state=${nonce}&code=c`,
|
|
168
|
+
{ headers: { Cookie: `oauth_state=${oauthState}` } },
|
|
169
|
+
);
|
|
170
|
+
expect(res.headers.get("Set-Cookie")).toContain(`Max-Age=${SEVEN_DAYS}`);
|
|
171
|
+
const token = sessionCookieFrom(res);
|
|
172
|
+
expect(token).not.toBeNull();
|
|
173
|
+
const p = await payloadOf(token as string);
|
|
174
|
+
expect((p.exp as number) - (p.iat as number)).toBe(SEVEN_DAYS);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it("renews a valid token older than 1h with a fresh 7-day token and cookie", async () => {
|
|
178
|
+
const app = createAdminUIApp(makeMockDeps());
|
|
179
|
+
const old = await tokenWith({ ageSeconds: 2 * 3600 });
|
|
180
|
+
const res = await app.request("/admin/session.json", {
|
|
181
|
+
headers: { Cookie: `admin_session=${old}` },
|
|
182
|
+
});
|
|
183
|
+
expect(res.status).toBe(200);
|
|
184
|
+
const setCookie = res.headers.get("Set-Cookie") ?? "";
|
|
185
|
+
expect(setCookie).toContain(`Max-Age=${SEVEN_DAYS}`);
|
|
186
|
+
expect(setCookie).toContain("HttpOnly");
|
|
187
|
+
expect(setCookie).toContain("Secure");
|
|
188
|
+
expect(setCookie).toContain("SameSite=Lax");
|
|
189
|
+
expect(setCookie).toContain("Path=/");
|
|
190
|
+
const fresh = sessionCookieFrom(res) as string;
|
|
191
|
+
const p = await payloadOf(fresh);
|
|
192
|
+
expect(p.email).toBe("admin@example.com");
|
|
193
|
+
expect(p.userId).toBe("google-sub-123");
|
|
194
|
+
expect((p.exp as number) - (p.iat as number)).toBe(SEVEN_DAYS);
|
|
195
|
+
expect(Math.abs((p.iat as number) - nowSec())).toBeLessThan(5);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it("does not renew a token younger than 1h", async () => {
|
|
199
|
+
const app = createAdminUIApp(makeMockDeps());
|
|
200
|
+
const young = await tokenWith({ ageSeconds: 600 });
|
|
201
|
+
const res = await app.request("/admin/session.json", {
|
|
202
|
+
headers: { Cookie: `admin_session=${young}` },
|
|
203
|
+
});
|
|
204
|
+
expect(res.status).toBe(200);
|
|
205
|
+
expect(res.headers.get("Set-Cookie")).toBeNull();
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it("never renews an expired token (401 for XHR, 302 for navigation)", async () => {
|
|
209
|
+
const app = createAdminUIApp(makeMockDeps());
|
|
210
|
+
const expired = await tokenWith({
|
|
211
|
+
ageSeconds: 3 * 3600,
|
|
212
|
+
expiresInSeconds: -10,
|
|
213
|
+
});
|
|
214
|
+
const xhr = await app.request("/admin/session.json", {
|
|
215
|
+
headers: { Cookie: `admin_session=${expired}` },
|
|
216
|
+
});
|
|
217
|
+
expect(xhr.status).toBe(401);
|
|
218
|
+
expect(xhr.headers.get("Set-Cookie")).toBeNull();
|
|
219
|
+
const nav = await app.request("/admin/agents", {
|
|
220
|
+
headers: {
|
|
221
|
+
Cookie: `admin_session=${expired}`,
|
|
222
|
+
"Sec-Fetch-Mode": "navigate",
|
|
223
|
+
},
|
|
224
|
+
});
|
|
225
|
+
expect(nav.status).toBe(302);
|
|
226
|
+
expect(nav.headers.get("Set-Cookie")).toBeNull();
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it("never renews a token with a bad signature", async () => {
|
|
230
|
+
const app = createAdminUIApp(makeMockDeps());
|
|
231
|
+
const bad = await sign(
|
|
232
|
+
{
|
|
233
|
+
userId: "x",
|
|
234
|
+
email: "admin@example.com",
|
|
235
|
+
isAdmin: true,
|
|
236
|
+
iat: nowSec() - 7200,
|
|
237
|
+
exp: nowSec() + 3600,
|
|
238
|
+
},
|
|
239
|
+
"some-other-secret",
|
|
240
|
+
"HS256",
|
|
241
|
+
);
|
|
242
|
+
const res = await app.request("/admin/session.json", {
|
|
243
|
+
headers: { Cookie: `admin_session=${bad}` },
|
|
244
|
+
});
|
|
245
|
+
expect(res.status).toBe(401);
|
|
246
|
+
expect(res.headers.get("Set-Cookie")).toBeNull();
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
it("re-derives isAdmin from the current allowlist: removed email loses admin on renewal", async () => {
|
|
250
|
+
const app = createAdminUIApp(
|
|
251
|
+
makeMockDeps({ adminAllowedEmails: ["someone-else@example.com"] }),
|
|
252
|
+
);
|
|
253
|
+
const old = await tokenWith({ ageSeconds: 2 * 3600, isAdmin: true });
|
|
254
|
+
const res = await app.request("/admin/session.json", {
|
|
255
|
+
headers: { Cookie: `admin_session=${old}` },
|
|
256
|
+
});
|
|
257
|
+
expect(res.status).toBe(200);
|
|
258
|
+
const p = await payloadOf(sessionCookieFrom(res) as string);
|
|
259
|
+
expect(p.isAdmin).toBe(false);
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
it("re-derives isAdmin case-insensitively and can promote a newly allowlisted email", async () => {
|
|
263
|
+
const app = createAdminUIApp(
|
|
264
|
+
makeMockDeps({ adminAllowedEmails: ["Member@Example.com"] }),
|
|
265
|
+
);
|
|
266
|
+
const old = await tokenWith({
|
|
267
|
+
ageSeconds: 2 * 3600,
|
|
268
|
+
email: "member@example.com",
|
|
269
|
+
isAdmin: false,
|
|
270
|
+
});
|
|
271
|
+
const res = await app.request("/admin/session.json", {
|
|
272
|
+
headers: { Cookie: `admin_session=${old}` },
|
|
273
|
+
});
|
|
274
|
+
const p = await payloadOf(sessionCookieFrom(res) as string);
|
|
275
|
+
expect(p.isAdmin).toBe(true);
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
it("applies the renewed isAdmin to the current request (admin-only page denied after demotion)", async () => {
|
|
279
|
+
const app = createAdminUIApp(
|
|
280
|
+
makeMockDeps({ adminAllowedEmails: ["someone-else@example.com"] }),
|
|
281
|
+
);
|
|
282
|
+
const old = await tokenWith({ ageSeconds: 2 * 3600, isAdmin: true });
|
|
283
|
+
const res = await app.request("/admin/accounts", {
|
|
284
|
+
headers: { Cookie: `admin_session=${old}` },
|
|
285
|
+
});
|
|
286
|
+
expect([401, 403, 404]).toContain(res.status);
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
it("keeps the dev session admin on renewal when devAuthEnabled", async () => {
|
|
290
|
+
const app = createAdminUIApp(makeMockDeps({ devAuthEnabled: true }));
|
|
291
|
+
const old = await tokenWith({
|
|
292
|
+
ageSeconds: 2 * 3600,
|
|
293
|
+
userId: "dev",
|
|
294
|
+
email: "dev@localhost",
|
|
295
|
+
isAdmin: true,
|
|
296
|
+
});
|
|
297
|
+
const res = await app.request("/admin/session.json", {
|
|
298
|
+
headers: { Cookie: `admin_session=${old}` },
|
|
299
|
+
});
|
|
300
|
+
const p = await payloadOf(sessionCookieFrom(res) as string);
|
|
301
|
+
expect(p.isAdmin).toBe(true);
|
|
302
|
+
expect(p.userId).toBe("dev");
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
it("dev-login session also has a 7-day exp", async () => {
|
|
306
|
+
const app = createAdminUIApp(makeMockDeps({ devAuthEnabled: true }));
|
|
307
|
+
const res = await app.request("/admin/dev-login");
|
|
308
|
+
expect(res.headers.get("Set-Cookie")).toContain(`Max-Age=${SEVEN_DAYS}`);
|
|
309
|
+
const p = await payloadOf(sessionCookieFrom(res) as string);
|
|
310
|
+
expect((p.exp as number) - (p.iat as number)).toBe(SEVEN_DAYS);
|
|
311
|
+
});
|
|
312
|
+
});
|
|
313
|
+
|
|
121
314
|
// ─── Login page ───────────────────────────────────────────────────────────────
|
|
122
315
|
|
|
123
316
|
describe("admin UI — login page", () => {
|
package/src/admin-ui.ts
CHANGED
|
@@ -466,7 +466,11 @@ export interface AdminUIDeps {
|
|
|
466
466
|
const SESSION_COOKIE = "admin_session";
|
|
467
467
|
const OAUTH_STATE_COOKIE = "oauth_state";
|
|
468
468
|
const GOOGLE_AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth";
|
|
469
|
-
|
|
469
|
+
// Sliding idle timeout: every session (Google, Okta, dev-login) lives 7 days
|
|
470
|
+
// and is re-issued by the auth middleware once its token is older than
|
|
471
|
+
// SESSION_RENEW_AFTER_SECONDS, so an active user never gets signed out.
|
|
472
|
+
const SESSION_TTL_SECONDS = 7 * 24 * 60 * 60; // 7 days
|
|
473
|
+
const SESSION_RENEW_AFTER_SECONDS = 60 * 60; // 1 hour (renewal throttle)
|
|
470
474
|
const OAUTH_STATE_TTL_SECONDS = 600; // 10 min
|
|
471
475
|
|
|
472
476
|
// ─── Auth helpers ─────────────────────────────────────────────────────────────
|
|
@@ -476,8 +480,9 @@ async function createSessionToken(
|
|
|
476
480
|
userId: string,
|
|
477
481
|
email: string,
|
|
478
482
|
isAdmin: boolean,
|
|
483
|
+
nowSeconds?: number,
|
|
479
484
|
): Promise<string> {
|
|
480
|
-
const now = Math.floor(Date.now() / 1000);
|
|
485
|
+
const now = nowSeconds ?? Math.floor(Date.now() / 1000);
|
|
481
486
|
return sign(
|
|
482
487
|
{
|
|
483
488
|
userId,
|
|
@@ -494,7 +499,12 @@ async function createSessionToken(
|
|
|
494
499
|
async function getSessionUser(
|
|
495
500
|
token: string,
|
|
496
501
|
secret: string,
|
|
497
|
-
): Promise<{
|
|
502
|
+
): Promise<{
|
|
503
|
+
userId: string;
|
|
504
|
+
email: string;
|
|
505
|
+
isAdmin: boolean;
|
|
506
|
+
iat: number | undefined;
|
|
507
|
+
} | null> {
|
|
498
508
|
try {
|
|
499
509
|
const payload = (await verify(token, secret, "HS256")) as Record<
|
|
500
510
|
string,
|
|
@@ -506,7 +516,12 @@ async function getSessionUser(
|
|
|
506
516
|
typeof payload.email === "string" &&
|
|
507
517
|
payload.email.length > 0
|
|
508
518
|
) {
|
|
509
|
-
return {
|
|
519
|
+
return {
|
|
520
|
+
userId: payload.userId,
|
|
521
|
+
email: payload.email,
|
|
522
|
+
isAdmin: payload.isAdmin === true,
|
|
523
|
+
iat: typeof payload.iat === "number" ? payload.iat : undefined,
|
|
524
|
+
};
|
|
510
525
|
}
|
|
511
526
|
return null;
|
|
512
527
|
} catch {
|
|
@@ -538,9 +553,27 @@ function isNavigationRequest(c: Context): boolean {
|
|
|
538
553
|
);
|
|
539
554
|
}
|
|
540
555
|
|
|
556
|
+
export interface UIAuthMiddlewareOptions {
|
|
557
|
+
/** Current admin allowlist; isAdmin is re-derived from it on renewal. */
|
|
558
|
+
adminAllowedEmails?: string[];
|
|
559
|
+
/** Set the Secure flag on the renewed cookie (appBaseUrl is https). */
|
|
560
|
+
secureCookie?: boolean;
|
|
561
|
+
/** Keep the dev-login session (userId "dev") admin on renewal. */
|
|
562
|
+
devAuthEnabled?: boolean;
|
|
563
|
+
/** Injectable clock (epoch ms) for tests. */
|
|
564
|
+
now?: () => number;
|
|
565
|
+
}
|
|
566
|
+
|
|
541
567
|
export function createUIAuthMiddleware(
|
|
542
568
|
sessionSecret: string,
|
|
569
|
+
options: UIAuthMiddlewareOptions = {},
|
|
543
570
|
): MiddlewareHandler<AdminUIEnv> {
|
|
571
|
+
const {
|
|
572
|
+
adminAllowedEmails,
|
|
573
|
+
secureCookie = false,
|
|
574
|
+
devAuthEnabled = false,
|
|
575
|
+
now = Date.now,
|
|
576
|
+
} = options;
|
|
544
577
|
return async (c, next) => {
|
|
545
578
|
const sessionToken = getCookie(c, SESSION_COOKIE);
|
|
546
579
|
const user = sessionToken
|
|
@@ -562,8 +595,37 @@ export function createUIAuthMiddleware(
|
|
|
562
595
|
302,
|
|
563
596
|
);
|
|
564
597
|
}
|
|
598
|
+
let isAdmin = user.isAdmin;
|
|
599
|
+
const nowSec = Math.floor(now() / 1000);
|
|
600
|
+
// Sliding renewal: only when the caller supplied the allowlist (so the
|
|
601
|
+
// admin claim can be re-derived rather than copied from the old token).
|
|
602
|
+
if (
|
|
603
|
+
adminAllowedEmails &&
|
|
604
|
+
user.iat !== undefined &&
|
|
605
|
+
nowSec - user.iat > SESSION_RENEW_AFTER_SECONDS
|
|
606
|
+
) {
|
|
607
|
+
isAdmin =
|
|
608
|
+
(devAuthEnabled && user.userId === "dev" && user.isAdmin) ||
|
|
609
|
+
adminAllowedEmails
|
|
610
|
+
.map((e) => e.toLowerCase())
|
|
611
|
+
.includes(user.email.toLowerCase());
|
|
612
|
+
const renewed = await createSessionToken(
|
|
613
|
+
sessionSecret,
|
|
614
|
+
user.userId,
|
|
615
|
+
user.email,
|
|
616
|
+
isAdmin,
|
|
617
|
+
nowSec,
|
|
618
|
+
);
|
|
619
|
+
setCookie(c, SESSION_COOKIE, renewed, {
|
|
620
|
+
httpOnly: true,
|
|
621
|
+
secure: secureCookie,
|
|
622
|
+
sameSite: "Lax",
|
|
623
|
+
maxAge: SESSION_TTL_SECONDS,
|
|
624
|
+
path: "/",
|
|
625
|
+
});
|
|
626
|
+
}
|
|
565
627
|
c.set("userEmail", user.email);
|
|
566
|
-
c.set("isAdmin",
|
|
628
|
+
c.set("isAdmin", isAdmin);
|
|
567
629
|
return next();
|
|
568
630
|
};
|
|
569
631
|
}
|
|
@@ -754,7 +816,11 @@ export function createAdminUIApp(deps: AdminUIDeps): Hono<AdminUIEnv> {
|
|
|
754
816
|
|
|
755
817
|
const app = new Hono<AdminUIEnv>();
|
|
756
818
|
|
|
757
|
-
const requireAuth = createUIAuthMiddleware(sessionSecret
|
|
819
|
+
const requireAuth = createUIAuthMiddleware(sessionSecret, {
|
|
820
|
+
adminAllowedEmails,
|
|
821
|
+
secureCookie: appBaseUrl.startsWith("https://"),
|
|
822
|
+
devAuthEnabled,
|
|
823
|
+
});
|
|
758
824
|
|
|
759
825
|
// SSP-3.2: show the toolbar's "Account" entry only for signed-in users who
|
|
760
826
|
// belong to an account, and only while self-serve is on. The flag is carried
|