Add session version handling
This commit is contained in:
@@ -21,6 +21,8 @@ const loginUsernameInput = $("loginUsername");
|
|||||||
const loginPasswordInput = $("loginPassword");
|
const loginPasswordInput = $("loginPassword");
|
||||||
const registerUsernameInput = $("registerUsername");
|
const registerUsernameInput = $("registerUsername");
|
||||||
const registerPasswordInput = $("registerPassword");
|
const registerPasswordInput = $("registerPassword");
|
||||||
|
const registerInviteField = $("registerInviteField");
|
||||||
|
const registerInviteCodeInput = $("registerInviteCode");
|
||||||
const loginSubmit = $("loginSubmit");
|
const loginSubmit = $("loginSubmit");
|
||||||
const registerSubmit = $("registerSubmit");
|
const registerSubmit = $("registerSubmit");
|
||||||
const chatList = $("chatList");
|
const chatList = $("chatList");
|
||||||
@@ -190,6 +192,8 @@ export {
|
|||||||
popupModal,
|
popupModal,
|
||||||
popupTitle,
|
popupTitle,
|
||||||
registerForm,
|
registerForm,
|
||||||
|
registerInviteCodeInput,
|
||||||
|
registerInviteField,
|
||||||
registerPasswordInput,
|
registerPasswordInput,
|
||||||
registerSubmit,
|
registerSubmit,
|
||||||
registerUsernameInput,
|
registerUsernameInput,
|
||||||
|
|||||||
@@ -52,6 +52,10 @@
|
|||||||
<span>Create password</span>
|
<span>Create password</span>
|
||||||
<input id="registerPassword" type="password" placeholder="Create a password" autocomplete="new-password"/>
|
<input id="registerPassword" type="password" placeholder="Create a password" autocomplete="new-password"/>
|
||||||
</label>
|
</label>
|
||||||
|
<label id="registerInviteField" class="field hidden">
|
||||||
|
<span>Invite code</span>
|
||||||
|
<input id="registerInviteCode" placeholder="Enter invite code" autocomplete="off"/>
|
||||||
|
</label>
|
||||||
<div class="consent-container">
|
<div class="consent-container">
|
||||||
<input type="checkbox" id="consentCheckbox" required/>
|
<input type="checkbox" id="consentCheckbox" required/>
|
||||||
<label for="consentCheckbox">
|
<label for="consentCheckbox">
|
||||||
|
|||||||
+7
-1
@@ -78,6 +78,8 @@ import {
|
|||||||
popupModal,
|
popupModal,
|
||||||
popupTitle,
|
popupTitle,
|
||||||
registerForm,
|
registerForm,
|
||||||
|
registerInviteCodeInput,
|
||||||
|
registerInviteField,
|
||||||
registerPasswordInput,
|
registerPasswordInput,
|
||||||
registerSubmit,
|
registerSubmit,
|
||||||
registerUsernameInput,
|
registerUsernameInput,
|
||||||
@@ -1643,6 +1645,7 @@ async function displayModels() {
|
|||||||
|
|
||||||
async function checkSession() {
|
async function checkSession() {
|
||||||
const res = await get("/session");
|
const res = await get("/session");
|
||||||
|
if (registerInviteField) registerInviteField.classList.toggle("hidden", !res.inviteOnlyRegistration);
|
||||||
if (!res.user) return false;
|
if (!res.user) return false;
|
||||||
currentUsername = res.user; authDiv.style.display = "none"; chatDiv.style.display = "block";
|
currentUsername = res.user; authDiv.style.display = "none"; chatDiv.style.display = "block";
|
||||||
await Promise.all([displayModels(), loadSummary(), loadChatSessions(), loadPersonas()]);
|
await Promise.all([displayModels(), loadSummary(), loadChatSessions(), loadPersonas()]);
|
||||||
@@ -1654,7 +1657,10 @@ async function handleAuth(endpoint, credentials) {
|
|||||||
const {username, password} = credentials, submitBtn = endpoint === "login" ? loginSubmit : registerSubmit;
|
const {username, password} = credentials, submitBtn = endpoint === "login" ? loginSubmit : registerSubmit;
|
||||||
if (!username || !password) return setAuthMessage("Please enter both username and password.", "error");
|
if (!username || !password) return setAuthMessage("Please enter both username and password.", "error");
|
||||||
submitBtn.disabled = true; setAuthMessage("Processing...");
|
submitBtn.disabled = true; setAuthMessage("Processing...");
|
||||||
const res = await post(`/${endpoint}`, {username, password});
|
const payload = endpoint === "register"
|
||||||
|
? {username, password, inviteCode: registerInviteCodeInput?.value.trim() || ""}
|
||||||
|
: {username, password};
|
||||||
|
const res = await post(`/${endpoint}`, payload);
|
||||||
submitBtn.disabled = false;
|
submitBtn.disabled = false;
|
||||||
if (res.error) return setAuthMessage(res.error, "error");
|
if (res.error) return setAuthMessage(res.error, "error");
|
||||||
if (endpoint === "login") {
|
if (endpoint === "login") {
|
||||||
|
|||||||
@@ -81,6 +81,33 @@
|
|||||||
<button id="savePassword" type="submit">Save password</button>
|
<button id="savePassword" type="submit">Save password</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-panel">
|
||||||
|
<div class="settings-section-header">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Account data</p>
|
||||||
|
<h2>Export and sessions</h2>
|
||||||
|
<p class="subtitle">Download your account data or sign out every active browser session.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="persona-actions">
|
||||||
|
<button id="exportAccount" type="button" class="secondary-action">Export account</button>
|
||||||
|
<button id="logoutAllSessions" type="button" class="secondary-action">Logout all sessions</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-panel">
|
||||||
|
<div class="settings-section-header">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Danger zone</p>
|
||||||
|
<h2>Delete account</h2>
|
||||||
|
<p class="subtitle">This permanently removes your account, chats, personas, templates, and market activity.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="persona-actions">
|
||||||
|
<button id="deleteAccount" type="button" class="secondary-action danger">Delete account</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="settings-view" role="tabpanel" aria-hidden="true" data-settings-panel="my-roleplay-companions">
|
<section class="settings-view" role="tabpanel" aria-hidden="true" data-settings-panel="my-roleplay-companions">
|
||||||
|
|||||||
@@ -21,6 +21,9 @@ const usernamePasswordInput = $("usernamePassword");
|
|||||||
const currentPasswordInput = $("currentPassword");
|
const currentPasswordInput = $("currentPassword");
|
||||||
const newPasswordInput = $("newPassword");
|
const newPasswordInput = $("newPassword");
|
||||||
const repeatPasswordInput = $("repeatPassword");
|
const repeatPasswordInput = $("repeatPassword");
|
||||||
|
const exportAccountBtn = $("exportAccount");
|
||||||
|
const logoutAllSessionsBtn = $("logoutAllSessions");
|
||||||
|
const deleteAccountBtn = $("deleteAccount");
|
||||||
const usernameForm = $("usernameForm");
|
const usernameForm = $("usernameForm");
|
||||||
const passwordForm = $("passwordForm");
|
const passwordForm = $("passwordForm");
|
||||||
const settingsNavItems = document.querySelectorAll("[data-settings-view]");
|
const settingsNavItems = document.querySelectorAll("[data-settings-view]");
|
||||||
@@ -458,6 +461,56 @@ async function unpublishPersona(id) {
|
|||||||
setNotice("Persona unpublished.", "success");
|
setNotice("Persona unpublished.", "success");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function downloadJson(filename, payload) {
|
||||||
|
const blob = new Blob([JSON.stringify(payload, null, 2)], {type: "application/json;charset=utf-8"});
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.href = url;
|
||||||
|
link.download = filename;
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
link.remove();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function exportAccount() {
|
||||||
|
const res = await get("/settings/export");
|
||||||
|
if (res.error) return setNotice(res.error, "error");
|
||||||
|
const username = currentUsernameInput.value || "account";
|
||||||
|
downloadJson(`account-export-${username}-${new Date().toISOString().slice(0, 10)}.json`, res.account);
|
||||||
|
setNotice("Account export downloaded.", "success");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function logoutAllSessions() {
|
||||||
|
const confirmed = await promptSettingsPopup({
|
||||||
|
eyebrow: "Security",
|
||||||
|
title: "Logout all sessions",
|
||||||
|
description: "Type logout to sign out this browser and every other active session.",
|
||||||
|
label: "Confirmation",
|
||||||
|
placeholder: "logout",
|
||||||
|
confirmLabel: "Logout all"
|
||||||
|
});
|
||||||
|
if (String(confirmed || "").trim().toLowerCase() !== "logout") return;
|
||||||
|
const res = await post("/settings/logout-all-sessions", {});
|
||||||
|
if (res.error) return setNotice(res.error, "error");
|
||||||
|
window.location.href = "/";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteAccount() {
|
||||||
|
const currentPassword = await promptSettingsPopup({
|
||||||
|
eyebrow: "Danger zone",
|
||||||
|
title: "Delete account",
|
||||||
|
description: "Enter your current password to permanently delete your account and all account data.",
|
||||||
|
label: "Current password",
|
||||||
|
placeholder: "Password",
|
||||||
|
confirmLabel: "Delete account"
|
||||||
|
});
|
||||||
|
if (currentPassword === null) return;
|
||||||
|
const res = await request("/settings/account", {currentPassword}, "DELETE");
|
||||||
|
if (res.error) return setNotice(res.error, "error");
|
||||||
|
window.location.href = "/";
|
||||||
|
}
|
||||||
|
|
||||||
usernameForm.addEventListener("submit", async (event) => {
|
usernameForm.addEventListener("submit", async (event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const username = newUsernameInput.value.trim();
|
const username = newUsernameInput.value.trim();
|
||||||
@@ -514,6 +567,9 @@ workspaceModeSelect.addEventListener("change", (event) => {
|
|||||||
localStorage.setItem("krishd-workspace-mode", mode);
|
localStorage.setItem("krishd-workspace-mode", mode);
|
||||||
setNotice(`Workspace mode set to ${mode}.`, "success");
|
setNotice(`Workspace mode set to ${mode}.`, "success");
|
||||||
});
|
});
|
||||||
|
exportAccountBtn.addEventListener("click", () => { void exportAccount(); });
|
||||||
|
logoutAllSessionsBtn.addEventListener("click", () => { void logoutAllSessions(); });
|
||||||
|
deleteAccountBtn.addEventListener("click", () => { void deleteAccount(); });
|
||||||
refreshDiagnosticsBtn.addEventListener("click", () => { void loadDiagnostics(); });
|
refreshDiagnosticsBtn.addEventListener("click", () => { void loadDiagnostics(); });
|
||||||
|
|
||||||
settingsNavItems.forEach((item) => item.addEventListener("click", () => setSettingsView(item.dataset.settingsView)));
|
settingsNavItems.forEach((item) => item.addEventListener("click", () => setSettingsView(item.dataset.settingsView)));
|
||||||
|
|||||||
+19
-1
@@ -137,6 +137,23 @@ function sameOriginProtection(config) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function enforceSessionVersion(repositories) {
|
||||||
|
return (req, res, next) => {
|
||||||
|
if (!req.session.user) return next();
|
||||||
|
const user = repositories.getUser(req.session.user);
|
||||||
|
const currentVersion = Number(user?.session_version || 0);
|
||||||
|
const sessionVersion = Number(req.session.sessionVersion || 0);
|
||||||
|
if (user && currentVersion === sessionVersion) return next();
|
||||||
|
|
||||||
|
req.session.destroy(() => {
|
||||||
|
if (req.path === "/session") {
|
||||||
|
return res.json({user: null});
|
||||||
|
}
|
||||||
|
return res.status(403).json({error: "Session expired. Please log in again."});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function createApp(options = {}) {
|
export function createApp(options = {}) {
|
||||||
const config = createConfig(options.config);
|
const config = createConfig(options.config);
|
||||||
const app = express();
|
const app = express();
|
||||||
@@ -181,6 +198,7 @@ export function createApp(options = {}) {
|
|||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
app.use(sameOriginProtection(config));
|
app.use(sameOriginProtection(config));
|
||||||
|
app.use(enforceSessionVersion(repositories));
|
||||||
|
|
||||||
app.locals.config = config;
|
app.locals.config = config;
|
||||||
app.locals.db = db;
|
app.locals.db = db;
|
||||||
@@ -188,7 +206,7 @@ export function createApp(options = {}) {
|
|||||||
app.locals.modelService = modelService;
|
app.locals.modelService = modelService;
|
||||||
app.locals.close = createCloseHandler(closeHandlers);
|
app.locals.close = createCloseHandler(closeHandlers);
|
||||||
|
|
||||||
app.use(createAuthRouter({repositories, authRateLimiters}));
|
app.use(createAuthRouter({repositories, authRateLimiters, config}));
|
||||||
app.use(createSettingsRouter({repositories}));
|
app.use(createSettingsRouter({repositories}));
|
||||||
app.use(createChatsRouter({repositories, chatService, modelService, config, chatRateLimiters}));
|
app.use(createChatsRouter({repositories, chatService, modelService, config, chatRateLimiters}));
|
||||||
app.use(createPersonasRouter({repositories, chatService, modelService, config}));
|
app.use(createPersonasRouter({repositories, chatService, modelService, config}));
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ export function createConfig(overrides = {}) {
|
|||||||
allowInsecureCookies: bool(process.env.ALLOW_INSECURE_COOKIES, false),
|
allowInsecureCookies: bool(process.env.ALLOW_INSECURE_COOKIES, false),
|
||||||
trustProxy: trustProxy(process.env.TRUST_PROXY, false),
|
trustProxy: trustProxy(process.env.TRUST_PROXY, false),
|
||||||
sessionMaxAgeMs: int(process.env.SESSION_MAX_AGE_MS, 7 * 24 * 60 * 60 * 1000),
|
sessionMaxAgeMs: int(process.env.SESSION_MAX_AGE_MS, 7 * 24 * 60 * 60 * 1000),
|
||||||
|
registrationInviteCode: process.env.REGISTRATION_INVITE_CODE || "",
|
||||||
sameOriginProtectionEnabled: bool(process.env.SAME_ORIGIN_PROTECTION_ENABLED, true),
|
sameOriginProtectionEnabled: bool(process.env.SAME_ORIGIN_PROTECTION_ENABLED, true),
|
||||||
staticMaxAge: process.env.STATIC_MAX_AGE || (process.env.NODE_ENV === "production" ? "1h" : 0),
|
staticMaxAge: process.env.STATIC_MAX_AGE || (process.env.NODE_ENV === "production" ? "1h" : 0),
|
||||||
staticEtag: bool(process.env.STATIC_ETAG, true),
|
staticEtag: bool(process.env.STATIC_ETAG, true),
|
||||||
|
|||||||
+32
-1
@@ -41,9 +41,10 @@ function normalizeMarketPersonaType(personaType) {
|
|||||||
export function createRepositories(db, config) {
|
export function createRepositories(db, config) {
|
||||||
const statements = {
|
const statements = {
|
||||||
insertUser: db.prepare("INSERT INTO users (username, password) VALUES (?, ?)"),
|
insertUser: db.prepare("INSERT INTO users (username, password) VALUES (?, ?)"),
|
||||||
getUser: db.prepare("SELECT username, password FROM users WHERE username = ?"),
|
getUser: db.prepare("SELECT username, password, session_version FROM users WHERE username = ?"),
|
||||||
updateUsername: db.prepare("UPDATE users SET username = ? WHERE username = ?"),
|
updateUsername: db.prepare("UPDATE users SET username = ? WHERE username = ?"),
|
||||||
updatePassword: db.prepare("UPDATE users SET password = ? WHERE username = ?"),
|
updatePassword: db.prepare("UPDATE users SET password = ? WHERE username = ?"),
|
||||||
|
incrementSessionVersion: db.prepare("UPDATE users SET session_version = COALESCE(session_version, 0) + 1 WHERE username = ?"),
|
||||||
insertChatSession: db.prepare(
|
insertChatSession: db.prepare(
|
||||||
"INSERT INTO chat_sessions (username, title, assistant_persona_id, user_persona_id, folder_name, is_pinned, archived_at, scenario_prompt, scenario_summary) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)"
|
"INSERT INTO chat_sessions (username, title, assistant_persona_id, user_persona_id, folder_name, is_pinned, archived_at, scenario_prompt, scenario_summary) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)"
|
||||||
),
|
),
|
||||||
@@ -547,6 +548,17 @@ export function createRepositories(db, config) {
|
|||||||
updateMarketFavoritesUsername: db.prepare("UPDATE persona_market_favorites SET username = ? WHERE username = ?"),
|
updateMarketFavoritesUsername: db.prepare("UPDATE persona_market_favorites SET username = ? WHERE username = ?"),
|
||||||
updateMarketRatingsUsername: db.prepare("UPDATE persona_market_ratings SET username = ? WHERE username = ?"),
|
updateMarketRatingsUsername: db.prepare("UPDATE persona_market_ratings SET username = ? WHERE username = ?"),
|
||||||
updateMarketReportsUsername: db.prepare("UPDATE persona_market_reports SET reporter_username = ? WHERE reporter_username = ?"),
|
updateMarketReportsUsername: db.prepare("UPDATE persona_market_reports SET reporter_username = ? WHERE reporter_username = ?"),
|
||||||
|
deleteChatMemoriesByUser: db.prepare("DELETE FROM chat_memories WHERE username = ?"),
|
||||||
|
deleteChatSessionsByUser: db.prepare("DELETE FROM chat_sessions WHERE username = ?"),
|
||||||
|
deletePersonasByUser: db.prepare("DELETE FROM personas WHERE username = ?"),
|
||||||
|
deleteUserSettingsByUser: db.prepare("DELETE FROM user_settings WHERE username = ?"),
|
||||||
|
deletePersonaVersionsByUser: db.prepare("DELETE FROM persona_versions WHERE username = ?"),
|
||||||
|
deletePromptTemplatesByUser: db.prepare("DELETE FROM prompt_templates WHERE username = ?"),
|
||||||
|
deleteMarketPersonasByCreator: db.prepare("DELETE FROM persona_market WHERE creator_username = ?"),
|
||||||
|
deleteMarketFavoritesByUser: db.prepare("DELETE FROM persona_market_favorites WHERE username = ?"),
|
||||||
|
deleteMarketRatingsByUser: db.prepare("DELETE FROM persona_market_ratings WHERE username = ?"),
|
||||||
|
deleteMarketReportsByUser: db.prepare("DELETE FROM persona_market_reports WHERE reporter_username = ?"),
|
||||||
|
deleteUser: db.prepare("DELETE FROM users WHERE username = ?"),
|
||||||
deleteMarketPersonaByPersonaId: db.prepare("DELETE FROM persona_market WHERE persona_id = ? AND creator_username = ?"),
|
deleteMarketPersonaByPersonaId: db.prepare("DELETE FROM persona_market WHERE persona_id = ? AND creator_username = ?"),
|
||||||
incrementMarketUsageCount: db.prepare("UPDATE persona_market SET usage_count = usage_count + 1 WHERE id = ?")
|
incrementMarketUsageCount: db.prepare("UPDATE persona_market SET usage_count = usage_count + 1 WHERE id = ?")
|
||||||
};
|
};
|
||||||
@@ -566,6 +578,20 @@ export function createRepositories(db, config) {
|
|||||||
statements.updateMarketReportsUsername.run(nextUsername, currentUsername);
|
statements.updateMarketReportsUsername.run(nextUsername, currentUsername);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const deleteUserTransaction = db.transaction((username) => {
|
||||||
|
statements.deleteMarketFavoritesByUser.run(username);
|
||||||
|
statements.deleteMarketRatingsByUser.run(username);
|
||||||
|
statements.deleteMarketReportsByUser.run(username);
|
||||||
|
statements.deleteMarketPersonasByCreator.run(username);
|
||||||
|
statements.deletePromptTemplatesByUser.run(username);
|
||||||
|
statements.deletePersonaVersionsByUser.run(username);
|
||||||
|
statements.deleteChatMemoriesByUser.run(username);
|
||||||
|
statements.deleteChatSessionsByUser.run(username);
|
||||||
|
statements.deleteUserSettingsByUser.run(username);
|
||||||
|
statements.deletePersonasByUser.run(username);
|
||||||
|
return statements.deleteUser.run(username);
|
||||||
|
});
|
||||||
|
|
||||||
const savePersonaVersion = db.transaction((personaId, username) => {
|
const savePersonaVersion = db.transaction((personaId, username) => {
|
||||||
const persona = statements.getPersona.get(personaId, username);
|
const persona = statements.getPersona.get(personaId, username);
|
||||||
if (!persona) return null;
|
if (!persona) return null;
|
||||||
@@ -670,7 +696,12 @@ export function createRepositories(db, config) {
|
|||||||
getUser: (username) => statements.getUser.get(username),
|
getUser: (username) => statements.getUser.get(username),
|
||||||
insertUser: (username, passwordHash) => statements.insertUser.run(username, passwordHash),
|
insertUser: (username, passwordHash) => statements.insertUser.run(username, passwordHash),
|
||||||
updatePassword: (passwordHash, username) => statements.updatePassword.run(passwordHash, username),
|
updatePassword: (passwordHash, username) => statements.updatePassword.run(passwordHash, username),
|
||||||
|
incrementSessionVersion(username) {
|
||||||
|
statements.incrementSessionVersion.run(username);
|
||||||
|
return statements.getUser.get(username);
|
||||||
|
},
|
||||||
renameUser: (currentUsername, nextUsername) => renameUserTransaction(currentUsername, nextUsername),
|
renameUser: (currentUsername, nextUsername) => renameUserTransaction(currentUsername, nextUsername),
|
||||||
|
deleteUser: (username) => deleteUserTransaction(username),
|
||||||
listChats: (username) => statements.listChatSessions.all(username),
|
listChats: (username) => statements.listChatSessions.all(username),
|
||||||
searchChats(username, query) {
|
searchChats(username, query) {
|
||||||
const like = `%${String(query || "").trim().toLowerCase()}%`;
|
const like = `%${String(query || "").trim().toLowerCase()}%`;
|
||||||
|
|||||||
+6
-1
@@ -6,7 +6,8 @@ export function initializeSchema(db) {
|
|||||||
CREATE TABLE IF NOT EXISTS users
|
CREATE TABLE IF NOT EXISTS users
|
||||||
(
|
(
|
||||||
username TEXT PRIMARY KEY,
|
username TEXT PRIMARY KEY,
|
||||||
password TEXT NOT NULL
|
password TEXT NOT NULL,
|
||||||
|
session_version INTEGER DEFAULT 0
|
||||||
)
|
)
|
||||||
`).run();
|
`).run();
|
||||||
|
|
||||||
@@ -204,6 +205,10 @@ export function runMigrations(db, {dropLegacyChats = true} = {}) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const userColumns = db.prepare("PRAGMA table_info(users)").all();
|
||||||
|
ensureColumn("users", userColumns, "session_version", "ALTER TABLE users ADD COLUMN session_version INTEGER DEFAULT 0");
|
||||||
|
db.prepare("UPDATE users SET session_version = 0 WHERE session_version IS NULL").run();
|
||||||
|
|
||||||
const personaColumns = db.prepare("PRAGMA table_info(personas)").all();
|
const personaColumns = db.prepare("PRAGMA table_info(personas)").all();
|
||||||
ensureColumn("personas", personaColumns, "custom_fields", "ALTER TABLE personas ADD COLUMN custom_fields TEXT");
|
ensureColumn("personas", personaColumns, "custom_fields", "ALTER TABLE personas ADD COLUMN custom_fields TEXT");
|
||||||
ensureColumn("personas", personaColumns, "persona_type", "ALTER TABLE personas ADD COLUMN persona_type TEXT DEFAULT 'assistant'");
|
ensureColumn("personas", personaColumns, "persona_type", "ALTER TABLE personas ADD COLUMN persona_type TEXT DEFAULT 'assistant'");
|
||||||
|
|||||||
+12
-4
@@ -5,18 +5,22 @@ import {validateBody} from "../middleware/validate.js";
|
|||||||
const credentialsValidator = (body) => {
|
const credentialsValidator = (body) => {
|
||||||
const username = String(body?.username || "").trim();
|
const username = String(body?.username || "").trim();
|
||||||
const password = String(body?.password || "");
|
const password = String(body?.password || "");
|
||||||
|
const inviteCode = String(body?.inviteCode || "").trim();
|
||||||
if (!username) return {error: "Username is required"};
|
if (!username) return {error: "Username is required"};
|
||||||
if (username.length < 3) return {error: "Username must be at least 3 characters"};
|
if (username.length < 3) return {error: "Username must be at least 3 characters"};
|
||||||
if (!password) return {error: "Password is required"};
|
if (!password) return {error: "Password is required"};
|
||||||
if (password.length < 6) return {error: "Password must be at least 6 characters"};
|
if (password.length < 6) return {error: "Password must be at least 6 characters"};
|
||||||
return {value: {username, password}};
|
return {value: {username, password, inviteCode}};
|
||||||
};
|
};
|
||||||
|
|
||||||
export function createAuthRouter({repositories, authRateLimiters}) {
|
export function createAuthRouter({repositories, authRateLimiters, config}) {
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
router.post("/register", ...authRateLimiters, validateBody(credentialsValidator), async (req, res) => {
|
router.post("/register", ...authRateLimiters, validateBody(credentialsValidator), async (req, res) => {
|
||||||
const {username, password} = req.validatedBody;
|
const {username, password, inviteCode} = req.validatedBody;
|
||||||
|
if (config.registrationInviteCode && inviteCode !== config.registrationInviteCode) {
|
||||||
|
return res.status(403).json({error: "Valid invite code is required"});
|
||||||
|
}
|
||||||
if (repositories.getUser(username)) {
|
if (repositories.getUser(username)) {
|
||||||
return res.status(400).json({error: "User already exists"});
|
return res.status(400).json({error: "User already exists"});
|
||||||
}
|
}
|
||||||
@@ -33,6 +37,7 @@ export function createAuthRouter({repositories, authRateLimiters}) {
|
|||||||
const valid = await bcrypt.compare(password, user.password);
|
const valid = await bcrypt.compare(password, user.password);
|
||||||
if (!valid) return res.status(403).json({error: "Invalid password"});
|
if (!valid) return res.status(403).json({error: "Invalid password"});
|
||||||
req.session.user = username;
|
req.session.user = username;
|
||||||
|
req.session.sessionVersion = Number(user.session_version || 0);
|
||||||
return res.json({message: "Login successful"});
|
return res.json({message: "Login successful"});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -41,7 +46,10 @@ export function createAuthRouter({repositories, authRateLimiters}) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
router.get("/session", (req, res) => {
|
router.get("/session", (req, res) => {
|
||||||
return res.json({user: req.session.user || null});
|
return res.json({
|
||||||
|
user: req.session.user || null,
|
||||||
|
inviteOnlyRegistration: Boolean(config.registrationInviteCode)
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
return router;
|
return router;
|
||||||
|
|||||||
@@ -20,6 +20,14 @@ const passwordValidator = (body) => {
|
|||||||
return {value: {currentPassword, newPassword}};
|
return {value: {currentPassword, newPassword}};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const currentPasswordValidator = (body) => {
|
||||||
|
const currentPassword = String(body?.currentPassword || "");
|
||||||
|
if (!currentPassword) return {error: "Current password is required"};
|
||||||
|
return {value: {currentPassword}};
|
||||||
|
};
|
||||||
|
|
||||||
|
const destroySession = (req) => new Promise((resolve) => req.session.destroy(() => resolve()));
|
||||||
|
|
||||||
export function createSettingsRouter({repositories}) {
|
export function createSettingsRouter({repositories}) {
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
router.use(requireLogin);
|
router.use(requireLogin);
|
||||||
@@ -59,8 +67,38 @@ export function createSettingsRouter({repositories}) {
|
|||||||
if (!valid) return res.status(403).json({error: "Current password is incorrect"});
|
if (!valid) return res.status(403).json({error: "Current password is incorrect"});
|
||||||
const hashed = await bcrypt.hash(newPassword, 10);
|
const hashed = await bcrypt.hash(newPassword, 10);
|
||||||
repositories.updatePassword(hashed, username);
|
repositories.updatePassword(hashed, username);
|
||||||
|
const updatedUser = repositories.incrementSessionVersion(username);
|
||||||
|
req.session.sessionVersion = Number(updatedUser.session_version || 0);
|
||||||
return res.json({message: "Password updated"});
|
return res.json({message: "Password updated"});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
router.post("/settings/logout-all-sessions", (req, res) => {
|
||||||
|
repositories.incrementSessionVersion(req.session.user);
|
||||||
|
req.session.destroy(() => res.json({message: "All sessions logged out"}));
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get("/settings/export", (req, res) => {
|
||||||
|
const user = repositories.getUser(req.session.user);
|
||||||
|
if (!user) return res.status(404).json({error: "User not found"});
|
||||||
|
return res.json({
|
||||||
|
account: {
|
||||||
|
username: user.username,
|
||||||
|
exportedAt: new Date().toISOString(),
|
||||||
|
workspace: repositories.exportWorkspace(req.session.user)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
router.delete("/settings/account", validateBody(currentPasswordValidator), async (req, res) => {
|
||||||
|
const username = req.session.user;
|
||||||
|
const user = repositories.getUser(username);
|
||||||
|
if (!user) return res.status(404).json({error: "User not found"});
|
||||||
|
const valid = await bcrypt.compare(req.validatedBody.currentPassword, user.password);
|
||||||
|
if (!valid) return res.status(403).json({error: "Current password is incorrect"});
|
||||||
|
repositories.deleteUser(username);
|
||||||
|
await destroySession(req);
|
||||||
|
return res.json({message: "Account deleted"});
|
||||||
|
});
|
||||||
|
|
||||||
return router;
|
return router;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -140,6 +140,25 @@ test("auth flow validates inputs and persists session", async () => {
|
|||||||
await server.close();
|
await server.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("invite-only registration requires invite code", async () => {
|
||||||
|
const server = await startTestServer({config: {registrationInviteCode: "let-me-in"}});
|
||||||
|
|
||||||
|
await request(server.baseUrl, "/register", {
|
||||||
|
method: "POST",
|
||||||
|
body: {username: "invitee", password: "password123"}
|
||||||
|
}).then(({status, json}) => {
|
||||||
|
assert.equal(status, 403);
|
||||||
|
assert.match(json.error, /invite code/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
await request(server.baseUrl, "/register", {
|
||||||
|
method: "POST",
|
||||||
|
body: {username: "invitee", password: "password123", inviteCode: "let-me-in"}
|
||||||
|
}).then(({status}) => assert.equal(status, 200));
|
||||||
|
|
||||||
|
await server.close();
|
||||||
|
});
|
||||||
|
|
||||||
test("createApp can use an injected session store", async () => {
|
test("createApp can use an injected session store", async () => {
|
||||||
class CountingStore extends session.Store {
|
class CountingStore extends session.Store {
|
||||||
constructor() {
|
constructor() {
|
||||||
@@ -183,6 +202,53 @@ test("createApp can use an injected session store", async () => {
|
|||||||
await server.close();
|
await server.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("account export, logout-all, and deletion controls work", async () => {
|
||||||
|
const server = await startTestServer();
|
||||||
|
const firstJar = new CookieJar();
|
||||||
|
const secondJar = new CookieJar();
|
||||||
|
|
||||||
|
await request(server.baseUrl, "/register", {method: "POST", body: {username: "owner", password: "password123"}});
|
||||||
|
await request(server.baseUrl, "/login", {method: "POST", body: {username: "owner", password: "password123"}, jar: firstJar});
|
||||||
|
await request(server.baseUrl, "/login", {method: "POST", body: {username: "owner", password: "password123"}, jar: secondJar});
|
||||||
|
|
||||||
|
const exported = await request(server.baseUrl, "/settings/export", {jar: firstJar});
|
||||||
|
assert.equal(exported.status, 200);
|
||||||
|
assert.equal(exported.json.account.username, "owner");
|
||||||
|
assert.ok(exported.json.account.workspace);
|
||||||
|
|
||||||
|
const logoutAll = await request(server.baseUrl, "/settings/logout-all-sessions", {
|
||||||
|
method: "POST",
|
||||||
|
body: {},
|
||||||
|
jar: firstJar
|
||||||
|
});
|
||||||
|
assert.equal(logoutAll.status, 200);
|
||||||
|
const staleSession = await request(server.baseUrl, "/settings/profile", {jar: secondJar});
|
||||||
|
assert.equal(staleSession.status, 403);
|
||||||
|
|
||||||
|
await request(server.baseUrl, "/login", {method: "POST", body: {username: "owner", password: "password123"}, jar: firstJar});
|
||||||
|
const wrongDelete = await request(server.baseUrl, "/settings/account", {
|
||||||
|
method: "DELETE",
|
||||||
|
body: {currentPassword: "wrong-password"},
|
||||||
|
jar: firstJar
|
||||||
|
});
|
||||||
|
assert.equal(wrongDelete.status, 403);
|
||||||
|
|
||||||
|
const deleted = await request(server.baseUrl, "/settings/account", {
|
||||||
|
method: "DELETE",
|
||||||
|
body: {currentPassword: "password123"},
|
||||||
|
jar: firstJar
|
||||||
|
});
|
||||||
|
assert.equal(deleted.status, 200);
|
||||||
|
|
||||||
|
const loginAfterDelete = await request(server.baseUrl, "/login", {
|
||||||
|
method: "POST",
|
||||||
|
body: {username: "owner", password: "password123"}
|
||||||
|
});
|
||||||
|
assert.equal(loginAfterDelete.status, 400);
|
||||||
|
|
||||||
|
await server.close();
|
||||||
|
});
|
||||||
|
|
||||||
test("admin diagnostics require admin and return model status", async () => {
|
test("admin diagnostics require admin and return model status", async () => {
|
||||||
const server = await startTestServer({
|
const server = await startTestServer({
|
||||||
config: {
|
config: {
|
||||||
|
|||||||
Reference in New Issue
Block a user