From fc9e88711c7114ec540e71fe82f1d6ec390da66a Mon Sep 17 00:00:00 2001 From: Clockwize Date: Thu, 8 Oct 2026 09:01:37 +0300 Subject: [PATCH 1/4] Remove the old Swift menu-bar app The desktop app (desktop/) replaces it: its tray popover, Dock menu and widgets cover everything the separate menu-bar app did. Co-Authored-By: Claude Opus 5.5 --- .../project.pbxproj | 345 ------------------ menubar/ClockwizeMenuBar/AppDelegate.swift | 42 --- menubar/ClockwizeMenuBar/AuthManager.swift | 63 ---- menubar/ClockwizeMenuBar/Info.plist | 37 -- .../LoginViewController.swift | 157 -------- menubar/ClockwizeMenuBar/Models.swift | 98 ----- .../StatusBarController.swift | 297 --------------- menubar/ClockwizeMenuBar/TimerAPI.swift | 276 -------------- menubar/README.md | 94 ----- menubar/build.sh | 91 ----- 10 files changed, 1500 deletions(-) delete mode 100644 menubar/ClockwizeMenuBar.xcodeproj/project.pbxproj delete mode 100644 menubar/ClockwizeMenuBar/AppDelegate.swift delete mode 100644 menubar/ClockwizeMenuBar/AuthManager.swift delete mode 100644 menubar/ClockwizeMenuBar/Info.plist delete mode 100644 menubar/ClockwizeMenuBar/LoginViewController.swift delete mode 100644 menubar/ClockwizeMenuBar/Models.swift delete mode 100644 menubar/ClockwizeMenuBar/StatusBarController.swift delete mode 100644 menubar/ClockwizeMenuBar/TimerAPI.swift delete mode 100644 menubar/README.md delete mode 100755 menubar/build.sh diff --git a/menubar/ClockwizeMenuBar.xcodeproj/project.pbxproj b/menubar/ClockwizeMenuBar.xcodeproj/project.pbxproj deleted file mode 100644 index 33fc26d..0000000 --- a/menubar/ClockwizeMenuBar.xcodeproj/project.pbxproj +++ /dev/null @@ -1,345 +0,0 @@ -// !$*UTF8*$! -{ - archiveVersion = 1; - classes = { - }; - objectVersion = 56; - objects = { - -/* Begin PBXBuildFile section */ - 001 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 101 /* AppDelegate.swift */; }; - 002 /* StatusBarController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 102 /* StatusBarController.swift */; }; - 003 /* TimerAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 103 /* TimerAPI.swift */; }; - 004 /* Models.swift in Sources */ = {isa = PBXBuildFile; fileRef = 104 /* Models.swift */; }; - 005 /* AuthManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 105 /* AuthManager.swift */; }; - 006 /* LoginViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 106 /* LoginViewController.swift */; }; -/* End PBXBuildFile section */ - -/* Begin PBXFileReference section */ - 100 /* ClockwizeMenuBar.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = ClockwizeMenuBar.app; sourceTree = BUILT_PRODUCTS_DIR; }; - 101 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; - 102 /* StatusBarController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusBarController.swift; sourceTree = ""; }; - 103 /* TimerAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimerAPI.swift; sourceTree = ""; }; - 104 /* Models.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Models.swift; sourceTree = ""; }; - 105 /* AuthManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthManager.swift; sourceTree = ""; }; - 106 /* LoginViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginViewController.swift; sourceTree = ""; }; - 107 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; -/* End PBXFileReference section */ - -/* Begin PBXFrameworksBuildPhase section */ - 200 /* Frameworks */ = { - isa = PBXFrameworksBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXFrameworksBuildPhase section */ - -/* Begin PBXGroup section */ - 300 = { - isa = PBXGroup; - children = ( - 310 /* ClockwizeMenuBar */, - 320 /* Products */, - ); - sourceTree = ""; - }; - 310 /* ClockwizeMenuBar */ = { - isa = PBXGroup; - children = ( - 101 /* AppDelegate.swift */, - 102 /* StatusBarController.swift */, - 103 /* TimerAPI.swift */, - 104 /* Models.swift */, - 105 /* AuthManager.swift */, - 106 /* LoginViewController.swift */, - 107 /* Info.plist */, - ); - path = ClockwizeMenuBar; - sourceTree = ""; - }; - 320 /* Products */ = { - isa = PBXGroup; - children = ( - 100 /* ClockwizeMenuBar.app */, - ); - name = Products; - sourceTree = ""; - }; -/* End PBXGroup section */ - -/* Begin PBXNativeTarget section */ - 400 /* ClockwizeMenuBar */ = { - isa = PBXNativeTarget; - buildConfigurationList = 500 /* Build configuration list for PBXNativeTarget "ClockwizeMenuBar" */; - buildPhases = ( - 410 /* Sources */, - 200 /* Frameworks */, - 420 /* Resources */, - ); - buildRules = ( - ); - dependencies = ( - ); - name = ClockwizeMenuBar; - productName = ClockwizeMenuBar; - productReference = 100 /* ClockwizeMenuBar.app */; - productType = "com.apple.product-type.application"; - }; -/* End PBXNativeTarget section */ - -/* Begin PBXProject section */ - 600 /* Project object */ = { - isa = PBXProject; - attributes = { - BuildIndependentTargetsInParallel = 1; - LastSwiftUpdateCheck = 1500; - LastUpgradeCheck = 1500; - TargetAttributes = { - 400 = { - CreatedOnToolsVersion = 15.0; - }; - }; - }; - buildConfigurationList = 610 /* Build configuration list for PBXProject "ClockwizeMenuBar" */; - compatibilityVersion = "Xcode 14.0"; - developmentRegion = he; - hasScannedForEncodings = 0; - knownRegions = ( - en, - he, - Base, - ); - mainGroup = 300; - productRefGroup = 320 /* Products */; - projectDirPath = ""; - projectRoot = ""; - targets = ( - 400 /* ClockwizeMenuBar */, - ); - }; -/* End PBXProject section */ - -/* Begin PBXResourcesBuildPhase section */ - 420 /* Resources */ = { - isa = PBXResourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXResourcesBuildPhase section */ - -/* Begin PBXSourcesBuildPhase section */ - 410 /* Sources */ = { - isa = PBXSourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - 001 /* AppDelegate.swift in Sources */, - 002 /* StatusBarController.swift in Sources */, - 003 /* TimerAPI.swift in Sources */, - 004 /* Models.swift in Sources */, - 005 /* AuthManager.swift in Sources */, - 006 /* LoginViewController.swift in Sources */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXSourcesBuildPhase section */ - -/* Begin XCBuildConfiguration section */ - 700 /* Debug */ = { - isa = XCBuildConfiguration; - buildSettings = { - ALWAYS_SEARCH_USER_PATHS = NO; - ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; - CLANG_ANALYZER_NONNULL = YES; - CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; - CLANG_ENABLE_MODULES = YES; - CLANG_ENABLE_OBJC_ARC = YES; - CLANG_ENABLE_OBJC_WEAK = YES; - CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; - CLANG_WARN_BOOL_CONVERSION = YES; - CLANG_WARN_COMMA = YES; - CLANG_WARN_CONSTANT_CONVERSION = YES; - CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; - CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; - CLANG_WARN_DOCUMENTATION_COMMENTS = YES; - CLANG_WARN_EMPTY_BODY = YES; - CLANG_WARN_ENUM_CONVERSION = YES; - CLANG_WARN_INFINITE_RECURSION = YES; - CLANG_WARN_INT_CONVERSION = YES; - CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; - CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; - CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; - CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; - CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; - CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; - CLANG_WARN_STRICT_PROTOTYPES = YES; - CLANG_WARN_SUSPICIOUS_MOVE = YES; - CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; - CLANG_WARN_UNREACHABLE_CODE = YES; - CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; - COPY_PHASE_STRIP = NO; - DEBUG_INFORMATION_FORMAT = dwarf; - ENABLE_STRICT_OBJC_MSGSEND = YES; - ENABLE_TESTABILITY = YES; - ENABLE_USER_SCRIPT_SANDBOXING = YES; - GCC_C_LANGUAGE_STANDARD = gnu17; - GCC_DYNAMIC_NO_PIC = NO; - GCC_NO_COMMON_BLOCKS = YES; - GCC_OPTIMIZATION_LEVEL = 0; - GCC_PREPROCESSOR_DEFINITIONS = ( - "DEBUG=1", - "$(inherited)", - ); - GCC_WARN_64_TO_32_BIT_CONVERSION = YES; - GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; - GCC_WARN_UNDECLARED_SELECTOR = YES; - GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; - GCC_WARN_UNUSED_FUNCTION = YES; - GCC_WARN_UNUSED_VARIABLE = YES; - LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MACOSX_DEPLOYMENT_TARGET = 11.0; - MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; - MTL_FAST_MATH = YES; - ONLY_ACTIVE_ARCH = YES; - SDKROOT = macosx; - SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; - SWIFT_OPTIMIZATION_LEVEL = "-Onone"; - }; - name = Debug; - }; - 701 /* Release */ = { - isa = XCBuildConfiguration; - buildSettings = { - ALWAYS_SEARCH_USER_PATHS = NO; - ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; - CLANG_ANALYZER_NONNULL = YES; - CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; - CLANG_ENABLE_MODULES = YES; - CLANG_ENABLE_OBJC_ARC = YES; - CLANG_ENABLE_OBJC_WEAK = YES; - CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; - CLANG_WARN_BOOL_CONVERSION = YES; - CLANG_WARN_COMMA = YES; - CLANG_WARN_CONSTANT_CONVERSION = YES; - CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; - CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; - CLANG_WARN_DOCUMENTATION_COMMENTS = YES; - CLANG_WARN_EMPTY_BODY = YES; - CLANG_WARN_ENUM_CONVERSION = YES; - CLANG_WARN_INFINITE_RECURSION = YES; - CLANG_WARN_INT_CONVERSION = YES; - CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; - CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; - CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; - CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; - CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; - CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; - CLANG_WARN_STRICT_PROTOTYPES = YES; - CLANG_WARN_SUSPICIOUS_MOVE = YES; - CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; - CLANG_WARN_UNREACHABLE_CODE = YES; - CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; - COPY_PHASE_STRIP = NO; - DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; - ENABLE_NS_ASSERTIONS = NO; - ENABLE_STRICT_OBJC_MSGSEND = YES; - ENABLE_USER_SCRIPT_SANDBOXING = YES; - GCC_C_LANGUAGE_STANDARD = gnu17; - GCC_NO_COMMON_BLOCKS = YES; - GCC_WARN_64_TO_32_BIT_CONVERSION = YES; - GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; - GCC_WARN_UNDECLARED_SELECTOR = YES; - GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; - GCC_WARN_UNUSED_FUNCTION = YES; - GCC_WARN_UNUSED_VARIABLE = YES; - LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MACOSX_DEPLOYMENT_TARGET = 11.0; - MTL_ENABLE_DEBUG_INFO = NO; - MTL_FAST_MATH = YES; - SDKROOT = macosx; - SWIFT_COMPILATION_MODE = wholemodule; - }; - name = Release; - }; - 710 /* Debug */ = { - isa = XCBuildConfiguration; - buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; - CODE_SIGN_ENTITLEMENTS = ""; - CODE_SIGN_STYLE = Automatic; - COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 1; - GENERATE_INFOPLIST_FILE = NO; - INFOPLIST_FILE = ClockwizeMenuBar/Info.plist; - INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.productivity"; - INFOPLIST_KEY_LSUIElement = YES; - INFOPLIST_KEY_NSHumanReadableCopyright = ""; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/../Frameworks", - ); - MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.clockwize.menubar; - PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_EMIT_LOC_STRINGS = YES; - SWIFT_VERSION = 5.0; - }; - name = Debug; - }; - 711 /* Release */ = { - isa = XCBuildConfiguration; - buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; - CODE_SIGN_ENTITLEMENTS = ""; - CODE_SIGN_STYLE = Automatic; - COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 1; - GENERATE_INFOPLIST_FILE = NO; - INFOPLIST_FILE = ClockwizeMenuBar/Info.plist; - INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.productivity"; - INFOPLIST_KEY_LSUIElement = YES; - INFOPLIST_KEY_NSHumanReadableCopyright = ""; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/../Frameworks", - ); - MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.clockwize.menubar; - PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_EMIT_LOC_STRINGS = YES; - SWIFT_VERSION = 5.0; - }; - name = Release; - }; -/* End XCBuildConfiguration section */ - -/* Begin XCConfigurationList section */ - 500 /* Build configuration list for PBXNativeTarget "ClockwizeMenuBar" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - 710 /* Debug */, - 711 /* Release */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; - 610 /* Build configuration list for PBXProject "ClockwizeMenuBar" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - 700 /* Debug */, - 701 /* Release */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; -/* End XCConfigurationList section */ - }; - rootObject = 600 /* Project object */; -} diff --git a/menubar/ClockwizeMenuBar/AppDelegate.swift b/menubar/ClockwizeMenuBar/AppDelegate.swift deleted file mode 100644 index ead23eb..0000000 --- a/menubar/ClockwizeMenuBar/AppDelegate.swift +++ /dev/null @@ -1,42 +0,0 @@ -import Cocoa - -@main -class AppDelegate: NSObject, NSApplicationDelegate { - - var statusBarController: StatusBarController? - var preferencesWindow: NSWindow? - - func applicationDidFinishLaunching(_ notification: Notification) { - // Hide dock icon (we're a menu bar only app) - NSApp.setActivationPolicy(.accessory) - - // Initialize status bar - statusBarController = StatusBarController() - - // Check if we have saved credentials - if !AuthManager.shared.isLoggedIn { - showLoginWindow() - } - } - - func showLoginWindow() { - let loginVC = LoginViewController() - let window = NSWindow( - contentRect: NSRect(x: 0, y: 0, width: 400, height: 300), - styleMask: [.titled, .closable], - backing: .buffered, - defer: false - ) - window.title = "Clockwize - התחברות" - window.contentViewController = loginVC - window.center() - window.makeKeyAndOrderFront(nil) - NSApp.activate(ignoringOtherApps: true) - - preferencesWindow = window - } - - func applicationWillTerminate(_ notification: Notification) { - // Cleanup - } -} diff --git a/menubar/ClockwizeMenuBar/AuthManager.swift b/menubar/ClockwizeMenuBar/AuthManager.swift deleted file mode 100644 index 93b1fb2..0000000 --- a/menubar/ClockwizeMenuBar/AuthManager.swift +++ /dev/null @@ -1,63 +0,0 @@ -import Foundation -import Security - -class AuthManager { - - static let shared = AuthManager() - - private let tokenKey = "com.clockwize.menubar.token" - private let workspaceKey = "com.clockwize.menubar.workspace" - private let userDefaults = UserDefaults.standard - - private init() {} - - // MARK: - Token - - var token: String? { - get { - return userDefaults.string(forKey: tokenKey) - } - set { - if let value = newValue { - userDefaults.set(value, forKey: tokenKey) - } else { - userDefaults.removeObject(forKey: tokenKey) - } - } - } - - // MARK: - Workspace ID - - var workspaceId: String? { - get { - return userDefaults.string(forKey: workspaceKey) - } - set { - if let value = newValue { - userDefaults.set(value, forKey: workspaceKey) - } else { - userDefaults.removeObject(forKey: workspaceKey) - } - } - } - - // MARK: - Auth Status - - var isLoggedIn: Bool { - return token != nil && workspaceId != nil - } - - // MARK: - Login - - func login(token: String, workspaceId: String) { - self.token = token - self.workspaceId = workspaceId - } - - // MARK: - Logout - - func logout() { - token = nil - workspaceId = nil - } -} diff --git a/menubar/ClockwizeMenuBar/Info.plist b/menubar/ClockwizeMenuBar/Info.plist deleted file mode 100644 index 4673bdf..0000000 --- a/menubar/ClockwizeMenuBar/Info.plist +++ /dev/null @@ -1,37 +0,0 @@ - - - - - CFBundleExecutable - ClockwizeMenuBar - CFBundleIconFile - AppIcon - CFBundleIdentifier - com.clockwize.menubar - CFBundleName - Clockwize Menu Bar - CFBundleDisplayName - Clockwize - CFBundlePackageType - APPL - CFBundleShortVersionString - 1.0.0 - CFBundleVersion - 1 - LSMinimumSystemVersion - 10.15 - NSHighResolutionCapable - - LSUIElement - - NSAppTransportSecurity - - NSAllowsLocalNetworking - - NSAllowsArbitraryLoads - - - LSApplicationCategoryType - public.app-category.productivity - - diff --git a/menubar/ClockwizeMenuBar/LoginViewController.swift b/menubar/ClockwizeMenuBar/LoginViewController.swift deleted file mode 100644 index 99d2cf4..0000000 --- a/menubar/ClockwizeMenuBar/LoginViewController.swift +++ /dev/null @@ -1,157 +0,0 @@ -import Cocoa - -class LoginViewController: NSViewController { - - private let emailField = NSTextField() - private let passwordField = NSSecureTextField() - private let loginButton = NSButton() - private let statusLabel = NSTextField() - private let spinner = NSProgressIndicator() - - override func loadView() { - view = NSView(frame: NSRect(x: 0, y: 0, width: 400, height: 300)) - } - - override func viewDidLoad() { - super.viewDidLoad() - setupUI() - } - - private func setupUI() { - view.wantsLayer = true - view.layer?.backgroundColor = NSColor.windowBackgroundColor.cgColor - - // לוגו / כותרת - let titleLabel = NSTextField(labelWithString: "🕐 Clockwize") - titleLabel.font = NSFont.systemFont(ofSize: 28, weight: .bold) - titleLabel.alignment = .center - titleLabel.frame = NSRect(x: 50, y: 230, width: 300, height: 40) - view.addSubview(titleLabel) - - let subtitleLabel = NSTextField(labelWithString: "התחבר לחשבון שלך") - subtitleLabel.font = NSFont.systemFont(ofSize: 14) - subtitleLabel.textColor = .secondaryLabelColor - subtitleLabel.alignment = .center - subtitleLabel.frame = NSRect(x: 50, y: 205, width: 300, height: 20) - view.addSubview(subtitleLabel) - - // שדה אימייל - let emailLabel = NSTextField(labelWithString: "אימייל:") - emailLabel.frame = NSRect(x: 50, y: 165, width: 300, height: 20) - view.addSubview(emailLabel) - - emailField.frame = NSRect(x: 50, y: 140, width: 300, height: 24) - emailField.placeholderString = "your@email.com" - view.addSubview(emailField) - - // שדה סיסמא - let passwordLabel = NSTextField(labelWithString: "סיסמא:") - passwordLabel.frame = NSRect(x: 50, y: 105, width: 300, height: 20) - view.addSubview(passwordLabel) - - passwordField.frame = NSRect(x: 50, y: 80, width: 300, height: 24) - passwordField.placeholderString = "••••••••" - view.addSubview(passwordField) - - // כפתור התחברות - loginButton.title = "התחבר" - loginButton.bezelStyle = .rounded - loginButton.frame = NSRect(x: 150, y: 35, width: 100, height: 32) - loginButton.target = self - loginButton.action = #selector(loginTapped) - loginButton.keyEquivalent = "\r" // Enter key - view.addSubview(loginButton) - - // ספינר - spinner.style = .spinning - spinner.frame = NSRect(x: 260, y: 40, width: 20, height: 20) - spinner.isHidden = true - view.addSubview(spinner) - - // הודעת סטטוס - statusLabel.frame = NSRect(x: 50, y: 5, width: 300, height: 20) - statusLabel.alignment = .center - statusLabel.textColor = .systemRed - statusLabel.font = NSFont.systemFont(ofSize: 12) - statusLabel.isBezeled = false - statusLabel.drawsBackground = false - statusLabel.isEditable = false - statusLabel.isSelectable = false - statusLabel.stringValue = "" - view.addSubview(statusLabel) - } - - @objc private func loginTapped() { - let email = emailField.stringValue.trimmingCharacters(in: .whitespaces) - let password = passwordField.stringValue - - guard !email.isEmpty else { - statusLabel.stringValue = "נא להזין אימייל" - return - } - - guard !password.isEmpty else { - statusLabel.stringValue = "נא להזין סיסמא" - return - } - - setLoading(true) - statusLabel.stringValue = "" - - TimerAPI.shared.login(email: email, password: password) { [weak self] result in - DispatchQueue.main.async { - switch result { - case .success(let response): - // קבלת רשימת workspaces - self?.fetchWorkspaces(token: response.token) - case .failure(let error): - self?.setLoading(false) - self?.statusLabel.stringValue = error.localizedDescription - } - } - } - } - - private func fetchWorkspaces(token: String) { - TimerAPI.shared.getWorkspaces(token: token) { [weak self] result in - DispatchQueue.main.async { - self?.setLoading(false) - - switch result { - case .success(let workspaces): - if let firstWorkspace = workspaces.first { - // שמירת ההתחברות - AuthManager.shared.login(token: token, workspaceId: firstWorkspace.id) - - // סגירת החלון - self?.view.window?.close() - - // הצגת הודעה - let notification = NSUserNotification() - notification.title = "Clockwize" - notification.informativeText = "התחברת בהצלחה!" - NSUserNotificationCenter.default.deliver(notification) - } else { - self?.statusLabel.stringValue = "לא נמצאו workspaces" - } - case .failure(let error): - self?.statusLabel.stringValue = error.localizedDescription - } - } - } - } - - private func setLoading(_ loading: Bool) { - loginButton.isEnabled = !loading - emailField.isEnabled = !loading - passwordField.isEnabled = !loading - - if loading { - spinner.isHidden = false - spinner.startAnimation(nil) - } else { - spinner.stopAnimation(nil) - spinner.isHidden = true - } - } -} diff --git a/menubar/ClockwizeMenuBar/Models.swift b/menubar/ClockwizeMenuBar/Models.swift deleted file mode 100644 index 8c25516..0000000 --- a/menubar/ClockwizeMenuBar/Models.swift +++ /dev/null @@ -1,98 +0,0 @@ -import Foundation - -// MARK: - Active Timer - -struct ActiveTimer: Codable { - let id: String - let user_id: String - let workspace_id: String - let project_id: String - let task_id: String? - let start_time: String - let accumulated_seconds: Int - let is_running: Bool - let project_name: String? - let task_name: String? - let client_name: String? - - // Handle different formats from server - enum CodingKeys: String, CodingKey { - case id - case user_id - case workspace_id - case project_id - case task_id - case start_time - case accumulated_seconds - case is_running - case project_name - case task_name - case client_name - } - - init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - id = try container.decode(String.self, forKey: .id) - user_id = try container.decode(String.self, forKey: .user_id) - workspace_id = try container.decode(String.self, forKey: .workspace_id) - project_id = try container.decode(String.self, forKey: .project_id) - task_id = try container.decodeIfPresent(String.self, forKey: .task_id) - start_time = try container.decode(String.self, forKey: .start_time) - accumulated_seconds = try container.decode(Int.self, forKey: .accumulated_seconds) - - // Handle both Int and Bool for is_running - if let boolValue = try? container.decode(Bool.self, forKey: .is_running) { - is_running = boolValue - } else if let intValue = try? container.decode(Int.self, forKey: .is_running) { - is_running = intValue != 0 - } else { - is_running = false - } - - project_name = try container.decodeIfPresent(String.self, forKey: .project_name) - task_name = try container.decodeIfPresent(String.self, forKey: .task_name) - client_name = try container.decodeIfPresent(String.self, forKey: .client_name) - } -} - -// MARK: - Time Entry - -struct TimeEntry: Codable { - let id: String - let user_id: String - let workspace_id: String - let project_id: String - let task_id: String? - let start_time: String - let end_time: String - let duration: Int - let notes: String? -} - -// MARK: - Login Response - -struct LoginResponse: Codable { - let token: String - let user: User -} - -struct User: Codable { - let id: String - let email: String - let name: String -} - -// MARK: - Workspace - -struct Workspace: Codable { - let id: String - let name: String - let owner_id: String - let role: String? -} - -// MARK: - Error Response - -struct ErrorResponse: Codable { - let error: String -} diff --git a/menubar/ClockwizeMenuBar/StatusBarController.swift b/menubar/ClockwizeMenuBar/StatusBarController.swift deleted file mode 100644 index 8863c5a..0000000 --- a/menubar/ClockwizeMenuBar/StatusBarController.swift +++ /dev/null @@ -1,297 +0,0 @@ -import Cocoa - -class StatusBarController { - - private var statusItem: NSStatusItem! - private var timer: Timer? - private var activeTimers: [ActiveTimer] = [] - private var currentElapsed: Int = 0 - private var displayTimer: Timer? - - init() { - setupStatusBar() - setupMenu() - startPolling() - } - - private func setupStatusBar() { - statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) - - if let button = statusItem.button { - updateStatusBarTitle(isRunning: false, elapsed: 0) - } - } - - private func updateStatusBarTitle(isRunning: Bool, elapsed: Int) { - guard let button = statusItem.button else { return } - - let hours = elapsed / 3600 - let minutes = (elapsed % 3600) / 60 - let seconds = elapsed % 60 - - if isRunning { - // שעון ירוק כשהטיימר פועל - let timeString = String(format: "%d:%02d:%02d", hours, minutes, seconds) - button.title = "🟢 \(timeString)" - } else if elapsed > 0 { - // שעון צהוב כשמושהה - let timeString = String(format: "%d:%02d:%02d", hours, minutes, seconds) - button.title = "🟡 \(timeString)" - } else { - // שעון רגיל כשאין טיימר - button.title = "⏱️" - } - } - - private func setupMenu() { - let menu = NSMenu() - - // כותרת - let titleItem = NSMenuItem(title: "Clockwize", action: nil, keyEquivalent: "") - titleItem.isEnabled = false - menu.addItem(titleItem) - - menu.addItem(NSMenuItem.separator()) - - // סטטוס טיימר (יתעדכן דינמית) - let statusItem = NSMenuItem(title: "אין טיימר פעיל", action: nil, keyEquivalent: "") - statusItem.tag = 100 - statusItem.isEnabled = false - menu.addItem(statusItem) - - menu.addItem(NSMenuItem.separator()) - - // פתיחת האפליקציה - let openAppItem = NSMenuItem(title: "📱 פתח Clockwize", action: #selector(openApp), keyEquivalent: "o") - openAppItem.target = self - menu.addItem(openAppItem) - - menu.addItem(NSMenuItem.separator()) - - // התחברות/התנתקות - if AuthManager.shared.isLoggedIn { - let logoutItem = NSMenuItem(title: "🚪 התנתק", action: #selector(logout), keyEquivalent: "") - logoutItem.target = self - menu.addItem(logoutItem) - } else { - let loginItem = NSMenuItem(title: "🔐 התחבר", action: #selector(showLogin), keyEquivalent: "") - loginItem.target = self - menu.addItem(loginItem) - } - - menu.addItem(NSMenuItem.separator()) - - // יציאה - let quitItem = NSMenuItem(title: "יציאה", action: #selector(quit), keyEquivalent: "q") - quitItem.target = self - menu.addItem(quitItem) - - self.statusItem.menu = menu - } - - private func startPolling() { - // עדכון מידי - fetchTimers() - - // עדכון כל 10 שניות - timer = Timer.scheduledTimer(withTimeInterval: 10.0, repeats: true) { [weak self] _ in - self?.fetchTimers() - } - - // עדכון התצוגה כל שנייה - displayTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in - self?.updateDisplay() - } - } - - private func updateDisplay() { - guard let primaryTimer = activeTimers.first else { - updateStatusBarTitle(isRunning: false, elapsed: 0) - return - } - - if primaryTimer.is_running { - // חישוב זמן שעבר מאז start_time + accumulated_seconds - let startDate = ISO8601DateFormatter().date(from: primaryTimer.start_time) ?? Date() - let elapsed = primaryTimer.accumulated_seconds + Int(Date().timeIntervalSince(startDate)) - currentElapsed = elapsed - updateStatusBarTitle(isRunning: true, elapsed: elapsed) - } else { - currentElapsed = primaryTimer.accumulated_seconds - updateStatusBarTitle(isRunning: false, elapsed: primaryTimer.accumulated_seconds) - } - } - - private func fetchTimers() { - guard AuthManager.shared.isLoggedIn else { return } - - TimerAPI.shared.getActiveTimers { [weak self] result in - DispatchQueue.main.async { - switch result { - case .success(let timers): - self?.activeTimers = timers - self?.updateMenu() - self?.updateDisplay() - case .failure(let error): - print("Error fetching timers: \(error)") - } - } - } - } - - private func updateMenu() { - guard let menu = statusItem.menu else { return } - - // מציאת פריט הסטטוס - guard let statusMenuItem = menu.item(withTag: 100) else { return } - - if let primaryTimer = activeTimers.first { - let projectName = primaryTimer.project_name ?? "פרויקט" - let taskName = primaryTimer.task_name - - var title = "⏱️ \(projectName)" - if let task = taskName { - title += " - \(task)" - } - - if primaryTimer.is_running { - title = "▶️ " + title.dropFirst(2) - } else { - title = "⏸️ " + title.dropFirst(2) - } - - statusMenuItem.title = title - - // הוספת כפתורי שליטה אם יש טיימר - addTimerControls(for: primaryTimer) - } else { - statusMenuItem.title = "אין טיימר פעיל" - removeTimerControls() - } - } - - private func addTimerControls(for timer: ActiveTimer) { - guard let menu = statusItem.menu else { return } - - // הסרת כפתורים קודמים - removeTimerControls() - - // מציאת מיקום להוספה (אחרי הסטטוס) - guard let statusIndex = menu.items.firstIndex(where: { $0.tag == 100 }) else { return } - - if timer.is_running { - // כפתור השהייה - let pauseItem = NSMenuItem(title: "⏸️ השהה", action: #selector(pauseTimer), keyEquivalent: "") - pauseItem.target = self - pauseItem.tag = 101 - pauseItem.representedObject = timer.id - menu.insertItem(pauseItem, at: statusIndex + 1) - } else { - // כפתור המשך - let resumeItem = NSMenuItem(title: "▶️ המשך", action: #selector(resumeTimer), keyEquivalent: "") - resumeItem.target = self - resumeItem.tag = 101 - resumeItem.representedObject = timer.id - menu.insertItem(resumeItem, at: statusIndex + 1) - } - - // כפתור עצירה - let stopItem = NSMenuItem(title: "⏹️ עצור ושמור", action: #selector(stopTimer), keyEquivalent: "") - stopItem.target = self - stopItem.tag = 102 - stopItem.representedObject = timer.id - menu.insertItem(stopItem, at: statusIndex + 2) - } - - private func removeTimerControls() { - guard let menu = statusItem.menu else { return } - - // הסרת פריטים עם tags 101 ו-102 - if let item = menu.item(withTag: 101) { - menu.removeItem(item) - } - if let item = menu.item(withTag: 102) { - menu.removeItem(item) - } - } - - // MARK: - Actions - - @objc private func openApp() { - if let url = URL(string: "http://localhost:5173") { - NSWorkspace.shared.open(url) - } - } - - @objc private func pauseTimer(_ sender: NSMenuItem) { - guard let timerId = sender.representedObject as? String else { return } - - TimerAPI.shared.pauseTimer(id: timerId) { [weak self] result in - DispatchQueue.main.async { - switch result { - case .success: - self?.fetchTimers() - case .failure(let error): - print("Error pausing timer: \(error)") - } - } - } - } - - @objc private func resumeTimer(_ sender: NSMenuItem) { - guard let timerId = sender.representedObject as? String else { return } - - TimerAPI.shared.resumeTimer(id: timerId) { [weak self] result in - DispatchQueue.main.async { - switch result { - case .success: - self?.fetchTimers() - case .failure(let error): - print("Error resuming timer: \(error)") - } - } - } - } - - @objc private func stopTimer(_ sender: NSMenuItem) { - guard let timerId = sender.representedObject as? String else { return } - - TimerAPI.shared.stopTimer(id: timerId) { [weak self] result in - DispatchQueue.main.async { - switch result { - case .success: - self?.fetchTimers() - // הצגת התראה - self?.showNotification(title: "Clockwize", body: "הטיימר נשמר בהצלחה") - case .failure(let error): - print("Error stopping timer: \(error)") - } - } - } - } - - @objc private func showLogin() { - if let appDelegate = NSApp.delegate as? AppDelegate { - appDelegate.showLoginWindow() - } - } - - @objc private func logout() { - AuthManager.shared.logout() - activeTimers = [] - updateStatusBarTitle(isRunning: false, elapsed: 0) - setupMenu() - } - - @objc private func quit() { - NSApp.terminate(nil) - } - - private func showNotification(title: String, body: String) { - let notification = NSUserNotification() - notification.title = title - notification.informativeText = body - notification.soundName = NSUserNotificationDefaultSoundName - NSUserNotificationCenter.default.deliver(notification) - } -} diff --git a/menubar/ClockwizeMenuBar/TimerAPI.swift b/menubar/ClockwizeMenuBar/TimerAPI.swift deleted file mode 100644 index b4c69ac..0000000 --- a/menubar/ClockwizeMenuBar/TimerAPI.swift +++ /dev/null @@ -1,276 +0,0 @@ -import Foundation - -class TimerAPI { - - static let shared = TimerAPI() - - private let baseURL = "http://localhost:3001/api" - - private init() {} - - // MARK: - Get Active Timers - - func getActiveTimers(completion: @escaping (Result<[ActiveTimer], Error>) -> Void) { - guard let token = AuthManager.shared.token, - let workspaceId = AuthManager.shared.workspaceId else { - completion(.failure(APIError.notAuthenticated)) - return - } - - guard let url = URL(string: "\(baseURL)/timer/active") else { - completion(.failure(APIError.invalidURL)) - return - } - - var request = URLRequest(url: url) - request.httpMethod = "GET" - request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") - request.setValue(workspaceId, forHTTPHeaderField: "X-Workspace-Id") - request.setValue("application/json", forHTTPHeaderField: "Content-Type") - - URLSession.shared.dataTask(with: request) { data, response, error in - if let error = error { - completion(.failure(error)) - return - } - - guard let data = data else { - completion(.failure(APIError.noData)) - return - } - - do { - let timers = try JSONDecoder().decode([ActiveTimer].self, from: data) - completion(.success(timers)) - } catch { - print("Decode error: \(error)") - completion(.failure(error)) - } - }.resume() - } - - // MARK: - Pause Timer - - func pauseTimer(id: String, completion: @escaping (Result) -> Void) { - guard let token = AuthManager.shared.token, - let workspaceId = AuthManager.shared.workspaceId else { - completion(.failure(APIError.notAuthenticated)) - return - } - - guard let url = URL(string: "\(baseURL)/timer/pause/\(id)") else { - completion(.failure(APIError.invalidURL)) - return - } - - var request = URLRequest(url: url) - request.httpMethod = "POST" - request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") - request.setValue(workspaceId, forHTTPHeaderField: "X-Workspace-Id") - request.setValue("application/json", forHTTPHeaderField: "Content-Type") - - URLSession.shared.dataTask(with: request) { data, response, error in - if let error = error { - completion(.failure(error)) - return - } - - guard let data = data else { - completion(.failure(APIError.noData)) - return - } - - do { - let timer = try JSONDecoder().decode(ActiveTimer.self, from: data) - completion(.success(timer)) - } catch { - completion(.failure(error)) - } - }.resume() - } - - // MARK: - Resume Timer - - func resumeTimer(id: String, completion: @escaping (Result) -> Void) { - guard let token = AuthManager.shared.token, - let workspaceId = AuthManager.shared.workspaceId else { - completion(.failure(APIError.notAuthenticated)) - return - } - - guard let url = URL(string: "\(baseURL)/timer/resume/\(id)") else { - completion(.failure(APIError.invalidURL)) - return - } - - var request = URLRequest(url: url) - request.httpMethod = "POST" - request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") - request.setValue(workspaceId, forHTTPHeaderField: "X-Workspace-Id") - request.setValue("application/json", forHTTPHeaderField: "Content-Type") - - URLSession.shared.dataTask(with: request) { data, response, error in - if let error = error { - completion(.failure(error)) - return - } - - guard let data = data else { - completion(.failure(APIError.noData)) - return - } - - do { - let timer = try JSONDecoder().decode(ActiveTimer.self, from: data) - completion(.success(timer)) - } catch { - completion(.failure(error)) - } - }.resume() - } - - // MARK: - Stop Timer - - func stopTimer(id: String, notes: String? = nil, completion: @escaping (Result) -> Void) { - guard let token = AuthManager.shared.token, - let workspaceId = AuthManager.shared.workspaceId else { - completion(.failure(APIError.notAuthenticated)) - return - } - - guard let url = URL(string: "\(baseURL)/timer/stop/\(id)") else { - completion(.failure(APIError.invalidURL)) - return - } - - var request = URLRequest(url: url) - request.httpMethod = "POST" - request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") - request.setValue(workspaceId, forHTTPHeaderField: "X-Workspace-Id") - request.setValue("application/json", forHTTPHeaderField: "Content-Type") - - let body: [String: Any] = ["notes": notes ?? ""] - request.httpBody = try? JSONSerialization.data(withJSONObject: body) - - URLSession.shared.dataTask(with: request) { data, response, error in - if let error = error { - completion(.failure(error)) - return - } - - guard let data = data else { - completion(.failure(APIError.noData)) - return - } - - do { - let entry = try JSONDecoder().decode(TimeEntry.self, from: data) - completion(.success(entry)) - } catch { - completion(.failure(error)) - } - }.resume() - } - - // MARK: - Login - - func login(email: String, password: String, completion: @escaping (Result) -> Void) { - guard let url = URL(string: "\(baseURL)/auth/login") else { - completion(.failure(APIError.invalidURL)) - return - } - - var request = URLRequest(url: url) - request.httpMethod = "POST" - request.setValue("application/json", forHTTPHeaderField: "Content-Type") - - let body: [String: Any] = ["email": email, "password": password] - request.httpBody = try? JSONSerialization.data(withJSONObject: body) - - URLSession.shared.dataTask(with: request) { data, response, error in - if let error = error { - completion(.failure(error)) - return - } - - guard let data = data else { - completion(.failure(APIError.noData)) - return - } - - // בדיקת תגובת שגיאה - if let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode != 200 { - if let errorResponse = try? JSONDecoder().decode(ErrorResponse.self, from: data) { - completion(.failure(APIError.serverError(errorResponse.error))) - } else { - completion(.failure(APIError.serverError("שגיאה בהתחברות"))) - } - return - } - - do { - let loginResponse = try JSONDecoder().decode(LoginResponse.self, from: data) - completion(.success(loginResponse)) - } catch { - print("Login decode error: \(error)") - completion(.failure(error)) - } - }.resume() - } - - // MARK: - Get Workspaces - - func getWorkspaces(token: String, completion: @escaping (Result<[Workspace], Error>) -> Void) { - guard let url = URL(string: "\(baseURL)/workspaces") else { - completion(.failure(APIError.invalidURL)) - return - } - - var request = URLRequest(url: url) - request.httpMethod = "GET" - request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") - request.setValue("application/json", forHTTPHeaderField: "Content-Type") - - URLSession.shared.dataTask(with: request) { data, response, error in - if let error = error { - completion(.failure(error)) - return - } - - guard let data = data else { - completion(.failure(APIError.noData)) - return - } - - do { - let workspaces = try JSONDecoder().decode([Workspace].self, from: data) - completion(.success(workspaces)) - } catch { - print("Workspaces decode error: \(error)") - completion(.failure(error)) - } - }.resume() - } -} - -// MARK: - API Errors - -enum APIError: LocalizedError { - case notAuthenticated - case invalidURL - case noData - case serverError(String) - - var errorDescription: String? { - switch self { - case .notAuthenticated: - return "לא מחובר" - case .invalidURL: - return "כתובת לא תקינה" - case .noData: - return "לא התקבל מידע" - case .serverError(let message): - return message - } - } -} diff --git a/menubar/README.md b/menubar/README.md deleted file mode 100644 index 3a75db2..0000000 --- a/menubar/README.md +++ /dev/null @@ -1,94 +0,0 @@ -# Clockwize Menu Bar App - -אפליקציית Menu Bar עבור Clockwize - מציגה את סטטוס הטיימר בשורת התפריטים של macOS. - -## 🎯 תכונות - -- ⏱️ הצגת זמן הטיימר הפעיל בשורת התפריטים -- 🟢 אינדיקטור סטטוס (פועל/מושהה/לא פעיל) -- ▶️/⏸️ התחלה/השהיה של הטיימר -- ⏹️ עצירה ושמירת הטיימר -- 📱 פתיחה מהירה של האפליקציה הראשית - -## 📦 התקנה - -### דרישות מקדימות - -נדרש אחד מהבאים: - -**אפשרות א': Xcode (מומלץ)** -1. התקן Xcode מה-[App Store](https://apps.apple.com/app/xcode/id497799835) -2. פתח Terminal והרץ: `sudo xcode-select -s /Applications/Xcode.app` - -**אפשרות ב': עדכון Command Line Tools** -```bash -sudo rm -rf /Library/Developer/CommandLineTools -xcode-select --install -``` - -### בנייה - -לאחר התקנת Xcode או עדכון CLT: - -```bash -cd menubar -./build.sh -``` - -האפליקציה תיבנה ותועתק ל: `ClockwizeMenuBar.app` - -### פתיחה ישירה ב-Xcode - -אם יש לך Xcode: -1. פתח את הקובץ `ClockwizeMenuBar.xcodeproj` -2. לחץ Cmd+B לבנייה -3. לחץ Cmd+R להרצה - -## 🚀 הפעלה - -```bash -open ../ClockwizeMenuBar.app -``` - -או לחץ פעמיים על `ClockwizeMenuBar.app` ב-Finder. - -## ⚙️ שימוש - -1. **התחברות ראשונית**: בפעם הראשונה תתבקש להתחבר עם האימייל והסיסמא של Clockwize -2. **תפריט**: לחץ על האייקון בשורת התפריטים לפתיחת התפריט -3. **שליטה בטיימר**: השתמש בכפתורים בתפריט להשהיה/המשך/עצירה - -## 📁 מבנה הקבצים - -``` -menubar/ -├── ClockwizeMenuBar/ -│ ├── AppDelegate.swift # נקודת כניסה -│ ├── StatusBarController.swift # ניהול התפריט -│ ├── TimerAPI.swift # API client -│ ├── Models.swift # מודלים -│ ├── AuthManager.swift # ניהול התחברות -│ ├── LoginViewController.swift # חלון התחברות -│ └── Info.plist # הגדרות -├── ClockwizeMenuBar.xcodeproj/ -├── build.sh -└── README.md -``` - -## 🔧 פתרון בעיות - -### "SDK is not supported by the compiler" -עדכן את Command Line Tools: -```bash -sudo rm -rf /Library/Developer/CommandLineTools -xcode-select --install -``` - -### "xcodebuild requires Xcode" -התקן Xcode מה-App Store והרץ: -```bash -sudo xcode-select -s /Applications/Xcode.app -``` - -### האפליקציה לא מתחברת ל-API -ודא שהשרת של Clockwize רץ על `localhost:3001` diff --git a/menubar/build.sh b/menubar/build.sh deleted file mode 100755 index 99628c0..0000000 --- a/menubar/build.sh +++ /dev/null @@ -1,91 +0,0 @@ -#!/bin/bash - -# Clockwize Menu Bar - Build Script -# This script compiles the Swift source files into a macOS app bundle - -set -e - -# צבעים להודעות -RED='\033[0;31m' -GREEN='\033[0;32m' -YELLOW='\033[1;33m' -CYAN='\033[0;36m' -NC='\033[0m' # No Color - -echo -e "${CYAN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" -echo -e "${CYAN}🔨 Building Clockwize Menu Bar${NC}" -echo -e "${CYAN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" - -# תיקיות -SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" -PROJECT_DIR="$SCRIPT_DIR/.." -BUILD_DIR="$SCRIPT_DIR/build" -APP_NAME="ClockwizeMenuBar" - -# בדיקה אם xcodebuild זמין -if command -v xcodebuild &> /dev/null; then - echo -e "${YELLOW}📦 Building with xcodebuild...${NC}" - - cd "$SCRIPT_DIR" - - xcodebuild -project ClockwizeMenuBar.xcodeproj \ - -scheme ClockwizeMenuBar \ - -configuration Release \ - -derivedDataPath "$BUILD_DIR" \ - build 2>&1 | while read line; do - # סינון פלט לקריאות - if [[ "$line" == *"error:"* ]]; then - echo -e "${RED}$line${NC}" - elif [[ "$line" == *"warning:"* ]]; then - echo -e "${YELLOW}$line${NC}" - elif [[ "$line" == *"BUILD SUCCEEDED"* ]]; then - echo -e "${GREEN}$line${NC}" - elif [[ "$line" == *"BUILD FAILED"* ]]; then - echo -e "${RED}$line${NC}" - fi - done - - # מציאת האפליקציה שנבנתה - BUILT_APP=$(find "$BUILD_DIR" -name "ClockwizeMenuBar.app" -type d | head -1) - - if [ -d "$BUILT_APP" ]; then - echo -e "${GREEN}✓ Build successful${NC}" - - # העתקה לתיקיית הפרויקט - rm -rf "$PROJECT_DIR/ClockwizeMenuBar.app" - cp -R "$BUILT_APP" "$PROJECT_DIR/ClockwizeMenuBar.app" - - echo "" - echo -e "${GREEN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" - echo -e "${GREEN}✅ Build completed successfully!${NC}" - echo -e "${GREEN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" - echo "" - echo -e "App location: ${YELLOW}$PROJECT_DIR/ClockwizeMenuBar.app${NC}" - echo "" - echo -e "To run: ${CYAN}open \"$PROJECT_DIR/ClockwizeMenuBar.app\"${NC}" - exit 0 - else - echo -e "${RED}✗ Could not find built app${NC}" - exit 1 - fi -else - echo -e "${RED}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" - echo -e "${RED}❌ xcodebuild not found${NC}" - echo -e "${RED}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" - echo "" - echo -e "To build the Menu Bar app, you need one of the following:" - echo "" - echo -e "${YELLOW}Option 1: Install Xcode from the App Store${NC}" - echo -e " After installing, run this script again." - echo "" - echo -e "${YELLOW}Option 2: Update Command Line Tools${NC}" - echo -e " Run these commands:" - echo -e " ${CYAN}sudo rm -rf /Library/Developer/CommandLineTools${NC}" - echo -e " ${CYAN}xcode-select --install${NC}" - echo "" - echo -e "${YELLOW}Option 3: Open the Xcode project manually${NC}" - echo -e " Open: ${CYAN}$SCRIPT_DIR/ClockwizeMenuBar.xcodeproj${NC}" - echo -e " Then press Cmd+B to build" - echo "" - exit 1 -fi From 950a60775e69196c28c1544692ce0200a23eeda9 Mon Sep 17 00:00:00 2001 From: Clockwize Date: Thu, 8 Oct 2026 09:53:55 +0300 Subject: [PATCH 2/4] Server: data integrity, auth hardening and local-time correctness - Addons and integrations are keyed per workspace (rebuilt unique keys via an idempotent, verified migration); deleting a workspace removes all of its rows and files; deleting an account removes personal workspaces and hands shared records to the workspace owner - Forced password reset is enforced server-side: no session before the reset (short-lived reset token), existing sessions revoked; password changes (self or admin) revoke other sessions; in-memory rate limiting on login, reset and passkey sign-in; passkey-only accounts can set a first password and delete their account by confirming their email - Global client sources need a system admin; addon settings need the workspace owner/admin - Monthly/yearly recurrences keep their day (31.1 -> 28.2 -> 31.3); stats ranges and months follow the user's local days; recurring due dates are correct east of UTC - Validation fixes: partial credential updates keep fields, paid_date cleared when leaving paid, non-positive expense updates rejected, catalog price 0 kept - 442 tests Co-Authored-By: Claude Opus 5.5 --- server/database.js | 179 ++++++++++-- server/middleware/auth.js | 36 ++- server/middleware/rateLimit.js | 59 ++++ server/routes/addons.js | 20 +- server/routes/admin.js | 17 +- server/routes/auth.js | 195 +++++++------ server/routes/catalog.js | 7 +- server/routes/client_sources.js | 11 + server/routes/credentials.js | 9 +- server/routes/expenses.js | 5 + server/routes/leads.js | 8 +- server/routes/passkeys.js | 48 +--- server/routes/payments.js | 4 +- server/routes/planned-slots.js | 23 +- server/routes/recurring.js | 38 +-- server/routes/reminders.js | 49 +++- server/routes/stats.js | 47 +-- server/routes/workspaces.js | 127 ++++++++- server/tests/auth-hardening.test.js | 204 +++++++++++++ server/tests/auth-profile-admin.test.js | 300 ++++++++++++++++++-- server/tests/catalog-sources-addons.test.js | 290 +++++++++++++++++-- server/tests/credentials-crypto.test.js | 19 +- server/tests/expenses-recurring.test.js | 107 ++++++- server/tests/leads.test.js | 29 +- server/tests/payments.test.js | 6 +- server/tests/reminders-alerts-slots.test.js | 146 +++++++++- server/tests/stats.test.js | 107 ++++++- server/tests/workspaces-isolation.test.js | 59 +++- server/utils/authSession.js | 45 +++ server/utils/recurrence.js | 12 + 30 files changed, 1909 insertions(+), 297 deletions(-) create mode 100644 server/middleware/rateLimit.js create mode 100644 server/tests/auth-hardening.test.js create mode 100644 server/utils/authSession.js create mode 100644 server/utils/recurrence.js diff --git a/server/database.js b/server/database.js index db19319..5fb2c38 100644 --- a/server/database.js +++ b/server/database.js @@ -135,6 +135,135 @@ class Database { } } +// user_addons table definition. Addons are switched on and off per workspace (every route reads and +// writes by workspace_id + addon_id), so that pair is the unique key. +function userAddonsTableSql(name) { + return `CREATE TABLE ${name} ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + workspace_id TEXT, + addon_id TEXT NOT NULL, + is_enabled INTEGER DEFAULT 1, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + UNIQUE(workspace_id, addon_id) + )`; +} + +const USER_ADDONS_COLUMNS = ['id', 'user_id', 'workspace_id', 'addon_id', 'is_enabled', 'created_at', 'updated_at']; + +// integrations table definition. A connection (e.g. Morning) belongs to the workspace - every route +// reads, writes and disconnects by workspace_id + provider; user_id is who connected it. +function integrationsTableSql(name) { + return `CREATE TABLE ${name} ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + workspace_id TEXT, + provider TEXT NOT NULL, + api_key TEXT, + api_secret TEXT, + access_token TEXT, + refresh_token TEXT, + expires_at DATETIME, + settings TEXT, + is_active INTEGER DEFAULT 1, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + UNIQUE(workspace_id, provider) + )`; +} + +const INTEGRATIONS_COLUMNS = [ + 'id', 'user_id', 'workspace_id', 'provider', 'api_key', 'api_secret', 'access_token', 'refresh_token', + 'expires_at', 'settings', 'is_active', 'created_at', 'updated_at' +]; + +// Rebuilds `table` so its unique key is `newKey` instead of `oldKey`, keeping every row: +// CREATE _new -> INSERT SELECT -> check the row count -> DROP old -> RENAME, all in one +// transaction. Runs only while the old key is there, so it is safe on every start. Any failure +// (e.g. rows that would break the new key) rolls back and leaves the old table exactly as it was. +function rebuildWithUniqueKey(db, { table, oldKey, newKey, tableSql, knownColumns }) { + const uniqueKeys = db.prepare(`SELECT name FROM pragma_index_list(?) WHERE "unique" = 1`).all(table) + .map(index => db.prepare('SELECT name FROM pragma_index_info(?) ORDER BY seqno').all(index.name).map(col => col.name)); + const hasOldKey = uniqueKeys.some(cols => cols.length === oldKey.length && cols.every((col, i) => col === oldKey[i])); + if (!hasOldKey) return false; + + const label = `${table} unique key`; + const columns = db.prepare('SELECT name FROM pragma_table_info(?)').all(table).map(col => col.name); + const unexpected = columns.filter(col => !knownColumns.includes(col)); + if (!newKey.every(col => columns.includes(col)) || unexpected.length > 0) { + console.error(`Migration skipped (${label}): unexpected columns [${columns.join(', ')}] - table left as is`); + return false; + } + + const columnList = columns.join(', '); + const before = db.prepare(`SELECT COUNT(*) AS n FROM ${table}`).get().n; + // Kept synchronous on purpose: the file is written on a later tick (save() -> setImmediate), + // and writing it (export()) would end an open transaction + try { + db.exec('BEGIN'); + db.exec(`DROP TABLE IF EXISTS ${table}_new`); + db.exec(tableSql(`${table}_new`)); + db.exec(`INSERT INTO ${table}_new (${columnList}) SELECT ${columnList} FROM ${table}`); + const copied = db.prepare(`SELECT COUNT(*) AS n FROM ${table}_new`).get().n; + if (copied !== before) throw new Error(`row count mismatch: ${before} -> ${copied}`); + db.exec(`DROP TABLE ${table}`); + db.exec(`ALTER TABLE ${table}_new RENAME TO ${table}`); + db.exec('COMMIT'); + console.log(`✅ Migrated ${table} to UNIQUE(${newKey.join(', ')}) (${copied} rows kept)`); + return true; + } catch (e) { + try { db.exec('ROLLBACK'); } catch { /* nothing to roll back */ } + console.error(`Migration error (${label}) - old table kept:`, e.message); + return false; + } +} + +// Older databases have user_addons with UNIQUE(user_id, addon_id): a member of two workspaces got a +// 500 the first time they toggled an addon in the second one +export function migrateUserAddonsUniqueKey(db) { + return rebuildWithUniqueKey(db, { + table: 'user_addons', + oldKey: ['user_id', 'addon_id'], + newKey: ['workspace_id', 'addon_id'], + tableSql: userAddonsTableSql, + knownColumns: USER_ADDONS_COLUMNS + }); +} + +// Older databases have integrations with UNIQUE(user_id, provider): connecting Morning in a second +// workspace failed for a user who had already connected it in another one +export function migrateIntegrationsUniqueKey(db) { + return rebuildWithUniqueKey(db, { + table: 'integrations', + oldKey: ['user_id', 'provider'], + newKey: ['workspace_id', 'provider'], + tableSql: integrationsTableSql, + knownColumns: INTEGRATIONS_COLUMNS + }); +} + +// Monthly/yearly reminders remember the day of the month they were set for, so a reminder on the 31st +// goes 31.1 -> 28.2 -> 31.3 instead of drifting to the 28th. The day is read in local time, exactly +// like routes/reminders.js reschedules. Fills only reminders that have no anchor yet (idempotent). +export function backfillReminderRecurrenceDay(db) { + const unanchored = db.prepare(` + SELECT id, due_date FROM reminders + WHERE recurrence_day IS NULL AND is_recurring = 1 AND recurrence_interval IN ('monthly', 'yearly') AND due_date IS NOT NULL + `).all(); + let filled = 0; + for (const reminder of unanchored) { + const due = new Date(reminder.due_date); + if (Number.isNaN(due.getTime())) continue; + db.prepare('UPDATE reminders SET recurrence_day = ? WHERE id = ? AND recurrence_day IS NULL').run(due.getDate(), reminder.id); + filled += 1; + } + if (filled > 0) console.log(`✅ Anchored ${filled} monthly/yearly reminders to their day of the month`); + return filled; +} + async function initDatabase() { const SQL = await initSqlJs(); @@ -344,23 +473,8 @@ async function initDatabase() { FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE ); - -- Integrations table - CREATE TABLE IF NOT EXISTS integrations ( - id TEXT PRIMARY KEY, - user_id TEXT NOT NULL, - provider TEXT NOT NULL, - api_key TEXT, - api_secret TEXT, - access_token TEXT, - refresh_token TEXT, - expires_at DATETIME, - settings TEXT, - is_active INTEGER DEFAULT 1, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, - UNIQUE(user_id, provider) - ); + -- Integrations table (one connection per provider per workspace; user_id = who connected it) + ${integrationsTableSql('IF NOT EXISTS integrations')}; -- Payments table CREATE TABLE IF NOT EXISTS payments ( @@ -422,17 +536,8 @@ async function initDatabase() { FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE ); - -- User Addons table (stores which addons are enabled/disabled per user) - CREATE TABLE IF NOT EXISTS user_addons ( - id TEXT PRIMARY KEY, - user_id TEXT NOT NULL, - addon_id TEXT NOT NULL, - is_enabled INTEGER DEFAULT 1, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, - UNIQUE(user_id, addon_id) - ); + -- User Addons table (which addons are enabled/disabled per workspace; user_id = who set it) + ${userAddonsTableSql('IF NOT EXISTS user_addons')}; -- Comment read status table (tracks when user last viewed comments for each context) CREATE TABLE IF NOT EXISTS comment_read_status ( @@ -544,6 +649,9 @@ async function initDatabase() { // Migration: Add force_password_reset column to users db.addColumn('users', 'force_password_reset', 'INTEGER DEFAULT 0'); + // Session tokens issued before this time (ms since epoch) are refused - set when the admin forces + // a password reset, so sessions from before it never come back (middleware/auth.js) + db.addColumn('users', 'sessions_valid_after', 'INTEGER'); // Migration: Add status column to clients db.addColumn('clients', 'status', "TEXT DEFAULT 'active'"); @@ -590,6 +698,14 @@ async function initDatabase() { // Migration: Add is_archived column to reminders table (separate from is_read/handled) db.addColumn('reminders', 'is_archived', 'INTEGER DEFAULT 0'); + // Migration: Add recurrence_day to reminders (the day of the month a monthly/yearly reminder keeps) + db.addColumn('reminders', 'recurrence_day', 'INTEGER'); + try { + backfillReminderRecurrenceDay(db); + } catch (e) { + console.error('Migration error (reminders recurrence_day):', e.message); + } + // Migration: Add new subtask fields (due_date, description, priority) db.addColumn('subtasks', 'due_date', 'TEXT'); db.addColumn('subtasks', 'description', 'TEXT'); @@ -611,6 +727,10 @@ async function initDatabase() { db.addColumn(table, 'workspace_id', 'TEXT'); } + // Migration: user_addons and integrations are keyed per workspace, not per user (need workspace_id, added above) + migrateUserAddonsUniqueKey(db); + migrateIntegrationsUniqueKey(db); + // Migration: Create personal workspace for existing users who don't have one try { const usersWithoutWorkspace = db.prepare(` @@ -1184,6 +1304,9 @@ async function initDatabase() { return db; } +// The wrapper class is exported so migrations can be tested against a throwaway database +export { Database }; + // Export a promise that resolves to the database export const dbPromise = initDatabase(); export default dbPromise; diff --git a/server/middleware/auth.js b/server/middleware/auth.js index b9af312..427cfd4 100644 --- a/server/middleware/auth.js +++ b/server/middleware/auth.js @@ -6,8 +6,27 @@ if (!JWT_SECRET || JWT_SECRET.length < 32) { throw new Error('JWT_SECRET must be set in the environment and be at least 32 characters long'); } +// iatMs: issue time in ms (the standard iat is whole seconds), compared with users.sessions_valid_after export const generateToken = (userId) => { - return jwt.sign({ userId }, JWT_SECRET, { expiresIn: '30d' }); + return jwt.sign({ userId, iatMs: Date.now() }, JWT_SECRET, { expiresIn: '30d' }); +}; + +// A user the admin flagged for a forced password reset gets this instead of a session token: it is +// accepted only by POST /api/auth/reset-password (authMiddleware rejects any token with a purpose) +const PASSWORD_RESET_PURPOSE = 'password-reset'; +export const PASSWORD_RESET_TOKEN_TTL = '15m'; + +export const generatePasswordResetToken = (userId) => + jwt.sign({ userId, purpose: PASSWORD_RESET_PURPOSE }, JWT_SECRET, { expiresIn: PASSWORD_RESET_TOKEN_TTL }); + +// The user id of a valid, unexpired password-reset token, or null +export const verifyPasswordResetToken = (token) => { + try { + const decoded = jwt.verify(token, JWT_SECRET); + return decoded.purpose === PASSWORD_RESET_PURPOSE && decoded.userId ? decoded.userId : null; + } catch { + return null; + } }; export const authMiddleware = (req, res, next) => { @@ -26,11 +45,20 @@ export const authMiddleware = (req, res, next) => { return res.status(401).json({ error: 'טוקן לא תקין' }); } + // Special-purpose tokens (a password-reset token) are not sessions + if (decoded.purpose) { + return res.status(401).json({ error: 'טוקן לא תקין' }); + } + // A valid signature is not enough: deleted or suspended users lose access right away, - // not when their 30-day token expires. 401 makes the client sign out. + // not when their 30-day token expires - and so does a user flagged for a forced password + // reset, until they set the new password. 401 makes the client sign out. + // Tokens issued before sessions_valid_after (the admin forced a password reset) are revoked. const db = req.app?.locals?.db; - const user = db?.prepare('SELECT is_active FROM users WHERE id = ?').get(decoded.userId); - if (db && (!user || user.is_active === 0)) { + const user = db?.prepare('SELECT is_active, force_password_reset, sessions_valid_after FROM users WHERE id = ?').get(decoded.userId); + const issuedAt = decoded.iatMs ?? (decoded.iat || 0) * 1000; + if (db && (!user || user.is_active === 0 || user.force_password_reset === 1 || + (user.sessions_valid_after && issuedAt < user.sessions_valid_after))) { return res.status(401).json({ error: 'אנא התחבר למערכת' }); } diff --git a/server/middleware/rateLimit.js b/server/middleware/rateLimit.js new file mode 100644 index 0000000..5728657 --- /dev/null +++ b/server/middleware/rateLimit.js @@ -0,0 +1,59 @@ +// Small in-memory rate limiter for the sign-in endpoints (one server process, no extra packages). +// +// Off when NODE_ENV is 'test' (the unit and e2e suites sign in constantly) unless +// CLOCKWIZE_RATE_LIMIT=on; CLOCKWIZE_RATE_LIMIT=off turns it off anywhere. Both are read on every +// request, so a test can switch it on for itself. +const isEnabled = () => { + const flag = process.env.CLOCKWIZE_RATE_LIMIT; + if (flag === 'on') return true; + if (flag === 'off') return false; + return process.env.NODE_ENV !== 'test'; +}; + +export const RATE_LIMIT_MESSAGE = 'יותר מדי ניסיונות. נסו שוב בעוד כמה דקות.'; + +// key(req) names the bucket (e.g. IP + email). With failuresOnly, only responses with status >= 400 +// count and a successful one clears the bucket - a user who signs in fine is never locked out. +export function createRateLimiter({ max = 10, windowMs = 15 * 60 * 1000, key = (req) => req.ip, failuresOnly = false } = {}) { + const buckets = new Map(); + + const sweep = (now) => { + for (const [k, bucket] of buckets) { + if (bucket.resetAt <= now) buckets.delete(k); + } + }; + + const limiter = (req, res, next) => { + if (!isEnabled()) return next(); + + const now = Date.now(); + if (buckets.size > 10_000) sweep(now); + const k = String(key(req)); + let bucket = buckets.get(k); + if (!bucket || bucket.resetAt <= now) { + bucket = { count: 0, resetAt: now + windowMs }; + buckets.set(k, bucket); + } + + if (bucket.count >= max) { + res.set('Retry-After', String(Math.ceil((bucket.resetAt - now) / 1000))); + return res.status(429).json({ error: RATE_LIMIT_MESSAGE }); + } + + if (!failuresOnly) { + bucket.count += 1; + } else { + res.on('finish', () => { + if (res.statusCode >= 400) bucket.count += 1; + else buckets.delete(k); + }); + } + next(); + }; + + limiter.reset = () => buckets.clear(); + return limiter; +} + +// "ip|identifier" - the identifier lower-cased so "Dana@x" and "dana@x" share a bucket +export const ipAnd = (identifier) => (req) => `${req.ip}|${String(identifier(req) ?? '').trim().toLowerCase()}`; diff --git a/server/routes/addons.js b/server/routes/addons.js index 1e34837..c95f54a 100644 --- a/server/routes/addons.js +++ b/server/routes/addons.js @@ -1,6 +1,6 @@ import express from 'express'; import { v4 as uuidv4 } from 'uuid'; -import { authMiddleware, workspaceMiddleware } from '../middleware/auth.js'; +import { authMiddleware, workspaceMiddleware, canManageWorkspace } from '../middleware/auth.js'; import { encrypt, decryptDetailed } from '../utils/crypto.js'; const router = express.Router(); @@ -11,6 +11,14 @@ const SENSITIVE_SETTINGS = ['openai_api_key', 'api_key', 'api_secret', 'password // Helper to get db const getDb = (req) => req.app.locals.db; +// הפעלה/כיבוי של תוספים והגדרותיהם (כולל מפתחות API) חלים על כל ה-workspace - רק בעלים או מנהל +const requireAddonManager = (req, res, next) => { + if (!canManageWorkspace(req)) { + return res.status(403).json({ error: 'רק הבעלים או מנהל ה-workspace יכולים לשנות תוספים' }); + } + next(); +}; + // הגדרת התוספים הזמינים במערכת // כל תוסף חדש צריך להתווסף כאן export const AVAILABLE_ADDONS = [ @@ -142,7 +150,7 @@ router.get('/enabled', authMiddleware, workspaceMiddleware, (req, res) => { }); // עדכון מצב תוסף (הפעלה/כיבוי) -router.put('/:addonId', authMiddleware, workspaceMiddleware, (req, res) => { +router.put('/:addonId', authMiddleware, workspaceMiddleware, requireAddonManager, (req, res) => { try { const db = getDb(req); const { addonId } = req.params; @@ -183,7 +191,7 @@ router.put('/:addonId', authMiddleware, workspaceMiddleware, (req, res) => { }); // עדכון מרובה של תוספים -router.put('/', authMiddleware, workspaceMiddleware, (req, res) => { +router.put('/', authMiddleware, workspaceMiddleware, requireAddonManager, (req, res) => { try { const db = getDb(req); const { addons } = req.body; // מערך של { id, isEnabled } @@ -287,6 +295,8 @@ router.get('/:addonId/settings', authMiddleware, workspaceMiddleware, (req, res) `).all(req.workspaceId, addonId); // המרה לאובייקט עם פענוח ומיסוך של ערכים רגישים + // חבר צוות רגיל רואה רק אם ערך רגיש מוגדר - גם הגרסה הממוסכת חושפת חלק מהמפתח + const canSeeMasked = canManageWorkspace(req); const result = {}; for (const setting of settings) { const isSensitive = SENSITIVE_SETTINGS.some(s => setting.setting_key.includes(s)); @@ -295,7 +305,7 @@ router.get('/:addonId/settings', authMiddleware, workspaceMiddleware, (req, res) // פענוח הערך המוצפן const decrypted = decryptSetting(db, req.workspaceId, req.userId, addonId, setting.setting_key, setting.setting_value); // מיסוך לתצוגה - result[setting.setting_key] = maskSensitiveValue(decrypted); + if (canSeeMasked) result[setting.setting_key] = maskSensitiveValue(decrypted); // שליחת דגל שיש ערך מוגדר result[`${setting.setting_key}_configured`] = !!decrypted; } else { @@ -311,7 +321,7 @@ router.get('/:addonId/settings', authMiddleware, workspaceMiddleware, (req, res) }); // עדכון הגדרות תוסף -router.put('/:addonId/settings', authMiddleware, workspaceMiddleware, (req, res) => { +router.put('/:addonId/settings', authMiddleware, workspaceMiddleware, requireAddonManager, (req, res) => { try { const db = getDb(req); const { addonId } = req.params; diff --git a/server/routes/admin.js b/server/routes/admin.js index 797015c..7db9136 100644 --- a/server/routes/admin.js +++ b/server/routes/admin.js @@ -1,6 +1,7 @@ import express from 'express'; import { generateToken, authMiddleware } from '../middleware/auth.js'; import bcrypt from 'bcryptjs'; +import { deleteUserAccount } from './workspaces.js'; const router = express.Router(); @@ -52,6 +53,11 @@ router.post('/impersonate/:userId', (req, res) => { if (!user) { return res.status(404).json({ error: 'משתמש לא נמצא' }); } + + // Such a user's session tokens are refused until they set a new password (authMiddleware) + if (user.force_password_reset === 1) { + return res.status(409).json({ error: 'המשתמש נדרש להגדיר סיסמה חדשה - לא ניתן להתחבר בשמו עד שיעשה זאת' }); + } const token = generateToken(user.id); @@ -87,7 +93,8 @@ router.post('/users/:userId/force-password-reset', (req, res) => { return res.status(403).json({ error: 'לא ניתן לאפס סיסמת אדמין' }); } - db.prepare('UPDATE users SET force_password_reset = 1 WHERE id = ?').run(userId); + // Also ends every session the user has now: they come back only through the reset + db.prepare('UPDATE users SET force_password_reset = 1, sessions_valid_after = ? WHERE id = ?').run(Date.now(), userId); res.json({ message: 'המשתמש יידרש לשנות סיסמה בהתחברות הבאה' }); } catch (error) { @@ -118,7 +125,9 @@ router.post('/users/:userId/set-password', async (req, res) => { } const hashedPassword = await bcrypt.hash(password, 10); - db.prepare('UPDATE users SET password = ?, force_password_reset = 0 WHERE id = ?').run(hashedPassword, userId); + // A password set by the admin also signs the user out everywhere + db.prepare('UPDATE users SET password = ?, force_password_reset = 0, sessions_valid_after = ? WHERE id = ?') + .run(hashedPassword, Date.now(), userId); res.json({ message: 'סיסמה שונתה בהצלחה' }); } catch (error) { @@ -172,8 +181,8 @@ router.delete('/users/:userId', (req, res) => { return res.status(403).json({ error: 'לא ניתן למחוק חשבון אדמין' }); } - // Delete user (CASCADE will handle related records) - db.prepare('DELETE FROM users WHERE id = ?').run(userId); + // Personal workspaces go with the account; what the user created in shared workspaces stays there + deleteUserAccount(db, userId); res.json({ message: 'חשבון נמחק בהצלחה' }); } catch (error) { diff --git a/server/routes/auth.js b/server/routes/auth.js index 8e73693..5613884 100644 --- a/server/routes/auth.js +++ b/server/routes/auth.js @@ -1,11 +1,28 @@ import { Router } from 'express'; import bcrypt from 'bcryptjs'; +import jwt from 'jsonwebtoken'; import { v4 as uuidv4 } from 'uuid'; -import { generateToken, authMiddleware } from '../middleware/auth.js'; +import { generateToken, authMiddleware, verifyPasswordResetToken } from '../middleware/auth.js'; +import { createRateLimiter, ipAnd } from '../middleware/rateLimit.js'; import { saveLocalSession, clearLocalSession } from '../utils/localSession.js'; +import { buildAuthResponse, buildPasswordResetRequiredResponse } from '../utils/authSession.js'; +import { deleteUserAccount } from './workspaces.js'; const router = Router(); +// Failed sign-in / reset attempts per IP + account; a successful one clears the count +const loginLimiter = createRateLimiter({ max: 10, failuresOnly: true, key: ipAnd((req) => req.body?.email) }); +// The account is read from the reset token without verifying it (that happens in the route): garbage +// tokens all share one bucket, so they can't lock real users out of their reset +const resetTokenUserId = (req) => { + try { + return jwt.decode(req.body?.resetToken)?.userId || ''; + } catch { + return ''; + } +}; +const resetLimiter = createRateLimiter({ max: 10, failuresOnly: true, key: ipAnd(resetTokenUserId) }); + // Helper to get db from app const getDb = (req) => req.app.locals.db; @@ -70,7 +87,7 @@ router.post('/register', async (req, res) => { }); // Login -router.post('/login', async (req, res) => { +router.post('/login', loginLimiter, async (req, res) => { try { const db = getDb(req); const { email, password } = req.body; @@ -99,40 +116,13 @@ router.post('/login', async (req, res) => { return res.status(401).json({ error: 'אימייל או סיסמה שגויים' }); } - const token = generateToken(user.id); - - // Check if password reset is required - allow login but flag it - const requiresPasswordReset = user.force_password_reset === 1; - - // Get user's workspaces - const workspaces = db.prepare(` - SELECT w.*, wm.role, - (SELECT COUNT(*) FROM workspace_members WHERE workspace_id = w.id) as member_count - FROM workspaces w - JOIN workspace_members wm ON w.id = wm.workspace_id - WHERE wm.user_id = ? - ORDER BY wm.joined_at ASC - `).all(user.id); - - // Save session locally for desktop apps (Menu Bar) - const currentWorkspace = workspaces[0] || null; - if (currentWorkspace) { - saveLocalSession(token, currentWorkspace.id); + // Flagged by the admin: no session until the new password is set (POST /reset-password) + if (user.force_password_reset === 1) { + return res.json(buildPasswordResetRequiredResponse(user)); } - res.json({ - user: { - id: user.id, - email: user.email, - name: user.name, - default_hourly_rate: user.default_hourly_rate, - is_admin: user.is_admin || 0 - }, - token, - requiresPasswordReset, - workspaces, - currentWorkspace - }); + // Also saves the session locally for the desktop apps (Menu Bar) + res.json(buildAuthResponse(db, user)); } catch (error) { console.error('Login error:', error); res.status(500).json({ error: 'שגיאה בהתחברות' }); @@ -160,7 +150,12 @@ router.post('/desktop-handoff', authMiddleware, (req, res) => { router.get('/me', authMiddleware, (req, res) => { try { const db = getDb(req); - const user = db.prepare('SELECT id, email, name, default_hourly_rate, is_admin, created_at FROM users WHERE id = ?').get(req.userId); + // has_password: 0 for passkey-only accounts (they confirm destructive actions with their email) + const user = db.prepare(` + SELECT id, email, name, default_hourly_rate, is_admin, created_at, + CASE WHEN password IS NOT NULL AND password != '' THEN 1 ELSE 0 END AS has_password + FROM users WHERE id = ? + `).get(req.userId); if (!user) { return res.status(404).json({ error: 'משתמש לא נמצא' }); } @@ -194,14 +189,17 @@ router.put('/profile', authMiddleware, async (req, res) => { const user = db.prepare('SELECT * FROM users WHERE id = ?').get(req.userId); - // If changing password, verify current password + // Changing the password needs the current one - except on a passkey-only account (no stored + // password), which sets its first password here if (password) { - if (!currentPassword) { - return res.status(400).json({ error: 'נדרשת סיסמה נוכחית' }); - } - const validPassword = await bcrypt.compare(currentPassword, user.password); - if (!validPassword) { - return res.status(401).json({ error: 'סיסמה נוכחית שגויה' }); + if (user.password) { + if (!currentPassword) { + return res.status(400).json({ error: 'נדרשת סיסמה נוכחית' }); + } + const validPassword = await bcrypt.compare(currentPassword, user.password); + if (!validPassword) { + return res.status(401).json({ error: 'סיסמה נוכחית שגויה' }); + } } } @@ -223,14 +221,34 @@ router.put('/profile', authMiddleware, async (req, res) => { const hashedPassword = await bcrypt.hash(password, 10); updates.push('password = ?'); params.push(hashedPassword); + // A new password signs out every other session (tokens issued before now, middleware/auth.js) + updates.push('sessions_valid_after = ?'); + params.push(Date.now()); } updates.push('updated_at = CURRENT_TIMESTAMP'); params.push(req.userId); db.prepare(`UPDATE users SET ${updates.join(', ')} WHERE id = ?`).run(...params); - const updatedUser = db.prepare('SELECT id, email, name, default_hourly_rate FROM users WHERE id = ?').get(req.userId); - res.json(updatedUser); + const updatedUser = db.prepare(` + SELECT id, email, name, default_hourly_rate, + CASE WHEN password IS NOT NULL AND password != '' THEN 1 ELSE 0 END AS has_password + FROM users WHERE id = ? + `).get(req.userId); + + if (!password) { + return res.json(updatedUser); + } + + // ... but keeps this one: a fresh token for the client, and for the desktop / menu-bar app + const token = generateToken(req.userId); + const requested = req.headers['x-workspace-id']; + const membership = (requested && db.prepare('SELECT workspace_id FROM workspace_members WHERE workspace_id = ? AND user_id = ?').get(requested, req.userId)) + || db.prepare('SELECT workspace_id FROM workspace_members WHERE user_id = ? ORDER BY joined_at ASC LIMIT 1').get(req.userId); + if (membership) { + saveLocalSession(token, membership.workspace_id); + } + res.json({ ...updatedUser, token }); } catch (error) { console.error('Update profile error:', error); res.status(500).json({ error: 'שגיאה בעדכון הפרופיל' }); @@ -241,20 +259,29 @@ router.put('/profile', authMiddleware, async (req, res) => { router.delete('/account', authMiddleware, async (req, res) => { try { const db = getDb(req); - const { password } = req.body; - - if (!password) { - return res.status(400).json({ error: 'נדרשת סיסמה' }); - } - + const { password, confirmEmail } = req.body || {}; const user = db.prepare('SELECT * FROM users WHERE id = ?').get(req.userId); - const validPassword = await bcrypt.compare(password, user.password); - - if (!validPassword) { - return res.status(401).json({ error: 'סיסמה שגויה' }); + + if (user.password) { + if (!password) { + return res.status(400).json({ error: 'נדרשת סיסמה' }); + } + const validPassword = await bcrypt.compare(password, user.password); + if (!validPassword) { + return res.status(401).json({ error: 'סיסמה שגויה' }); + } + } else { + // Passkey-only account (no password stored): the user confirms by typing the account's email + if (!confirmEmail) { + return res.status(400).json({ error: 'לאישור המחיקה יש להקליד את כתובת האימייל של החשבון' }); + } + if (String(confirmEmail).toLowerCase() !== String(user.email).toLowerCase()) { + return res.status(401).json({ error: 'כתובת האימייל אינה תואמת לחשבון' }); + } } - db.prepare('DELETE FROM users WHERE id = ?').run(req.userId); + // Personal workspaces go with the account; what the user created in shared workspaces stays there + deleteUserAccount(db, req.userId); res.json({ message: 'החשבון נמחק בהצלחה' }); } catch (error) { console.error('Delete account error:', error); @@ -262,63 +289,49 @@ router.delete('/account', authMiddleware, async (req, res) => { } }); -// Reset password (for forced password reset) -router.post('/reset-password', async (req, res) => { +// Forced password reset: the login (password or passkey) of a flagged user returned a short-lived +// resetToken instead of a session. Setting the new password with it clears the flag and signs in. +router.post('/reset-password', resetLimiter, async (req, res) => { try { const db = getDb(req); - const { userId, oldPassword, newPassword } = req.body; + const { resetToken, newPassword } = req.body || {}; - if (!userId || !oldPassword || !newPassword) { + if (!resetToken || !newPassword) { return res.status(400).json({ error: 'כל השדות נדרשים' }); } - if (newPassword.length < 4) { + if (typeof newPassword !== 'string' || newPassword.length < 4) { return res.status(400).json({ error: 'סיסמה חייבת להכיל לפחות 4 תווים' }); } + const userId = verifyPasswordResetToken(resetToken); + if (!userId) { + return res.status(401).json({ error: 'תוקף האיפוס פג. התחבר שוב כדי להגדיר סיסמה חדשה' }); + } + const user = db.prepare('SELECT * FROM users WHERE id = ?').get(userId); if (!user) { return res.status(404).json({ error: 'משתמש לא נמצא' }); } - - // Verify old password - const validPassword = await bcrypt.compare(oldPassword, user.password); - if (!validPassword) { - return res.status(401).json({ error: 'סיסמה נוכחית שגויה' }); + if (user.is_active === 0) { + return res.status(403).json({ error: 'החשבון הושהה. נא פנה למנהל המערכת' }); } - // Check if password reset is required + // Also makes the token single-use: once the flag is cleared it can't set another password if (user.force_password_reset !== 1) { return res.status(400).json({ error: 'איפוס סיסמה לא נדרש' }); } - // Update password and clear force reset flag - const hashedPassword = await bcrypt.hash(newPassword, 10); - db.prepare('UPDATE users SET password = ?, force_password_reset = 0 WHERE id = ?').run(hashedPassword, userId); - - const token = generateToken(user.id); + if (user.password && await bcrypt.compare(newPassword, user.password)) { + return res.status(400).json({ error: 'הסיסמה החדשה חייבת להיות שונה מהסיסמה הנוכחית' }); + } - // Get workspaces - const workspaces = db.prepare(` - SELECT w.*, wm.role - FROM workspaces w - JOIN workspace_members wm ON w.id = wm.workspace_id - WHERE wm.user_id = ? - `).all(user.id); + const hashedPassword = await bcrypt.hash(newPassword, 10); + db.prepare('UPDATE users SET password = ?, force_password_reset = 0, updated_at = CURRENT_TIMESTAMP WHERE id = ?') + .run(hashedPassword, userId); - res.json({ - message: 'סיסמה שונתה בהצלחה', - user: { - id: user.id, - email: user.email, - name: user.name, - default_hourly_rate: user.default_hourly_rate, - is_admin: user.is_admin || 0 - }, - token, - workspaces, - currentWorkspace: workspaces[0] || null - }); + const updated = db.prepare('SELECT * FROM users WHERE id = ?').get(userId); + res.json({ message: 'סיסמה שונתה בהצלחה', ...buildAuthResponse(db, updated) }); } catch (error) { console.error('Reset password error:', error); res.status(500).json({ error: 'שגיאה באיפוס סיסמה' }); diff --git a/server/routes/catalog.js b/server/routes/catalog.js index 16f97d1..cf5a48d 100644 --- a/server/routes/catalog.js +++ b/server/routes/catalog.js @@ -7,6 +7,9 @@ const router = express.Router(); // Helper to get db const getDb = (req) => req.app.locals.db; +// A price of 0 (a free item) is a real price; only a missing/empty one means "no price" +const priceOrNull = (price) => (price === undefined || price === null || price === '' ? null : price); + // קבלת כל הפריטים בקטלוג router.get('/', authMiddleware, workspaceMiddleware, (req, res) => { try { @@ -73,7 +76,7 @@ router.post('/', authMiddleware, workspaceMiddleware, (req, res) => { db.prepare(` INSERT INTO catalog_items (id, workspace_id, user_id, name, description, price, pricing_type, unit, category, notes) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `).run(id, req.workspaceId, req.userId, name, description || null, price || null, pricing_type || 'fixed', unit || null, category || null, notes || null); + `).run(id, req.workspaceId, req.userId, name, description || null, priceOrNull(price), pricing_type || 'fixed', unit || null, category || null, notes || null); const item = db.prepare('SELECT * FROM catalog_items WHERE id = ?').get(id); res.status(201).json(item); @@ -109,7 +112,7 @@ router.put('/:id', authMiddleware, workspaceMiddleware, (req, res) => { `).run( name, description || null, - price || null, + priceOrNull(price), pricing_type || 'fixed', unit || null, category || null, diff --git a/server/routes/client_sources.js b/server/routes/client_sources.js index 388d433..0354bee 100644 --- a/server/routes/client_sources.js +++ b/server/routes/client_sources.js @@ -7,6 +7,9 @@ const router = Router(); // Helper to get db const getDb = (req) => req.app.locals.db; +// Global sources (workspace_id NULL) show up in every workspace, so only a system admin manages them +const isSystemAdmin = (req) => getDb(req).prepare('SELECT is_admin FROM users WHERE id = ?').get(req.userId)?.is_admin === 1; + // Apply auth middleware to all routes router.use(authMiddleware); router.use(workspaceMiddleware); @@ -84,6 +87,10 @@ router.get('/stats', async (req, res) => { router.post('/:id/assign-to-workspace', async (req, res) => { try { const db = getDb(req); + if (!isSystemAdmin(req)) { + return res.status(403).json({ error: 'רק מנהל מערכת יכול לשייך מקור גלובלי' }); + } + const source = db.prepare('SELECT * FROM client_sources WHERE id = ?').get(req.params.id); if (!source) { @@ -116,6 +123,10 @@ router.post('/', async (req, res) => { return res.status(400).json({ error: 'שם מקור נדרש' }); } + if (is_global && !isSystemAdmin(req)) { + return res.status(403).json({ error: 'רק מנהל מערכת יכול ליצור מקור גלובלי' }); + } + // Determine workspace_id: NULL for global sources, current workspace otherwise const workspaceId = is_global ? null : req.workspaceId; diff --git a/server/routes/credentials.js b/server/routes/credentials.js index 75271c9..e90c493 100644 --- a/server/routes/credentials.js +++ b/server/routes/credentials.js @@ -189,16 +189,17 @@ router.put('/:id', authMiddleware, workspaceMiddleware, (req, res) => { const encrypted = encryptCredential({ username, password, notes }, req.workspaceId); + // Omitted secrets keep their stored (encrypted) value; an explicit null / '' clears them db.prepare(` - UPDATE credentials + UPDATE credentials SET service_name = ?, username = ?, password = ?, url = ?, notes = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? AND workspace_id = ? `).run( service_name || existing.service_name, - encrypted.username, - encrypted.password, + username !== undefined ? encrypted.username : existing.username, + password !== undefined ? encrypted.password : existing.password, url !== undefined ? url : existing.url, - encrypted.notes, + notes !== undefined ? encrypted.notes : existing.notes, req.params.id, req.workspaceId ); diff --git a/server/routes/expenses.js b/server/routes/expenses.js index 8008c55..f989a80 100644 --- a/server/routes/expenses.js +++ b/server/routes/expenses.js @@ -247,6 +247,11 @@ router.put('/:id', authMiddleware, workspaceMiddleware, (req, res) => { return res.status(404).json({ error: 'Expense not found' }); } + // Same rule as POST, but only when the amount is being changed + if (amount !== undefined && (!amount || amount <= 0)) { + return res.status(400).json({ error: 'Valid amount is required' }); + } + // Only values that change are checked, so a stale stored reference does not block edits const foreignError = findForeignReference(db, req.workspaceId, { project_id: project_id !== existing.project_id ? project_id : null, diff --git a/server/routes/leads.js b/server/routes/leads.js index 4926a7c..87037c1 100644 --- a/server/routes/leads.js +++ b/server/routes/leads.js @@ -2,6 +2,7 @@ import { Router } from 'express'; import crypto from 'crypto'; import { v4 as uuidv4 } from 'uuid'; import { authMiddleware, workspaceMiddleware } from '../middleware/auth.js'; +import { recurrenceDayFor } from '../utils/recurrence.js'; import { decrypt } from '../utils/crypto.js'; const router = Router(); @@ -996,10 +997,11 @@ router.post('/:id/reminders', async (req, res) => { const id = uuidv4(); db.prepare(` - INSERT INTO reminders (id, user_id, workspace_id, content, notes, due_date, association_type, association_id, is_recurring, recurrence_interval) - VALUES (?, ?, ?, ?, ?, ?, 'lead', ?, ?, ?) + INSERT INTO reminders (id, user_id, workspace_id, content, notes, due_date, association_type, association_id, is_recurring, recurrence_interval, recurrence_day) + VALUES (?, ?, ?, ?, ?, ?, 'lead', ?, ?, ?, ?) `).run(id, req.userId, req.workspaceId, content, notes || null, due_date, req.params.id, - is_recurring ? 1 : 0, recurrence_interval || null); + is_recurring ? 1 : 0, recurrence_interval || null, + recurrenceDayFor({ is_recurring, recurrence_interval, due_date })); const reminder = db.prepare(`SELECT r.*, r.is_read as is_completed FROM reminders r WHERE r.id = ?`).get(id); res.status(201).json(reminder); diff --git a/server/routes/passkeys.js b/server/routes/passkeys.js index 1a4658b..189fe88 100644 --- a/server/routes/passkeys.js +++ b/server/routes/passkeys.js @@ -6,8 +6,9 @@ import { generateAuthenticationOptions, verifyAuthenticationResponse } from '@simplewebauthn/server'; -import { generateToken, authMiddleware } from '../middleware/auth.js'; -import { saveLocalSession } from '../utils/localSession.js'; +import { authMiddleware } from '../middleware/auth.js'; +import { createRateLimiter, ipAnd } from '../middleware/rateLimit.js'; +import { buildAuthResponse, buildPasswordResetRequiredResponse } from '../utils/authSession.js'; const router = Router(); @@ -95,35 +96,10 @@ const insertPasskey = (db, { userId, registrationInfo, name }) => { return db.prepare('SELECT * FROM passkeys WHERE id = ?').get(id); }; -const getUserWorkspaces = (db, userId) => db.prepare(` - SELECT w.*, wm.role, - (SELECT COUNT(*) FROM workspace_members WHERE workspace_id = w.id) as member_count - FROM workspaces w - JOIN workspace_members wm ON w.id = wm.workspace_id - WHERE wm.user_id = ? - ORDER BY wm.joined_at ASC -`).all(userId); - -const buildAuthResponse = (db, user) => { - const token = generateToken(user.id); - const workspaces = getUserWorkspaces(db, user.id); - const currentWorkspace = workspaces[0] || null; - if (currentWorkspace) { - saveLocalSession(token, currentWorkspace.id); - } - return { - user: { - id: user.id, - email: user.email, - name: user.name, - default_hourly_rate: user.default_hourly_rate, - is_admin: user.is_admin - }, - token, - workspaces, - currentWorkspace - }; -}; +// Passkey sign-in: challenges per IP (every attempt counts - each one stores a flow), and failed +// verifications per IP + credential +const loginOptionsLimiter = createRateLimiter({ max: 30, key: (req) => req.ip }); +const loginVerifyLimiter = createRateLimiter({ max: 10, failuresOnly: true, key: ipAnd((req) => req.body?.response?.id) }); // ===== Passwordless signup (public) ===== @@ -216,7 +192,7 @@ router.post('/signup/verify', async (req, res) => { // ===== Passwordless login (public, usernameless via discoverable credential) ===== -router.post('/login/options', async (req, res) => { +router.post('/login/options', loginOptionsLimiter, async (req, res) => { try { const options = await generateAuthenticationOptions({ rpID: RP_ID, @@ -230,7 +206,7 @@ router.post('/login/options', async (req, res) => { } }); -router.post('/login/verify', async (req, res) => { +router.post('/login/verify', loginVerifyLimiter, async (req, res) => { try { const db = getDb(req); const { flowId, response } = req.body; @@ -275,6 +251,12 @@ router.post('/login/verify', async (req, res) => { db.prepare('UPDATE passkeys SET counter = ?, last_used_at = CURRENT_TIMESTAMP WHERE id = ?') .run(verification.authenticationInfo.newCounter, passkey.id); + // Flagged by the admin: a passkey doesn't skip the forced reset - no session until the new + // password is set (POST /api/auth/reset-password with this resetToken) + if (user.force_password_reset === 1) { + return res.json(buildPasswordResetRequiredResponse(user)); + } + res.json(buildAuthResponse(db, user)); } catch (error) { console.error('Passkey login verify error:', error); diff --git a/server/routes/payments.js b/server/routes/payments.js index 4758be0..8169795 100644 --- a/server/routes/payments.js +++ b/server/routes/payments.js @@ -487,11 +487,11 @@ router.put('/:id/status', authMiddleware, workspaceMiddleware, (req, res) => { return res.status(404).json({ error: 'תשלום לא נמצא' }); } - // Set paid_date when marking as paid + // Set paid_date when marking as paid, clear it for any other status (like PUT /:id) const paidDate = status === 'paid' ? new Date().toISOString() : null; db.prepare(` - UPDATE payments SET status = ?, paid_date = COALESCE(?, paid_date) WHERE id = ? + UPDATE payments SET status = ?, paid_date = ? WHERE id = ? `).run(status, paidDate, id); // Update project paid amount diff --git a/server/routes/planned-slots.js b/server/routes/planned-slots.js index 6d1beba..994d3d3 100644 --- a/server/routes/planned-slots.js +++ b/server/routes/planned-slots.js @@ -14,6 +14,18 @@ function formatDate(d) { return `${y}-${m}-${day}`; } +// Helper: add whole months, clamping to the last day of the target month while keeping the +// original day when the month has it (Jan 31 -> Feb 28/29 -> Mar 31; Date#setMonth would overflow) +function addMonthsClamped(date, months) { + const day = date.getDate(); + const d = new Date(date); + d.setDate(1); + d.setMonth(d.getMonth() + months); + const lastDay = new Date(d.getFullYear(), d.getMonth() + 1, 0).getDate(); + d.setDate(Math.min(day, lastDay)); + return d; +} + // Helper: generate recurrence dates until endDate function generateRecurrenceDates(startDate, recurrenceType, interval, endDate) { const dates = []; @@ -22,7 +34,7 @@ function generateRecurrenceDates(startDate, recurrenceType, interval, endDate) { const MAX_OCCURRENCES = 365; // safety limit for (let i = 0; i < MAX_OCCURRENCES; i++) { - const d = new Date(start); + let d = new Date(start); switch (recurrenceType) { case 'daily': @@ -35,10 +47,11 @@ function generateRecurrenceDates(startDate, recurrenceType, interval, endDate) { d.setDate(start.getDate() + (i * 14 * interval)); break; case 'monthly': - d.setMonth(start.getMonth() + (i * interval)); + // Always counted from the start, so the original day comes back after a short month + d = addMonthsClamped(start, i * interval); break; case 'yearly': - d.setFullYear(start.getFullYear() + (i * interval)); + d = addMonthsClamped(start, i * interval * 12); break; default: d.setDate(start.getDate() + (i * 7)); @@ -108,6 +121,10 @@ router.post('/', authMiddleware, workspaceMiddleware, (req, res) => { if (!recurrence_end_date) { return res.status(400).json({ error: 'נדרש תאריך סיום לאירוע חוזר' }); } + // Both are YYYY-MM-DD, so string order is date order; the same day is a one-slot series + if (String(recurrence_end_date).slice(0, 10) < String(date).slice(0, 10)) { + return res.status(400).json({ error: 'תאריך הסיום של אירוע חוזר לא יכול להיות לפני תאריך ההתחלה' }); + } // Generate multiple slots with shared group ID const groupId = uuidv4(); diff --git a/server/routes/recurring.js b/server/routes/recurring.js index 6ffccee..b9e29a2 100644 --- a/server/routes/recurring.js +++ b/server/routes/recurring.js @@ -7,6 +7,24 @@ const router = express.Router(); // Helper to get db from app const getDb = (req) => req.app.locals.db; +// Local calendar day as YYYY-MM-DD - toISOString() is UTC, so local midnight east of UTC +// (e.g. Israel) would come out as the previous day +const toLocalDateString = (d) => + `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; + +// Next local calendar day a monthly template falls due, today included. day_of_month 29-31 is +// clamped to the last day of a short month (31 -> Feb 28/29, Apr 30) instead of overflowing. +const nextDueDate = (dayOfMonth, now) => { + const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()); + const dueIn = (monthOffset) => { + const lastDay = new Date(today.getFullYear(), today.getMonth() + monthOffset + 1, 0).getDate(); + return new Date(today.getFullYear(), today.getMonth() + monthOffset, Math.min(dayOfMonth, lastDay)); + }; + const due = dueIn(0) >= today ? dueIn(0) : dueIn(1); + // Whole calendar days between local midnights (rounded: a DST day is 23 or 25 hours long) + return { due, daysUntilDue: Math.round((due - today) / 86400000) }; +}; + // Referenced client / project must belong to the workspace (otherwise their names leak through the joins) const findForeignReference = (db, workspaceId, { client_id, project_id }) => { if (client_id && !db.prepare('SELECT id FROM clients WHERE id = ? AND workspace_id = ?').get(client_id, workspaceId)) { @@ -300,7 +318,6 @@ router.get('/upcoming/reminders', authMiddleware, workspaceMiddleware, (req, res const { days = 7 } = req.query; const today = new Date(); - const currentDay = today.getDate(); const daysAhead = parseInt(days); // Get active recurring payments that are due within the specified days @@ -319,25 +336,14 @@ router.get('/upcoming/reminders', authMiddleware, workspaceMiddleware, (req, res `).all(req.workspaceId); // Filter to those due within the next X days - const upcoming = recurringPayments.filter(rp => { - const dayOfMonth = rp.day_of_month; - const daysUntilDue = dayOfMonth >= currentDay - ? dayOfMonth - currentDay - : (new Date(today.getFullYear(), today.getMonth() + 1, 0).getDate() - currentDay) + dayOfMonth; - - return daysUntilDue <= daysAhead; - }).map(rp => { - const dayOfMonth = rp.day_of_month; - const daysUntilDue = dayOfMonth >= currentDay - ? dayOfMonth - currentDay - : (new Date(today.getFullYear(), today.getMonth() + 1, 0).getDate() - currentDay) + dayOfMonth; - + const upcoming = recurringPayments.map(rp => { + const { due, daysUntilDue } = nextDueDate(rp.day_of_month, today); return { ...rp, days_until_due: daysUntilDue, - due_date: new Date(today.getFullYear(), today.getMonth() + (dayOfMonth >= currentDay ? 0 : 1), dayOfMonth).toISOString() + due_date: toLocalDateString(due) }; - }); + }).filter(rp => rp.days_until_due <= daysAhead); res.json(upcoming); } catch (error) { diff --git a/server/routes/reminders.js b/server/routes/reminders.js index 26026af..04da8dd 100644 --- a/server/routes/reminders.js +++ b/server/routes/reminders.js @@ -1,6 +1,7 @@ import express from 'express'; import { v4 as uuidv4 } from 'uuid'; import { authMiddleware, workspaceMiddleware } from '../middleware/auth.js'; +import { ANCHORED_INTERVALS, recurrenceDayFor } from '../utils/recurrence.js'; const router = express.Router(); @@ -10,6 +11,19 @@ const getDb = (req) => req.app.locals.db; router.use(authMiddleware); router.use(workspaceMiddleware); +// Add whole months, landing on `anchorDay` or on the last day of a shorter month (Jan 31 + 1 month = +// Feb 28/29, not Mar 3 as Date#setMonth would give). Local time, like the rest of the rescheduling. +const addMonthsClamped = (date, months, anchorDay = date.getDate()) => { + const next = new Date(date); + next.setDate(1); + next.setMonth(next.getMonth() + months); + const lastDay = new Date(next.getFullYear(), next.getMonth() + 1, 0).getDate(); + next.setDate(Math.min(anchorDay, lastDay)); + return next; +}; + +const sameMoment = (a, b) => !!a && !!b && new Date(a).getTime() === new Date(b).getTime(); + // The record a reminder points at (and every linked project) must belong to the workspace - // GET joins their names into the response const ASSOCIATION_TABLES = { client: 'clients', project: 'projects', task: 'tasks', lead: 'leads' }; @@ -141,10 +155,11 @@ router.post('/', (req, res) => { db.prepare(` INSERT INTO reminders ( - id, user_id, workspace_id, content, notes, due_date, association_type, association_id, is_recurring, recurrence_interval - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + id, user_id, workspace_id, content, notes, due_date, association_type, association_id, is_recurring, recurrence_interval, recurrence_day + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `).run( - id, req.userId, req.workspaceId, content, notes || null, due_date || null, association_type, cleanAssociationId, is_recurring ? 1 : 0, recurrence_interval || null + id, req.userId, req.workspaceId, content, notes || null, due_date || null, association_type, cleanAssociationId, is_recurring ? 1 : 0, recurrence_interval || null, + recurrenceDayFor({ is_recurring, recurrence_interval, due_date }) ); // Create project associations if project_ids provided @@ -205,6 +220,10 @@ router.put('/:id', (req, res) => { // Handle recurring logic when marking as read (is_read = 1) if (updates.is_read === true && reminder.is_recurring && reminder.recurrence_interval) { let nextDate = new Date(reminder.due_date || new Date()); + // Reminders from before the anchor existed (or created elsewhere) anchor on their current day + const anchorDay = ANCHORED_INTERVALS.includes(reminder.recurrence_interval) + ? (reminder.recurrence_day || nextDate.getDate()) + : null; switch (reminder.recurrence_interval) { case 'daily': @@ -214,18 +233,18 @@ router.put('/:id', (req, res) => { nextDate.setDate(nextDate.getDate() + 7); break; case 'monthly': - nextDate.setMonth(nextDate.getMonth() + 1); + nextDate = addMonthsClamped(nextDate, 1, anchorDay); break; case 'yearly': - nextDate.setFullYear(nextDate.getFullYear() + 1); + nextDate = addMonthsClamped(nextDate, 12, anchorDay); break; } db.prepare(` UPDATE reminders - SET due_date = ?, updated_at = CURRENT_TIMESTAMP + SET due_date = ?, recurrence_day = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? - `).run(nextDate.toISOString(), id); + `).run(nextDate.toISOString(), anchorDay, id); const updated = db.prepare('SELECT * FROM reminders WHERE id = ?').get(id); updated.project_associations = db.prepare(` @@ -267,6 +286,22 @@ router.put('/:id', (req, res) => { } }); + // The anchor day follows the schedule: a new due date (or turning monthly/yearly on) re-anchors, + // while re-sending the unchanged date with the rest of the form keeps it (28.2 of a 31st reminder) + if (['due_date', 'is_recurring', 'recurrence_interval'].some(key => updates[key] !== undefined)) { + const next = { + due_date: updates.due_date !== undefined ? updates.due_date : reminder.due_date, + is_recurring: updates.is_recurring !== undefined ? !!updates.is_recurring : !!reminder.is_recurring, + recurrence_interval: updates.recurrence_interval !== undefined ? updates.recurrence_interval : reminder.recurrence_interval + }; + let recurrenceDay = recurrenceDayFor(next); + if (recurrenceDay !== null && reminder.recurrence_day && sameMoment(next.due_date, reminder.due_date)) { + recurrenceDay = reminder.recurrence_day; + } + sets.push('recurrence_day = ?'); + values.push(recurrenceDay); + } + if (sets.length > 0) { sets.push('updated_at = CURRENT_TIMESTAMP'); diff --git a/server/routes/stats.js b/server/routes/stats.js index f7d331e..e3c127a 100644 --- a/server/routes/stats.js +++ b/server/routes/stats.js @@ -87,14 +87,17 @@ router.get('/dashboard', authMiddleware, workspaceMiddleware, (req, res) => { let isCustomRange = false; if (customStartDate && customEndDate) { - // Custom date range mode - use UTC boundaries + // The client sends the user's local start-of-day / end-of-day (a custom range, or the + // selected month together with month/year) as full ISO instants, used as-is - the server + // cannot know the user's timezone. A plain YYYY-MM-DD (older callers) still means the whole UTC day. + const isPlainDate = (value) => /^\d{4}-\d{2}-\d{2}$/.test(value); periodStart = new Date(customStartDate); - periodStart.setUTCHours(0, 0, 0, 0); + if (isPlainDate(req.query.startDate)) periodStart.setUTCHours(0, 0, 0, 0); periodEnd = new Date(customEndDate); - periodEnd.setUTCHours(23, 59, 59, 999); - isCustomRange = true; + if (isPlainDate(req.query.endDate)) periodEnd.setUTCHours(23, 59, 59, 999); + isCustomRange = requestedMonth === null || requestedYear === null; } else if (requestedMonth !== null && requestedYear !== null) { - // Specific month requested - build in UTC + // Specific month requested without instants (older callers) - build in UTC periodStart = new Date(Date.UTC(requestedYear, requestedMonth, 1)); const lastDay = new Date(Date.UTC(requestedYear, requestedMonth + 1, 0)).getUTCDate(); periodEnd = new Date(Date.UTC(requestedYear, requestedMonth, lastDay, 23, 59, 59, 999)); @@ -112,10 +115,9 @@ router.get('/dashboard', authMiddleware, workspaceMiddleware, (req, res) => { let activeSecondsPeriod = 0; let activeSecondsTotal = 0; - // Check if period includes today (UTC) - const todayStart = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate())); - const todayEnd = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate(), 23, 59, 59, 999)); - const periodIncludesToday = periodStart <= todayEnd && periodEnd >= todayStart; + // Periods are whole days of the caller (local days, or UTC days for plain dates), so the + // period includes today exactly when it contains the current instant + const periodIncludesToday = periodStart <= now && now <= periodEnd; // Always calculate activeSecondsTotal for all-time stat const activeTimers = db.prepare('SELECT start_time, accumulated_seconds, is_running FROM active_timers WHERE user_id = ? AND workspace_id = ?').all(req.userId, req.workspaceId); @@ -128,23 +130,22 @@ router.get('/dashboard', authMiddleware, workspaceMiddleware, (req, res) => { } activeSecondsTotal += timerDuration; - // Calculate proportional period attribution for active timers - if (periodIncludesToday) { - const timerStartMs = new Date(timer.start_time).getTime(); - const timerEndMs = nowMs; - const clockSpan = timerEndMs - timerStartMs; + // Calculate proportional period attribution for active timers. The overlap decides, so a + // timer running since before midnight also counts its share for yesterday. + const timerStartMs = new Date(timer.start_time).getTime(); + const timerEndMs = nowMs; + const clockSpan = timerEndMs - timerStartMs; - if (clockSpan <= 0) { - activeSecondsPeriod += timerDuration; - return; - } + if (clockSpan <= 0) { + if (periodIncludesToday) activeSecondsPeriod += timerDuration; + return; + } - const overlapStart = Math.max(timerStartMs, periodStart.getTime()); - const overlapEnd = Math.min(timerEndMs, periodEnd.getTime()); + const overlapStart = Math.max(timerStartMs, periodStart.getTime()); + const overlapEnd = Math.min(timerEndMs, periodEnd.getTime()); - if (overlapEnd > overlapStart) { - activeSecondsPeriod += Math.round(timerDuration * ((overlapEnd - overlapStart) / clockSpan)); - } + if (overlapEnd > overlapStart) { + activeSecondsPeriod += Math.round(timerDuration * ((overlapEnd - overlapStart) / clockSpan)); } }); diff --git a/server/routes/workspaces.js b/server/routes/workspaces.js index 25cc729..8733746 100644 --- a/server/routes/workspaces.js +++ b/server/routes/workspaces.js @@ -1,12 +1,135 @@ import { Router } from 'express'; +import fs from 'fs'; +import path from 'path'; import { v4 as uuidv4 } from 'uuid'; import { authMiddleware, workspaceMiddleware, canInviteMembers, canRemoveMember, canManageWorkspace } from '../middleware/auth.js'; +import { UPLOADS_DIR } from '../paths.js'; const router = Router(); // Helper to get db from app const getDb = (req) => req.app.locals.db; +// ===== Deleting workspaces and accounts ===== +// Data belongs to the workspace: deleting a workspace deletes everything in it, and deleting an +// account deletes the workspaces nobody else uses while the records the user created in shared +// workspaces stay there, handed to the workspace owner (or another remaining member). + +// Runs fn as one transaction. fn must be synchronous: the database file is written on a later +// tick (save() -> setImmediate), and writing it (export()) would end an open transaction. +function inTransaction(db, fn) { + db.exec('BEGIN'); + try { + const result = fn(); + db.exec('COMMIT'); + return result; + } catch (error) { + try { db.exec('ROLLBACK'); } catch { /* nothing to roll back */ } + throw error; + } +} + +const tablesWithColumn = (db, column) => db.prepare(` + SELECT m.name FROM sqlite_master m + WHERE m.type = 'table' AND m.name NOT LIKE 'sqlite_%' + AND EXISTS (SELECT 1 FROM pragma_table_info(m.name) WHERE name = ?) +`).all(column).map(row => row.name); + +// Deletes a workspace and every row that belongs to it. workspace_id on the data tables is a plain +// column without a foreign key, so each table is cleared explicitly (children go with their parents +// through the ON DELETE CASCADE keys). Returns the stored file names to remove from disk afterwards. +function deleteWorkspaceRows(db, workspaceId) { + const storedFiles = db.prepare('SELECT storage_path FROM files WHERE workspace_id = ?').all(workspaceId) + .map(file => file.storage_path); + for (const table of tablesWithColumn(db, 'workspace_id')) { + db.prepare(`DELETE FROM ${table} WHERE workspace_id = ?`).run(workspaceId); + } + db.prepare('DELETE FROM workspaces WHERE id = ?').run(workspaceId); + return storedFiles; +} + +// Removes uploaded files whose database rows are gone (only once the deletion is committed) +function removeStoredFiles(db, storedFiles) { + for (const name of storedFiles) { + if (!name) continue; + try { + if (db.prepare('SELECT 1 FROM files WHERE storage_path = ?').get(name)) continue; + const filePath = path.join(UPLOADS_DIR, path.basename(name)); + if (fs.existsSync(filePath)) fs.unlinkSync(filePath); + } catch (error) { + console.error('Failed to remove uploaded file:', error.message); + } + } +} + +export function deleteWorkspace(db, workspaceId) { + const storedFiles = inTransaction(db, () => deleteWorkspaceRows(db, workspaceId)); + removeStoredFiles(db, storedFiles); +} + +// The member who takes over a departing user's records in a workspace: the owner first, then +// admins, then members, earliest joined first. `scope` is the SQL expression for the workspace id. +const heirOf = (scope) => ` + SELECT wm.user_id FROM workspace_members wm + WHERE wm.workspace_id = ${scope} AND wm.user_id != ? + ORDER BY CASE wm.role WHEN 'owner' THEN 0 WHEN 'admin' THEN 1 ELSE 2 END, wm.joined_at, wm.id + LIMIT 1 +`; + +// Per-user state that leaves with the account instead of being handed over +const PERSONAL_TABLES = ['workspace_members', 'active_timers', 'planned_slots']; + +export function deleteUserAccount(db, userId) { + const storedFiles = inTransaction(db, () => { + const removedFiles = []; + + // 1. Workspaces nobody else is a member of go away with all their data + const soleWorkspaces = db.prepare(` + SELECT w.id FROM workspaces w + WHERE (w.created_by = ? OR EXISTS (SELECT 1 FROM workspace_members wm WHERE wm.workspace_id = w.id AND wm.user_id = ?)) + AND NOT EXISTS (SELECT 1 FROM workspace_members wm WHERE wm.workspace_id = w.id AND wm.user_id != ?) + `).all(userId, userId, userId); + for (const { id } of soleWorkspaces) { + removedFiles.push(...deleteWorkspaceRows(db, id)); + } + + // 2. A shared workspace the user owns gets a new owner (admins first, then members) + const owned = db.prepare(`SELECT workspace_id FROM workspace_members WHERE user_id = ? AND role = 'owner'`).all(userId); + for (const { workspace_id: workspaceId } of owned) { + const heir = db.prepare(heirOf('?')).get(workspaceId, userId); + if (heir) { + db.prepare(`UPDATE workspace_members SET role = 'owner' WHERE workspace_id = ? AND user_id = ?`).run(workspaceId, heir.user_id); + } + } + // workspaces.created_by is ON DELETE CASCADE - without this the shared workspace would be deleted + db.prepare(` + UPDATE workspaces SET created_by = (${heirOf('workspaces.id')}) + WHERE created_by = ? AND EXISTS (SELECT 1 FROM workspace_members wm WHERE wm.workspace_id = workspaces.id AND wm.user_id != ?) + `).run(userId, userId, userId); + + // 3. Everything they created in workspaces that stay is handed over (user_id is ON DELETE CASCADE) + const handOver = tablesWithColumn(db, 'user_id') + .filter(table => !PERSONAL_TABLES.includes(table) && db.columnExists(table, 'workspace_id')) + .map(table => [table, 'user_id']); + handOver.push(['shared_links', 'owner_id'], ['workspace_invites', 'created_by']); + for (const [table, column] of handOver) { + if (!db.columnExists(table, column) || !db.columnExists(table, 'workspace_id')) continue; + // Unique keys here are per workspace (e.g. integrations is UNIQUE(workspace_id, provider)), so + // changing user_id cannot collide; anything that still does fails the whole deletion (rolled back) + db.prepare(` + UPDATE ${table} SET ${column} = (${heirOf(`${table}.workspace_id`)}) + WHERE ${column} = ? AND EXISTS (SELECT 1 FROM workspace_members wm WHERE wm.workspace_id = ${table}.workspace_id AND wm.user_id != ?) + `).run(userId, userId, userId); + } + + // 4. The account itself; personal rows (memberships, running timers, planned slots, passkeys, + // read markers) go with it through ON DELETE CASCADE + db.prepare('DELETE FROM users WHERE id = ?').run(userId); + return removedFiles; + }); + removeStoredFiles(db, storedFiles); +} + // Helper to generate a slug from name const generateSlug = (name) => { return name @@ -150,8 +273,8 @@ router.delete('/:id', authMiddleware, workspaceMiddleware, (req, res) => { return res.status(400).json({ error: 'לא ניתן למחוק את ה-workspace היחיד שלך' }); } - // Delete workspace (CASCADE will handle members, invites, and data) - db.prepare('DELETE FROM workspaces WHERE id = ?').run(req.workspaceId); + // Delete the workspace with all of its data (workspace_id has no foreign key, so no CASCADE) + deleteWorkspace(db, req.workspaceId); res.json({ message: 'Workspace נמחק בהצלחה' }); } catch (error) { diff --git a/server/tests/auth-hardening.test.js b/server/tests/auth-hardening.test.js new file mode 100644 index 0000000..f1be8ee --- /dev/null +++ b/server/tests/auth-hardening.test.js @@ -0,0 +1,204 @@ +// Forced password reset across both sign-in paths (password and passkey), the desktop session file, +// and the sign-in rate limits. +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import request from 'supertest'; +import { v4 as uuidv4 } from 'uuid'; + +const localSession = vi.hoisted(() => ({ saveLocalSession: vi.fn(), clearLocalSession: vi.fn() })); +vi.mock('../utils/localSession.js', () => localSession); + +// The WebAuthn ceremony itself is the library's job; here every assertion is accepted +const webauthn = vi.hoisted(() => ({ + generateAuthenticationOptions: vi.fn(async () => ({ challenge: 'test-challenge' })), + verifyAuthenticationResponse: vi.fn(async () => ({ verified: true, authenticationInfo: { newCounter: 1 } })), + generateRegistrationOptions: vi.fn(), + verifyRegistrationResponse: vi.fn() +})); +vi.mock('@simplewebauthn/server', () => webauthn); + +import { getApp, createUser } from './helpers.js'; +import { adminClient } from './helpers-core.js'; +import { RATE_LIMIT_MESSAGE } from '../middleware/rateLimit.js'; + +const post = async (url, body) => { + const { app } = await getApp(); + return request(app).post(url).send(body); +}; +const login = (email, password) => post('/api/auth/login', { email, password }); + +const flag = async (userId) => { + const admin = await adminClient(); + expect((await admin.post(`/api/admin/users/${userId}/force-password-reset`)).status).toBe(200); +}; + +// Gives the user a passkey and signs in with it (the mocked library accepts the assertion) +const addPasskey = async (userId) => { + const { db } = await getApp(); + const credentialId = `cred-${uuidv4()}`; + db.prepare(` + INSERT INTO passkeys (id, user_id, credential_id, public_key, counter, transports, device_type, backed_up, name) + VALUES (?, ?, ?, ?, 0, '[]', 'singleDevice', 0, 'Test key') + `).run(uuidv4(), userId, credentialId, Buffer.from('public-key').toString('base64url')); + return credentialId; +}; +const passkeyLogin = async (credentialId) => { + const options = await post('/api/passkeys/login/options', {}); + expect(options.status).toBe(200); + return post('/api/passkeys/login/verify', { flowId: options.body.flowId, response: { id: credentialId } }); +}; + +beforeEach(() => { + localSession.saveLocalSession.mockClear(); +}); + +describe('forced password reset - no session before the reset', () => { + it('password login: a reset token, nothing saved for the desktop app; the reset saves the new session', async () => { + const user = await createUser(); + await flag(user.user.id); + localSession.saveLocalSession.mockClear(); + + const res = await login(user.user.email, user.password); + expect(res.body).toEqual({ requiresPasswordReset: true, resetToken: expect.any(String) }); + expect(localSession.saveLocalSession).not.toHaveBeenCalled(); + + const reset = await post('/api/auth/reset-password', { resetToken: res.body.resetToken, newPassword: 'Fresh123!' }); + expect(reset.status).toBe(200); + expect(localSession.saveLocalSession).toHaveBeenCalledWith(reset.body.token, user.workspaceId); + }); + + it('passkey login of a flagged user gets a reset token too, and the reset works without the old password', async () => { + const user = await createUser(); + const credentialId = await addPasskey(user.user.id); + await flag(user.user.id); + localSession.saveLocalSession.mockClear(); + + const res = await passkeyLogin(credentialId); + expect(res.status).toBe(200); + expect(res.body).toEqual({ requiresPasswordReset: true, resetToken: expect.any(String) }); + expect(localSession.saveLocalSession).not.toHaveBeenCalled(); + + const reset = await post('/api/auth/reset-password', { resetToken: res.body.resetToken, newPassword: 'ViaPasskey1' }); + expect(reset.status).toBe(200); + expect(reset.body.user).toMatchObject({ id: user.user.id, has_password: 1 }); + expect((await login(user.user.email, 'ViaPasskey1')).body.requiresPasswordReset).toBe(false); + + // Not flagged any more: the passkey signs in normally again + const again = await passkeyLogin(credentialId); + expect(again.body).toMatchObject({ requiresPasswordReset: false, token: expect.any(String) }); + }); + + it('an unflagged passkey login is a normal session and is handed to the desktop app', async () => { + const user = await createUser(); + const credentialId = await addPasskey(user.user.id); + + const res = await passkeyLogin(credentialId); + expect(res.body).toMatchObject({ requiresPasswordReset: false, user: { id: user.user.id, has_password: 1 } }); + expect(localSession.saveLocalSession).toHaveBeenCalledWith(res.body.token, user.workspaceId); + }); +}); + +describe('password change - the current session stays signed in', () => { + it('hands the fresh token to the desktop app', async () => { + const user = await createUser(); + localSession.saveLocalSession.mockClear(); + + const res = await user.put('/api/auth/profile').send({ password: 'NewPass1!', currentPassword: user.password }); + + expect(res.status).toBe(200); + expect(localSession.saveLocalSession).toHaveBeenCalledWith(res.body.token, user.workspaceId); + }); + + it('does not touch the desktop session for other profile updates', async () => { + const user = await createUser(); + localSession.saveLocalSession.mockClear(); + + await user.put('/api/auth/profile').send({ name: 'Renamed' }); + + expect(localSession.saveLocalSession).not.toHaveBeenCalled(); + }); +}); + +describe('sign-in rate limits', () => { + afterEach(() => { + delete process.env.CLOCKWIZE_RATE_LIMIT; + }); + + it('are off in tests unless switched on', async () => { + const user = await createUser(); + for (let i = 0; i < 12; i += 1) { + expect((await login(user.user.email, 'wrong')).status).toBe(401); + } + expect((await login(user.user.email, user.password)).status).toBe(200); + }); + + it('login: 10 failed attempts per IP + account, then 429 even with the right password', async () => { + process.env.CLOCKWIZE_RATE_LIMIT = 'on'; + const user = await createUser(); + const other = await createUser(); + + for (let i = 0; i < 10; i += 1) { + expect((await login(user.user.email, 'wrong')).status).toBe(401); + } + const blocked = await login(user.user.email.toUpperCase(), user.password); + expect(blocked.status).toBe(429); + expect(blocked.body.error).toBe(RATE_LIMIT_MESSAGE); + expect(Number(blocked.headers['retry-after'])).toBeGreaterThan(0); + + // Another account from the same IP is not affected + expect((await login(other.user.email, other.password)).status).toBe(200); + }); + + it('login: a successful sign-in clears the failed attempts', async () => { + process.env.CLOCKWIZE_RATE_LIMIT = 'on'; + const user = await createUser(); + + for (let i = 0; i < 9; i += 1) await login(user.user.email, 'wrong'); + expect((await login(user.user.email, user.password)).status).toBe(200); + for (let i = 0; i < 9; i += 1) { + expect((await login(user.user.email, 'wrong')).status).toBe(401); + } + expect((await login(user.user.email, user.password)).status).toBe(200); + }); + + it('reset-password: 10 failed attempts per IP + account, then 429; other accounts still reset', async () => { + process.env.CLOCKWIZE_RATE_LIMIT = 'on'; + const user = await createUser(); + const other = await createUser(); + await flag(user.user.id); + await flag(other.user.id); + const { resetToken } = (await login(user.user.email, user.password)).body; + const otherToken = (await login(other.user.email, other.password)).body.resetToken; + + // Rejected attempts for this account (reusing the current password) + for (let i = 0; i < 10; i += 1) { + expect((await post('/api/auth/reset-password', { resetToken, newPassword: user.password })).status).toBe(400); + } + expect((await post('/api/auth/reset-password', { resetToken, newPassword: 'Fresh123!' })).status).toBe(429); + + // Garbage tokens fill their own bucket ... + for (let i = 0; i < 11; i += 1) { + await post('/api/auth/reset-password', { resetToken: 'garbage', newPassword: 'abcd1234' }); + } + expect((await post('/api/auth/reset-password', { resetToken: 'garbage', newPassword: 'abcd1234' })).status).toBe(429); + // ... and lock nobody else out + expect((await post('/api/auth/reset-password', { resetToken: otherToken, newPassword: 'Fresh123!' })).status).toBe(200); + }); + + it('passkey sign-in: failed verifications per IP + credential, and challenges per IP', async () => { + process.env.CLOCKWIZE_RATE_LIMIT = 'on'; + for (let i = 0; i < 10; i += 1) { + expect((await post('/api/passkeys/login/verify', { flowId: 'expired', response: { id: 'unknown-cred' } })).status).toBe(400); + } + expect((await post('/api/passkeys/login/verify', { flowId: 'expired', response: { id: 'unknown-cred' } })).status).toBe(429); + + // Challenges: 30 per IP (this file already used a few while the limiter was off - those don't count) + let status = 200; + let allowed = 0; + while (status === 200 && allowed < 40) { + status = (await post('/api/passkeys/login/options', {})).status; + if (status === 200) allowed += 1; + } + expect(allowed).toBe(30); + expect(status).toBe(429); + }); +}); diff --git a/server/tests/auth-profile-admin.test.js b/server/tests/auth-profile-admin.test.js index 0c88fe1..023f8ab 100644 --- a/server/tests/auth-profile-admin.test.js +++ b/server/tests/auth-profile-admin.test.js @@ -20,7 +20,7 @@ describe('auth - profile', () => { const email = `renamed-${Date.now()}@example.com`; const res = await user.put('/api/auth/profile').send({ name: 'New Name', email, default_hourly_rate: 400 }); expect(res.status).toBe(200); - expect(res.body).toEqual({ id: user.user.id, email, name: 'New Name', default_hourly_rate: 400 }); + expect(res.body).toEqual({ id: user.user.id, email, name: 'New Name', default_hourly_rate: 400, has_password: 1 }); const me = (await user.get('/api/auth/me')).body; expect(me).toMatchObject({ name: 'New Name', email, default_hourly_rate: 400 }); @@ -60,6 +60,51 @@ describe('auth - profile', () => { expect((await login(email, user.password)).status).toBe(401); }); + it('a password change signs out the other sessions and returns a fresh token for this one', async () => { + const { app } = await getApp(); + const user = await createUser(); + const otherSession = (await login(user.user.email, user.password)).body.token; + const me = (token) => request(app).get('/api/auth/me').set('Authorization', `Bearer ${token}`); + expect((await me(otherSession)).status).toBe(200); + + const res = await user.put('/api/auth/profile').send({ password: 'NewPass1!', currentPassword: user.password }); + expect(res.status).toBe(200); + expect(res.body).toMatchObject({ id: user.user.id, has_password: 1, token: expect.any(String) }); + + expect((await user.get('/api/auth/me')).status).toBe(401); + expect((await me(otherSession)).status).toBe(401); + const fresh = await me(res.body.token); + expect(fresh.status).toBe(200); + expect(fresh.body.id).toBe(user.user.id); + // Signing in again works as usual + expect((await me((await login(user.user.email, 'NewPass1!')).body.token)).status).toBe(200); + }); + + it('a profile update without a new password keeps every session and returns no token', async () => { + const user = await createUser(); + const res = await user.put('/api/auth/profile').send({ name: 'Renamed' }); + expect(res.body.token).toBeUndefined(); + expect((await user.get('/api/auth/me')).status).toBe(200); + }); + + it('a passkey-only account sets its first password without a current one', async () => { + const user = await createUser(); + user.db.prepare("UPDATE users SET password = '' WHERE id = ?").run(user.user.id); + expect((await user.get('/api/auth/me')).body.has_password).toBe(0); + + const res = await user.put('/api/auth/profile').send({ password: 'First123!' }); + expect(res.status).toBe(200); + expect(res.body).toMatchObject({ has_password: 1, token: expect.any(String) }); + expect((await login(user.user.email, 'First123!')).status).toBe(200); + + // From now on the account has a password, so changing it needs the current one + const { app } = await getApp(); + const again = await request(app).put('/api/auth/profile') + .set('Authorization', `Bearer ${res.body.token}`) + .send({ password: 'Second123!' }); + expect(again.status).toBe(400); + }); + it('the stored password is a hash, never the plain text', async () => { const user = await createUser(); const row = user.db.prepare('SELECT password FROM users WHERE id = ?').get(user.user.id); @@ -108,53 +153,242 @@ describe('auth - account deletion', () => { expect(count('SELECT COUNT(*) AS n FROM active_timers WHERE user_id = ?', user.user.id)).toBe(0); }); - // clients.user_id is ON DELETE CASCADE (database.js), so when a member deletes their account every - // client they created in a shared workspace - with all projects and everyone's entries - disappears - it.fails("a member deleting their account keeps the clients they created in someone else's workspace", async () => { + // clients.user_id is ON DELETE CASCADE (database.js), so deleting the user row alone would take every + // client they created in a shared workspace - with all projects and everyone's entries - with it + it("a member deleting their account keeps the clients they created in someone else's workspace", async () => { const owner = await createUser(); const member = await addMember(owner, 'member'); const client = (await member.post('/api/clients').send({ name: 'Team client' })).body; + const project = (await member.post('/api/projects').send({ client_id: client.id, name: 'Team project' })).body; + const task = (await member.post('/api/tasks').send({ project_id: project.id, name: 'Team task' })).body; + const memberEntry = await addEntry(member, { project_id: project.id, task_id: task.id, start: '2025-04-01T09:00:00.000Z', seconds: 1800 }); + const ownerEntry = await addEntry(owner, { project_id: project.id, start: '2025-04-02T09:00:00.000Z', seconds: 600 }); + await member.post('/api/timer/start').send({ project_id: project.id }); + // ...and something in their own personal workspace + const personal = (await member.post('/api/clients', member.ownWorkspaceId).send({ name: 'Private client' })).body; + + expect((await member.delete('/api/auth/account').send({ password: member.password })).status).toBe(200); + + expect((await owner.get(`/api/clients/${client.id}`)).status).toBe(200); + expect((await owner.get(`/api/projects/${project.id}`)).status).toBe(200); + expect((await owner.get(`/api/tasks/${task.id}`)).status).toBe(200); + expect((await owner.get('/api/timer/entries')).body.map((e) => e.id).sort()).toEqual([memberEntry.id, ownerEntry.id].sort()); + + // The kept records now belong to the workspace owner; the account's personal state is gone + const { db } = owner; + const n = (sql, ...args) => db.prepare(sql).get(...args).n; + for (const table of ['clients', 'projects', 'tasks', 'time_entries', 'timer_intervals']) { + expect(n(`SELECT COUNT(*) AS n FROM ${table} WHERE user_id = ?`, member.user.id), table).toBe(0); + } + expect(db.prepare('SELECT user_id FROM clients WHERE id = ?').get(client.id).user_id).toBe(owner.user.id); + expect(n('SELECT COUNT(*) AS n FROM active_timers WHERE user_id = ?', member.user.id)).toBe(0); + expect(n('SELECT COUNT(*) AS n FROM workspace_members WHERE user_id = ?', member.user.id)).toBe(0); + expect(n('SELECT COUNT(*) AS n FROM workspaces WHERE id = ?', owner.workspaceId)).toBe(1); + + // Their personal workspace went with the account, data included + expect(n('SELECT COUNT(*) AS n FROM workspaces WHERE id = ?', member.ownWorkspaceId)).toBe(0); + expect(n('SELECT COUNT(*) AS n FROM clients WHERE id = ?', personal.id)).toBe(0); + expect(n('SELECT COUNT(*) AS n FROM clients WHERE workspace_id = ?', member.ownWorkspaceId)).toBe(0); + }); + + // integrations is UNIQUE(workspace_id, provider), so the workspace's connection moves to the owner + // even when the owner has the same provider connected in another workspace + it("keeps the workspace's integration a member connected, even if the owner has that provider elsewhere", async () => { + const owner = await createUser(); + const member = await addMember(owner, 'member'); + const other = (await owner.post('/api/workspaces').send({ name: 'Owner other' })).body; + const insert = owner.db.prepare(`INSERT INTO integrations (id, user_id, workspace_id, provider) VALUES (?, ?, ?, 'morning')`); + const ownersId = `int-${Date.now()}-a`; + const teamId = `int-${Date.now()}-b`; + insert.run(ownersId, owner.user.id, other.id); + insert.run(teamId, member.user.id, owner.workspaceId); + const client = (await member.post('/api/clients').send({ name: 'Still kept' })).body; + expect((await member.delete('/api/auth/account').send({ password: member.password })).status).toBe(200); expect((await owner.get(`/api/clients/${client.id}`)).status).toBe(200); + const rows = owner.db.prepare('SELECT id, user_id, workspace_id FROM integrations WHERE id IN (?, ?) ORDER BY id').all(ownersId, teamId); + expect(rows).toEqual([ + { id: ownersId, user_id: owner.user.id, workspace_id: other.id }, + { id: teamId, user_id: owner.user.id, workspace_id: owner.workspaceId } + ]); + expect((await owner.get('/api/integrations')).body.map((i) => i.provider)).toEqual(['morning']); + }); + + describe('passkey-only accounts (no password stored)', () => { + // A passkey signup stores password '' (routes/passkeys.js) - same state as this + const passkeyOnly = async () => { + const user = await createUser({ email: `Passkey.User-${Date.now()}@Example.com` }); + user.db.prepare("UPDATE users SET password = '' WHERE id = ?").run(user.user.id); + return user; + }; + + it('/me tells the client whether the account has a password', async () => { + const withPassword = await createUser(); + expect((await withPassword.get('/api/auth/me')).body.has_password).toBe(1); + expect((await (await passkeyOnly()).get('/api/auth/me')).body.has_password).toBe(0); + }); + + it('confirm the deletion with their email (exact, case-insensitive) instead of a password', async () => { + const user = await passkeyOnly(); + const { client } = await createClientProjectTask(user); + const email = user.user.email; + + const missing = await user.delete('/api/auth/account').send({}); + expect(missing.status).toBe(400); + expect(missing.body.error).toBeTruthy(); + expect((await user.delete('/api/auth/account').send({ password: 'anything' })).status).toBe(400); + expect((await user.delete('/api/auth/account').send({ confirmEmail: 'someone-else@example.com' })).status).toBe(401); + expect((await user.delete('/api/auth/account').send({ confirmEmail: `${email}x` })).status).toBe(401); + expect((await user.delete('/api/auth/account').send({ confirmEmail: ` ${email}` })).status).toBe(401); + expect((await user.get('/api/auth/me')).status).toBe(200); + + const res = await user.delete('/api/auth/account').send({ confirmEmail: email.toUpperCase() }); + expect(res.status).toBe(200); + expect((await user.get('/api/auth/me')).status).toBe(401); + expect(user.db.prepare('SELECT COUNT(*) AS n FROM clients WHERE id = ?').get(client.id).n).toBe(0); + expect(user.db.prepare('SELECT COUNT(*) AS n FROM workspaces WHERE id = ?').get(user.workspaceId).n).toBe(0); + }); + + it('a password account still needs its password - the email is not enough', async () => { + const user = await createUser(); + const res = await user.delete('/api/auth/account').send({ confirmEmail: user.user.email }); + expect(res.status).toBe(400); + expect((await user.get('/api/auth/me')).status).toBe(200); + expect((await user.delete('/api/auth/account').send({ confirmEmail: user.user.email, password: 'wrong' })).status).toBe(401); + expect((await user.get('/api/auth/me')).status).toBe(200); + }); + }); + + it('an owner deleting their account hands a shared workspace to a remaining member, admins first', async () => { + const owner = await createUser({ name: 'Leaving owner' }); + const { client, project } = await createClientProjectTask(owner); + const member = await addMember(owner, 'member', { name: 'Early member' }); + const admin = await addMember(owner, 'admin', { name: 'Later admin' }); + const memberClient = (await member.post('/api/clients').send({ name: 'Member client' })).body; + const invite = (await owner.post(`/api/workspaces/${owner.workspaceId}/invites`).send({})).body; + await owner.put('/api/addons/catalog').send({ isEnabled: true }); + const ws = owner.workspaceId; + + expect((await owner.delete('/api/auth/account').send({ password: owner.password })).status).toBe(200); + + // The workspace survives (workspaces.created_by is ON DELETE CASCADE) with the admin as its owner + const { db } = admin; + expect(db.prepare('SELECT created_by FROM workspaces WHERE id = ?').get(ws)).toEqual({ created_by: admin.user.id }); + expect((await admin.get('/api/workspaces/current')).body.role).toBe('owner'); + expect((await member.get('/api/workspaces/current')).body.role).toBe('member'); + expect((await admin.get(`/api/workspaces/${ws}/members`)).body.map((m) => m.name).sort()).toEqual(['Early member', 'Later admin']); + + // All of the workspace's data is still there; the departed owner's records now belong to the new owner + expect((await admin.get('/api/clients')).body.map((c) => c.id).sort()).toEqual([client.id, memberClient.id].sort()); + expect((await member.get(`/api/projects/${project.id}`)).status).toBe(200); + expect(db.prepare('SELECT user_id FROM clients WHERE id = ?').get(client.id).user_id).toBe(admin.user.id); + expect(db.prepare('SELECT user_id FROM clients WHERE id = ?').get(memberClient.id).user_id).toBe(member.user.id); + expect(db.prepare('SELECT created_by FROM workspace_invites WHERE id = ?').get(invite.id)).toEqual({ created_by: admin.user.id }); + expect((await admin.get('/api/addons/enabled')).body).toContain('catalog'); + expect((await member.get(`/api/workspaces/invite/${invite.token}`)).body.inviter_name).toBe('Later admin'); }); }); describe('auth - forced password reset', () => { - it('validates the request', async () => { + const flag = async (user) => { + const admin = await adminClient(); + expect((await admin.post(`/api/admin/users/${user.user.id}/force-password-reset`)).status).toBe(200); + }; + const resetPassword = async (body) => { const { app } = await getApp(); + return request(app).post('/api/auth/reset-password').send(body); + }; + + it('validates the request', async () => { const user = await createUser(); - const post = (body) => request(app).post('/api/auth/reset-password').send(body); - expect((await post({ userId: user.user.id, oldPassword: user.password })).status).toBe(400); - expect((await post({ userId: user.user.id, oldPassword: user.password, newPassword: 'abc' })).status).toBe(400); - expect((await post({ userId: 'nope', oldPassword: 'x', newPassword: 'abcd1234' })).status).toBe(404); - expect((await post({ userId: user.user.id, oldPassword: 'wrong', newPassword: 'abcd1234' })).status).toBe(401); - // Not flagged for a reset - expect((await post({ userId: user.user.id, oldPassword: user.password, newPassword: 'abcd1234' })).status).toBe(400); - expect((await login(user.user.email, user.password)).status).toBe(200); + await flag(user); + const { resetToken } = (await login(user.user.email, user.password)).body; + + expect((await resetPassword({ resetToken })).status).toBe(400); + expect((await resetPassword({ newPassword: 'abcd1234' })).status).toBe(400); + expect((await resetPassword({ resetToken, newPassword: 'abc' })).status).toBe(400); + expect((await resetPassword({ resetToken: 'not-a-token', newPassword: 'abcd1234' })).status).toBe(401); + // A normal session token is not a reset token + const other = await createUser(); + expect((await resetPassword({ resetToken: other.token, newPassword: 'abcd1234' })).status).toBe(401); + // The old password again doesn't count as a new one + expect((await resetPassword({ resetToken, newPassword: user.password })).status).toBe(400); + // Nothing changed: the user still has to reset + expect((await login(user.user.email, user.password)).body.requiresPasswordReset).toBe(true); }); - it('admin forces a reset, login flags it, and the reset clears it', async () => { + it('admin forces a reset, login gets a reset token instead of a session, and the reset signs in', async () => { const { app } = await getApp(); - const admin = await adminClient(); const user = await createUser(); - expect((await admin.post(`/api/admin/users/${user.user.id}/force-password-reset`)).status).toBe(200); + await createUser(); // another user's workspace must not leak into the response + await flag(user); const flagged = await login(user.user.email, user.password); expect(flagged.status).toBe(200); - expect(flagged.body.requiresPasswordReset).toBe(true); + expect(flagged.body).toEqual({ requiresPasswordReset: true, resetToken: expect.any(String) }); - const reset = await request(app) - .post('/api/auth/reset-password') - .send({ userId: user.user.id, oldPassword: user.password, newPassword: 'Fresh123!' }); + // The reset token is not a session + const asSession = await request(app).get('/api/auth/me').set('Authorization', `Bearer ${flagged.body.resetToken}`); + expect(asSession.status).toBe(401); + + const reset = await resetPassword({ resetToken: flagged.body.resetToken, newPassword: 'Fresh123!' }); expect(reset.status).toBe(200); - expect(reset.body.token).toBeTruthy(); - expect(reset.body.currentWorkspace.id).toBe(user.workspaceId); + expect(reset.body).toMatchObject({ + requiresPasswordReset: false, + user: { id: user.user.id, email: user.user.email, has_password: 1, is_admin: 0 }, + currentWorkspace: { id: user.workspaceId, role: 'owner', member_count: 1 } + }); + expect(reset.body.workspaces.map((w) => w.id)).toEqual([user.workspaceId]); + const me = await request(app).get('/api/auth/me').set('Authorization', `Bearer ${reset.body.token}`); + expect(me.status).toBe(200); + expect(me.body.id).toBe(user.user.id); + + // Single use: the flag is gone, so the same reset token can't set another password + expect((await resetPassword({ resetToken: flagged.body.resetToken, newPassword: 'Again123!' })).status).toBe(400); const after = await login(user.user.email, 'Fresh123!'); expect(after.status).toBe(200); expect(after.body.requiresPasswordReset).toBe(false); + expect(after.body.token).toBeTruthy(); expect((await login(user.user.email, user.password)).status).toBe(401); }); + + it('flagging a user ends their existing sessions for good', async () => { + const user = await createUser(); + expect((await user.get('/api/auth/me')).status).toBe(200); + + await flag(user); + const blocked = await user.get('/api/clients'); + expect(blocked.status).toBe(401); + expect(blocked.body.error).toBe('אנא התחבר למערכת'); + expect((await user.get('/api/auth/me')).status).toBe(401); + + const { resetToken } = (await login(user.user.email, user.password)).body; + const reset = await resetPassword({ resetToken, newPassword: 'Fresh123!' }); + // Sessions from before the flag stay revoked after the reset; the new one works + expect((await user.get('/api/auth/me')).status).toBe(401); + const { app } = await getApp(); + expect((await request(app).get('/api/auth/me').set('Authorization', `Bearer ${reset.body.token}`)).status).toBe(200); + }); + + it('the admin cannot impersonate a flagged user', async () => { + const admin = await adminClient(); + const user = await createUser(); + await flag(user); + const res = await admin.post(`/api/admin/impersonate/${user.user.id}`); + expect(res.status).toBe(409); + expect(res.body.token).toBeUndefined(); + }); + + it('a suspended user cannot use a reset token', async () => { + const admin = await adminClient(); + const user = await createUser(); + await flag(user); + const { resetToken } = (await login(user.user.email, user.password)).body; + await admin.post(`/api/admin/users/${user.user.id}/toggle-active`); + + expect((await resetPassword({ resetToken, newPassword: 'Fresh123!' })).status).toBe(403); + }); }); describe('admin - access control', () => { @@ -246,6 +480,17 @@ describe('admin - actions', () => { expect((await login(user.user.email, user.password)).status).toBe(401); }); + it('a password set by the admin signs the user out of existing sessions', async () => { + const admin = await adminClient(); + const user = await createUser(); + expect((await user.get('/api/auth/me')).status).toBe(200); + // iatMs is in milliseconds; make sure the revocation time is strictly later than the token + await new Promise((resolve) => setTimeout(resolve, 5)); + expect((await admin.post(`/api/admin/users/${user.user.id}/set-password`).send({ password: 'byAdmin2' })).status).toBe(200); + expect((await user.get('/api/auth/me')).status).toBe(401); + expect((await login(user.user.email, 'byAdmin2')).status).toBe(200); + }); + it('suspends and re-activates an account', async () => { const admin = await adminClient(); const user = await createUser(); @@ -276,4 +521,15 @@ describe('admin - actions', () => { expect(user.db.prepare('SELECT COUNT(*) AS n FROM workspaces WHERE id = ?').get(user.workspaceId).n).toBe(0); expect((await admin.get('/api/admin/users')).body.find((u) => u.id === user.user.id)).toBeUndefined(); }); + + it("deleting a user keeps what they created in someone else's workspace", async () => { + const admin = await adminClient(); + const owner = await createUser(); + const member = await addMember(owner, 'member'); + const client = (await member.post('/api/clients').send({ name: 'Kept client' })).body; + expect((await admin.delete(`/api/admin/users/${member.user.id}`)).status).toBe(200); + expect((await owner.get(`/api/clients/${client.id}`)).status).toBe(200); + expect(owner.db.prepare('SELECT user_id FROM clients WHERE id = ?').get(client.id).user_id).toBe(owner.user.id); + expect(owner.db.prepare('SELECT COUNT(*) AS n FROM workspaces WHERE id = ?').get(member.ownWorkspaceId).n).toBe(0); + }); }); diff --git a/server/tests/catalog-sources-addons.test.js b/server/tests/catalog-sources-addons.test.js index 1a36a58..ebf7a74 100644 --- a/server/tests/catalog-sources-addons.test.js +++ b/server/tests/catalog-sources-addons.test.js @@ -1,8 +1,10 @@ -import { describe, it, expect, beforeAll } from 'vitest'; +import { describe, it, expect, beforeAll, vi } from 'vitest'; import request from 'supertest'; import { v4 as uuidv4 } from 'uuid'; +import initSqlJs from 'sql.js'; import { getApp, createUser, createClientProjectTask } from './helpers.js'; import { addMember, ENCRYPTED_SHAPE, legacyEncrypt } from './helpers-money.js'; +import { adminClient } from './helpers-core.js'; let app; let cryptoUtil; @@ -61,11 +63,19 @@ describe('catalog', () => { expect((await user.get(`/api/catalog/${it_.id}`)).body.name).toBe('Site v2'); }); - // Bug: `price || null` turns a free (price 0) item into "no price" - it.fails('keeps a price of 0', async () => { + // `price || null` used to turn a free (price 0) item into "no price" + it('keeps a price of 0', async () => { const user = await createUser(); const res = await item(user, { name: 'Free consult', price: 0 }); expect(res.body.price).toBe(0); + + const paid = (await item(user, { name: 'Paid', price: 100 })).body; + const updated = await user.put(`/api/catalog/${paid.id}`).send({ name: 'Paid', price: 0 }); + expect(updated.body.price).toBe(0); + + // An empty or missing price is still "no price" + expect((await item(user, { name: 'Empty', price: '' })).body.price).toBeNull(); + expect((await user.put(`/api/catalog/${paid.id}`).send({ name: 'Paid' })).body.price).toBeNull(); }); it('deletes and then 404s', async () => { @@ -114,9 +124,10 @@ describe('client sources', () => { it('lists the workspace\'s own sources plus global ones, never another workspace\'s', async () => { const alice = await createUser(); const bob = await createUser(); + const admin = await adminClient(); const own = (await source(alice, { name: uniq('Own') })).body; const bobs = (await source(bob, { name: uniq('Bobs') })).body; - const global = (await source(bob, { name: uniq('Global'), is_global: true })).body; + const global = (await source(admin, { name: uniq('Global'), is_global: true })).body; expect(global.workspace_id).toBeNull(); const ids = (await alice.get('/api/client-sources')).body.map(s => s.id); @@ -129,7 +140,7 @@ describe('client sources', () => { const alice = await createUser(); const bob = await createUser(); const ref = (await source(alice, { name: uniq('Referral') })).body; - const global = (await source(alice, { name: uniq('Shared Global'), is_global: true })).body; + const global = (await source(await adminClient(), { name: uniq('Shared Global'), is_global: true })).body; const c1 = (await alice.post('/api/clients').send({ name: 'C1', source_id: ref.id, sub_source: 'Moshe' })).body; await alice.post('/api/clients').send({ name: 'C2', source_id: ref.id }); await alice.post('/api/clients').send({ name: 'C3', source_id: global.id }); @@ -148,21 +159,53 @@ describe('client sources', () => { expect(JSON.stringify(res.body)).not.toContain('Bob client'); }); - it('assigns a global source to the current workspace once', async () => { - const alice = await createUser(); + it('a system admin assigns a global source to their current workspace once', async () => { + const admin = await adminClient(); const bob = await createUser(); - const global = (await source(alice, { name: uniq('Claimable'), is_global: true })).body; + const global = (await source(admin, { name: uniq('Claimable'), is_global: true })).body; const bobs = (await source(bob, { name: uniq('Bob private') })).body; - expect((await alice.post(`/api/client-sources/${uuidv4()}/assign-to-workspace`)).status).toBe(404); - const res = await alice.post(`/api/client-sources/${global.id}/assign-to-workspace`); + expect((await admin.post(`/api/client-sources/${uuidv4()}/assign-to-workspace`)).status).toBe(404); + const res = await admin.post(`/api/client-sources/${global.id}/assign-to-workspace`); expect(res.status).toBe(200); - expect(res.body.workspace_id).toBe(alice.workspaceId); - expect((await alice.post(`/api/client-sources/${global.id}/assign-to-workspace`)).status).toBe(400); + expect(res.body.workspace_id).toBe(admin.workspaceId); + expect((await admin.post(`/api/client-sources/${global.id}/assign-to-workspace`)).status).toBe(400); // Another workspace's own source cannot be taken over - expect((await alice.post(`/api/client-sources/${bobs.id}/assign-to-workspace`)).status).toBe(400); - expect(alice.db.prepare('SELECT workspace_id FROM client_sources WHERE id = ?').get(bobs.id).workspace_id).toBe(bob.workspaceId); + expect((await admin.post(`/api/client-sources/${bobs.id}/assign-to-workspace`)).status).toBe(400); + expect(admin.db.prepare('SELECT workspace_id FROM client_sources WHERE id = ?').get(bobs.id).workspace_id).toBe(bob.workspaceId); + }); + + // A global source shows up in every workspace, so a regular user must not be able to create one + // (planting a name in everyone's list) or claim one for their own workspace (taking it from everyone) + it('only a system admin creates global sources; a workspace owner or admin role is not enough', async () => { + const owner = await createUser(); + const wsAdmin = await createUser(); + addMember(owner, wsAdmin, 'admin'); + const name = uniq('Everywhere'); + + for (const [user, ws] of [[owner, owner.workspaceId], [wsAdmin, owner.workspaceId]]) { + const res = await user.post('/api/client-sources', ws).send({ name, is_global: true }); + expect(res.status).toBe(403); + expect(res.body.error).toBeTruthy(); + } + expect(owner.db.prepare('SELECT COUNT(*) AS n FROM client_sources WHERE name = ?').get(name).n).toBe(0); + + // Without the flag the source is created in the user's own workspace + const own = await source(owner, { name }); + expect(own.status).toBe(201); + expect(own.body.workspace_id).toBe(owner.workspaceId); + }); + + it('a regular user cannot claim a global source for their workspace', async () => { + const admin = await adminClient(); + const user = await createUser(); + const global = (await source(admin, { name: uniq('Not yours'), is_global: true })).body; + + const res = await user.post(`/api/client-sources/${global.id}/assign-to-workspace`); + expect(res.status).toBe(403); + expect(user.db.prepare('SELECT workspace_id FROM client_sources WHERE id = ?').get(global.id).workspace_id).toBeNull(); + expect((await user.get('/api/client-sources')).body.map(s => s.id)).toContain(global.id); }); }); @@ -218,14 +261,126 @@ describe('addons', () => { expect((await bob.get('/api/addons/enabled')).body).not.toContain('catalog'); }); - // Bug (database.js, not fixed here): user_addons has UNIQUE(user_id, addon_id), so a member of - // two workspaces gets a 500 the first time they toggle an addon in their second workspace - it.fails('a member of two workspaces can toggle the same addon in both', async () => { + // user_addons used to be UNIQUE(user_id, addon_id), so a member of two workspaces got a 500 the + // first time they toggled an addon in their second workspace + it('a member of two workspaces can toggle the same addon in both', async () => { const owner = await createUser(); const member = await createUser(); addMember(owner, member, 'admin'); expect((await member.put('/api/addons/catalog').send({ isEnabled: true })).status).toBe(200); expect((await member.put('/api/addons/catalog', owner.workspaceId).send({ isEnabled: true })).status).toBe(200); + expect((await member.put('/api/addons', owner.workspaceId).send({ addons: [{ id: 'notes', isEnabled: false }] })).status).toBe(200); + expect((await member.put('/api/addons').send({ addons: [{ id: 'notes', isEnabled: false }] })).status).toBe(200); + + // Each workspace keeps its own state + await member.put('/api/addons/catalog', owner.workspaceId).send({ isEnabled: false }); + expect((await member.get('/api/addons/enabled')).body).toContain('catalog'); + expect((await owner.get('/api/addons/enabled')).body).not.toContain('catalog'); + const rows = member.db.prepare("SELECT workspace_id FROM user_addons WHERE addon_id = 'catalog' AND workspace_id IN (?, ?)").all(owner.workspaceId, member.workspaceId); + expect(rows).toHaveLength(2); + }); + + // Turning an addon on or off changes the workspace for everyone, so it is an owner/admin decision + it('a plain member cannot toggle addons; the owner and a workspace admin can', async () => { + const owner = await createUser(); + const admin = await createUser(); + const member = await createUser(); + addMember(owner, admin, 'admin'); + addMember(owner, member, 'member'); + const ws = owner.workspaceId; + + expect((await member.put('/api/addons/files', ws).send({ isEnabled: false })).status).toBe(403); + expect((await member.put('/api/addons', ws).send({ addons: [{ id: 'files', isEnabled: false }] })).status).toBe(403); + expect(owner.db.prepare('SELECT COUNT(*) AS n FROM user_addons WHERE workspace_id = ?').get(ws).n).toBe(0); + expect((await member.get('/api/addons/enabled', ws)).body).toContain('files'); + + expect((await admin.put('/api/addons/catalog', ws).send({ isEnabled: true })).status).toBe(200); + expect((await owner.put('/api/addons/files').send({ isEnabled: false })).status).toBe(200); + const enabled = (await member.get('/api/addons/enabled', ws)).body; + expect(enabled).toContain('catalog'); + expect(enabled).not.toContain('files'); + + // A member still manages their own personal workspace + expect((await member.put('/api/addons/catalog').send({ isEnabled: true })).status).toBe(200); + }); + + describe('user_addons unique key migration', () => { + const OLD_SCHEMA = `CREATE TABLE user_addons ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + addon_id TEXT NOT NULL, + is_enabled INTEGER DEFAULT 1, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, workspace_id TEXT, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + UNIQUE(user_id, addon_id) + )`; + + // A throwaway database with the pre-migration user_addons table + async function oldDatabase() { + const { Database } = await import('../database.js'); + const SQL = await initSqlJs(); + const db = new Database(new SQL.Database()); + db.pragma('foreign_keys = ON'); + db.exec('CREATE TABLE users (id TEXT PRIMARY KEY)'); + db.exec(OLD_SCHEMA); + for (const id of ['u1', 'u2']) db.prepare('INSERT INTO users (id) VALUES (?)').run(id); + return db; + } + const uniqueKeys = (db) => db.prepare(`SELECT name FROM pragma_index_list('user_addons') WHERE "unique" = 1 AND origin = 'u'`).all() + .map(i => db.prepare('SELECT name FROM pragma_index_info(?) ORDER BY seqno').all(i.name).map(c => c.name)); + const allRows = (db) => db.prepare('SELECT id, user_id, workspace_id, addon_id, is_enabled, created_at, updated_at FROM user_addons ORDER BY id').all(); + + it('rebuilds the table keyed by workspace, keeps every row, and runs only once', async () => { + const { migrateUserAddonsUniqueKey } = await import('../database.js'); + const db = await oldDatabase(); + const insert = db.prepare(`INSERT INTO user_addons (id, user_id, workspace_id, addon_id, is_enabled, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)`); + insert.run('a', 'u1', 'w1', 'catalog', 1, '2025-01-01 10:00:00', '2025-01-02 10:00:00'); + insert.run('b', 'u1', 'w1', 'files', 0, '2025-01-01 10:00:00', '2025-01-01 10:00:00'); + insert.run('c', 'u2', 'w2', 'catalog', 0, '2025-02-01 10:00:00', '2025-02-01 10:00:00'); + insert.run('d', 'u2', null, 'notes', 1, '2025-02-01 10:00:00', '2025-02-01 10:00:00'); + const before = allRows(db); + + expect(migrateUserAddonsUniqueKey(db)).toBe(true); + expect(uniqueKeys(db)).toEqual([['workspace_id', 'addon_id']]); + expect(allRows(db)).toEqual(before); + expect(db.prepare("SELECT COUNT(*) AS n FROM sqlite_master WHERE name = 'user_addons_new'").get().n).toBe(0); + + // Idempotent: a second start changes nothing + expect(migrateUserAddonsUniqueKey(db)).toBe(false); + expect(allRows(db)).toEqual(before); + + // The bug is gone: the same user can now have the addon in a second workspace... + insert.run('e', 'u1', 'w2b', 'catalog', 1, '2025-03-01 10:00:00', '2025-03-01 10:00:00'); + // ...and the foreign key to users still holds + const errors = vi.spyOn(console, 'error').mockImplementation(() => {}); + try { + expect(() => insert.run('f', 'ghost', 'w3', 'catalog', 1, null, null)).toThrow(); + } finally { + errors.mockRestore(); + } + db.prepare('DELETE FROM users WHERE id = ?').run('u2'); + expect(allRows(db).map(r => r.id)).toEqual(['a', 'b', 'e']); + }); + + it('leaves the old table untouched when the rows do not fit the new key', async () => { + const { migrateUserAddonsUniqueKey } = await import('../database.js'); + const db = await oldDatabase(); + // Two users toggled the same addon in one workspace - allowed by the old key, not by the new one + db.prepare(`INSERT INTO user_addons (id, user_id, workspace_id, addon_id) VALUES ('a', 'u1', 'w1', 'catalog')`).run(); + db.prepare(`INSERT INTO user_addons (id, user_id, workspace_id, addon_id) VALUES ('b', 'u2', 'w1', 'catalog')`).run(); + const before = allRows(db); + + const errors = vi.spyOn(console, 'error').mockImplementation(() => {}); + try { + expect(migrateUserAddonsUniqueKey(db)).toBe(false); + } finally { + errors.mockRestore(); + } + expect(uniqueKeys(db)).toEqual([['user_id', 'addon_id']]); + expect(allRows(db)).toEqual(before); + expect(db.prepare("SELECT COUNT(*) AS n FROM sqlite_master WHERE name = 'user_addons_new'").get().n).toBe(0); + }); }); describe('settings', () => { @@ -310,6 +465,32 @@ describe('addons', () => { expect(cryptoUtil.decryptDetailed(row.setting_value, user.workspaceId)).toEqual({ value: 'sk-legacy-abcdefgh', legacy: false }); }); + it('only the owner or a workspace admin changes settings; a member only sees whether a key is configured', async () => { + const owner = await createUser(); + const admin = await createUser(); + const member = await createUser(); + addMember(owner, admin, 'admin'); + addMember(owner, member, 'member'); + const ws = owner.workspaceId; + const { getAddonSetting } = await import('../routes/addons.js'); + await owner.put('/api/addons/ai_assistant/settings').send({ openai_api_key: 'sk-owner-key-1234567890', model: 'gpt-4o' }); + + const denied = await member.put('/api/addons/ai_assistant/settings', ws).send({ openai_api_key: 'sk-member-override-0000' }); + expect(denied.status).toBe(403); + expect(denied.body.error).toBeTruthy(); + expect(await getAddonSetting(owner.db, ws, owner.user.id, 'ai_assistant', 'openai_api_key')).toBe('sk-owner-key-1234567890'); + + const seen = await member.get('/api/addons/ai_assistant/settings', ws); + expect(seen.status).toBe(200); + expect(seen.body).toEqual({ openai_api_key_configured: true, model: 'gpt-4o' }); + expect(JSON.stringify(seen.body)).not.toContain('sk-o'); + + expect((await owner.get('/api/addons/ai_assistant/settings')).body.openai_api_key).toBe('sk-o****7890'); + expect((await admin.get('/api/addons/ai_assistant/settings', ws)).body.openai_api_key).toBe('sk-o****7890'); + expect((await admin.put('/api/addons/ai_assistant/settings', ws).send({ openai_api_key: 'sk-admin-rotated-12345' })).status).toBe(200); + expect(await getAddonSetting(owner.db, ws, owner.user.id, 'ai_assistant', 'openai_api_key')).toBe('sk-admin-rotated-12345'); + }); + it('settings are isolated between workspaces, and a stolen ciphertext does not decrypt elsewhere', async () => { const alice = await createUser(); const bob = await createUser(); @@ -328,6 +509,81 @@ describe('addons', () => { }); }); +describe('integrations unique key migration', () => { + const OLD_SCHEMA = `CREATE TABLE integrations ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + provider TEXT NOT NULL, + api_key TEXT, + api_secret TEXT, + access_token TEXT, + refresh_token TEXT, + expires_at DATETIME, + settings TEXT, + is_active INTEGER DEFAULT 1, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, workspace_id TEXT, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + UNIQUE(user_id, provider) + )`; + const COLUMNS = 'id, user_id, workspace_id, provider, api_key, api_secret, access_token, refresh_token, expires_at, settings, is_active, created_at, updated_at'; + + async function oldDatabase() { + const { Database } = await import('../database.js'); + const SQL = await initSqlJs(); + const db = new Database(new SQL.Database()); + db.pragma('foreign_keys = ON'); + db.exec('CREATE TABLE users (id TEXT PRIMARY KEY)'); + db.exec(OLD_SCHEMA); + for (const id of ['u1', 'u2']) db.prepare('INSERT INTO users (id) VALUES (?)').run(id); + return db; + } + const uniqueKeys = (db) => db.prepare(`SELECT name FROM pragma_index_list('integrations') WHERE "unique" = 1 AND origin = 'u'`).all() + .map(i => db.prepare('SELECT name FROM pragma_index_info(?) ORDER BY seqno').all(i.name).map(c => c.name)); + const allRows = (db) => db.prepare(`SELECT ${COLUMNS} FROM integrations ORDER BY id`).all(); + const quiet = (fn) => { + const errors = vi.spyOn(console, 'error').mockImplementation(() => {}); + try { return fn(); } finally { errors.mockRestore(); } + }; + + it('rebuilds the table keyed by workspace + provider, keeps every row and column, and runs only once', async () => { + const { migrateIntegrationsUniqueKey } = await import('../database.js'); + const db = await oldDatabase(); + const insert = db.prepare(`INSERT INTO integrations (${COLUMNS}) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`); + insert.run('a', 'u1', 'w1', 'morning', 'key', 'secret', 'tok', 'ref', '2026-01-01', '{"x":1}', 1, '2025-01-01 10:00:00', '2025-01-02 10:00:00'); + insert.run('b', 'u2', 'w2', 'morning', null, null, null, null, null, null, 0, '2025-02-01 10:00:00', '2025-02-01 10:00:00'); + const before = allRows(db); + + expect(migrateIntegrationsUniqueKey(db)).toBe(true); + expect(uniqueKeys(db)).toEqual([['workspace_id', 'provider']]); + expect(allRows(db)).toEqual(before); + expect(migrateIntegrationsUniqueKey(db)).toBe(false); + expect(allRows(db)).toEqual(before); + + // The same user can now connect the provider in a second workspace, but a workspace has one connection + insert.run('c', 'u1', 'w3', 'morning', null, null, null, null, null, null, 1, null, null); + expect(() => quiet(() => insert.run('d', 'u2', 'w3', 'morning', null, null, null, null, null, null, 1, null, null))).toThrow(); + expect(() => quiet(() => insert.run('e', 'ghost', 'w4', 'morning', null, null, null, null, null, null, 1, null, null))).toThrow(); + }); + + it('leaves the old table untouched when two connections share a workspace + provider', async () => { + const { migrateIntegrationsUniqueKey } = await import('../database.js'); + const db = await oldDatabase(); + db.prepare(`INSERT INTO integrations (id, user_id, workspace_id, provider) VALUES ('a', 'u1', 'w1', 'morning')`).run(); + db.prepare(`INSERT INTO integrations (id, user_id, workspace_id, provider) VALUES ('b', 'u2', 'w1', 'morning')`).run(); + const before = allRows(db); + expect(quiet(() => migrateIntegrationsUniqueKey(db))).toBe(false); + expect(uniqueKeys(db)).toEqual([['user_id', 'provider']]); + expect(allRows(db)).toEqual(before); + expect(db.prepare("SELECT COUNT(*) AS n FROM sqlite_master WHERE name = 'integrations_new'").get().n).toBe(0); + }); + + it('the app database already uses the new key', async () => { + const { db } = await getApp(); + expect(uniqueKeys(db)).toEqual([['workspace_id', 'provider']]); + }); +}); + describe('ai and integrations (auth only - no network)', () => { it('require a login', async () => { const calls = [ diff --git a/server/tests/credentials-crypto.test.js b/server/tests/credentials-crypto.test.js index ad3b5ea..cde0865 100644 --- a/server/tests/credentials-crypto.test.js +++ b/server/tests/credentials-crypto.test.js @@ -254,14 +254,25 @@ describe('credentials API', () => { expect(after.password).not.toContain('p2'); }); - // Bug: PUT keeps service_name/url when they are omitted, but silently wipes the encrypted - // username/password/notes to NULL (the UI always sends every field, so it is API-only). - it.fails('keeps stored secrets when a partial update omits them', async () => { + it('keeps stored secrets when a partial update omits them', async () => { const user = await createUser(); const created = await user.post('/api/credentials').send({ service_name: 'S', username: 'keep-me', password: 'keep-pass', notes: 'keep-notes' }); + const before = rawCredential(user.db, created.body.id); const res = await user.put(`/api/credentials/${created.body.id}`).send({ url: 'https://new.example.com' }); expect(res.status).toBe(200); - expect(res.body).toMatchObject({ username: 'keep-me', password: 'keep-pass', notes: 'keep-notes' }); + expect(res.body).toMatchObject({ username: 'keep-me', password: 'keep-pass', notes: 'keep-notes', url: 'https://new.example.com' }); + // The stored ciphertext is kept as-is, not re-encrypted + expect(rawCredential(user.db, created.body.id).password).toBe(before.password); + }); + + it('an explicit null or empty string clears only that secret', async () => { + const user = await createUser(); + const created = await user.post('/api/credentials').send({ service_name: 'S', username: 'u', password: 'p', notes: 'n' }); + const noNotes = await user.put(`/api/credentials/${created.body.id}`).send({ notes: null }); + expect(noNotes.body).toMatchObject({ username: 'u', password: 'p', notes: null }); + const noUser = await user.put(`/api/credentials/${created.body.id}`).send({ username: '' }); + expect(noUser.body).toMatchObject({ username: null, password: 'p', notes: null }); + expect(rawCredential(user.db, created.body.id).username).toBeNull(); }); it('deletes and then 404s', async () => { diff --git a/server/tests/expenses-recurring.test.js b/server/tests/expenses-recurring.test.js index 6368d1b..7dbbed8 100644 --- a/server/tests/expenses-recurring.test.js +++ b/server/tests/expenses-recurring.test.js @@ -130,11 +130,13 @@ describe('expenses', () => { expect(cleared.body.category_id).toBeNull(); }); - // Bug: POST rejects amount <= 0, but PUT accepts any amount (negative expenses skew totals) - it.fails('rejects a non-positive amount on update too', async () => { + it('rejects a non-positive amount on update too', async () => { const user = await createUser(); const e = (await expense(user, { amount: 100 })).body; expect((await user.put(`/api/expenses/${e.id}`).send({ amount: -100 })).status).toBe(400); + expect((await user.put(`/api/expenses/${e.id}`).send({ amount: 0 })).status).toBe(400); + expect((await user.put(`/api/expenses/${e.id}`).send({ amount: null })).status).toBe(400); + expect(user.db.prepare('SELECT amount FROM payments WHERE id = ?').get(e.id).amount).toBe(100); }); it('an expense pointing at a category that no longer belongs to the workspace can still be edited', async () => { @@ -336,6 +338,9 @@ describe('recurring payments', () => { describe('upcoming reminders', () => { afterEach(() => vi.useRealTimers()); + // Local calendar day of a Date, the way due_date is reported + const ymd = (d) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; + it('returns active templates due within N days, wrapping into next month', async () => { // Pin "today" to the 25th of the current month (local time) - only Date is faked const now = new Date(); @@ -364,7 +369,7 @@ describe('recurring payments', () => { expect(byId[due25.id].days_until_due).toBe(0); expect(byId[due28.id].days_until_due).toBe(3); - expect(new Date(byId[due28.id].due_date).getDate()).toBe(28); + expect(byId[due28.id].due_date).toBe(ymd(new Date(today.getFullYear(), today.getMonth(), 28))); expect(Boolean(byId[due2.id])).toBe(expected2 <= 7); expect(byId[inactive.id]).toBeUndefined(); expect(res.body).toHaveLength(expected2 <= 7 ? 3 : 2); @@ -372,12 +377,102 @@ describe('recurring payments', () => { const wide = await user.get('/api/recurring/upcoming/reminders?days=31'); const wideById = Object.fromEntries(wide.body.map(r => [r.id, r])); expect(wideById[due2.id].days_until_due).toBe(expected2); - const nextMonth2 = new Date(wideById[due2.id].due_date); - expect(nextMonth2.getDate()).toBe(2); - expect(nextMonth2.getMonth()).toBe((today.getMonth() + 1) % 12); + expect(wideById[due2.id].due_date).toBe(ymd(new Date(today.getFullYear(), today.getMonth() + 1, 2))); expect(wideById[due20.id].days_until_due).toBe(daysInMonth - 25 + 20); }); + it('reports due_date as the intended local calendar day east of UTC (Israel)', async () => { + // Local midnight in Israel is 21:00/22:00 UTC of the previous day, so toISOString() used to + // report the 14th for a payment due on the 15th + const originalTZ = process.env.TZ; + process.env.TZ = 'Asia/Jerusalem'; + try { + vi.useFakeTimers({ toFake: ['Date'] }); + vi.setSystemTime(new Date('2026-10-08T09:00:00.000Z')); // Oct 8, 12:00 in Israel + const user = await createUser(); + const { client } = await createClientProjectTask(user); + const mk = async (day) => (await recurring(user, { client_id: client.id, amount: 10, day_of_month: day })).body; + const due8 = await mk(8); + const due15 = await mk(15); + const due2 = await mk(2); + + const byId = Object.fromEntries((await user.get('/api/recurring/upcoming/reminders?days=31')).body.map(r => [r.id, r])); + expect(byId[due8.id].due_date).toBe('2026-10-08'); + expect(byId[due15.id].due_date).toBe('2026-10-15'); + expect(byId[due2.id].due_date).toBe('2026-11-02'); + expect(byId[due15.id].days_until_due).toBe(7); + + // Wrapping into January of the next year (a fresh login - the old token is not valid in December) + vi.setSystemTime(new Date('2026-12-25T09:00:00.000Z')); + const later = await createUser(); + const laterClient = (await createClientProjectTask(later)).client; + const jan2 = (await recurring(later, { client_id: laterClient.id, amount: 10, day_of_month: 2 })).body; + const jan = (await later.get('/api/recurring/upcoming/reminders?days=31')).body.find(r => r.id === jan2.id); + expect(jan.due_date).toBe('2027-01-02'); + } finally { + if (originalTZ === undefined) delete process.env.TZ; + else process.env.TZ = originalTZ; + } + }); + + it('clamps day 29-31 to the end of short months and counts local calendar days', async () => { + const originalTZ = process.env.TZ; + process.env.TZ = 'Asia/Jerusalem'; + vi.useFakeTimers({ toFake: ['Date'] }); + // Templates due on the 28th-31st, seen at a pinned "now" (a fresh login for every date) + const upcomingAt = async (iso, days) => { + vi.setSystemTime(new Date(iso)); + const user = await createUser(); + const { client } = await createClientProjectTask(user); + const ids = {}; + for (const day of [28, 29, 30, 31]) { + ids[day] = (await recurring(user, { client_id: client.id, amount: 10, day_of_month: day })).body.id; + } + const list = (await user.get(`/api/recurring/upcoming/reminders?days=${days}`)).body; + return (day) => { + const r = list.find(x => x.id === ids[day]); + return r && [r.due_date, r.days_until_due]; + }; + }; + try { + // Feb 25 2026 (28 days): 29/30/31 fall due on Feb 28 in 3 days, not on Mar 1-3 + let at = await upcomingAt('2026-02-25T10:00:00.000Z', 7); + expect(at(28)).toEqual(['2026-02-28', 3]); + expect(at(29)).toEqual(['2026-02-28', 3]); + expect(at(30)).toEqual(['2026-02-28', 3]); + expect(at(31)).toEqual(['2026-02-28', 3]); + + // On Feb 28 itself the "31st" is due today + at = await upcomingAt('2026-02-28T10:00:00.000Z', 0); + expect(at(31)).toEqual(['2026-02-28', 0]); + expect(at(28)).toEqual(['2026-02-28', 0]); + + // Leap year: the 29th exists, the 30th/31st clamp to it + at = await upcomingAt('2028-02-27T10:00:00.000Z', 7); + expect(at(29)).toEqual(['2028-02-29', 2]); + expect(at(31)).toEqual(['2028-02-29', 2]); + + // Jan 31: the 31st is today; the 29th/30th have passed and are next due on Feb 28 + at = await upcomingAt('2026-01-31T10:00:00.000Z', 31); + expect(at(31)).toEqual(['2026-01-31', 0]); + expect(at(30)).toEqual(['2026-02-28', 28]); + expect(at(29)).toEqual(['2026-02-28', 28]); + + // April has 30 days: the 31st is due on Apr 30 + at = await upcomingAt('2026-04-29T09:00:00.000Z', 7); + expect(at(31)).toEqual(['2026-04-30', 1]); + expect(at(30)).toEqual(['2026-04-30', 1]); + + // Across the DST switch (Fri Mar 27, a 23-hour day) days are still whole calendar days + at = await upcomingAt('2026-03-25T10:00:00.000Z', 7); + expect(at(29)).toEqual(['2026-03-29', 4]); + expect(at(31)).toEqual(['2026-03-31', 6]); + } finally { + if (originalTZ === undefined) delete process.env.TZ; + else process.env.TZ = originalTZ; + } + }); + it('defaults to 7 days and treats a non-numeric window as none', async () => { const user = await createUser(); const { client } = await createClientProjectTask(user); diff --git a/server/tests/leads.test.js b/server/tests/leads.test.js index d31e99a..9dc4584 100644 --- a/server/tests/leads.test.js +++ b/server/tests/leads.test.js @@ -3,6 +3,7 @@ import request from 'supertest'; import { v4 as uuidv4 } from 'uuid'; import { getApp, createUser, createClientProjectTask } from './helpers.js'; import { twoWorkspaces, addMember, insertTimeEntry } from './helpers-money.js'; +import { adminClient } from './helpers-core.js'; let app; beforeAll(async () => { ({ app } = await getApp()); }); @@ -505,8 +506,30 @@ describe('leads: cross-workspace isolation', () => { const dump = JSON.stringify((await bob.get(`/api/leads/${lead.id}`)).body); expect(dump).not.toContain('Alice'); - // A global source (no workspace) is fine - const global = (await bob.post('/api/client-sources').send({ name: `Global ${uuidv4()}`, is_global: true })).body; - expect((await bob.post('/api/leads').send({ name: 'ok', source_id: global.id })).status).toBe(201); + // A global source (no workspace, created by a system admin) is fine + const created = await (await adminClient()).post('/api/client-sources').send({ name: `Global ${uuidv4()}`, is_global: true }); + expect(created.status).toBe(201); + const global = created.body; + expect(global.workspace_id).toBeNull(); + const ok = await bob.post('/api/leads').send({ name: 'ok', source_id: global.id }); + expect(ok.status).toBe(201); + expect(ok.body.source_id).toBe(global.id); + expect((await bob.get(`/api/leads/${ok.body.id}`)).body.source_id).toBe(global.id); + }); +}); + +describe('lead reminders keep their day of the month', () => { + it('stores the recurrence day when a monthly lead reminder is created', async () => { + const user = await createUser(); + const lead = await user.post('/api/leads').send({ name: 'Recurring lead' }); + expect(lead.status).toBe(201); + const res = await user.post(`/api/leads/${lead.body.id}/reminders`).send({ + content: 'follow up', + due_date: new Date(2027, 0, 31, 10, 0).toISOString(), + is_recurring: true, + recurrence_interval: 'monthly' + }); + expect(res.status).toBe(201); + expect(res.body.recurrence_day).toBe(31); }); }); diff --git a/server/tests/payments.test.js b/server/tests/payments.test.js index b7a971d..9ca53d0 100644 --- a/server/tests/payments.test.js +++ b/server/tests/payments.test.js @@ -285,12 +285,12 @@ describe('payments: update, status and delete', () => { expect(paidAmount(user, project.id)).toBe(0); }); - // Bug: PUT /:id/status uses COALESCE(?, paid_date), so un-paying a payment keeps its old - // paid_date, while the full PUT /:id clears it. - it.fails('quick status change away from paid clears paid_date', async () => { + it('quick status change away from paid clears paid_date', async () => { const user = await createUser(); const p = (await pay(user, { amount: 90 })).body; + expect(p.paid_date).toBeTruthy(); const res = await user.put(`/api/payments/${p.id}/status`).send({ status: 'pending' }); + expect(res.body.status).toBe('pending'); expect(res.body.paid_date).toBeNull(); }); diff --git a/server/tests/reminders-alerts-slots.test.js b/server/tests/reminders-alerts-slots.test.js index 91f87aa..9a1163f 100644 --- a/server/tests/reminders-alerts-slots.test.js +++ b/server/tests/reminders-alerts-slots.test.js @@ -100,12 +100,133 @@ describe('reminders', () => { } }); - // Bug: monthly recurrence uses Date#setMonth, so the 31st rolls over into the month after next - it.fails('a monthly reminder on Jan 31 moves to the end of February, not into March', async () => { + it('a monthly reminder on Jan 31 moves to the end of February, not into March', async () => { const user = await createUser(); const r = (await remind(user, { content: 'm', due_date: '2026-01-31T09:00:00.000Z', is_recurring: true, recurrence_interval: 'monthly' })).body; const res = await user.put(`/api/reminders/${r.id}`).send({ is_read: true }); expect(res.body.due_date.slice(0, 7)).toBe('2026-02'); + expect(res.body.due_date).toBe('2026-02-28T09:00:00.000Z'); + }); + + it('a yearly reminder on Feb 29 moves to Feb 28 of the next year, not into March', async () => { + const user = await createUser(); + const r = (await remind(user, { content: 'y', due_date: '2028-02-29T09:00:00.000Z', is_recurring: true, recurrence_interval: 'yearly' })).body; + const res = await user.put(`/api/reminders/${r.id}`).send({ is_read: true }); + expect(res.body.due_date).toBe('2029-02-28T09:00:00.000Z'); + }); + + describe('monthly / yearly reminders keep their day of the month', () => { + // Rescheduling works in the server's local time (as the UI does), so compare local calendar dates + const localDate = (iso) => { + const d = new Date(iso); + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; + }; + const localHour = (iso) => new Date(iso).getHours(); + const complete = async (user, id) => { + const res = await user.put(`/api/reminders/${id}`).send({ is_read: true }); + expect(res.status).toBe(200); + return res.body; + }; + const monthly = (user, due_date, extra = {}) => + remind(user, { content: 'rent', due_date, is_recurring: true, recurrence_interval: 'monthly', ...extra }); + + it('a reminder on the 31st goes 31.1 -> 28.2 -> 31.3 -> 30.4 -> 31.5', async () => { + const user = await createUser(); + const r = (await monthly(user, '2026-01-31T09:00:00.000Z')).body; + expect(r.recurrence_day).toBe(new Date('2026-01-31T09:00:00.000Z').getDate()); + const seen = []; + let current = r; + for (let i = 0; i < 4; i++) { + current = await complete(user, r.id); + seen.push(localDate(current.due_date)); + expect(localHour(current.due_date)).toBe(localHour(r.due_date)); + } + expect(seen).toEqual(['2026-02-28', '2026-03-31', '2026-04-30', '2026-05-31']); + expect(current.recurrence_day).toBe(31); + }); + + it('a yearly reminder on Feb 29 is on Feb 28 in common years and back on Feb 29 in the next leap year', async () => { + const user = await createUser(); + const r = (await remind(user, { content: 'y', due_date: '2028-02-29T09:00:00.000Z', is_recurring: true, recurrence_interval: 'yearly' })).body; + const seen = []; + for (let i = 0; i < 4; i++) seen.push(localDate((await complete(user, r.id)).due_date)); + expect(seen).toEqual(['2029-02-28', '2030-02-28', '2031-02-28', '2032-02-29']); + }); + + it('re-saving the form with the same date keeps the anchor; a new date or a non-monthly interval resets it', async () => { + const user = await createUser(); + const r = (await monthly(user, '2026-01-31T09:00:00.000Z')).body; + const feb = await complete(user, r.id); + expect(localDate(feb.due_date)).toBe('2026-02-28'); + + // The edit form sends everything back, including the (clamped) due date + const resaved = await user.put(`/api/reminders/${r.id}`).send({ + content: 'rent (edited)', due_date: feb.due_date, is_recurring: true, recurrence_interval: 'monthly' + }); + expect(resaved.body).toMatchObject({ content: 'rent (edited)', recurrence_day: 31 }); + expect(localDate((await complete(user, r.id)).due_date)).toBe('2026-03-31'); + + // Editing only the text keeps it too + expect((await user.put(`/api/reminders/${r.id}`).send({ content: 'rent again' })).body.recurrence_day).toBe(31); + + // Picking a new date re-anchors on that day + const moved = await user.put(`/api/reminders/${r.id}`).send({ due_date: '2026-04-15T09:00:00.000Z' }); + expect(moved.body.recurrence_day).toBe(new Date('2026-04-15T09:00:00.000Z').getDate()); + expect(localDate((await complete(user, r.id)).due_date)).toBe('2026-05-15'); + + // Weekly reminders and non-recurring ones have no anchor + expect((await user.put(`/api/reminders/${r.id}`).send({ recurrence_interval: 'weekly' })).body.recurrence_day).toBeNull(); + const once = (await remind(user, { content: 'once', due_date: '2026-01-31T09:00:00.000Z' })).body; + expect(once.recurrence_day).toBeNull(); + }); + + it('a monthly reminder without an anchor (older data) anchors on its current day', async () => { + const user = await createUser(); + const lead = (await user.post('/api/leads').send({ name: 'Anchor lead' })).body; + const created = await user.post(`/api/leads/${lead.id}/reminders`).send({ + content: 'follow up', due_date: '2026-01-31T09:00:00.000Z', is_recurring: true, recurrence_interval: 'monthly' + }); + expect(created.status).toBe(201); + // Lead reminders store their anchor now; clear it to simulate a row from before the column existed + user.db.prepare('UPDATE reminders SET recurrence_day = NULL WHERE id = ?').run(created.body.id); + + const feb = await complete(user, created.body.id); + expect(localDate(feb.due_date)).toBe('2026-02-28'); + expect(feb.recurrence_day).toBe(31); + expect(localDate((await complete(user, created.body.id)).due_date)).toBe('2026-03-31'); + }); + + it('the startup backfill anchors existing monthly/yearly reminders once and leaves the rest alone', async () => { + const { backfillReminderRecurrenceDay } = await import('../database.js'); + const user = await createUser(); + const ids = {}; + for (const [key, body] of Object.entries({ + monthly: { content: 'm', due_date: '2026-01-31T09:00:00.000Z', is_recurring: true, recurrence_interval: 'monthly' }, + yearly: { content: 'y', due_date: '2028-02-29T09:00:00.000Z', is_recurring: true, recurrence_interval: 'yearly' }, + weekly: { content: 'w', due_date: '2026-01-31T09:00:00.000Z', is_recurring: true, recurrence_interval: 'weekly' }, + once: { content: 'o', due_date: '2026-01-31T09:00:00.000Z' }, + undated: { content: 'u', is_recurring: true, recurrence_interval: 'monthly' } + })) { + ids[key] = (await remind(user, body)).body.id; + } + // As they were before the column existed + const { db } = user; + const placeholders = Object.values(ids).map(() => '?').join(','); + db.prepare(`UPDATE reminders SET recurrence_day = NULL WHERE id IN (${placeholders})`).run(...Object.values(ids)); + const anchors = () => Object.fromEntries(Object.entries(ids).map(([k, id]) => [k, db.prepare('SELECT recurrence_day FROM reminders WHERE id = ?').get(id).recurrence_day])); + + expect(backfillReminderRecurrenceDay(db)).toBeGreaterThanOrEqual(2); + const expected = { + monthly: new Date('2026-01-31T09:00:00.000Z').getDate(), + yearly: new Date('2028-02-29T09:00:00.000Z').getDate(), + weekly: null, + once: null, + undated: null + }; + expect(anchors()).toEqual(expected); + expect(backfillReminderRecurrenceDay(db)).toBe(0); + expect(anchors()).toEqual(expected); + }); }); it('a reminder whose associated record was deleted can still be edited with its full payload', async () => { @@ -340,21 +461,34 @@ describe('planned slots', () => { expect(res.body).toHaveLength(365); }); - // Bug: an end date before the start date creates nothing but still answers 201 with [] - it.fails('rejects a series whose end date is before its start', async () => { + it('rejects a series whose end date is before its start', async () => { const user = await createUser(); const { client } = await createClientProjectTask(user); const res = await slot(user, { client_id: client.id, date: '2026-06-10', duration: 60, is_recurring: true, recurrence_type: 'weekly', recurrence_end_date: '2026-06-01' }); expect(res.status).toBe(400); + expect(res.body.error).toBeTruthy(); + expect((await user.get('/api/planned-slots')).body).toEqual([]); + + // Ending on the start day is a valid one-slot series + const sameDay = await slot(user, { client_id: client.id, date: '2026-06-10', duration: 60, is_recurring: true, recurrence_type: 'weekly', recurrence_end_date: '2026-06-10' }); + expect(sameDay.status).toBe(201); + expect(sameDay.body.map(s => s.date)).toEqual(['2026-06-10']); }); - // Bug: monthly series use Date#setMonth, so a series starting on the 31st skips/duplicates months - it.fails('a monthly series starting on the 31st has at most one slot per month', async () => { + it('a monthly series starting on the 31st has at most one slot per month', async () => { const user = await createUser(); const { client } = await createClientProjectTask(user); const res = await slot(user, { client_id: client.id, date: '2026-01-31', duration: 60, is_recurring: true, recurrence_type: 'monthly', recurrence_end_date: '2026-06-30' }); const months = res.body.map(s => s.date.slice(0, 7)); expect(new Set(months).size).toBe(months.length); + // Clamped to the end of short months, back to the 31st whenever the month has one + expect(res.body.map(s => s.date)).toEqual(['2026-01-31', '2026-02-28', '2026-03-31', '2026-04-30', '2026-05-31', '2026-06-30']); + + const everyOther = await slot(user, { client_id: client.id, date: '2027-12-31', duration: 60, is_recurring: true, recurrence_type: 'monthly', recurrence_interval: 2, recurrence_end_date: '2028-06-30' }); + expect(everyOther.body.map(s => s.date)).toEqual(['2027-12-31', '2028-02-29', '2028-04-30', '2028-06-30']); + + const yearly = await slot(user, { client_id: client.id, date: '2028-02-29', duration: 60, is_recurring: true, recurrence_type: 'yearly', recurrence_end_date: '2032-03-01' }); + expect(yearly.body.map(s => s.date)).toEqual(['2028-02-29', '2029-02-28', '2030-02-28', '2031-02-28', '2032-02-29']); }); it('updates fields, deletes one slot or a whole series', async () => { diff --git a/server/tests/stats.test.js b/server/tests/stats.test.js index c32095b..c15dd8c 100644 --- a/server/tests/stats.test.js +++ b/server/tests/stats.test.js @@ -1,4 +1,4 @@ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, vi } from 'vitest'; import request from 'supertest'; import { getApp, createUser } from './helpers.js'; import { addEntry, backdateTimer } from './helpers-core.js'; @@ -101,6 +101,111 @@ describe('stats - dashboard', () => { expect(span.body.time.thisMonth).toBe(3600 + 1800 + 14400); }); + // The dashboard sends the user's local calendar days as explicit instants (Israel is UTC+3 in October) + it('uses explicit ISO instants as-is, so local calendar days work east of UTC', async () => { + const user = await createUser(); + const client = (await user.post('/api/clients').send({ name: 'C' })).body; + const project = (await user.post('/api/projects').send({ client_id: client.id, name: 'P' })).body; + // 01:00-01:30 on Oct 8 in Israel = 22:00-22:30 UTC on Oct 7 + await addEntry(user, { project_id: project.id, start: '2026-10-07T22:00:00.000Z', seconds: 1800 }); + // .query() encodes the "+" of an offset (a raw "+" in a URL would arrive as a space) + const range = async (startDate, endDate) => (await user.get('/api/stats/dashboard').query({ startDate, endDate })).body; + + // Oct 8 in Israel, as the UTC ISO of local midnight / end of day + const oct8 = await range('2026-10-07T21:00:00.000Z', '2026-10-08T20:59:59.999Z'); + expect(oct8.time.thisMonth).toBe(1800); + expect(oct8.selectedPeriod).toMatchObject({ + startDate: '2026-10-07T21:00:00.000Z', + endDate: '2026-10-08T20:59:59.999Z', + isCustomRange: true + }); + + // The same day written with an explicit offset + const withOffset = await range('2026-10-08T00:00:00+03:00', '2026-10-08T23:59:59.999+03:00'); + expect(withOffset.time.thisMonth).toBe(1800); + expect(withOffset.selectedPeriod.startDate).toBe('2026-10-07T21:00:00.000Z'); + expect(withOffset.selectedPeriod.endDate).toBe('2026-10-08T20:59:59.999Z'); + + // Oct 7 in Israel ends at 21:00 UTC, before the entry + expect((await range('2026-10-06T21:00:00.000Z', '2026-10-07T20:59:59.999Z')).time.thisMonth).toBe(0); + + // Plain dates still mean whole UTC days (older callers): the entry is on Oct 7 in UTC + expect((await range('2026-10-08', '2026-10-08')).time.thisMonth).toBe(0); + expect((await range('2026-10-07', '2026-10-07')).time.thisMonth).toBe(1800); + }); + + // The dashboard's month view sends the user's local month as instants, next to month/year + it('month mode uses the local month instants when sent, and UTC months for month/year alone', async () => { + const user = await createUser(); + const client = (await user.post('/api/clients').send({ name: 'C' })).body; + const project = (await user.post('/api/projects').send({ client_id: client.id, name: 'P' })).body; + // 01:00-01:30 on Nov 1 in Israel (UTC+2 after the DST switch) = 23:00-23:30 UTC on Oct 31 + await addEntry(user, { project_id: project.id, start: '2026-10-31T23:00:00.000Z', seconds: 1800 }); + const get = async (query) => (await user.get('/api/stats/dashboard').query(query)).body; + + // November in Israel: Nov 1 00:00 (+02:00) to Nov 30 23:59:59.999 (+02:00) + const nov = await get({ month: 10, year: 2026, startDate: '2026-10-31T22:00:00.000Z', endDate: '2026-11-30T21:59:59.999Z' }); + expect(nov.time.thisMonth).toBe(1800); + expect(nov.selectedPeriod).toMatchObject({ + startDate: '2026-10-31T22:00:00.000Z', + endDate: '2026-11-30T21:59:59.999Z', + isCustomRange: false + }); + + // October in Israel starts at +03:00 and ends at +02:00 - the entry is not in it + const oct = await get({ month: 9, year: 2026, startDate: '2026-09-30T21:00:00.000Z', endDate: '2026-10-31T21:59:59.999Z' }); + expect(oct.time.thisMonth).toBe(0); + + // month/year alone (older callers) still means the UTC month: the entry is on Oct 31 in UTC + expect((await get({ month: 10, year: 2026 })).time.thisMonth).toBe(0); + const utcOct = await get({ month: 9, year: 2026 }); + expect(utcOct.time.thisMonth).toBe(1800); + expect(utcOct.selectedPeriod).toMatchObject({ startDate: '2026-10-01T00:00:00.000Z', isCustomRange: false }); + }); + + it('periodIncludesToday follows the period that was sent, not the UTC day', async () => { + vi.useFakeTimers({ toFake: ['Date'] }); + try { + // 01:30 on Oct 8 in Israel - still Oct 7 in UTC + vi.setSystemTime(new Date('2026-10-07T22:30:00.000Z')); + const user = await createUser(); + const get = async (startDate, endDate) => (await user.get('/api/stats/dashboard').query({ startDate, endDate })).body.selectedPeriod; + + expect((await get('2026-10-07T21:00:00.000Z', '2026-10-08T20:59:59.999Z')).periodIncludesToday).toBe(true); // local Oct 8 + expect((await get('2026-10-06T21:00:00.000Z', '2026-10-07T20:59:59.999Z')).periodIncludesToday).toBe(false); // local Oct 7 + expect((await get('2026-10-01T00:00:00+03:00', '2026-10-31T23:59:59.999+02:00')).periodIncludesToday).toBe(true); + // Plain dates are UTC days: now is on Oct 7 in UTC + expect((await get('2026-10-07', '2026-10-07')).periodIncludesToday).toBe(true); + expect((await get('2026-10-08', '2026-10-08')).periodIncludesToday).toBe(false); + } finally { + vi.useRealTimers(); + } + }); + + it('a timer running since before local midnight counts its share for yesterday too', async () => { + vi.useFakeTimers({ toFake: ['Date'] }); + try { + // 08:30 on Oct 8 in Israel; the timer started at 23:30 on Oct 7 (20:30 UTC), 9 hours ago + vi.setSystemTime(new Date('2026-10-08T05:30:00.000Z')); + const user = await createUser(); + const client = (await user.post('/api/clients').send({ name: 'C' })).body; + const project = (await user.post('/api/projects').send({ client_id: client.id, name: 'P' })).body; + const running = (await user.post('/api/timer/start').send({ project_id: project.id })).body; + backdateTimer(user.db, running.id, 9 * 3600); + const get = async (startDate, endDate) => (await user.get('/api/stats/dashboard').query({ startDate, endDate })).body; + + const yesterday = await get('2026-10-06T21:00:00.000Z', '2026-10-07T20:59:59.999Z'); + expect(yesterday.selectedPeriod.periodIncludesToday).toBe(false); + near(yesterday.time.thisMonth, 1800); // 23:30-24:00 + const today = await get('2026-10-07T21:00:00.000Z', '2026-10-08T20:59:59.999Z'); + expect(today.selectedPeriod.periodIncludesToday).toBe(true); + near(today.time.thisMonth, 8.5 * 3600); // 00:00-08:30 + near(today.time.total, 9 * 3600); + } finally { + vi.useRealTimers(); + } + }); + it('attributes a zero-span entry to its start day', async () => { const user = await createUser(); const client = (await user.post('/api/clients').send({ name: 'C' })).body; diff --git a/server/tests/workspaces-isolation.test.js b/server/tests/workspaces-isolation.test.js index 8db7c5f..e194383 100644 --- a/server/tests/workspaces-isolation.test.js +++ b/server/tests/workspaces-isolation.test.js @@ -1,4 +1,6 @@ import { describe, it, expect, beforeAll } from 'vitest'; +import fs from 'fs'; +import path from 'path'; import request from 'supertest'; import { getApp, createUser, createClientProjectTask } from './helpers.js'; import { addEntry, addMember, backdateTimer } from './helpers-core.js'; @@ -375,16 +377,57 @@ describe('workspaces - CRUD', () => { expect((await user.get('/api/workspaces')).body.map((w) => w.id)).toEqual([user.workspaceId]); }); - // workspace_id on clients/projects/... is a plain column without a foreign key, so the comment - // "CASCADE will handle ... data" in DELETE /workspaces/:id is wrong and the rows are orphaned - it.fails('deleting a workspace also deletes its clients, projects and entries', async () => { + // workspace_id on clients/projects/... is a plain column without a foreign key, so deleting only the + // workspaces row (relying on CASCADE) used to leave all of its data orphaned + it('deleting a workspace also deletes its clients, projects and entries', async () => { const user = await createUser(); + const keep = await createClientProjectTask(user); const second = (await user.post('/api/workspaces').send({ name: 'Doomed' })).body; - const c = (await user.post('/api/clients', second.id).send({ name: 'Doomed client' })).body; - await user.post('/api/projects', second.id).send({ client_id: c.id, name: 'Doomed project' }); - expect((await user.delete(`/api/workspaces/${second.id}`, second.id)).status).toBe(200); - expect(user.db.prepare('SELECT COUNT(*) AS n FROM clients WHERE workspace_id = ?').get(second.id).n).toBe(0); - expect(user.db.prepare('SELECT COUNT(*) AS n FROM projects WHERE workspace_id = ?').get(second.id).n).toBe(0); + const ws = second.id; + const c = (await user.post('/api/clients', ws).send({ name: 'Doomed client' })).body; + const p = (await user.post('/api/projects', ws).send({ client_id: c.id, name: 'Doomed project' })).body; + const t = (await user.post('/api/tasks', ws).send({ project_id: p.id, name: 'Doomed task' })).body; + await addEntry({ ...user, post: (url) => user.post(url, ws) }, { project_id: p.id, task_id: t.id, start: '2025-05-01T09:00:00.000Z', seconds: 900 }); + await user.post('/api/timer/start', ws).send({ project_id: p.id }); + await user.post('/api/catalog', ws).send({ name: 'Doomed item', price: 10 }); + await user.put('/api/addons/catalog', ws).send({ isEnabled: true }); + await user.put('/api/addons/ai_assistant/settings', ws).send({ openai_api_key: 'sk-doomed-1234567890' }); + await user.post('/api/client-sources', ws).send({ name: `Doomed source ${Date.now()}` }); + await user.post(`/api/workspaces/${ws}/invites`, ws).send({}); + + // An uploaded file of the workspace, on disk and in the files table + const storedName = `doomed-${Date.now()}.txt`; + fs.mkdirSync(process.env.CLOCKWIZE_UPLOADS_DIR, { recursive: true }); + const storedPath = path.join(process.env.CLOCKWIZE_UPLOADS_DIR, storedName); + fs.writeFileSync(storedPath, 'doomed'); + user.db.prepare(` + INSERT INTO files (id, user_id, workspace_id, client_id, original_name, storage_path) + VALUES (?, ?, ?, ?, 'doomed.txt', ?) + `).run(`file-${Date.now()}`, user.user.id, ws, c.id, storedName); + + const { db } = user; + const workspaceTables = db.prepare(` + SELECT m.name FROM sqlite_master m + WHERE m.type = 'table' AND EXISTS (SELECT 1 FROM pragma_table_info(m.name) WHERE name = 'workspace_id') + `).all().map((r) => r.name); + const rowsIn = (workspaceId) => Object.fromEntries(workspaceTables + .map((table) => [table, db.prepare(`SELECT COUNT(*) AS n FROM ${table} WHERE workspace_id = ?`).get(workspaceId).n]) + .filter(([, n]) => n > 0)); + const otherBefore = rowsIn(user.workspaceId); + expect(Object.keys(rowsIn(ws)).length).toBeGreaterThan(10); + + expect((await user.delete(`/api/workspaces/${ws}`, ws)).status).toBe(200); + + expect(rowsIn(ws)).toEqual({}); + expect(db.prepare('SELECT COUNT(*) AS n FROM workspaces WHERE id = ?').get(ws).n).toBe(0); + for (const [table, id] of [['clients', c.id], ['projects', p.id], ['tasks', t.id]]) { + expect(db.prepare(`SELECT COUNT(*) AS n FROM ${table} WHERE id = ?`).get(id).n, table).toBe(0); + } + expect(fs.existsSync(storedPath)).toBe(false); + + // The user's other workspace is untouched + expect(rowsIn(user.workspaceId)).toEqual(otherBefore); + expect((await user.get(`/api/clients/${keep.client.id}`)).status).toBe(200); }); }); diff --git a/server/utils/authSession.js b/server/utils/authSession.js new file mode 100644 index 0000000..7d01942 --- /dev/null +++ b/server/utils/authSession.js @@ -0,0 +1,45 @@ +import { generateToken, generatePasswordResetToken } from '../middleware/auth.js'; +import { saveLocalSession } from './localSession.js'; + +// The user's workspaces in the order login picks the default one from (oldest membership first) +export const getUserWorkspaces = (db, userId) => db.prepare(` + SELECT w.*, wm.role, + (SELECT COUNT(*) FROM workspace_members WHERE workspace_id = w.id) as member_count + FROM workspaces w + JOIN workspace_members wm ON w.id = wm.workspace_id + WHERE wm.user_id = ? + ORDER BY wm.joined_at ASC +`).all(userId); + +// A signed-in session: a normal token, the user, their workspaces and the default workspace. +// Also hands the session to the desktop / menu-bar app (local session file). +export const buildAuthResponse = (db, user) => { + const token = generateToken(user.id); + const workspaces = getUserWorkspaces(db, user.id); + const currentWorkspace = workspaces[0] || null; + if (currentWorkspace) { + saveLocalSession(token, currentWorkspace.id); + } + return { + user: { + id: user.id, + email: user.email, + name: user.name, + default_hourly_rate: user.default_hourly_rate, + is_admin: user.is_admin || 0, + // 0 for passkey-only accounts (they confirm account deletion with their email, not a password) + has_password: user.password ? 1 : 0 + }, + token, + requiresPasswordReset: false, + workspaces, + currentWorkspace + }; +}; + +// The admin flagged this user for a forced password reset: no session (no token, nothing saved for +// the desktop app) until they set a new password with this short-lived reset token +export const buildPasswordResetRequiredResponse = (user) => ({ + requiresPasswordReset: true, + resetToken: generatePasswordResetToken(user.id) +}); diff --git a/server/utils/recurrence.js b/server/utils/recurrence.js new file mode 100644 index 0000000..f5ef19b --- /dev/null +++ b/server/utils/recurrence.js @@ -0,0 +1,12 @@ +// Monthly/yearly reminders keep the day of the month they were set for (reminders.recurrence_day), +// so 31.1 -> 28.2 -> 31.3 instead of drifting to the 28th after one short month. +// Shared by routes/reminders.js and the lead reminders in routes/leads.js. +export const ANCHORED_INTERVALS = ['monthly', 'yearly']; + +export const dayOfMonth = (value) => { + const date = new Date(value); + return Number.isNaN(date.getTime()) ? null : date.getDate(); +}; + +export const recurrenceDayFor = ({ is_recurring, recurrence_interval, due_date }) => + is_recurring && ANCHORED_INTERVALS.includes(recurrence_interval) && due_date ? dayOfMonth(due_date) : null; From d19f96f2526446f7ddb059830f4960f6d7f425d6 Mon Sep 17 00:00:00 2001 From: Clockwize Date: Thu, 8 Oct 2026 09:53:55 +0300 Subject: [PATCH 3/4] Client: forced reset flow, accessible forms, per-route code splitting - The forced password reset happens before the user enters the app and the new password is masked; prompts support input types - Every form label is linked to its field (useId); e2e uses getByLabel - Pages load per route (React.lazy) with CSS kept in one file in the original order: main chunk 1,026kB -> 412kB, screenshots unchanged - Dashboard stats keep the selected month/range across workspace switches and use local day/month boundaries - Admins can manage workspace settings like on the server; profile supports passkey-only accounts; removed two unused pages - 378 unit tests, 31 e2e tests Co-Authored-By: Claude Opus 5.5 --- client/src/App.jsx | 77 ++- client/src/App.test.jsx | 50 +- client/src/apps/morning/Settings.jsx | 9 +- .../src/components/AIAssistant/PlanCard.jsx | 10 +- client/src/components/ActiveTimer.jsx | 32 +- client/src/components/AlertModal.jsx | 15 +- client/src/components/CatalogModal.jsx | 30 +- client/src/components/ClientModal.jsx | 49 +- client/src/components/ClientModal.test.jsx | 95 ++++ client/src/components/Credentials.jsx | 18 +- client/src/components/Layout.jsx | 4 + client/src/components/LeadConvertModal.jsx | 21 +- client/src/components/LeadModal.jsx | 48 +- client/src/components/Modal/CustomModal.jsx | 11 +- client/src/components/Modal/ModalContext.jsx | 17 +- .../components/Modal/ModalContext.test.jsx | 72 +++ client/src/components/PaymentModal.jsx | 27 +- client/src/components/ProjectModal.jsx | 40 +- client/src/components/ProjectModal.test.jsx | 89 +++ client/src/components/RemindersModal.jsx | 35 +- client/src/components/RouteSuspense.jsx | 23 + client/src/components/ShareModal.jsx | 28 +- client/src/components/SmartPaymentModal.jsx | 15 +- client/src/components/StatsBar.jsx | 40 +- client/src/components/StatsBar.range.test.js | 123 ++++ client/src/components/StatsBar.test.jsx | 11 +- client/src/components/SubtaskModal.jsx | 22 +- client/src/components/TaskModal.jsx | 37 +- client/src/components/TimeEntryModal.jsx | 50 +- client/src/components/TimerConflictModal.jsx | 6 +- client/src/pages/Addons.css | 269 --------- client/src/pages/Addons.jsx | 163 ------ client/src/pages/CatalogPage.jsx | 4 +- client/src/pages/ClientDetail.jsx | 12 +- client/src/pages/Dashboard.jsx | 26 +- client/src/pages/Login.jsx | 133 ++--- client/src/pages/Login.test.jsx | 106 +++- client/src/pages/MarketingFunnels.css | 396 ------------- client/src/pages/MarketingFunnels.jsx | 528 ------------------ client/src/pages/Profile.jsx | 76 ++- client/src/pages/Profile.test.jsx | 155 +++++ client/src/pages/ProjectDetail.jsx | 6 +- client/src/pages/Projects.jsx | 6 +- client/src/pages/Schedule.jsx | 19 +- client/src/pages/SettingsPage.jsx | 40 +- client/src/pages/TaskDetail.jsx | 18 +- client/src/pages/TimeEntries.jsx | 6 +- client/src/pages/WorkspaceSettings.jsx | 12 +- client/src/pages/WorkspaceSettings.test.jsx | 91 +++ client/src/routeStyles.js | 59 ++ client/src/services/api.js | 5 +- client/src/store/useStore.js | 35 +- client/src/store/useStore.test.js | 62 +- client/vite.config.js | 26 +- e2e/auth.spec.js | 65 ++- e2e/core-flow.spec.js | 13 +- e2e/helpers.js | 11 +- 57 files changed, 1683 insertions(+), 1763 deletions(-) create mode 100644 client/src/components/ClientModal.test.jsx create mode 100644 client/src/components/Modal/ModalContext.test.jsx create mode 100644 client/src/components/ProjectModal.test.jsx create mode 100644 client/src/components/RouteSuspense.jsx create mode 100644 client/src/components/StatsBar.range.test.js delete mode 100644 client/src/pages/Addons.css delete mode 100644 client/src/pages/Addons.jsx delete mode 100644 client/src/pages/MarketingFunnels.css delete mode 100644 client/src/pages/MarketingFunnels.jsx create mode 100644 client/src/pages/Profile.test.jsx create mode 100644 client/src/pages/WorkspaceSettings.test.jsx create mode 100644 client/src/routeStyles.js diff --git a/client/src/App.jsx b/client/src/App.jsx index 26c4a27..155b531 100644 --- a/client/src/App.jsx +++ b/client/src/App.jsx @@ -1,41 +1,67 @@ -import { useEffect, useState } from 'react'; +import { lazy, useEffect, useState } from 'react'; import { Routes, Route, Navigate, Link } from 'react-router-dom'; import { authAPI } from './services/api'; import useStore from './store/useStore'; import { ModalProvider } from './components/Modal'; import { TimerSyncProvider } from './components/TimerSyncProvider'; import ErrorBoundary from './components/ErrorBoundary'; +import RouteSuspense from './components/RouteSuspense'; // Layout import Layout from './components/Layout'; import AuthLayout from './components/AuthLayout'; -// Pages +// Pages on the first screen a user sees stay in the main bundle import Login from './pages/Login'; import Register from './pages/Register'; import Dashboard from './pages/Dashboard'; -import ClientDetail from './pages/ClientDetail'; -import Projects from './pages/Projects'; -import ProjectDetail from './pages/ProjectDetail'; -import TaskDetail from './pages/TaskDetail'; -import Tasks from './pages/Tasks'; -import Profile from './pages/Profile'; -import SharedClient from './pages/SharedClient'; -import SharedProject from './pages/SharedProject'; -import SharedAccess from './pages/SharedAccess'; -import SharedWithMe from './pages/SharedWithMe'; -import Reminders from './pages/Reminders'; -import AdminPanel from './pages/AdminPanel'; -import SettingsPage from './pages/SettingsPage'; -import CredentialsPage from './pages/CredentialsPage'; -import LeadsManagement from './pages/LeadsManagement'; -import LeadDetail from './pages/LeadDetail'; -import TimeEntries from './pages/TimeEntries'; -import Payments from './pages/Payments'; -import Schedule from './pages/Schedule'; -import WorkspaceSettings from './pages/WorkspaceSettings'; -import JoinWorkspace from './pages/JoinWorkspace'; -import CatalogPage from './pages/CatalogPage'; + +// The CSS of the lazy pages below, in its original cascade order (see routeStyles.js) +import './routeStyles'; + +// After a new build is deployed, an open tab still asks for the previous build's chunk files, which +// are gone. Reload once to pick up the new build instead of showing the error screen. +const RELOADED_FOR_CHUNK = 'clockwize:reloaded-for-chunk'; +const lazyPage = (load) => lazy(() => load().then( + (module) => { + try { sessionStorage.removeItem(RELOADED_FOR_CHUNK); } catch { /* storage unavailable */ } + return module; + }, + (error) => { + let reloaded = true; + try { + reloaded = sessionStorage.getItem(RELOADED_FOR_CHUNK) === '1'; + if (!reloaded) sessionStorage.setItem(RELOADED_FOR_CHUNK, '1'); + } catch { /* storage unavailable - don't risk a reload loop */ } + if (reloaded) throw error; + window.location.reload(); + return new Promise(() => {}); + } +)); + +// Every other page is split into its own chunk, loaded on first visit +const ClientDetail = lazyPage(() => import('./pages/ClientDetail')); +const Projects = lazyPage(() => import('./pages/Projects')); +const ProjectDetail = lazyPage(() => import('./pages/ProjectDetail')); +const TaskDetail = lazyPage(() => import('./pages/TaskDetail')); +const Tasks = lazyPage(() => import('./pages/Tasks')); +const Profile = lazyPage(() => import('./pages/Profile')); +const SharedClient = lazyPage(() => import('./pages/SharedClient')); +const SharedProject = lazyPage(() => import('./pages/SharedProject')); +const SharedAccess = lazyPage(() => import('./pages/SharedAccess')); +const SharedWithMe = lazyPage(() => import('./pages/SharedWithMe')); +const Reminders = lazyPage(() => import('./pages/Reminders')); +const AdminPanel = lazyPage(() => import('./pages/AdminPanel')); +const SettingsPage = lazyPage(() => import('./pages/SettingsPage')); +const CredentialsPage = lazyPage(() => import('./pages/CredentialsPage')); +const LeadsManagement = lazyPage(() => import('./pages/LeadsManagement')); +const LeadDetail = lazyPage(() => import('./pages/LeadDetail')); +const TimeEntries = lazyPage(() => import('./pages/TimeEntries')); +const Payments = lazyPage(() => import('./pages/Payments')); +const Schedule = lazyPage(() => import('./pages/Schedule')); +const WorkspaceSettings = lazyPage(() => import('./pages/WorkspaceSettings')); +const JoinWorkspace = lazyPage(() => import('./pages/JoinWorkspace')); +const CatalogPage = lazyPage(() => import('./pages/CatalogPage')); // Protected Route const ProtectedRoute = ({ children }) => { @@ -138,6 +164,8 @@ function App() { + {/* Pages inside Layout have their own boundary around the ; this one covers the rest */} + {/* New unified share access (with password/email support) */} } /> @@ -206,6 +234,7 @@ function App() { {/* Catch all */} } /> + diff --git a/client/src/App.test.jsx b/client/src/App.test.jsx index e704bde..2d66111 100644 --- a/client/src/App.test.jsx +++ b/client/src/App.test.jsx @@ -3,11 +3,11 @@ import { render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { MemoryRouter, useLocation } from 'react-router-dom'; -const login = vi.hoisted(() => vi.fn()); +const { login, resetPassword } = vi.hoisted(() => ({ login: vi.fn(), resetPassword: vi.fn() })); vi.mock('./services/api', async (importOriginal) => { const actual = await importOriginal(); - return { ...actual, authAPI: { ...actual.authAPI, login } }; + return { ...actual, authAPI: { ...actual.authAPI, login, resetPassword } }; }); vi.mock('@simplewebauthn/browser', () => ({ startAuthentication: vi.fn(), @@ -43,11 +43,13 @@ const loginThroughApp = async () => { await user.type(await screen.findByPlaceholderText('admin או your@email.com'), 'dana@example.com'); await user.type(screen.getByPlaceholderText('••••••••'), 'old-password'); await user.click(screen.getByRole('button', { name: 'התחבר' })); + return user; }; beforeEach(() => { useStore.setState(initialState, true); login.mockReset(); + resetPassword.mockReset(); // Everything other than login goes through the real api module to a stubbed backend vi.stubGlobal('fetch', vi.fn(async () => ({ ok: true, @@ -69,14 +71,48 @@ describe('App routing around login', () => { expect(screen.queryByPlaceholderText('••••••••')).not.toBeInTheDocument(); }); - // completeAuth() sets isAuthenticated before Login can open the reset prompt, so GuestRoute - // (App.jsx) redirects to "/" and unmounts Login: a user the admin flagged for a forced - // password reset goes straight to the dashboard and is never asked to change it - it.fails('a user flagged for a forced password reset is asked for a new password first', async () => { - login.mockResolvedValue(loginResponse({ requiresPasswordReset: true })); + // The login response of a flagged user is not a session yet: GuestRoute must keep Login (and its + // reset prompt) on screen instead of redirecting to the dashboard + it('a user flagged for a forced password reset is asked for a new password first', async () => { + login.mockResolvedValue({ requiresPasswordReset: true, resetToken: 'reset-jwt' }); await loginThroughApp(); expect(await screen.findByRole('heading', { name: 'נדרש איפוס סיסמה' })).toBeInTheDocument(); + expect(screen.getByLabelText('הזן סיסמה חדשה (לפחות 4 תווים):')).toHaveAttribute('type', 'password'); + expect(screen.getByTestId('path')).toHaveTextContent(/^\/login$/); + expect(useStore.getState().isAuthenticated).toBe(false); + expect(localStorage.getItem('token')).toBeNull(); + }); + + it('cancelling the forced reset keeps the user logged out on /login', async () => { + login.mockResolvedValue({ requiresPasswordReset: true, resetToken: 'reset-jwt' }); + + const user = await loginThroughApp(); + await screen.findByRole('heading', { name: 'נדרש איפוס סיסמה' }); + await user.click(screen.getByRole('button', { name: 'ביטול' })); + + expect(await screen.findByText('חובה לשנות סיסמה כדי להמשיך')).toBeInTheDocument(); + expect(screen.getByTestId('path')).toHaveTextContent(/^\/login$/); + expect(useStore.getState().isAuthenticated).toBe(false); + expect(localStorage.getItem('token')).toBeNull(); + }); + + it('enters the app only after the new password was saved', async () => { + login.mockResolvedValue({ requiresPasswordReset: true, resetToken: 'reset-jwt' }); + resetPassword.mockResolvedValue({ ...loginResponse(), token: 'jwt-after-reset' }); + + const user = await loginThroughApp(); + await user.type(await screen.findByLabelText('הזן סיסמה חדשה (לפחות 4 תווים):'), 'new-password'); + await user.click(screen.getByRole('button', { name: 'אישור' })); + + expect(resetPassword).toHaveBeenCalledWith({ resetToken: 'reset-jwt', newPassword: 'new-password' }); + expect(await screen.findByText('סיסמה שונתה בהצלחה!')).toBeInTheDocument(); + expect(useStore.getState().isAuthenticated).toBe(false); + await user.click(screen.getByRole('button', { name: 'אישור' })); + + await waitFor(() => expect(screen.getByTestId('path')).toHaveTextContent(/^\/$/)); + expect(useStore.getState().isAuthenticated).toBe(true); + expect(localStorage.getItem('token')).toBe('jwt-after-reset'); }); }); diff --git a/client/src/apps/morning/Settings.jsx b/client/src/apps/morning/Settings.jsx index 07f50db..710c433 100644 --- a/client/src/apps/morning/Settings.jsx +++ b/client/src/apps/morning/Settings.jsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useRef } from 'react'; +import { useState, useEffect, useRef, useId } from 'react'; import { Check, AlertCircle, Loader, Search, Settings, LogOut } from 'lucide-react'; import api from '../../services/api'; import '../../pages/Settings.css'; @@ -97,6 +97,7 @@ function SearchableSelect({ options, value, onChange, placeholder }) { } const MorningSettings = ({ integration, onUpdate }) => { + const uid = useId(); const [connecting, setConnecting] = useState(false); const [apiKey, setApiKey] = useState(''); const [apiSecret, setApiSecret] = useState(''); @@ -540,8 +541,9 @@ const MorningSettings = ({ integration, onUpdate }) => {
- + { />
- + {config.fields.map(field => (
- + {field === 'description' || field === 'notes' || field === 'content' ? (