* feat: add OIDC/SSO database schema and models (Phase 1) Add database foundation for OpenID Connect authentication: Database Migrations: - Create oidc_identities table (links users to OIDC accounts) - Create oidc_state_nonces table (OAuth state/nonce for CSRF protection) - Create auth_audit_log table (security event logging) - Make password_digest nullable in users table (allow OIDC-only users) Models: - OIDCIdentity: Links users to external OIDC providers - OIDCStateNonce: Temporary OAuth state management - AuthAuditLog: Authentication event audit trail Changes: - Updated User model to allow null password_digest - Added model associations in models/index.js - All migrations tested and verified Related to #977 * feat: add OIDC core services (Phase 2) - Install openid-client@^6.2.0 for OIDC protocol support - Implement providerConfig.js for loading providers from .env - Support single provider or numbered providers (OIDC_PROVIDER_1_*, etc.) - Auto-provision and admin email domain configuration - Provider caching for performance - Implement stateManager.js for OAuth state/nonce management - CSRF protection with 10-minute TTL - One-time use state consumption - Automatic cleanup of expired states - Implement auditService.js for authentication event logging - Track login success/failure, logout, OIDC linking/unlinking - Store IP address, user agent, and metadata - Support for event queries and retention cleanup - Add comprehensive unit tests (60 tests, all passing) - providerConfig: 36 tests for env parsing and validation - stateManager: 12 tests for state lifecycle and security - auditService: 12 tests for event logging and queries Phase 2 completes the backend core services needed for OIDC authentication. * feat: implement OIDC authentication flow (Phase 3) Core OIDC Flow (service.js): - Provider discovery with issuer caching - Authorization URL generation with state/nonce - OAuth callback handling and token exchange - ID token validation using openid-client - Token refresh functionality JIT User Provisioning (provisioningService.js): - Auto-create users from OIDC claims - Link existing email accounts to OIDC identities - Admin role assignment based on email domain rules - Automatic username generation from email - Transaction-safe identity creation Identity Management (oidcIdentityService.js): - List user's linked OIDC identities - Link additional providers to existing accounts - Unlink identities with safety checks - Prevent unlinking last auth method - Update identity claims on login HTTP Layer (controller.js + routes.js): - GET /api/oidc/providers - List configured providers - GET /api/oidc/auth/:slug - Initiate OIDC flow - GET /api/oidc/callback/:slug - Handle OAuth callback - POST /api/oidc/link/:slug - Link provider to current user - DELETE /api/oidc/unlink/:id - Unlink identity - GET /api/oidc/identities - Get user's identities Integration: - Register OIDC routes in Express app (public + authenticated) - Update auth service to reject password login for OIDC-only users - Audit logging for all OIDC operations - Session creation on successful authentication Security: - State/nonce CSRF protection - One-time use state consumption - Transaction-safe user provisioning - Foreign key constraints enforced * feat: implement OIDC frontend login flow (Phase 4) - Created OIDCProviderButtons component for SSO login options - Created OIDCCallback component for OAuth callback handling - Updated Login page to fetch and display OIDC providers - Added /auth/callback/:provider route to App.tsx - Added i18n translations for OIDC UI elements - Downgraded openid-client to v5.7.0 (CommonJS compatibility) - Fixed linting issues in backend OIDC modules Phase 4 completes the frontend login flow for OIDC/SSO authentication. Users can now see configured SSO providers on the login page. * feat: implement OIDC account linking UI (Phase 5) Add Connected Accounts section to Profile Security tab allowing users to: - View linked OIDC provider accounts - Link new SSO providers to their account - Unlink OIDC identities with validation - Prevent unlinking last authentication method Backend changes: - Add has_password virtual field to User model - Include has_password in profile API response - Track whether user has password set for validation Frontend changes: - Create oidcService for OIDC API operations - Create ConnectedAccounts component with link/unlink flows - Add confirmation dialog before unlinking accounts - Validate that users cannot unlink their last auth method - Show warning if user has no password set - Integrate Connected Accounts into SecurityTab User experience: - View all linked SSO provider accounts with email and link date - Link additional providers via "Link Provider" buttons - Unlink with two-step confirmation to prevent accidents - Clear error messages when unlinking would leave no auth method - Warning message suggesting password setup for OIDC-only users Fixes #977 * feat: complete OIDC documentation and UI improvements (Phase 6) This commit completes Phase 6 of the OIDC/SSO implementation with comprehensive documentation, bug fixes, and UI reorganization. Documentation: - Add comprehensive user guide at docs/10-oidc-sso.md with: - Setup guides for 6 major providers (Google, Okta, Keycloak, Authentik, PocketID, Azure AD) - Configuration examples for single and multiple providers - User features documentation (login, account linking, management) - Advanced topics (auto-provisioning, admin role assignment, hybrid auth) - Comprehensive troubleshooting section - Security considerations and best practices - Update README.md with OIDC/SSO section and quick setup examples Internationalization: - Add i18n support to OIDCProviderButtons component - Add translation keys for all OIDC UI text - Update English translations with "sign_in_with" key Bug Fixes: - Fix oidcService.ts to correctly unwrap API responses - Backend returns {providers: [...]} and {identities: [...]} - Frontend was expecting plain arrays, causing "map is not a function" error - Fix initiateOIDCLink to properly handle POST response UI Improvements: - Move OIDC/SSO to dedicated tab in profile settings - Create new OIDCTab component with green LinkIcon - Remove ConnectedAccounts from SecurityTab - Add OIDC tab between Security and API Keys tabs - Update ProfileSettings with new tab configuration - Security tab now focuses solely on password management Testing: - All linting passes - All tests pass (82 suites, 1223 tests) Related to #977 * feat: add OIDC/SSO translations for all 24 languages Add i18n support for OIDC/SSO features across all supported languages: - "Sign in with {{provider}}" button text - "OIDC/SSO" tab label in profile settings - OIDC authentication flow messages Translations added for: Arabic, Bulgarian, Danish, German, Greek, Spanish, Finnish, French, Indonesian, Italian, Japanese, Korean, Dutch, Norwegian, Polish, Portuguese, Romanian, Russian, Slovenian, Swedish, Turkish, Ukrainian, Vietnamese, and Chinese. * fix: resolve 13 CodeQL security alerts This commit addresses critical security vulnerabilities identified by CodeQL scanning: **Security Configuration (2 fixes)** - Fix insecure Helmet configuration - enable CSP and HSTS in production - Fix clear text cookie transmission - enable secure cookies in production **Path Injection (3 fixes)** - Add path validation in users/controller.js to prevent arbitrary file deletion - Add path validation in users/service.js for avatar operations - Add path sanitization in attachment-utils.js deleteFileFromDisk function **Cross-Site Scripting (1 fix)** - Fix XSS vulnerability in GeneralTab.tsx avatar URL handling - Add URL sanitization to prevent javascript: protocol attacks **URL Security (2 fixes)** - Fix double escaping in url/service.js HTML entity decoding - Fix incomplete URL sanitization for YouTube domain validation **Denial of Service (1 fix)** - Add loop bound protection in inboxProcessingService.js (10k char limit) **Rate Limiting (3 fixes)** - Add rate limiting to auth routes (register, verify-email) - Add rate limiting to task attachment upload/delete endpoints - Add rate limiting to user avatar upload/delete endpoints **GitHub Actions Security (1 fix)** - Add explicit read-only permissions to CI workflow Note: CSRF middleware (#10) requires frontend changes and is tracked separately. Relates to PR #1008 * fix: allow test files in path validation for tests * fix: format long condition in attachment-utils for Prettier compliance Break the path validation condition across multiple lines to meet Prettier formatting requirements and fix CI linting failure. * fix: resolve CodeQL security alerts - Add rate limiting to OIDC authentication routes using authLimiter and authenticatedApiLimiter - Implement CSRF protection middleware using csrf-sync (skips for API tokens and test environment) - Add CSRF token endpoint at /api/csrf-token - Fix incomplete URL scheme validation in GeneralTab to block all dangerous schemes (javascript:, data:, vbscript:, file:) This addresses 5 high-severity CodeQL security vulnerabilities: - Missing rate limiting on OIDC auth routes - Missing CSRF middleware protection - Incomplete URL sanitization in avatar handling All 1223 tests passing. * fix: implement CSRF protection with lusca for CodeQL compliance Add CSRF protection using lusca.csrf (CodeQL's recommended library) to protect session-based authentication while supporting hybrid auth patterns. Implementation: - Pre-check middleware marks exempt requests (test env, Bearer tokens) - Lusca CSRF middleware applied with exemption flag check - Session-based requests require valid x-csrf-token header - Bearer token requests exempt (don't use cookies) - Test environment exempt for test execution This addresses CodeQL security alert js/missing-token-validation while maintaining support for both cookie-based and token-based authentication. Related: #977 (OIDC/SSO authentication feature)
544 lines
17 KiB
JavaScript
544 lines
17 KiB
JavaScript
'use strict';
|
|
|
|
const usersRepository = require('./repository');
|
|
const {
|
|
validateFirstDayOfWeek,
|
|
validatePassword,
|
|
validateFrequency,
|
|
validateApiKeyId,
|
|
validateApiKeyName,
|
|
validateExpiresAt,
|
|
validateSidebarSettings,
|
|
} = require('./validation');
|
|
const { NotFoundError, ValidationError } = require('../../shared/errors');
|
|
const { User } = require('../../models');
|
|
const {
|
|
createApiToken,
|
|
revokeApiToken,
|
|
deleteApiToken,
|
|
serializeApiToken,
|
|
} = require('./apiTokenService');
|
|
const taskSummaryService = require('../tasks/taskSummaryService');
|
|
const { logError } = require('../../services/logService');
|
|
const fs = require('fs').promises;
|
|
const path = require('path');
|
|
const { getConfig } = require('../../config/config');
|
|
|
|
const config = getConfig();
|
|
|
|
async function safeDeleteFile(filePath) {
|
|
if (!filePath) return;
|
|
|
|
const uploadDir = path.resolve(config.uploadPath);
|
|
const resolvedPath = path.resolve(filePath);
|
|
const relativePath = path.relative(uploadDir, resolvedPath);
|
|
|
|
if (relativePath.startsWith('..') || path.isAbsolute(relativePath)) {
|
|
logError('Attempt to delete file outside upload directory:', filePath);
|
|
return;
|
|
}
|
|
|
|
await fs.unlink(resolvedPath).catch(() => {});
|
|
}
|
|
|
|
class UsersService {
|
|
/**
|
|
* List all users with roles.
|
|
*/
|
|
async listUsers() {
|
|
const users = await usersRepository.findAllBasic();
|
|
const roles = await usersRepository.findAllRoles();
|
|
const userIdToRole = new Map(roles.map((r) => [r.user_id, r.is_admin]));
|
|
|
|
return users.map((u) => ({
|
|
id: u.id,
|
|
email: u.email,
|
|
name: u.name,
|
|
surname: u.surname,
|
|
role: userIdToRole.get(u.id) ? 'admin' : 'user',
|
|
}));
|
|
}
|
|
|
|
/**
|
|
* Get user profile.
|
|
*/
|
|
async getProfile(userId) {
|
|
const user = await usersRepository.findProfileById(userId);
|
|
if (!user) {
|
|
throw new NotFoundError('Profile not found.');
|
|
}
|
|
|
|
// Parse today_settings if it's a string
|
|
if (user.today_settings && typeof user.today_settings === 'string') {
|
|
try {
|
|
user.today_settings = JSON.parse(user.today_settings);
|
|
} catch (error) {
|
|
logError('Error parsing today_settings:', error);
|
|
user.today_settings = null;
|
|
}
|
|
}
|
|
if (user.ui_settings && typeof user.ui_settings === 'string') {
|
|
try {
|
|
user.ui_settings = JSON.parse(user.ui_settings);
|
|
} catch (error) {
|
|
logError('Error parsing ui_settings:', error);
|
|
user.ui_settings = null;
|
|
}
|
|
}
|
|
|
|
return user;
|
|
}
|
|
|
|
/**
|
|
* Update user profile.
|
|
*/
|
|
async updateProfile(userId, data) {
|
|
const user = await usersRepository.findByIdWithPassword(userId);
|
|
if (!user) {
|
|
throw new NotFoundError('Profile not found.');
|
|
}
|
|
|
|
const {
|
|
name,
|
|
surname,
|
|
appearance,
|
|
language,
|
|
timezone,
|
|
first_day_of_week,
|
|
avatar_image,
|
|
telegram_bot_token,
|
|
telegram_allowed_users,
|
|
task_intelligence_enabled,
|
|
task_summary_enabled,
|
|
task_summary_frequency,
|
|
auto_suggest_next_actions_enabled,
|
|
productivity_assistant_enabled,
|
|
next_task_suggestion_enabled,
|
|
pomodoro_enabled,
|
|
ui_settings,
|
|
notification_preferences,
|
|
keyboard_shortcuts,
|
|
currentPassword,
|
|
newPassword,
|
|
} = data;
|
|
|
|
const allowedUpdates = {};
|
|
if (name !== undefined) allowedUpdates.name = name;
|
|
if (surname !== undefined) allowedUpdates.surname = surname;
|
|
if (appearance !== undefined) allowedUpdates.appearance = appearance;
|
|
if (language !== undefined) allowedUpdates.language = language;
|
|
if (timezone !== undefined) allowedUpdates.timezone = timezone;
|
|
if (first_day_of_week !== undefined) {
|
|
validateFirstDayOfWeek(first_day_of_week);
|
|
allowedUpdates.first_day_of_week = first_day_of_week;
|
|
}
|
|
if (avatar_image !== undefined)
|
|
allowedUpdates.avatar_image = avatar_image;
|
|
if (telegram_bot_token !== undefined)
|
|
allowedUpdates.telegram_bot_token = telegram_bot_token;
|
|
if (telegram_allowed_users !== undefined)
|
|
allowedUpdates.telegram_allowed_users = telegram_allowed_users;
|
|
if (task_intelligence_enabled !== undefined)
|
|
allowedUpdates.task_intelligence_enabled =
|
|
task_intelligence_enabled;
|
|
if (task_summary_enabled !== undefined)
|
|
allowedUpdates.task_summary_enabled = task_summary_enabled;
|
|
if (task_summary_frequency !== undefined)
|
|
allowedUpdates.task_summary_frequency = task_summary_frequency;
|
|
if (auto_suggest_next_actions_enabled !== undefined)
|
|
allowedUpdates.auto_suggest_next_actions_enabled =
|
|
auto_suggest_next_actions_enabled;
|
|
if (productivity_assistant_enabled !== undefined)
|
|
allowedUpdates.productivity_assistant_enabled =
|
|
productivity_assistant_enabled;
|
|
if (next_task_suggestion_enabled !== undefined)
|
|
allowedUpdates.next_task_suggestion_enabled =
|
|
next_task_suggestion_enabled;
|
|
if (pomodoro_enabled !== undefined)
|
|
allowedUpdates.pomodoro_enabled = pomodoro_enabled;
|
|
if (ui_settings !== undefined) allowedUpdates.ui_settings = ui_settings;
|
|
if (notification_preferences !== undefined)
|
|
allowedUpdates.notification_preferences = notification_preferences;
|
|
if (keyboard_shortcuts !== undefined)
|
|
allowedUpdates.keyboard_shortcuts = keyboard_shortcuts;
|
|
|
|
// Handle password change if provided
|
|
if (currentPassword && newPassword) {
|
|
validatePassword(newPassword, 'newPassword');
|
|
|
|
const isValidPassword = await User.checkPassword(
|
|
currentPassword,
|
|
user.password_digest
|
|
);
|
|
if (!isValidPassword) {
|
|
throw new ValidationError(
|
|
'Current password is incorrect',
|
|
'currentPassword'
|
|
);
|
|
}
|
|
|
|
const hashedNewPassword = await User.hashPassword(newPassword);
|
|
allowedUpdates.password_digest = hashedNewPassword;
|
|
}
|
|
|
|
await usersRepository.update(user, allowedUpdates);
|
|
return usersRepository.findUpdatedProfile(user.id);
|
|
}
|
|
|
|
/**
|
|
* Upload avatar.
|
|
*/
|
|
async uploadAvatar(userId, file) {
|
|
if (!file) {
|
|
throw new ValidationError('No file uploaded');
|
|
}
|
|
|
|
const user = await usersRepository.findById(userId);
|
|
if (!user) {
|
|
await safeDeleteFile(file.path);
|
|
throw new NotFoundError('User not found');
|
|
}
|
|
|
|
if (user.avatar_image) {
|
|
const oldAvatarPath = path.join(
|
|
__dirname,
|
|
'../../uploads/avatars',
|
|
path.basename(user.avatar_image)
|
|
);
|
|
await safeDeleteFile(oldAvatarPath);
|
|
}
|
|
|
|
const avatarUrl = `/uploads/avatars/${path.basename(file.path)}`;
|
|
await usersRepository.update(user, { avatar_image: avatarUrl });
|
|
|
|
return {
|
|
success: true,
|
|
avatar_image: avatarUrl,
|
|
message: 'Avatar uploaded successfully',
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Delete avatar.
|
|
*/
|
|
async deleteAvatar(userId) {
|
|
const user = await usersRepository.findById(userId);
|
|
if (!user) {
|
|
throw new NotFoundError('User not found');
|
|
}
|
|
|
|
if (user.avatar_image) {
|
|
const avatarPath = path.join(
|
|
__dirname,
|
|
'../../uploads/avatars',
|
|
path.basename(user.avatar_image)
|
|
);
|
|
await safeDeleteFile(avatarPath);
|
|
}
|
|
|
|
await usersRepository.update(user, { avatar_image: null });
|
|
|
|
return { success: true, message: 'Avatar removed successfully' };
|
|
}
|
|
|
|
/**
|
|
* Change password.
|
|
*/
|
|
async changePassword(userId, currentPassword, newPassword) {
|
|
if (!currentPassword || !newPassword) {
|
|
throw new ValidationError(
|
|
'Current password and new password are required'
|
|
);
|
|
}
|
|
|
|
validatePassword(newPassword, 'newPassword');
|
|
|
|
const user = await usersRepository.findByIdWithPassword(userId);
|
|
if (!user) {
|
|
throw new NotFoundError('User not found');
|
|
}
|
|
|
|
const isValidPassword = await User.checkPassword(
|
|
currentPassword,
|
|
user.password_digest
|
|
);
|
|
if (!isValidPassword) {
|
|
throw new ValidationError(
|
|
'Current password is incorrect',
|
|
'currentPassword'
|
|
);
|
|
}
|
|
|
|
const hashedNewPassword = await User.hashPassword(newPassword);
|
|
await usersRepository.update(user, {
|
|
password_digest: hashedNewPassword,
|
|
});
|
|
|
|
return { message: 'Password changed successfully' };
|
|
}
|
|
|
|
/**
|
|
* List API keys.
|
|
*/
|
|
async listApiKeys(userId) {
|
|
const tokens = await usersRepository.findApiTokens(userId);
|
|
return tokens.map(serializeApiToken);
|
|
}
|
|
|
|
/**
|
|
* Create API key.
|
|
*/
|
|
async createApiKey(userId, name, expires_at) {
|
|
const validatedName = validateApiKeyName(name);
|
|
const expiresAtDate = validateExpiresAt(expires_at);
|
|
|
|
const { rawToken, tokenRecord } = await createApiToken({
|
|
userId,
|
|
name: validatedName,
|
|
expiresAt: expiresAtDate,
|
|
});
|
|
|
|
return {
|
|
token: rawToken,
|
|
apiKey: serializeApiToken(tokenRecord),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Revoke API key.
|
|
*/
|
|
async revokeApiKey(userId, keyId) {
|
|
const tokenId = validateApiKeyId(keyId);
|
|
const token = await revokeApiToken(tokenId, userId);
|
|
if (!token) {
|
|
throw new NotFoundError('API key not found.');
|
|
}
|
|
return serializeApiToken(token);
|
|
}
|
|
|
|
/**
|
|
* Delete API key.
|
|
*/
|
|
async deleteApiKey(userId, keyId) {
|
|
const tokenId = validateApiKeyId(keyId);
|
|
const deleted = await deleteApiToken(tokenId, userId);
|
|
if (!deleted) {
|
|
throw new NotFoundError('API key not found.');
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Toggle task summary.
|
|
*/
|
|
async toggleTaskSummary(userId) {
|
|
const user = await usersRepository.findById(userId);
|
|
if (!user) {
|
|
throw new NotFoundError('User not found.');
|
|
}
|
|
|
|
const enabled = !user.task_summary_enabled;
|
|
await usersRepository.update(user, { task_summary_enabled: enabled });
|
|
|
|
return {
|
|
success: true,
|
|
enabled,
|
|
message: enabled
|
|
? 'Task summary notifications have been enabled.'
|
|
: 'Task summary notifications have been disabled.',
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Update task summary frequency.
|
|
*/
|
|
async updateTaskSummaryFrequency(userId, frequency) {
|
|
const validatedFrequency = validateFrequency(frequency);
|
|
|
|
const user = await usersRepository.findById(userId);
|
|
if (!user) {
|
|
throw new NotFoundError('User not found.');
|
|
}
|
|
|
|
await usersRepository.update(user, {
|
|
task_summary_frequency: validatedFrequency,
|
|
});
|
|
|
|
return {
|
|
success: true,
|
|
frequency: validatedFrequency,
|
|
message: `Task summary frequency has been set to ${validatedFrequency}.`,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Send task summary now.
|
|
*/
|
|
async sendTaskSummaryNow(userId) {
|
|
const user = await usersRepository.findById(userId);
|
|
if (!user) {
|
|
throw new NotFoundError('User not found.');
|
|
}
|
|
|
|
if (!user.telegram_bot_token || !user.telegram_chat_id) {
|
|
throw new ValidationError(
|
|
'Telegram bot is not properly configured.'
|
|
);
|
|
}
|
|
|
|
const success = await taskSummaryService.sendSummaryToUser(user.id);
|
|
|
|
if (!success) {
|
|
throw new ValidationError('Failed to send message to Telegram.');
|
|
}
|
|
|
|
return {
|
|
success: true,
|
|
message: 'Task summary was sent to your Telegram.',
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Get task summary status.
|
|
*/
|
|
async getTaskSummaryStatus(userId) {
|
|
const user = await usersRepository.findById(userId);
|
|
if (!user) {
|
|
throw new NotFoundError('User not found.');
|
|
}
|
|
|
|
return {
|
|
success: true,
|
|
enabled: user.task_summary_enabled,
|
|
frequency: user.task_summary_frequency,
|
|
last_run: user.task_summary_last_run,
|
|
next_run: user.task_summary_next_run,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Update today settings.
|
|
*/
|
|
async updateTodaySettings(userId, data) {
|
|
const user = await usersRepository.findById(userId);
|
|
if (!user) {
|
|
throw new NotFoundError('User not found.');
|
|
}
|
|
|
|
const {
|
|
showMetrics,
|
|
projectShowMetrics,
|
|
showProductivity,
|
|
showNextTaskSuggestion,
|
|
showSuggestions,
|
|
showDueToday,
|
|
showCompleted,
|
|
showDailyQuote,
|
|
} = data;
|
|
|
|
const todaySettings = {
|
|
projectShowMetrics:
|
|
projectShowMetrics !== undefined
|
|
? projectShowMetrics
|
|
: (user.today_settings?.projectShowMetrics ?? true),
|
|
showMetrics:
|
|
showMetrics !== undefined
|
|
? showMetrics
|
|
: user.today_settings?.showMetrics || false,
|
|
showProductivity:
|
|
showProductivity !== undefined
|
|
? showProductivity
|
|
: user.today_settings?.showProductivity || false,
|
|
showNextTaskSuggestion:
|
|
showNextTaskSuggestion !== undefined
|
|
? showNextTaskSuggestion
|
|
: user.today_settings?.showNextTaskSuggestion || false,
|
|
showSuggestions:
|
|
showSuggestions !== undefined
|
|
? showSuggestions
|
|
: user.today_settings?.showSuggestions || false,
|
|
showDueToday:
|
|
showDueToday !== undefined
|
|
? showDueToday
|
|
: user.today_settings?.showDueToday || true,
|
|
showCompleted:
|
|
showCompleted !== undefined
|
|
? showCompleted
|
|
: user.today_settings?.showCompleted || true,
|
|
showProgressBar: true,
|
|
showDailyQuote:
|
|
showDailyQuote !== undefined
|
|
? showDailyQuote
|
|
: user.today_settings?.showDailyQuote || true,
|
|
};
|
|
|
|
const profileUpdates = { today_settings: todaySettings };
|
|
if (showProductivity !== undefined) {
|
|
profileUpdates.productivity_assistant_enabled = showProductivity;
|
|
}
|
|
if (showNextTaskSuggestion !== undefined) {
|
|
profileUpdates.next_task_suggestion_enabled =
|
|
showNextTaskSuggestion;
|
|
}
|
|
|
|
await usersRepository.update(user, profileUpdates);
|
|
|
|
return { success: true, today_settings: todaySettings };
|
|
}
|
|
|
|
/**
|
|
* Update sidebar settings.
|
|
*/
|
|
async updateSidebarSettings(userId, data) {
|
|
const user = await usersRepository.findById(userId);
|
|
if (!user) {
|
|
throw new NotFoundError('User not found.');
|
|
}
|
|
|
|
const { pinnedViewsOrder } = validateSidebarSettings(data);
|
|
const sidebarSettings = { pinnedViewsOrder };
|
|
|
|
await usersRepository.update(user, {
|
|
sidebar_settings: sidebarSettings,
|
|
});
|
|
|
|
return { success: true, sidebar_settings: sidebarSettings };
|
|
}
|
|
|
|
/**
|
|
* Update UI settings.
|
|
*/
|
|
async updateUiSettings(userId, data) {
|
|
const user = await usersRepository.findById(userId);
|
|
if (!user) {
|
|
throw new NotFoundError('User not found.');
|
|
}
|
|
|
|
const { project } = data;
|
|
|
|
const currentSettings =
|
|
user.ui_settings && typeof user.ui_settings === 'object'
|
|
? user.ui_settings
|
|
: { project: { details: {} } };
|
|
|
|
const newSettings = {
|
|
...currentSettings,
|
|
project: {
|
|
...(currentSettings.project || {}),
|
|
...(project || {}),
|
|
details: {
|
|
...((currentSettings.project &&
|
|
currentSettings.project.details) ||
|
|
{}),
|
|
...((project && project.details) || {}),
|
|
},
|
|
},
|
|
};
|
|
|
|
await usersRepository.update(user, { ui_settings: newSettings });
|
|
|
|
return { success: true, ui_settings: newSettings };
|
|
}
|
|
}
|
|
|
|
module.exports = new UsersService();
|