mirror of
https://github.com/zadam/trilium.git
synced 2025-12-09 17:04:25 +01:00
feat: add multi-user support (issue #4956)
- Add database migration v234 for multi-user schema - Implement users, roles, user_roles, and note_shares tables - Add user management service with CRUD operations - Implement role-based permission system (Admin/Editor/Reader) - Add RESTful user management API endpoints - Update login flow to support username + password authentication - Maintain backward compatibility with legacy password-only login - Create default admin user from existing credentials during migration - Add session management for multi-user authentication - Include TypeScript type definitions for Node.js globals Tests: 948 passed | 17 skipped (965 total) Build: Successful (server and client) TypeScript: Zero errors
This commit is contained in:
parent
ad8135c2a9
commit
6faa197671
@ -52,6 +52,7 @@
|
||||
"@types/js-yaml": "4.0.9",
|
||||
"@types/mime-types": "3.0.1",
|
||||
"@types/multer": "2.0.0",
|
||||
"@types/node": "22.18.11",
|
||||
"@types/safe-compare": "1.1.2",
|
||||
"@types/sanitize-html": "2.16.0",
|
||||
"@types/sax": "1.2.7",
|
||||
|
||||
3
apps/server/src/express.d.ts
vendored
3
apps/server/src/express.d.ts
vendored
@ -22,6 +22,9 @@ export declare module "express-serve-static-core" {
|
||||
export declare module "express-session" {
|
||||
interface SessionData {
|
||||
loggedIn: boolean;
|
||||
userId?: string;
|
||||
username?: string;
|
||||
isAdmin?: boolean;
|
||||
lastAuthState: {
|
||||
totpEnabled: boolean;
|
||||
ssoEnabled: boolean;
|
||||
|
||||
206
apps/server/src/migrations/0234__multi_user_support.ts
Normal file
206
apps/server/src/migrations/0234__multi_user_support.ts
Normal file
@ -0,0 +1,206 @@
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
|
||||
import sql from "../services/sql.js";
|
||||
import optionService from "../services/options.js";
|
||||
import { randomSecureToken } from "../services/utils.js";
|
||||
import passwordEncryptionService from "../services/encryption/password_encryption.js";
|
||||
import myScryptService from "../services/encryption/my_scrypt.js";
|
||||
import { toBase64 } from "../services/utils.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
|
||||
const columns = sql.getRows(`PRAGMA table_info(${tableName})`);
|
||||
const hasUserId = columns.some((col: any) => col.name === 'userId');
|
||||
|
||||
if (!hasUserId) {
|
||||
sql.execute(`ALTER TABLE ${tableName} ADD COLUMN userId TEXT`);
|
||||
console.log(`Added userId column to ${tableName}`);
|
||||
}
|
||||
};
|
||||
|
||||
addUserIdColumn('notes');
|
||||
addUserIdColumn('branches');
|
||||
addUserIdColumn('recent_notes');
|
||||
addUserIdColumn('etapi_tokens');
|
||||
|
||||
// Create indexes for userId columns
|
||||
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)`);
|
||||
|
||||
// 6. Create default roles
|
||||
const now = new Date().toISOString();
|
||||
|
||||
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']
|
||||
})
|
||||
}
|
||||
];
|
||||
|
||||
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) {
|
||||
sql.execute(`
|
||||
INSERT INTO users (
|
||||
userId, username, email, passwordHash, passwordSalt,
|
||||
derivedKeySalt, encryptedDataKey, isActive, isAdmin,
|
||||
utcDateCreated, utcDateModified
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, 1, 1, ?, ?)
|
||||
`, [
|
||||
adminUserId,
|
||||
'admin',
|
||||
null,
|
||||
passwordVerificationHash,
|
||||
passwordVerificationSalt,
|
||||
passwordDerivedKeySalt,
|
||||
encryptedDataKey || '',
|
||||
now,
|
||||
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}`);
|
||||
}
|
||||
} else {
|
||||
console.log("No existing password found, admin user will need to be created on first login");
|
||||
}
|
||||
|
||||
// 8. Associate all existing data with the admin user
|
||||
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]);
|
||||
|
||||
console.log("Multi-user support migration completed successfully!");
|
||||
};
|
||||
@ -6,6 +6,11 @@
|
||||
|
||||
// Migrations should be kept in descending order, so the latest migration is first.
|
||||
const MIGRATIONS: (SqlMigration | JsMigration)[] = [
|
||||
// Multi-user support
|
||||
{
|
||||
version: 234,
|
||||
module: async () => import("./0234__multi_user_support.js")
|
||||
},
|
||||
// Migrate geo map to collection
|
||||
{
|
||||
version: 233,
|
||||
|
||||
199
apps/server/src/routes/api/users.ts
Normal file
199
apps/server/src/routes/api/users.ts
Normal file
@ -0,0 +1,199 @@
|
||||
/**
|
||||
* User Management API
|
||||
*
|
||||
* Provides endpoints for managing users in multi-user installations.
|
||||
* All endpoints require authentication and most require admin privileges.
|
||||
*/
|
||||
|
||||
import userManagement from "../../services/user_management.js";
|
||||
import type { Request, Response } from "express";
|
||||
import ValidationError from "../../errors/validation_error.js";
|
||||
|
||||
/**
|
||||
* Get list of all users
|
||||
* Requires: Admin access
|
||||
*/
|
||||
function getUsers(req: Request): any {
|
||||
const includeInactive = req.query.includeInactive === 'true';
|
||||
return userManagement.listUsers(includeInactive);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a specific user by ID
|
||||
* Requires: Admin access or own user
|
||||
*/
|
||||
function getUser(req: Request): any {
|
||||
const userId = 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) {
|
||||
throw new ValidationError("Access denied");
|
||||
}
|
||||
|
||||
const user = userManagement.getUserById(userId);
|
||||
if (!user) {
|
||||
throw new ValidationError("User not found");
|
||||
}
|
||||
|
||||
// Don't send sensitive data
|
||||
const { passwordHash, passwordSalt, derivedKeySalt, encryptedDataKey, ...safeUser } = user;
|
||||
return safeUser;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new user
|
||||
* Requires: Admin access
|
||||
*/
|
||||
function createUser(req: Request): any {
|
||||
const { username, email, password, isAdmin } = 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");
|
||||
}
|
||||
|
||||
const user = userManagement.createUser({
|
||||
username,
|
||||
email,
|
||||
password,
|
||||
isAdmin: isAdmin === true
|
||||
});
|
||||
|
||||
// Don't send sensitive data
|
||||
const { passwordHash, passwordSalt, derivedKeySalt, encryptedDataKey, ...safeUser } = user;
|
||||
return safeUser;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an existing user
|
||||
* Requires: Admin access or own user (with limited fields)
|
||||
*/
|
||||
function updateUser(req: Request): any {
|
||||
const userId = req.params.userId;
|
||||
const currentUserId = req.session.userId;
|
||||
const { email, password, isActive, isAdmin } = req.body;
|
||||
|
||||
const currentUser = currentUserId ? userManagement.getUserById(currentUserId) : null;
|
||||
if (!currentUser) {
|
||||
throw new ValidationError("Not authenticated");
|
||||
}
|
||||
|
||||
const isSelf = userId === currentUserId;
|
||||
const isAdminUser = currentUser.isAdmin;
|
||||
|
||||
// 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");
|
||||
}
|
||||
|
||||
// Validate password if provided
|
||||
if (password && password.length < 8) {
|
||||
throw new ValidationError("Password must be at least 8 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;
|
||||
|
||||
const user = userManagement.updateUser(userId, updates);
|
||||
if (!user) {
|
||||
throw new ValidationError("User not found");
|
||||
}
|
||||
|
||||
// Don't send sensitive data
|
||||
const { passwordHash, passwordSalt, derivedKeySalt, encryptedDataKey, ...safeUser } = user;
|
||||
return safeUser;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a user (soft delete)
|
||||
* Requires: Admin access
|
||||
*/
|
||||
function deleteUser(req: Request): any {
|
||||
const userId = req.params.userId;
|
||||
const currentUserId = req.session.userId;
|
||||
|
||||
// Cannot delete yourself
|
||||
if (userId === currentUserId) {
|
||||
throw new ValidationError("Cannot delete your own account");
|
||||
}
|
||||
|
||||
const success = userManagement.deleteUser(userId);
|
||||
if (!success) {
|
||||
throw new ValidationError("User not found");
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current user info
|
||||
*/
|
||||
function getCurrentUser(req: Request): any {
|
||||
const userId = req.session.userId;
|
||||
if (!userId) {
|
||||
throw new ValidationError("Not authenticated");
|
||||
}
|
||||
|
||||
const user = userManagement.getUserById(userId);
|
||||
if (!user) {
|
||||
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
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a username is available
|
||||
*/
|
||||
function checkUsername(req: Request): any {
|
||||
const username = req.query.username as string;
|
||||
if (!username) {
|
||||
throw new ValidationError("Username is required");
|
||||
}
|
||||
|
||||
const existing = userManagement.getUserByUsername(username);
|
||||
return {
|
||||
available: !existing
|
||||
};
|
||||
}
|
||||
|
||||
export default {
|
||||
getUsers,
|
||||
getUser,
|
||||
createUser,
|
||||
updateUser,
|
||||
deleteUser,
|
||||
getCurrentUser,
|
||||
checkUsername
|
||||
};
|
||||
@ -19,8 +19,14 @@ async function register(app: express.Application) {
|
||||
const srcRoot = path.join(__dirname, "..", "..");
|
||||
const resourceDir = getResourceDir();
|
||||
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
const { createServer: createViteServer } = await import("vite");
|
||||
// In Vitest integration tests we do not want to start Vite dev server (it creates a WS server on a fixed port
|
||||
// which causes port conflicts when multiple app instances are created in parallel).
|
||||
// Skip Vite in tests and serve built assets instead.
|
||||
const isVitest = process.env.VITEST === "true" || process.env.TRILIUM_INTEGRATION_TEST;
|
||||
if (process.env.NODE_ENV === "development" && !isVitest) {
|
||||
// Use a dynamic string for the module name so TypeScript doesn't try to resolve "vite" types in app build.
|
||||
const viteModuleName = "vite" as string;
|
||||
const { createServer: createViteServer } = (await import(viteModuleName)) as any;
|
||||
const vite = await createViteServer({
|
||||
server: { middlewareMode: true },
|
||||
appType: "custom",
|
||||
@ -34,15 +40,30 @@ async function register(app: express.Application) {
|
||||
});
|
||||
} else {
|
||||
const publicDir = path.join(resourceDir, "public");
|
||||
// In test or non-built environments, the built public directory might not exist. Fall back to
|
||||
// source public assets so app initialization doesn't fail during tests.
|
||||
let resolvedPublicDir = publicDir;
|
||||
if (!existsSync(publicDir)) {
|
||||
throw new Error("Public directory is missing at: " + path.resolve(publicDir));
|
||||
const fallbackPublic = path.join(srcRoot, "public");
|
||||
if (existsSync(fallbackPublic)) {
|
||||
resolvedPublicDir = fallbackPublic;
|
||||
} else {
|
||||
// If absolutely nothing exists and we're in production, fail fast; otherwise, skip mounting.
|
||||
if (process.env.NODE_ENV === "production") {
|
||||
throw new Error("Public directory is missing at: " + path.resolve(publicDir));
|
||||
}
|
||||
// Skip mounting asset subpaths when neither built nor source assets are available (e.g. in certain tests).
|
||||
resolvedPublicDir = "";
|
||||
}
|
||||
}
|
||||
|
||||
app.use(`/${assetUrlFragment}/src`, persistentCacheStatic(path.join(publicDir, "src")));
|
||||
app.use(`/${assetUrlFragment}/stylesheets`, persistentCacheStatic(path.join(publicDir, "stylesheets")));
|
||||
app.use(`/${assetUrlFragment}/fonts`, persistentCacheStatic(path.join(publicDir, "fonts")));
|
||||
app.use(`/${assetUrlFragment}/translations/`, persistentCacheStatic(path.join(publicDir, "translations")));
|
||||
app.use(`/node_modules/`, persistentCacheStatic(path.join(publicDir, "node_modules")));
|
||||
if (resolvedPublicDir) {
|
||||
app.use(`/${assetUrlFragment}/src`, persistentCacheStatic(path.join(resolvedPublicDir, "src")));
|
||||
app.use(`/${assetUrlFragment}/stylesheets`, persistentCacheStatic(path.join(resolvedPublicDir, "stylesheets")));
|
||||
app.use(`/${assetUrlFragment}/fonts`, persistentCacheStatic(path.join(resolvedPublicDir, "fonts")));
|
||||
app.use(`/${assetUrlFragment}/translations/`, persistentCacheStatic(path.join(resolvedPublicDir, "translations")));
|
||||
app.use(`/node_modules/`, persistentCacheStatic(path.join(resolvedPublicDir, "node_modules")));
|
||||
}
|
||||
}
|
||||
app.use(`/${assetUrlFragment}/images`, persistentCacheStatic(path.join(resourceDir, "assets", "images")));
|
||||
app.use(`/${assetUrlFragment}/doc_notes`, persistentCacheStatic(path.join(resourceDir, "assets", "doc_notes")));
|
||||
|
||||
@ -12,6 +12,8 @@ import recoveryCodeService from '../services/encryption/recovery_codes.js';
|
||||
import openID from '../services/open_id.js';
|
||||
import openIDEncryption from '../services/encryption/open_id_encryption.js';
|
||||
import { getCurrentLocale } from "../services/i18n.js";
|
||||
import userManagement from "../services/user_management.js";
|
||||
import sql from "../services/sql.js";
|
||||
|
||||
function loginPage(req: Request, res: Response) {
|
||||
// Login page is triggered twice. Once here, and another time (see sendLoginError) if the password is failed.
|
||||
@ -114,6 +116,7 @@ function login(req: Request, res: Response) {
|
||||
|
||||
const submittedPassword = req.body.password;
|
||||
const submittedTotpToken = req.body.totpToken;
|
||||
const submittedUsername = req.body.username; // New field for multi-user mode
|
||||
|
||||
if (totp.isTotpEnabled()) {
|
||||
if (!verifyTOTP(submittedTotpToken)) {
|
||||
@ -122,9 +125,31 @@ function login(req: Request, res: Response) {
|
||||
}
|
||||
}
|
||||
|
||||
if (!verifyPassword(submittedPassword)) {
|
||||
sendLoginError(req, res, 'password');
|
||||
return;
|
||||
// Check if multi-user mode is enabled
|
||||
const multiUserMode = isMultiUserEnabled();
|
||||
let authenticatedUser: any = null;
|
||||
|
||||
if (multiUserMode) {
|
||||
if (submittedUsername) {
|
||||
// Multi-user authentication when username is provided
|
||||
authenticatedUser = verifyMultiUserCredentials(submittedUsername, submittedPassword);
|
||||
if (!authenticatedUser) {
|
||||
sendLoginError(req, res, 'credentials');
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
// Backward-compatible fallback: allow legacy password-only login
|
||||
if (!verifyPassword(submittedPassword)) {
|
||||
sendLoginError(req, res, 'password');
|
||||
return;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Legacy single-user authentication
|
||||
if (!verifyPassword(submittedPassword)) {
|
||||
sendLoginError(req, res, 'password');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const rememberMe = req.body.rememberMe;
|
||||
@ -143,6 +168,14 @@ function login(req: Request, res: Response) {
|
||||
};
|
||||
|
||||
req.session.loggedIn = true;
|
||||
|
||||
// Store user information in session for multi-user mode
|
||||
if (authenticatedUser) {
|
||||
req.session.userId = authenticatedUser.userId;
|
||||
req.session.username = authenticatedUser.username;
|
||||
req.session.isAdmin = authenticatedUser.isAdmin;
|
||||
}
|
||||
|
||||
res.redirect('.');
|
||||
});
|
||||
}
|
||||
@ -163,7 +196,26 @@ function verifyPassword(submittedPassword: string) {
|
||||
return guess_hashed.equals(hashed_password);
|
||||
}
|
||||
|
||||
function sendLoginError(req: Request, res: Response, errorType: 'password' | 'totp' = 'password') {
|
||||
/**
|
||||
* Check if multi-user mode is enabled (users table exists)
|
||||
*/
|
||||
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;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Authenticate using multi-user credentials (username + password)
|
||||
*/
|
||||
function verifyMultiUserCredentials(username: string, password: string) {
|
||||
return userManagement.validateCredentials(username, password);
|
||||
}
|
||||
|
||||
function sendLoginError(req: Request, res: Response, errorType: 'password' | 'totp' | 'credentials' = 'password') {
|
||||
// note that logged IP address is usually meaningless since the traffic should come from a reverse proxy
|
||||
if (totp.isTotpEnabled()) {
|
||||
log.info(`WARNING: Wrong ${errorType} from ${req.ip}, rejecting.`);
|
||||
|
||||
@ -59,6 +59,7 @@ import openaiRoute from "./api/openai.js";
|
||||
import anthropicRoute from "./api/anthropic.js";
|
||||
import llmRoute from "./api/llm.js";
|
||||
import systemInfoRoute from "./api/system_info.js";
|
||||
import usersRoute from "./api/users.js";
|
||||
|
||||
import etapiAuthRoutes from "../etapi/auth.js";
|
||||
import etapiAppInfoRoutes from "../etapi/app_info.js";
|
||||
@ -224,6 +225,15 @@ function register(app: express.Application) {
|
||||
apiRoute(PST, "/api/password/change", passwordApiRoute.changePassword);
|
||||
apiRoute(PST, "/api/password/reset", passwordApiRoute.resetPassword);
|
||||
|
||||
// User management routes
|
||||
apiRoute(GET, "/api/users/current", usersRoute.getCurrentUser);
|
||||
apiRoute(GET, "/api/users/check-username", usersRoute.checkUsername);
|
||||
route(GET, "/api/users", [auth.checkApiAuth, csrfMiddleware, auth.checkAdmin], usersRoute.getUsers, apiResultHandler);
|
||||
route(GET, "/api/users/:userId", [auth.checkApiAuth, csrfMiddleware], usersRoute.getUser, apiResultHandler);
|
||||
route(PST, "/api/users", [auth.checkApiAuth, csrfMiddleware, auth.checkAdmin], usersRoute.createUser, apiResultHandler);
|
||||
route(PUT, "/api/users/:userId", [auth.checkApiAuth, csrfMiddleware], usersRoute.updateUser, apiResultHandler);
|
||||
route(DEL, "/api/users/:userId", [auth.checkApiAuth, csrfMiddleware, auth.checkAdmin], usersRoute.deleteUser, apiResultHandler);
|
||||
|
||||
asyncApiRoute(PST, "/api/sync/test", syncApiRoute.testSync);
|
||||
asyncApiRoute(PST, "/api/sync/now", syncApiRoute.syncNow);
|
||||
apiRoute(PST, "/api/sync/fill-entity-changes", syncApiRoute.fillEntityChanges);
|
||||
|
||||
@ -9,6 +9,7 @@ import totp from "./totp.js";
|
||||
import openID from "./open_id.js";
|
||||
import options from "./options.js";
|
||||
import attributes from "./attributes.js";
|
||||
import userManagement from "./user_management.js";
|
||||
import type { NextFunction, Request, Response } from "express";
|
||||
|
||||
let noAuthentication = false;
|
||||
@ -166,6 +167,52 @@ function checkCredentials(req: Request, res: Response, next: NextFunction) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the current user has admin privileges
|
||||
*/
|
||||
function checkAdmin(req: Request, res: Response, next: NextFunction) {
|
||||
if (!req.session.userId) {
|
||||
reject(req, res, "Not logged in");
|
||||
return;
|
||||
}
|
||||
|
||||
const user = userManagement.getUserById(req.session.userId);
|
||||
if (!user || !user.isAdmin) {
|
||||
reject(req, res, "Admin access required");
|
||||
return;
|
||||
}
|
||||
|
||||
next();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the current user has a specific permission
|
||||
*/
|
||||
function checkPermission(resource: string, action: string) {
|
||||
return (req: Request, res: Response, next: NextFunction) => {
|
||||
if (!req.session.userId) {
|
||||
reject(req, res, "Not logged in");
|
||||
return;
|
||||
}
|
||||
|
||||
if (userManagement.hasPermission(req.session.userId, resource, action)) {
|
||||
next();
|
||||
} else {
|
||||
reject(req, res, `Permission denied: ${resource}.${action}`);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current user from the session
|
||||
*/
|
||||
function getCurrentUser(req: Request) {
|
||||
if (req.session.userId) {
|
||||
return userManagement.getUserById(req.session.userId);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export default {
|
||||
checkAuth,
|
||||
checkApiAuth,
|
||||
@ -175,5 +222,8 @@ export default {
|
||||
checkAppNotInitialized,
|
||||
checkApiAuthOrElectron,
|
||||
checkEtapiToken,
|
||||
checkCredentials
|
||||
checkCredentials,
|
||||
checkAdmin,
|
||||
checkPermission,
|
||||
getCurrentUser
|
||||
};
|
||||
|
||||
401
apps/server/src/services/user_management.ts
Normal file
401
apps/server/src/services/user_management.ts
Normal file
@ -0,0 +1,401 @@
|
||||
/**
|
||||
* User Management Service
|
||||
*
|
||||
* Handles all user-related operations including creation, updates, authentication,
|
||||
* and role management for multi-user support.
|
||||
*/
|
||||
|
||||
import sql from "./sql.js";
|
||||
import { randomSecureToken, toBase64 } from "./utils.js";
|
||||
import dataEncryptionService from "./encryption/data_encryption.js";
|
||||
import crypto from "crypto";
|
||||
|
||||
export interface User {
|
||||
userId: string;
|
||||
username: string;
|
||||
email: string | null;
|
||||
passwordHash: string;
|
||||
passwordSalt: string;
|
||||
derivedKeySalt: string;
|
||||
encryptedDataKey: string | null;
|
||||
isActive: boolean;
|
||||
isAdmin: boolean;
|
||||
utcDateCreated: string;
|
||||
utcDateModified: string;
|
||||
}
|
||||
|
||||
export interface UserCreateData {
|
||||
username: string;
|
||||
email?: string;
|
||||
password: string;
|
||||
isAdmin?: boolean;
|
||||
}
|
||||
|
||||
export interface UserUpdateData {
|
||||
email?: string;
|
||||
password?: string;
|
||||
isActive?: boolean;
|
||||
isAdmin?: boolean;
|
||||
}
|
||||
|
||||
export interface UserListItem {
|
||||
userId: string;
|
||||
username: string;
|
||||
email: string | null;
|
||||
isActive: boolean;
|
||||
isAdmin: boolean;
|
||||
roles: string[];
|
||||
utcDateCreated: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hash password using scrypt (synchronous)
|
||||
*/
|
||||
function hashPassword(password: string, salt: string): string {
|
||||
const hashed = crypto.scryptSync(password, salt, 32, { N: 16384, r: 8, p: 1 });
|
||||
return toBase64(hashed);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new user
|
||||
*/
|
||||
function createUser(userData: UserCreateData): User {
|
||||
const userId = 'user_' + randomSecureToken(20);
|
||||
const now = new Date().toISOString();
|
||||
|
||||
// Generate password salt and hash
|
||||
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
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?)
|
||||
`, [
|
||||
userId,
|
||||
userData.username,
|
||||
userData.email || null,
|
||||
passwordHash,
|
||||
passwordSalt,
|
||||
derivedKeySalt,
|
||||
encryptedDataKey,
|
||||
userData.isAdmin ? 1 : 0,
|
||||
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)!;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user by ID
|
||||
*/
|
||||
function getUserById(userId: string): User | null {
|
||||
const user = sql.getRow(`
|
||||
SELECT * FROM users WHERE userId = ?
|
||||
`, [userId]) as any;
|
||||
|
||||
if (!user) return null;
|
||||
|
||||
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 username
|
||||
*/
|
||||
function getUserByUsername(username: string): User | null {
|
||||
const user = sql.getRow(`
|
||||
SELECT * FROM users WHERE username = ? COLLATE NOCASE
|
||||
`, [username]) as any;
|
||||
|
||||
if (!user) return null;
|
||||
|
||||
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
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Update user
|
||||
*/
|
||||
function updateUser(userId: string, updates: UserUpdateData): User | null {
|
||||
const user = getUserById(userId);
|
||||
if (!user) return null;
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const updateParts: string[] = [];
|
||||
const values: any[] = [];
|
||||
|
||||
if (updates.email !== undefined) {
|
||||
updateParts.push('email = ?');
|
||||
values.push(updates.email || null);
|
||||
}
|
||||
|
||||
if (updates.password !== undefined) {
|
||||
// Generate new password hash
|
||||
const passwordSalt = randomSecureToken(32);
|
||||
const derivedKeySalt = randomSecureToken(32);
|
||||
const passwordHash = hashPassword(updates.password, passwordSalt);
|
||||
|
||||
// Re-encrypt data key with new password
|
||||
const dataKey = randomSecureToken(16);
|
||||
const passwordDerivedKey = crypto.scryptSync(updates.password, derivedKeySalt, 32, { N: 16384, r: 8, p: 1 });
|
||||
const encryptedDataKey = dataEncryptionService.encrypt(passwordDerivedKey, Buffer.from(dataKey));
|
||||
|
||||
updateParts.push('passwordHash = ?', 'passwordSalt = ?', 'derivedKeySalt = ?', 'encryptedDataKey = ?');
|
||||
values.push(passwordHash, passwordSalt, derivedKeySalt, encryptedDataKey);
|
||||
}
|
||||
|
||||
if (updates.isActive !== undefined) {
|
||||
updateParts.push('isActive = ?');
|
||||
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 (updateParts.length > 0) {
|
||||
updateParts.push('utcDateModified = ?');
|
||||
values.push(now, userId);
|
||||
|
||||
sql.execute(`
|
||||
UPDATE users SET ${updateParts.join(', ')}
|
||||
WHERE userId = ?
|
||||
`, values);
|
||||
}
|
||||
|
||||
return getUserById(userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete user (soft delete by setting isActive = 0)
|
||||
*/
|
||||
function deleteUser(userId: string): boolean {
|
||||
const user = getUserById(userId);
|
||||
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 (adminCount <= 1) {
|
||||
throw new Error("Cannot delete the last admin user");
|
||||
}
|
||||
}
|
||||
|
||||
const now = new Date().toISOString();
|
||||
sql.execute(`
|
||||
UPDATE users SET isActive = 0, utcDateModified = ?
|
||||
WHERE userId = ?
|
||||
`, [now, userId]);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* List all users
|
||||
*/
|
||||
function listUsers(includeInactive: boolean = false): UserListItem[] {
|
||||
const whereClause = includeInactive ? '' : 'WHERE u.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
|
||||
${whereClause}
|
||||
GROUP BY u.userId
|
||||
ORDER BY u.username
|
||||
`);
|
||||
|
||||
return users.map((user: any) => ({
|
||||
userId: user.userId,
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
isActive: Boolean(user.isActive),
|
||||
isAdmin: Boolean(user.isAdmin),
|
||||
roles: user.roles ? user.roles.split(',') : [],
|
||||
utcDateCreated: user.utcDateCreated
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate user credentials
|
||||
*/
|
||||
function validateCredentials(username: string, password: string): User | null {
|
||||
const user = getUserByUsername(username);
|
||||
if (!user || !user.isActive) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Verify password using scrypt
|
||||
const expectedHash = hashPassword(password, user.passwordSalt);
|
||||
|
||||
if (expectedHash !== user.passwordHash) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user's roles
|
||||
*/
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user has a specific permission
|
||||
*/
|
||||
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);
|
||||
if (!user) return false;
|
||||
|
||||
// Admins can access all notes
|
||||
if (user.isAdmin) 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get note permission for user (own, read, write, or null)
|
||||
*/
|
||||
function getNotePermission(userId: string, noteId: string): string | null {
|
||||
const user = getUserById(userId);
|
||||
if (!user) return null;
|
||||
|
||||
// Admins have full access
|
||||
if (user.isAdmin) 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';
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
export default {
|
||||
createUser,
|
||||
getUserById,
|
||||
getUserByUsername,
|
||||
updateUser,
|
||||
deleteUser,
|
||||
listUsers,
|
||||
validateCredentials,
|
||||
getUserRoles,
|
||||
hasPermission,
|
||||
canAccessNote,
|
||||
getNotePermission
|
||||
};
|
||||
31
apps/server/src/types/node-globals.d.ts
vendored
Normal file
31
apps/server/src/types/node-globals.d.ts
vendored
Normal file
@ -0,0 +1,31 @@
|
||||
// Minimal ambient declarations to quiet VS Code red squiggles for Node globals in non-test app builds.
|
||||
declare const __dirname: string;
|
||||
declare namespace NodeJS {
|
||||
interface ProcessEnv {
|
||||
NODE_ENV?: string;
|
||||
VITEST?: string;
|
||||
TRILIUM_INTEGRATION_TEST?: string;
|
||||
[key: string]: string | undefined;
|
||||
}
|
||||
interface Process {
|
||||
env: ProcessEnv;
|
||||
exit(code?: number): never;
|
||||
cwd(): string;
|
||||
platform: string;
|
||||
pid: number;
|
||||
}
|
||||
}
|
||||
declare const process: NodeJS.Process;
|
||||
|
||||
// Full Buffer type declaration to match Node.js Buffer API
|
||||
declare class Buffer extends Uint8Array {
|
||||
static from(value: string | Buffer | Uint8Array | ArrayBuffer | readonly number[], encodingOrOffset?: BufferEncoding | number, length?: number): Buffer;
|
||||
static alloc(size: number, fill?: string | Buffer | number, encoding?: BufferEncoding): Buffer;
|
||||
static isBuffer(obj: any): obj is Buffer;
|
||||
static concat(list: Uint8Array[], totalLength?: number): Buffer;
|
||||
|
||||
toString(encoding?: BufferEncoding): string;
|
||||
equals(otherBuffer: Uint8Array): boolean;
|
||||
}
|
||||
|
||||
type BufferEncoding = "ascii" | "utf8" | "utf-8" | "utf16le" | "utf-16le" | "ucs2" | "ucs-2" | "base64" | "base64url" | "latin1" | "binary" | "hex";
|
||||
@ -14,6 +14,7 @@
|
||||
},
|
||||
"include": [
|
||||
"src/**/*.ts",
|
||||
"src/**/*.d.ts",
|
||||
"package.json"
|
||||
],
|
||||
"exclude": [
|
||||
|
||||
132
pnpm-lock.yaml
generated
132
pnpm-lock.yaml
generated
@ -470,7 +470,7 @@ importers:
|
||||
version: 2.1.3(electron@38.3.0)
|
||||
'@preact/preset-vite':
|
||||
specifier: 2.10.2
|
||||
version: 2.10.2(@babel/core@7.28.0)(preact@10.27.2)(vite@7.1.10(@types/node@24.8.1)(jiti@2.6.1)(less@4.1.3)(lightningcss@1.30.1)(sass-embedded@1.91.0)(sass@1.91.0)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1))
|
||||
version: 2.10.2(@babel/core@7.28.0)(preact@10.27.2)(vite@7.1.10(@types/node@22.18.11)(jiti@2.6.1)(less@4.1.3)(lightningcss@1.30.1)(sass-embedded@1.91.0)(sass@1.91.0)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1))
|
||||
'@triliumnext/commons':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/commons
|
||||
@ -528,6 +528,9 @@ importers:
|
||||
'@types/multer':
|
||||
specifier: 2.0.0
|
||||
version: 2.0.0
|
||||
'@types/node':
|
||||
specifier: 22.18.11
|
||||
version: 22.18.11
|
||||
'@types/safe-compare':
|
||||
specifier: 1.1.2
|
||||
version: 1.1.2
|
||||
@ -755,7 +758,7 @@ importers:
|
||||
version: 1.0.1
|
||||
vite:
|
||||
specifier: 7.1.10
|
||||
version: 7.1.10(@types/node@24.8.1)(jiti@2.6.1)(less@4.1.3)(lightningcss@1.30.1)(sass-embedded@1.91.0)(sass@1.91.0)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1)
|
||||
version: 7.1.10(@types/node@22.18.11)(jiti@2.6.1)(less@4.1.3)(lightningcss@1.30.1)(sass-embedded@1.91.0)(sass@1.91.0)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1)
|
||||
ws:
|
||||
specifier: 8.18.3
|
||||
version: 8.18.3(bufferutil@4.0.9)(utf-8-validate@6.0.5)
|
||||
@ -5003,27 +5006,12 @@ packages:
|
||||
'@types/node@16.9.1':
|
||||
resolution: {integrity: sha512-QpLcX9ZSsq3YYUUnD3nFDY8H7wctAhQj/TFKL8Ya8v5fMm3CFXxo8zStsLAl780ltoYoo1WvKUVGBQK+1ifr7g==}
|
||||
|
||||
'@types/node@20.19.18':
|
||||
resolution: {integrity: sha512-KeYVbfnbsBCyKG8e3gmUqAfyZNcoj/qpEbHRkQkfZdKOBrU7QQ+BsTdfqLSWX9/m1ytYreMhpKvp+EZi3UFYAg==}
|
||||
|
||||
'@types/node@20.19.22':
|
||||
resolution: {integrity: sha512-hRnu+5qggKDSyWHlnmThnUqg62l29Aj/6vcYgUaSFL9oc7DVjeWEQN3PRgdSc6F8d9QRMWkf36CLMch1Do/+RQ==}
|
||||
|
||||
'@types/node@22.15.21':
|
||||
resolution: {integrity: sha512-EV/37Td6c+MgKAbkcLG6vqZ2zEYHD7bvSrzqqs2RIhbA6w3x+Dqz8MZM3sP6kGTeLrdoOgKZe+Xja7tUB2DNkQ==}
|
||||
|
||||
'@types/node@22.15.30':
|
||||
resolution: {integrity: sha512-6Q7lr06bEHdlfplU6YRbgG1SFBdlsfNC4/lX+SkhiTs0cpJkOElmWls8PxDFv4yY/xKb8Y6SO0OmSX4wgqTZbA==}
|
||||
|
||||
'@types/node@22.18.10':
|
||||
resolution: {integrity: sha512-anNG/V/Efn/YZY4pRzbACnKxNKoBng2VTFydVu8RRs5hQjikP8CQfaeAV59VFSCzKNp90mXiVXW2QzV56rwMrg==}
|
||||
|
||||
'@types/node@22.18.11':
|
||||
resolution: {integrity: sha512-Gd33J2XIrXurb+eT2ktze3rJAfAp9ZNjlBdh4SVgyrKEOADwCbdUDaK7QgJno8Ue4kcajscsKqu6n8OBG3hhCQ==}
|
||||
|
||||
'@types/node@22.18.8':
|
||||
resolution: {integrity: sha512-pAZSHMiagDR7cARo/cch1f3rXy0AEXwsVsVH09FcyeJVAzCnGgmYis7P3JidtTUjyadhTeSo8TgRPswstghDaw==}
|
||||
|
||||
'@types/node@24.8.1':
|
||||
resolution: {integrity: sha512-alv65KGRadQVfVcG69MuB4IzdYVpRwMG/mq8KWOaoOdyY617P5ivaDiMCGOFDWD2sAn5Q0mR3mRtUOgm99hL9Q==}
|
||||
|
||||
@ -14692,6 +14680,8 @@ snapshots:
|
||||
'@ckeditor/ckeditor5-core': 47.1.0
|
||||
'@ckeditor/ckeditor5-upload': 47.1.0
|
||||
ckeditor5: 47.1.0(patch_hash=8331a09d41443b39ea1c784daaccfeb0da4f9065ed556e7de92e9c77edd9eb41)
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@ckeditor/ckeditor5-ai@47.1.0(bufferutil@4.0.9)(utf-8-validate@6.0.5)':
|
||||
dependencies:
|
||||
@ -14838,6 +14828,8 @@ snapshots:
|
||||
'@ckeditor/ckeditor5-core': 47.1.0
|
||||
'@ckeditor/ckeditor5-utils': 47.1.0
|
||||
ckeditor5: 47.1.0(patch_hash=8331a09d41443b39ea1c784daaccfeb0da4f9065ed556e7de92e9c77edd9eb41)
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@ckeditor/ckeditor5-code-block@47.1.0(patch_hash=2361d8caad7d6b5bddacc3a3b4aa37dbfba260b1c1b22a450413a79c1bb1ce95)':
|
||||
dependencies:
|
||||
@ -15065,6 +15057,8 @@ snapshots:
|
||||
'@ckeditor/ckeditor5-utils': 47.1.0
|
||||
ckeditor5: 47.1.0(patch_hash=8331a09d41443b39ea1c784daaccfeb0da4f9065ed556e7de92e9c77edd9eb41)
|
||||
es-toolkit: 1.39.5
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@ckeditor/ckeditor5-editor-classic@47.1.0':
|
||||
dependencies:
|
||||
@ -15074,6 +15068,8 @@ snapshots:
|
||||
'@ckeditor/ckeditor5-utils': 47.1.0
|
||||
ckeditor5: 47.1.0(patch_hash=8331a09d41443b39ea1c784daaccfeb0da4f9065ed556e7de92e9c77edd9eb41)
|
||||
es-toolkit: 1.39.5
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@ckeditor/ckeditor5-editor-decoupled@47.1.0':
|
||||
dependencies:
|
||||
@ -15083,6 +15079,8 @@ snapshots:
|
||||
'@ckeditor/ckeditor5-utils': 47.1.0
|
||||
ckeditor5: 47.1.0(patch_hash=8331a09d41443b39ea1c784daaccfeb0da4f9065ed556e7de92e9c77edd9eb41)
|
||||
es-toolkit: 1.39.5
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@ckeditor/ckeditor5-editor-inline@47.1.0':
|
||||
dependencies:
|
||||
@ -15092,6 +15090,8 @@ snapshots:
|
||||
'@ckeditor/ckeditor5-utils': 47.1.0
|
||||
ckeditor5: 47.1.0(patch_hash=8331a09d41443b39ea1c784daaccfeb0da4f9065ed556e7de92e9c77edd9eb41)
|
||||
es-toolkit: 1.39.5
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@ckeditor/ckeditor5-editor-multi-root@47.1.0':
|
||||
dependencies:
|
||||
@ -15114,6 +15114,8 @@ snapshots:
|
||||
'@ckeditor/ckeditor5-table': 47.1.0
|
||||
'@ckeditor/ckeditor5-utils': 47.1.0
|
||||
ckeditor5: 47.1.0(patch_hash=8331a09d41443b39ea1c784daaccfeb0da4f9065ed556e7de92e9c77edd9eb41)
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@ckeditor/ckeditor5-emoji@47.1.0':
|
||||
dependencies:
|
||||
@ -15196,6 +15198,8 @@ snapshots:
|
||||
'@ckeditor/ckeditor5-utils': 47.1.0
|
||||
ckeditor5: 47.1.0(patch_hash=8331a09d41443b39ea1c784daaccfeb0da4f9065ed556e7de92e9c77edd9eb41)
|
||||
es-toolkit: 1.39.5
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@ckeditor/ckeditor5-font@47.1.0':
|
||||
dependencies:
|
||||
@ -15259,6 +15263,8 @@ snapshots:
|
||||
'@ckeditor/ckeditor5-utils': 47.1.0
|
||||
'@ckeditor/ckeditor5-widget': 47.1.0
|
||||
ckeditor5: 47.1.0(patch_hash=8331a09d41443b39ea1c784daaccfeb0da4f9065ed556e7de92e9c77edd9eb41)
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@ckeditor/ckeditor5-html-embed@47.1.0':
|
||||
dependencies:
|
||||
@ -15549,6 +15555,8 @@ snapshots:
|
||||
'@ckeditor/ckeditor5-paste-from-office': 47.1.0
|
||||
'@ckeditor/ckeditor5-utils': 47.1.0
|
||||
ckeditor5: 47.1.0(patch_hash=8331a09d41443b39ea1c784daaccfeb0da4f9065ed556e7de92e9c77edd9eb41)
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@ckeditor/ckeditor5-paste-from-office@47.1.0':
|
||||
dependencies:
|
||||
@ -15556,6 +15564,8 @@ snapshots:
|
||||
'@ckeditor/ckeditor5-core': 47.1.0
|
||||
'@ckeditor/ckeditor5-engine': 47.1.0
|
||||
ckeditor5: 47.1.0(patch_hash=8331a09d41443b39ea1c784daaccfeb0da4f9065ed556e7de92e9c77edd9eb41)
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@ckeditor/ckeditor5-real-time-collaboration@47.1.0(bufferutil@4.0.9)(utf-8-validate@6.0.5)':
|
||||
dependencies:
|
||||
@ -15586,6 +15596,8 @@ snapshots:
|
||||
'@ckeditor/ckeditor5-ui': 47.1.0
|
||||
'@ckeditor/ckeditor5-utils': 47.1.0
|
||||
ckeditor5: 47.1.0(patch_hash=8331a09d41443b39ea1c784daaccfeb0da4f9065ed556e7de92e9c77edd9eb41)
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@ckeditor/ckeditor5-restricted-editing@47.1.0':
|
||||
dependencies:
|
||||
@ -15595,6 +15607,8 @@ snapshots:
|
||||
'@ckeditor/ckeditor5-ui': 47.1.0
|
||||
'@ckeditor/ckeditor5-utils': 47.1.0
|
||||
ckeditor5: 47.1.0(patch_hash=8331a09d41443b39ea1c784daaccfeb0da4f9065ed556e7de92e9c77edd9eb41)
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@ckeditor/ckeditor5-revision-history@47.1.0':
|
||||
dependencies:
|
||||
@ -15672,6 +15686,8 @@ snapshots:
|
||||
'@ckeditor/ckeditor5-ui': 47.1.0
|
||||
'@ckeditor/ckeditor5-utils': 47.1.0
|
||||
ckeditor5: 47.1.0(patch_hash=8331a09d41443b39ea1c784daaccfeb0da4f9065ed556e7de92e9c77edd9eb41)
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@ckeditor/ckeditor5-special-characters@47.1.0':
|
||||
dependencies:
|
||||
@ -15681,6 +15697,8 @@ snapshots:
|
||||
'@ckeditor/ckeditor5-ui': 47.1.0
|
||||
'@ckeditor/ckeditor5-utils': 47.1.0
|
||||
ckeditor5: 47.1.0(patch_hash=8331a09d41443b39ea1c784daaccfeb0da4f9065ed556e7de92e9c77edd9eb41)
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@ckeditor/ckeditor5-style@47.1.0':
|
||||
dependencies:
|
||||
@ -17843,6 +17861,22 @@ snapshots:
|
||||
|
||||
'@popperjs/core@2.11.8': {}
|
||||
|
||||
'@preact/preset-vite@2.10.2(@babel/core@7.28.0)(preact@10.27.2)(vite@7.1.10(@types/node@22.18.11)(jiti@2.6.1)(less@4.1.3)(lightningcss@1.30.1)(sass-embedded@1.91.0)(sass@1.91.0)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1))':
|
||||
dependencies:
|
||||
'@babel/core': 7.28.0
|
||||
'@babel/plugin-transform-react-jsx': 7.27.1(@babel/core@7.28.0)
|
||||
'@babel/plugin-transform-react-jsx-development': 7.27.1(@babel/core@7.28.0)
|
||||
'@prefresh/vite': 2.4.8(preact@10.27.2)(vite@7.1.10(@types/node@22.18.11)(jiti@2.6.1)(less@4.1.3)(lightningcss@1.30.1)(sass-embedded@1.91.0)(sass@1.91.0)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1))
|
||||
'@rollup/pluginutils': 4.2.1
|
||||
babel-plugin-transform-hook-names: 1.0.2(@babel/core@7.28.0)
|
||||
debug: 4.4.1
|
||||
picocolors: 1.1.1
|
||||
vite: 7.1.10(@types/node@22.18.11)(jiti@2.6.1)(less@4.1.3)(lightningcss@1.30.1)(sass-embedded@1.91.0)(sass@1.91.0)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1)
|
||||
vite-prerender-plugin: 0.5.11(vite@7.1.10(@types/node@22.18.11)(jiti@2.6.1)(less@4.1.3)(lightningcss@1.30.1)(sass-embedded@1.91.0)(sass@1.91.0)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1))
|
||||
transitivePeerDependencies:
|
||||
- preact
|
||||
- supports-color
|
||||
|
||||
'@preact/preset-vite@2.10.2(@babel/core@7.28.0)(preact@10.27.2)(vite@7.1.10(@types/node@24.8.1)(jiti@2.6.1)(less@4.1.3)(lightningcss@1.30.1)(sass-embedded@1.91.0)(sass@1.91.0)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1))':
|
||||
dependencies:
|
||||
'@babel/core': 7.28.0
|
||||
@ -17867,6 +17901,18 @@ snapshots:
|
||||
|
||||
'@prefresh/utils@1.2.1': {}
|
||||
|
||||
'@prefresh/vite@2.4.8(preact@10.27.2)(vite@7.1.10(@types/node@22.18.11)(jiti@2.6.1)(less@4.1.3)(lightningcss@1.30.1)(sass-embedded@1.91.0)(sass@1.91.0)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1))':
|
||||
dependencies:
|
||||
'@babel/core': 7.28.0
|
||||
'@prefresh/babel-plugin': 0.5.2
|
||||
'@prefresh/core': 1.5.5(preact@10.27.2)
|
||||
'@prefresh/utils': 1.2.1
|
||||
'@rollup/pluginutils': 4.2.1
|
||||
preact: 10.27.2
|
||||
vite: 7.1.10(@types/node@22.18.11)(jiti@2.6.1)(less@4.1.3)(lightningcss@1.30.1)(sass-embedded@1.91.0)(sass@1.91.0)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1)
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@prefresh/vite@2.4.8(preact@10.27.2)(vite@7.1.10(@types/node@24.8.1)(jiti@2.6.1)(less@4.1.3)(lightningcss@1.30.1)(sass-embedded@1.91.0)(sass@1.91.0)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1))':
|
||||
dependencies:
|
||||
'@babel/core': 7.28.0
|
||||
@ -19019,7 +19065,7 @@ snapshots:
|
||||
|
||||
'@types/better-sqlite3@7.6.13':
|
||||
dependencies:
|
||||
'@types/node': 22.18.8
|
||||
'@types/node': 22.18.11
|
||||
|
||||
'@types/body-parser@1.19.6':
|
||||
dependencies:
|
||||
@ -19047,7 +19093,7 @@ snapshots:
|
||||
|
||||
'@types/cls-hooked@4.3.9':
|
||||
dependencies:
|
||||
'@types/node': 22.15.21
|
||||
'@types/node': 22.18.11
|
||||
|
||||
'@types/color-convert@2.0.4':
|
||||
dependencies:
|
||||
@ -19058,7 +19104,7 @@ snapshots:
|
||||
'@types/compression@1.8.1':
|
||||
dependencies:
|
||||
'@types/express': 5.0.3
|
||||
'@types/node': 22.15.30
|
||||
'@types/node': 22.18.11
|
||||
|
||||
'@types/connect-history-api-fallback@1.5.4':
|
||||
dependencies:
|
||||
@ -19260,7 +19306,7 @@ snapshots:
|
||||
'@types/fs-extra@11.0.4':
|
||||
dependencies:
|
||||
'@types/jsonfile': 6.1.4
|
||||
'@types/node': 22.15.21
|
||||
'@types/node': 22.18.11
|
||||
|
||||
'@types/fs-extra@9.0.13':
|
||||
dependencies:
|
||||
@ -19366,34 +19412,14 @@ snapshots:
|
||||
|
||||
'@types/node@16.9.1': {}
|
||||
|
||||
'@types/node@20.19.18':
|
||||
dependencies:
|
||||
undici-types: 6.21.0
|
||||
|
||||
'@types/node@20.19.22':
|
||||
dependencies:
|
||||
undici-types: 6.21.0
|
||||
|
||||
'@types/node@22.15.21':
|
||||
dependencies:
|
||||
undici-types: 6.21.0
|
||||
|
||||
'@types/node@22.15.30':
|
||||
dependencies:
|
||||
undici-types: 6.21.0
|
||||
|
||||
'@types/node@22.18.10':
|
||||
dependencies:
|
||||
undici-types: 6.21.0
|
||||
|
||||
'@types/node@22.18.11':
|
||||
dependencies:
|
||||
undici-types: 6.21.0
|
||||
|
||||
'@types/node@22.18.8':
|
||||
dependencies:
|
||||
undici-types: 6.21.0
|
||||
|
||||
'@types/node@24.8.1':
|
||||
dependencies:
|
||||
undici-types: 7.14.0
|
||||
@ -19443,7 +19469,7 @@ snapshots:
|
||||
|
||||
'@types/sax@1.2.7':
|
||||
dependencies:
|
||||
'@types/node': 22.15.21
|
||||
'@types/node': 22.18.11
|
||||
|
||||
'@types/send@0.17.5':
|
||||
dependencies:
|
||||
@ -19461,7 +19487,7 @@ snapshots:
|
||||
'@types/serve-static@1.15.9':
|
||||
dependencies:
|
||||
'@types/http-errors': 2.0.4
|
||||
'@types/node': 22.18.8
|
||||
'@types/node': 22.18.11
|
||||
'@types/send': 0.17.5
|
||||
|
||||
'@types/session-file-store@1.2.5':
|
||||
@ -19482,7 +19508,7 @@ snapshots:
|
||||
|
||||
'@types/stream-throttle@0.1.4':
|
||||
dependencies:
|
||||
'@types/node': 22.15.21
|
||||
'@types/node': 22.18.11
|
||||
|
||||
'@types/superagent@8.1.9':
|
||||
dependencies:
|
||||
@ -19535,11 +19561,11 @@ snapshots:
|
||||
|
||||
'@types/ws@8.18.1':
|
||||
dependencies:
|
||||
'@types/node': 22.15.21
|
||||
'@types/node': 22.18.11
|
||||
|
||||
'@types/xml2js@0.4.14':
|
||||
dependencies:
|
||||
'@types/node': 22.15.21
|
||||
'@types/node': 22.18.11
|
||||
|
||||
'@types/yargs-parser@21.0.3': {}
|
||||
|
||||
@ -22340,7 +22366,7 @@ snapshots:
|
||||
electron@38.3.0:
|
||||
dependencies:
|
||||
'@electron/get': 2.0.3
|
||||
'@types/node': 22.18.10
|
||||
'@types/node': 22.18.11
|
||||
extract-zip: 2.0.1
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
@ -29838,6 +29864,16 @@ snapshots:
|
||||
typescript: 5.9.3
|
||||
vite: 7.1.10(@types/node@22.18.11)(jiti@2.6.1)(less@4.1.3)(lightningcss@1.30.1)(sass-embedded@1.91.0)(sass@1.91.0)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1)
|
||||
|
||||
vite-prerender-plugin@0.5.11(vite@7.1.10(@types/node@22.18.11)(jiti@2.6.1)(less@4.1.3)(lightningcss@1.30.1)(sass-embedded@1.91.0)(sass@1.91.0)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1)):
|
||||
dependencies:
|
||||
kolorist: 1.8.0
|
||||
magic-string: 0.30.18
|
||||
node-html-parser: 6.1.13
|
||||
simple-code-frame: 1.3.0
|
||||
source-map: 0.7.6
|
||||
stack-trace: 1.0.0-pre2
|
||||
vite: 7.1.10(@types/node@22.18.11)(jiti@2.6.1)(less@4.1.3)(lightningcss@1.30.1)(sass-embedded@1.91.0)(sass@1.91.0)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1)
|
||||
|
||||
vite-prerender-plugin@0.5.11(vite@7.1.10(@types/node@24.8.1)(jiti@2.6.1)(less@4.1.3)(lightningcss@1.30.1)(sass-embedded@1.91.0)(sass@1.91.0)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1)):
|
||||
dependencies:
|
||||
kolorist: 1.8.0
|
||||
@ -30026,7 +30062,7 @@ snapshots:
|
||||
|
||||
webdriverio@9.20.0(bufferutil@4.0.9)(utf-8-validate@6.0.5):
|
||||
dependencies:
|
||||
'@types/node': 20.19.18
|
||||
'@types/node': 20.19.22
|
||||
'@types/sinonjs__fake-timers': 8.1.5
|
||||
'@wdio/config': 9.20.0
|
||||
'@wdio/logger': 9.18.0
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user