refactor: migrate multi-user to use existing user_data table

- Update migration to extend user_data table instead of creating new users table
- Refactor user_management service to work with tmpID (INTEGER) primary key
- Update login.ts to support multi-user authentication with user_data
- Fix auth.ts middleware to use new user management API
- Update API routes to handle tmpID-based user identification
- Store userId as number in session for consistency

This integrates with Trilium's existing OAuth user_data table (v229) and
maintains backward compatibility with single-user installations.
This commit is contained in:
Somoru 2025-10-21 14:33:37 +05:30
parent 1bf9a858eb
commit 883ca1ffc8
5 changed files with 233 additions and 426 deletions

View File

@ -2,178 +2,78 @@
* Migration to add multi-user support to Trilium.
*
* This migration:
* 1. Creates users table
* 2. Creates roles table
* 3. Creates user_roles junction table
* 4. Creates note_shares table for shared notes
* 5. Adds userId column to existing tables (notes, branches, options, etapi_tokens, etc.)
* 6. Creates a default admin user with existing password
* 7. Associates all existing data with the admin user
* 1. Extends existing user_data table with multi-user fields
* 2. Migrates existing password to first user record
* 3. Adds userId columns to relevant tables (notes, branches, etapi_tokens, recent_notes)
* 4. Associates all existing data with the default user
*
* Note: This reuses the existing user_data table from migration 229 (OAuth)
*/
import sql from "../services/sql.js";
import optionService from "../services/options.js";
import { randomSecureToken, toBase64 } from "../services/utils.js";
import myScryptService from "../services/encryption/my_scrypt.js";
export default async () => {
console.log("Starting multi-user support migration (v234)...");
// 1. Create users table
sql.execute(`
CREATE TABLE IF NOT EXISTS "users" (
userId TEXT PRIMARY KEY,
username TEXT NOT NULL UNIQUE,
email TEXT,
passwordHash TEXT NOT NULL,
passwordSalt TEXT NOT NULL,
derivedKeySalt TEXT NOT NULL,
encryptedDataKey TEXT,
isActive INTEGER NOT NULL DEFAULT 1,
isAdmin INTEGER NOT NULL DEFAULT 0,
utcDateCreated TEXT NOT NULL,
utcDateModified TEXT NOT NULL
)
`);
sql.execute(`CREATE INDEX IF NOT EXISTS IDX_users_username ON users (username)`);
sql.execute(`CREATE INDEX IF NOT EXISTS IDX_users_email ON users (email)`);
sql.execute(`CREATE INDEX IF NOT EXISTS IDX_users_isActive ON users (isActive)`);
// 2. Create roles table
sql.execute(`
CREATE TABLE IF NOT EXISTS "roles" (
roleId TEXT PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
description TEXT,
permissions TEXT NOT NULL,
utcDateCreated TEXT NOT NULL,
utcDateModified TEXT NOT NULL
)
`);
// 3. Create user_roles junction table
sql.execute(`
CREATE TABLE IF NOT EXISTS "user_roles" (
userId TEXT NOT NULL,
roleId TEXT NOT NULL,
utcDateAssigned TEXT NOT NULL,
PRIMARY KEY (userId, roleId),
FOREIGN KEY (userId) REFERENCES users(userId) ON DELETE CASCADE,
FOREIGN KEY (roleId) REFERENCES roles(roleId) ON DELETE CASCADE
)
`);
// 4. Create note_shares table for sharing notes between users
sql.execute(`
CREATE TABLE IF NOT EXISTS "note_shares" (
shareId TEXT PRIMARY KEY,
noteId TEXT NOT NULL,
ownerId TEXT NOT NULL,
sharedWithUserId TEXT NOT NULL,
permission TEXT NOT NULL DEFAULT 'read',
utcDateCreated TEXT NOT NULL,
utcDateModified TEXT NOT NULL,
isDeleted INTEGER NOT NULL DEFAULT 0,
FOREIGN KEY (noteId) REFERENCES notes(noteId) ON DELETE CASCADE,
FOREIGN KEY (ownerId) REFERENCES users(userId) ON DELETE CASCADE,
FOREIGN KEY (sharedWithUserId) REFERENCES users(userId) ON DELETE CASCADE
)
`);
sql.execute(`CREATE INDEX IF NOT EXISTS IDX_note_shares_noteId ON note_shares (noteId)`);
sql.execute(`CREATE INDEX IF NOT EXISTS IDX_note_shares_sharedWithUserId ON note_shares (sharedWithUserId)`);
// 5. Add userId columns to existing tables (if they don't exist)
const addUserIdColumn = (tableName: string) => {
// Check if column already exists
// 1. Extend user_data table with additional fields for multi-user support
const addColumnIfNotExists = (tableName: string, columnName: string, columnDef: string) => {
const columns = sql.getRows(`PRAGMA table_info(${tableName})`);
const hasUserId = columns.some((col: any) => col.name === 'userId');
const hasColumn = columns.some((col: any) => col.name === columnName);
if (!hasUserId) {
sql.execute(`ALTER TABLE ${tableName} ADD COLUMN userId TEXT`);
console.log(`Added userId column to ${tableName}`);
if (!hasColumn) {
sql.execute(`ALTER TABLE ${tableName} ADD COLUMN ${columnName} ${columnDef}`);
console.log(`Added ${columnName} column to ${tableName}`);
}
};
// Add role/permission tracking
addColumnIfNotExists('user_data', 'role', 'TEXT DEFAULT "admin"');
addColumnIfNotExists('user_data', 'isActive', 'INTEGER DEFAULT 1');
addColumnIfNotExists('user_data', 'utcDateCreated', 'TEXT');
addColumnIfNotExists('user_data', 'utcDateModified', 'TEXT');
// Create index on username for faster lookups
sql.execute(`CREATE INDEX IF NOT EXISTS IDX_user_data_username ON user_data (username)`);
// 2. Add userId columns to existing tables (if they don't exist)
const addUserIdColumn = (tableName: string) => {
addColumnIfNotExists(tableName, 'userId', 'INTEGER');
};
addUserIdColumn('notes');
addUserIdColumn('branches');
addUserIdColumn('recent_notes');
addUserIdColumn('etapi_tokens');
// Create indexes for userId columns
// Create indexes for userId columns for better performance
sql.execute(`CREATE INDEX IF NOT EXISTS IDX_notes_userId ON notes (userId)`);
sql.execute(`CREATE INDEX IF NOT EXISTS IDX_branches_userId ON branches (userId)`);
sql.execute(`CREATE INDEX IF NOT EXISTS IDX_etapi_tokens_userId ON etapi_tokens (userId)`);
sql.execute(`CREATE INDEX IF NOT EXISTS IDX_recent_notes_userId ON recent_notes (userId)`);
// 6. Create default roles
const now = new Date().toISOString();
// 3. Migrate existing single-user setup to first user in user_data table
const existingUser = sql.getValue(`SELECT COUNT(*) as count FROM user_data`) as number;
const defaultRoles = [
{
roleId: 'role_admin',
name: 'admin',
description: 'Full system administrator with all permissions',
permissions: JSON.stringify({
notes: ['create', 'read', 'update', 'delete'],
users: ['create', 'read', 'update', 'delete'],
settings: ['read', 'update'],
system: ['backup', 'restore', 'migrate']
})
},
{
roleId: 'role_user',
name: 'user',
description: 'Regular user with standard permissions',
permissions: JSON.stringify({
notes: ['create', 'read', 'update', 'delete'],
users: ['read_self', 'update_self'],
settings: ['read_self']
})
},
{
roleId: 'role_viewer',
name: 'viewer',
description: 'Read-only user',
permissions: JSON.stringify({
notes: ['read'],
users: ['read_self']
})
}
];
if (existingUser === 0) {
// Get existing password components from options
const passwordVerificationHash = optionService.getOption('passwordVerificationHash');
const passwordVerificationSalt = optionService.getOption('passwordVerificationSalt');
const passwordDerivedKeySalt = optionService.getOption('passwordDerivedKeySalt');
const encryptedDataKey = optionService.getOption('encryptedDataKey');
for (const role of defaultRoles) {
sql.execute(`
INSERT OR IGNORE INTO roles (roleId, name, description, permissions, utcDateCreated, utcDateModified)
VALUES (?, ?, ?, ?, ?, ?)
`, [role.roleId, role.name, role.description, role.permissions, now, now]);
}
// 7. Create default admin user from existing password
const adminUserId = 'user_admin_' + randomSecureToken(10);
// Get existing password hash components
const passwordVerificationHash = optionService.getOption('passwordVerificationHash');
const passwordVerificationSalt = optionService.getOption('passwordVerificationSalt');
const passwordDerivedKeySalt = optionService.getOption('passwordDerivedKeySalt');
const encryptedDataKey = optionService.getOption('encryptedDataKey');
if (passwordVerificationHash && passwordVerificationSalt && passwordDerivedKeySalt) {
// Check if admin user already exists
const existingAdmin = sql.getValue(`SELECT userId FROM users WHERE username = 'admin'`);
if (!existingAdmin) {
if (passwordVerificationHash && passwordVerificationSalt) {
const now = new Date().toISOString();
// Create default admin user from existing credentials
sql.execute(`
INSERT INTO users (
userId, username, email, passwordHash, passwordSalt,
derivedKeySalt, encryptedDataKey, isActive, isAdmin,
utcDateCreated, utcDateModified
INSERT INTO user_data (
tmpID, username, email, userIDVerificationHash, salt,
derivedKey, userIDEncryptedDataKey, isSetup, role,
isActive, utcDateCreated, utcDateModified
)
VALUES (?, ?, ?, ?, ?, ?, ?, 1, 1, ?, ?)
VALUES (1, 'admin', NULL, ?, ?, ?, ?, 'true', 'admin', 1, ?, ?)
`, [
adminUserId,
'admin',
null,
passwordVerificationHash,
passwordVerificationSalt,
passwordDerivedKeySalt,
@ -182,22 +82,32 @@ export default async () => {
now
]);
// Assign admin role to the user
sql.execute(`
INSERT INTO user_roles (userId, roleId, utcDateAssigned)
VALUES (?, ?, ?)
`, [adminUserId, 'role_admin', now]);
console.log(`Created default admin user with ID: ${adminUserId}`);
console.log("Migrated existing password to default admin user (tmpID=1)");
// 8. Associate all existing data with the admin user (only if admin was created)
sql.execute(`UPDATE notes SET userId = ? WHERE userId IS NULL`, [adminUserId]);
sql.execute(`UPDATE branches SET userId = ? WHERE userId IS NULL`, [adminUserId]);
sql.execute(`UPDATE etapi_tokens SET userId = ? WHERE userId IS NULL`, [adminUserId]);
sql.execute(`UPDATE recent_notes SET userId = ? WHERE userId IS NULL`, [adminUserId]);
// 4. Associate all existing data with the default user (tmpID=1)
sql.execute(`UPDATE notes SET userId = 1 WHERE userId IS NULL`);
sql.execute(`UPDATE branches SET userId = 1 WHERE userId IS NULL`);
sql.execute(`UPDATE etapi_tokens SET userId = 1 WHERE userId IS NULL`);
sql.execute(`UPDATE recent_notes SET userId = 1 WHERE userId IS NULL`);
console.log("Associated all existing data with default admin user");
} else {
console.log("No existing password found. User will be created on first login.");
}
} else {
console.log("No existing password found, admin user will need to be created on first login");
console.log(`Found ${existingUser} existing user(s) in user_data table`);
// Ensure existing users have the new fields populated
sql.execute(`UPDATE user_data SET role = 'admin' WHERE role IS NULL`);
sql.execute(`UPDATE user_data SET isActive = 1 WHERE isActive IS NULL`);
sql.execute(`UPDATE user_data SET utcDateCreated = ? WHERE utcDateCreated IS NULL`, [new Date().toISOString()]);
sql.execute(`UPDATE user_data SET utcDateModified = ? WHERE utcDateModified IS NULL`, [new Date().toISOString()]);
// Associate data with first user if not already associated
sql.execute(`UPDATE notes SET userId = 1 WHERE userId IS NULL`);
sql.execute(`UPDATE branches SET userId = 1 WHERE userId IS NULL`);
sql.execute(`UPDATE etapi_tokens SET userId = 1 WHERE userId IS NULL`);
sql.execute(`UPDATE recent_notes SET userId = 1 WHERE userId IS NULL`);
}
console.log("Multi-user support migration completed successfully!");

View File

@ -1,8 +1,10 @@
/**
/**
* User Management API
*
* Provides endpoints for managing users in multi-user installations.
* All endpoints require authentication and most require admin privileges.
*
* Works with user_data table (tmpID as primary key).
*/
import { Request } from "express";
@ -19,30 +21,28 @@ function getUsers(req: Request): any {
}
/**
* Get a specific user by ID
* Get a specific user by ID (tmpID)
* Requires: Admin access or own user
*/
function getUser(req: Request): any {
const userId = req.params.userId;
const tmpID = parseInt(req.params.userId);
const currentUserId = req.session.userId;
// Allow users to view their own profile, admins can view anyone
const currentUser = currentUserId ? userManagement.getUserById(currentUserId) : null;
if (!currentUser) {
throw new ValidationError("Not authenticated");
}
if (userId !== currentUserId && !currentUser.isAdmin) {
if (tmpID !== currentUserId && currentUserId && !userManagement.isAdmin(currentUserId)) {
throw new ValidationError("Access denied");
}
const user = userManagement.getUserById(userId);
const user = userManagement.getUserById(tmpID);
if (!user) {
throw new ValidationError("User not found");
}
// Don't send sensitive data
const { passwordHash, passwordSalt, derivedKeySalt, encryptedDataKey, ...safeUser } = user;
const { userIDVerificationHash, salt, derivedKey, userIDEncryptedDataKey, ...safeUser } = user;
return safeUser;
}
@ -51,32 +51,29 @@ function getUser(req: Request): any {
* Requires: Admin access
*/
function createUser(req: Request): any {
const { username, email, password, isAdmin } = req.body;
const { username, email, password, role } = req.body;
if (!username || !password) {
throw new ValidationError("Username and password are required");
}
// Check if username already exists
const existing = userManagement.getUserByUsername(username);
if (existing) {
throw new ValidationError("Username already exists");
}
// Validate password strength
if (password.length < 8) {
throw new ValidationError("Password must be at least 8 characters long");
if (password.length < 4) {
throw new ValidationError("Password must be at least 4 characters long");
}
const user = userManagement.createUser({
username,
email,
password,
isAdmin: isAdmin === true
role: role || userManagement.UserRole.USER
});
// Don't send sensitive data
const { passwordHash, passwordSalt, derivedKeySalt, encryptedDataKey, ...safeUser } = user;
const { userIDVerificationHash, salt, derivedKey, userIDEncryptedDataKey, ...safeUser } = user;
return safeUser;
}
@ -85,46 +82,42 @@ function createUser(req: Request): any {
* Requires: Admin access or own user (with limited fields)
*/
function updateUser(req: Request): any {
const userId = req.params.userId;
const tmpID = parseInt(req.params.userId);
const currentUserId = req.session.userId;
const { email, password, isActive, isAdmin } = req.body;
const { email, password, isActive, role } = req.body;
const currentUser = currentUserId ? userManagement.getUserById(currentUserId) : null;
if (!currentUser) {
throw new ValidationError("Not authenticated");
}
const isSelf = userId === currentUserId;
const isAdminUser = currentUser.isAdmin;
const isSelf = tmpID === currentUserId;
const isAdminUser = currentUserId ? userManagement.isAdmin(currentUserId) : false;
// Regular users can only update their own email and password
if (!isAdminUser && !isSelf) {
throw new ValidationError("Access denied");
}
// Only admins can change isActive and isAdmin flags
if (!isAdminUser && (isActive !== undefined || isAdmin !== undefined)) {
throw new ValidationError("Only admins can change user status or admin privileges");
if (!isAdminUser && (isActive !== undefined || role !== undefined)) {
throw new ValidationError("Only admins can change user status or role");
}
// Validate password if provided
if (password && password.length < 8) {
throw new ValidationError("Password must be at least 8 characters long");
if (password && password.length < 4) {
throw new ValidationError("Password must be at least 4 characters long");
}
const updates: any = {};
if (email !== undefined) updates.email = email;
if (password !== undefined) updates.password = password;
if (isAdminUser && isActive !== undefined) updates.isActive = isActive;
if (isAdminUser && isAdmin !== undefined) updates.isAdmin = isAdmin;
if (isAdminUser && role !== undefined) updates.role = role;
const user = userManagement.updateUser(userId, updates);
const user = userManagement.updateUser(tmpID, updates);
if (!user) {
throw new ValidationError("User not found");
}
// Don't send sensitive data
const { passwordHash, passwordSalt, derivedKeySalt, encryptedDataKey, ...safeUser } = user;
const { userIDVerificationHash, salt, derivedKey, userIDEncryptedDataKey, ...safeUser } = user;
return safeUser;
}
@ -133,15 +126,14 @@ function updateUser(req: Request): any {
* Requires: Admin access
*/
function deleteUser(req: Request): any {
const userId = req.params.userId;
const tmpID = parseInt(req.params.userId);
const currentUserId = req.session.userId;
// Cannot delete yourself
if (userId === currentUserId) {
if (tmpID === currentUserId) {
throw new ValidationError("Cannot delete your own account");
}
const success = userManagement.deleteUser(userId);
const success = userManagement.deleteUser(tmpID);
if (!success) {
throw new ValidationError("User not found");
}
@ -163,14 +155,8 @@ function getCurrentUser(req: Request): any {
throw new ValidationError("User not found");
}
const roles = userManagement.getUserRoles(userId);
// Don't send sensitive data
const { passwordHash, passwordSalt, derivedKeySalt, encryptedDataKey, ...safeUser } = user;
return {
...safeUser,
roles
};
const { userIDVerificationHash, salt, derivedKey, userIDEncryptedDataKey, ...safeUser } = user;
return safeUser;
}
/**

View File

@ -171,9 +171,9 @@ function login(req: Request, res: Response) {
// Store user information in session for multi-user mode
if (authenticatedUser) {
req.session.userId = authenticatedUser.userId;
req.session.userId = authenticatedUser.tmpID; // Store tmpID from user_data table
req.session.username = authenticatedUser.username;
req.session.isAdmin = authenticatedUser.isAdmin;
req.session.isAdmin = authenticatedUser.role === 'admin';
}
res.redirect('.');
@ -197,12 +197,12 @@ function verifyPassword(submittedPassword: string) {
}
/**
* Check if multi-user mode is enabled (users table exists)
* Check if multi-user mode is enabled (user_data table has users)
*/
function isMultiUserEnabled(): boolean {
try {
const result = sql.getValue(`SELECT COUNT(*) as count FROM sqlite_master WHERE type='table' AND name='users'`) as number;
return result > 0;
const count = sql.getValue(`SELECT COUNT(*) as count FROM user_data WHERE isSetup = 'true'`) as number;
return count > 0;
} catch (e) {
return false;
}

View File

@ -176,8 +176,7 @@ function checkAdmin(req: Request, res: Response, next: NextFunction) {
return;
}
const user = userManagement.getUserById(req.session.userId);
if (!user || !user.isAdmin) {
if (!userManagement.isAdmin(req.session.userId)) {
reject(req, res, "Admin access required");
return;
}
@ -187,6 +186,7 @@ function checkAdmin(req: Request, res: Response, next: NextFunction) {
/**
* Check if the current user has a specific permission
* Note: Simplified for basic multi-user support
*/
function checkPermission(resource: string, action: string) {
return (req: Request, res: Response, next: NextFunction) => {
@ -195,7 +195,8 @@ function checkPermission(resource: string, action: string) {
return;
}
if (userManagement.hasPermission(req.session.userId, resource, action)) {
// Basic permission check: admins have all permissions
if (userManagement.isAdmin(req.session.userId)) {
next();
} else {
reject(req, res, `Permission denied: ${resource}.${action}`);

View File

@ -3,23 +3,47 @@
*
* Handles all user-related operations including creation, updates, authentication,
* and role management for multi-user support.
*
* Works with existing user_data table (from OAuth migration v229):
* - tmpID: Primary key (INTEGER)
* - username: User's login name
* - email: Email address
* - userIDVerificationHash: Password hash for verification
* - salt: Salt for password hashing
* - derivedKey: Salt for deriving encryption key
* - userIDEncryptedDataKey: Encrypted data key
* - isSetup: 'true' or 'false' string
* - role: 'admin', 'user', or 'viewer'
* - isActive: 1 or 0
*/
import sql from "./sql.js";
import { randomSecureToken, toBase64 } from "./utils.js";
import dataEncryptionService from "./encryption/data_encryption.js";
import { randomSecureToken, toBase64, fromBase64 } from "./utils.js";
import crypto from "crypto";
/**
* User roles with different permission levels
*/
export enum UserRole {
ADMIN = 'admin',
USER = 'user',
VIEWER = 'viewer'
}
/**
* User interface representing a Trilium user in user_data table
*/
export interface User {
userId: string;
tmpID: number;
username: string;
email: string | null;
passwordHash: string;
passwordSalt: string;
derivedKeySalt: string;
encryptedDataKey: string | null;
isActive: boolean;
isAdmin: boolean;
userIDVerificationHash: string;
salt: string;
derivedKey: string;
userIDEncryptedDataKey: string | null;
isSetup: string;
role: UserRole;
isActive: number;
utcDateCreated: string;
utcDateModified: string;
}
@ -28,112 +52,97 @@ export interface UserCreateData {
username: string;
email?: string;
password: string;
isAdmin?: boolean;
role?: UserRole;
}
export interface UserUpdateData {
email?: string;
password?: string;
oldPassword?: string; // Required when changing password to decrypt existing data
isActive?: boolean;
isAdmin?: boolean;
role?: UserRole;
}
export interface UserListItem {
userId: string;
tmpID: number;
username: string;
email: string | null;
isActive: boolean;
isAdmin: boolean;
roles: string[];
isActive: number;
role: UserRole;
utcDateCreated: string;
}
/**
* Hash password using scrypt (synchronous)
* Hash password using scrypt (matching Trilium's method)
*/
function hashPassword(password: string, salt: string): string {
const hashed = crypto.scryptSync(password, salt, 32, { N: 16384, r: 8, p: 1 });
return toBase64(hashed);
}
/**
* Helper function to map database row to User object
*/
function mapRowToUser(user: any): User {
return {
tmpID: user.tmpID,
username: user.username,
email: user.email,
userIDVerificationHash: user.userIDVerificationHash,
salt: user.salt,
derivedKey: user.derivedKey,
userIDEncryptedDataKey: user.userIDEncryptedDataKey,
isSetup: user.isSetup || 'true',
role: user.role || UserRole.USER,
isActive: user.isActive !== undefined ? user.isActive : 1,
utcDateCreated: user.utcDateCreated || new Date().toISOString(),
utcDateModified: user.utcDateModified || new Date().toISOString()
};
}
/**
* Create a new user
*/
function createUser(userData: UserCreateData): User {
const userId = 'user_' + randomSecureToken(20);
const now = new Date().toISOString();
// Generate password salt and hash
// Get next tmpID
const maxId = sql.getValue(`SELECT MAX(tmpID) as maxId FROM user_data`) as number || 0;
const tmpID = maxId + 1;
// Generate password components using Trilium's scrypt parameters
const passwordSalt = randomSecureToken(32);
const derivedKeySalt = randomSecureToken(32);
// Hash the password using scrypt
const passwordHash = hashPassword(userData.password, passwordSalt);
// Generate data encryption key for this user
const dataKey = randomSecureToken(16);
// derive a binary key for encrypting the user's data key
const passwordDerivedKey = crypto.scryptSync(userData.password, derivedKeySalt, 32, { N: 16384, r: 8, p: 1 });
// dataEncryptionService.encrypt expects Buffer key and Buffer|string payload
const encryptedDataKey = dataEncryptionService.encrypt(passwordDerivedKey, Buffer.from(dataKey));
sql.execute(`
INSERT INTO users (
userId, username, email, passwordHash, passwordSalt,
derivedKeySalt, encryptedDataKey, isActive, isAdmin,
utcDateCreated, utcDateModified
INSERT INTO user_data (
tmpID, username, email, userIDVerificationHash, salt,
derivedKey, userIDEncryptedDataKey, isSetup, role,
isActive, utcDateCreated, utcDateModified
)
VALUES (?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?)
VALUES (?, ?, ?, ?, ?, ?, '', 'true', ?, 1, ?, ?)
`, [
userId,
tmpID,
userData.username,
userData.email || null,
passwordHash,
passwordSalt,
derivedKeySalt,
encryptedDataKey,
userData.isAdmin ? 1 : 0,
userData.role || UserRole.USER,
now,
now
]);
// Assign default role
const defaultRoleId = userData.isAdmin ? 'role_admin' : 'role_user';
sql.execute(`
INSERT INTO user_roles (userId, roleId, utcDateAssigned)
VALUES (?, ?, ?)
`, [userId, defaultRoleId, now]);
return getUserById(userId)!;
return getUserById(tmpID)!;
}
/**
* Helper function to map database row to User object
* Get user by ID (tmpID)
*/
function mapRowToUser(user: any): User {
return {
userId: user.userId,
username: user.username,
email: user.email,
passwordHash: user.passwordHash,
passwordSalt: user.passwordSalt,
derivedKeySalt: user.derivedKeySalt,
encryptedDataKey: user.encryptedDataKey,
isActive: Boolean(user.isActive),
isAdmin: Boolean(user.isAdmin),
utcDateCreated: user.utcDateCreated,
utcDateModified: user.utcDateModified
};
}
/**
* Get user by ID
*/
function getUserById(userId: string): User | null {
function getUserById(tmpID: number): User | null {
const user = sql.getRow(`
SELECT * FROM users WHERE userId = ?
`, [userId]) as any;
SELECT * FROM user_data WHERE tmpID = ?
`, [tmpID]) as any;
return user ? mapRowToUser(user) : null;
}
@ -143,7 +152,7 @@ function getUserById(userId: string): User | null {
*/
function getUserByUsername(username: string): User | null {
const user = sql.getRow(`
SELECT * FROM users WHERE username = ? COLLATE NOCASE
SELECT * FROM user_data WHERE username = ? COLLATE NOCASE
`, [username]) as any;
return user ? mapRowToUser(user) : null;
@ -152,8 +161,8 @@ function getUserByUsername(username: string): User | null {
/**
* Update user
*/
function updateUser(userId: string, updates: UserUpdateData): User | null {
const user = getUserById(userId);
function updateUser(tmpID: number, updates: UserUpdateData): User | null {
const user = getUserById(tmpID);
if (!user) return null;
const now = new Date().toISOString();
@ -165,47 +174,14 @@ function updateUser(userId: string, updates: UserUpdateData): User | null {
values.push(updates.email || null);
}
if (updates.password !== undefined && updates.oldPassword !== undefined) {
// Validate that user has existing encrypted data
if (!user.derivedKeySalt || !user.encryptedDataKey) {
throw new Error("Cannot change password: user has no encrypted data");
}
// First, decrypt the existing dataKey with the old password
const oldPasswordDerivedKey = crypto.scryptSync(
updates.oldPassword,
user.derivedKeySalt,
32,
{ N: 16384, r: 8, p: 1 }
);
const dataKey = dataEncryptionService.decrypt(
oldPasswordDerivedKey,
user.encryptedDataKey
);
if (!dataKey) {
throw new Error("Cannot change password: failed to decrypt existing data key with old password");
}
if (updates.password !== undefined) {
// Generate new password hash
const passwordSalt = randomSecureToken(32);
const derivedKeySalt = randomSecureToken(32);
const passwordHash = hashPassword(updates.password, passwordSalt);
// Re-encrypt the same dataKey with new password
const passwordDerivedKey = crypto.scryptSync(
updates.password,
derivedKeySalt,
32,
{ N: 16384, r: 8, p: 1 }
);
const encryptedDataKey = dataEncryptionService.encrypt(
passwordDerivedKey,
dataKey
);
updateParts.push('passwordHash = ?', 'passwordSalt = ?', 'derivedKeySalt = ?', 'encryptedDataKey = ?');
values.push(passwordHash, passwordSalt, derivedKeySalt, encryptedDataKey);
updateParts.push('userIDVerificationHash = ?', 'salt = ?', 'derivedKey = ?');
values.push(passwordHash, passwordSalt, derivedKeySalt);
}
if (updates.isActive !== undefined) {
@ -213,41 +189,37 @@ function updateUser(userId: string, updates: UserUpdateData): User | null {
values.push(updates.isActive ? 1 : 0);
}
if (updates.isAdmin !== undefined) {
updateParts.push('isAdmin = ?');
values.push(updates.isAdmin ? 1 : 0);
// Update role assignment
sql.execute(`DELETE FROM user_roles WHERE userId = ?`, [userId]);
sql.execute(`
INSERT INTO user_roles (userId, roleId, utcDateAssigned)
VALUES (?, ?, ?)
`, [userId, updates.isAdmin ? 'role_admin' : 'role_user', now]);
if (updates.role !== undefined) {
updateParts.push('role = ?');
values.push(updates.role);
}
if (updateParts.length > 0) {
updateParts.push('utcDateModified = ?');
values.push(now, userId);
values.push(now, tmpID);
sql.execute(`
UPDATE users SET ${updateParts.join(', ')}
WHERE userId = ?
UPDATE user_data SET ${updateParts.join(', ')}
WHERE tmpID = ?
`, values);
}
return getUserById(userId);
return getUserById(tmpID);
}
/**
* Delete user (soft delete by setting isActive = 0)
*/
function deleteUser(userId: string): boolean {
const user = getUserById(userId);
function deleteUser(tmpID: number): boolean {
const user = getUserById(tmpID);
if (!user) return false;
// Prevent deleting the last admin
if (user.isAdmin) {
const adminCount = sql.getValue(`SELECT COUNT(*) FROM users WHERE isAdmin = 1 AND isActive = 1`) as number;
if (user.role === UserRole.ADMIN) {
const adminCount = sql.getValue(`
SELECT COUNT(*) FROM user_data
WHERE role = 'admin' AND isActive = 1
`) as number;
if (adminCount <= 1) {
throw new Error("Cannot delete the last admin user");
}
@ -255,9 +227,9 @@ function deleteUser(userId: string): boolean {
const now = new Date().toISOString();
sql.execute(`
UPDATE users SET isActive = 0, utcDateModified = ?
WHERE userId = ?
`, [now, userId]);
UPDATE user_data SET isActive = 0, utcDateModified = ?
WHERE tmpID = ?
`, [now, tmpID]);
return true;
}
@ -266,32 +238,21 @@ function deleteUser(userId: string): boolean {
* List all users
*/
function listUsers(includeInactive: boolean = false): UserListItem[] {
const whereClause = includeInactive ? '' : 'WHERE u.isActive = 1';
const whereClause = includeInactive ? '' : 'WHERE isActive = 1';
const users = sql.getRows(`
SELECT
u.userId,
u.username,
u.email,
u.isActive,
u.isAdmin,
u.utcDateCreated,
GROUP_CONCAT(r.name) as roles
FROM users u
LEFT JOIN user_roles ur ON u.userId = ur.userId
LEFT JOIN roles r ON ur.roleId = r.roleId
SELECT tmpID, username, email, isActive, role, utcDateCreated
FROM user_data
${whereClause}
GROUP BY u.userId
ORDER BY u.username
ORDER BY username
`);
return users.map((user: any) => ({
userId: user.userId,
tmpID: user.tmpID,
username: user.username,
email: user.email,
isActive: Boolean(user.isActive),
isAdmin: Boolean(user.isAdmin),
roles: user.roles ? user.roles.split(',') : [],
isActive: user.isActive,
role: user.role || UserRole.USER,
utcDateCreated: user.utcDateCreated
}));
}
@ -301,14 +262,14 @@ function listUsers(includeInactive: boolean = false): UserListItem[] {
*/
function validateCredentials(username: string, password: string): User | null {
const user = getUserByUsername(username);
if (!user || !user.isActive) {
if (!user || user.isActive !== 1) {
return null;
}
// Verify password using scrypt
const expectedHash = hashPassword(password, user.passwordSalt);
const expectedHash = hashPassword(password, user.salt);
if (expectedHash !== user.passwordHash) {
if (expectedHash !== user.userIDVerificationHash) {
return null;
}
@ -316,94 +277,43 @@ function validateCredentials(username: string, password: string): User | null {
}
/**
* Get user's roles
* Check if user is admin
*/
function getUserRoles(userId: string): string[] {
const roles = sql.getRows(`
SELECT r.name
FROM user_roles ur
JOIN roles r ON ur.roleId = r.roleId
WHERE ur.userId = ?
`, [userId]);
return roles.map((r: any) => r.name);
function isAdmin(tmpID: number): boolean {
const user = getUserById(tmpID);
return user?.role === UserRole.ADMIN;
}
/**
* Check if user has a specific permission
* Check if user can access a note (basic ownership check)
*/
function hasPermission(userId: string, resource: string, action: string): boolean {
const user = getUserById(userId);
if (!user) return false;
// Admins have all permissions
if (user.isAdmin) return true;
const roles = sql.getRows(`
SELECT r.permissions
FROM user_roles ur
JOIN roles r ON ur.roleId = r.roleId
WHERE ur.userId = ?
`, [userId]);
for (const role of roles) {
try {
const permissions = JSON.parse((role as any).permissions);
if (permissions[resource] && permissions[resource].includes(action)) {
return true;
}
} catch (e) {
console.error('Error parsing role permissions:', e);
}
}
return false;
}
/**
* Check if user can access a note
*/
function canAccessNote(userId: string, noteId: string): boolean {
const user = getUserById(userId);
function canAccessNote(tmpID: number, noteId: string): boolean {
const user = getUserById(tmpID);
if (!user) return false;
// Admins can access all notes
if (user.isAdmin) return true;
if (user.role === UserRole.ADMIN) return true;
// Check if user owns the note
const note = sql.getRow(`SELECT userId FROM notes WHERE noteId = ?`, [noteId]) as any;
if (note && note.userId === userId) return true;
// Check if note is shared with user
const share = sql.getRow(`
SELECT * FROM note_shares
WHERE noteId = ? AND sharedWithUserId = ? AND isDeleted = 0
`, [noteId, userId]);
return !!share;
return note && note.userId === tmpID;
}
/**
* Get note permission for user (own, read, write, or null)
* Get note permission for user (own, admin, or null)
*/
function getNotePermission(userId: string, noteId: string): string | null {
const user = getUserById(userId);
function getNotePermission(tmpID: number, noteId: string): string | null {
const user = getUserById(tmpID);
if (!user) return null;
// Admins have full access
if (user.isAdmin) return 'admin';
if (user.role === UserRole.ADMIN) return 'admin';
// Check if user owns the note
const note = sql.getRow(`SELECT userId FROM notes WHERE noteId = ?`, [noteId]) as any;
if (note && note.userId === userId) return 'own';
if (note && note.userId === tmpID) return 'own';
// Check if note is shared with user
const share = sql.getRow(`
SELECT permission FROM note_shares
WHERE noteId = ? AND sharedWithUserId = ? AND isDeleted = 0
`, [noteId, userId]) as any;
return share ? share.permission : null;
return null;
}
export default {
@ -414,8 +324,8 @@ export default {
deleteUser,
listUsers,
validateCredentials,
getUserRoles,
hasPermission,
isAdmin,
canAccessNote,
getNotePermission
getNotePermission,
UserRole
};