diff --git a/app/__init__.py b/app/__init__.py index 50d0c050..7b2eb502 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -575,6 +575,16 @@ def create_app(config=None): except Exception: pass + @app.before_request + def handle_api_cors_preflight(): + if request.method == "OPTIONS" and request.path.startswith("/api/v1/"): + response = app.response_class(status=204) + response.headers["Access-Control-Allow-Origin"] = request.headers.get("Origin") or "*" + response.headers["Access-Control-Allow-Methods"] = "GET, POST, PUT, PATCH, DELETE, OPTIONS" + response.headers["Access-Control-Allow-Headers"] = "Authorization, Content-Type, Accept, X-Request-ID" + response.headers["Access-Control-Max-Age"] = "600" + return response + # Start timer for Prometheus metrics @app.before_request def prom_start_timer(): @@ -746,6 +756,15 @@ def create_app(config=None): # CSRF cookie/token handling # If CSRF is enabled, ensure CSRF cookie exists for HTML GET responses # If CSRF is disabled, explicitly clear any existing CSRF cookie to avoid confusion + try: + if request.path.startswith("/api/v1/"): + response.headers["Access-Control-Allow-Origin"] = request.headers.get("Origin") or "*" + response.headers["Vary"] = "Origin" + response.headers["Access-Control-Allow-Methods"] = "GET, POST, PUT, PATCH, DELETE, OPTIONS" + response.headers["Access-Control-Allow-Headers"] = "Authorization, Content-Type, Accept, X-Request-ID" + except Exception: + pass + if app.config.get("WTF_CSRF_ENABLED"): try: # Only for safe, HTML page responses diff --git a/app/routes/api_v1.py b/app/routes/api_v1.py index b6c157d1..dbbd6d7b 100644 --- a/app/routes/api_v1.py +++ b/app/routes/api_v1.py @@ -218,11 +218,11 @@ def health_check(): @api_v1_bp.route("/auth/login", methods=["POST"]) @limiter.limit("5 per minute", methods=["POST"]) def auth_login(): - """Login with username and password; returns an API token for mobile/app use. + """Login with username and password; returns an API token for app use. Accepts JSON: { "username": "...", "password": "..." }. Returns 200 with { "token": "tt_..." } or 401 with { "error": "..." }. - The token has scopes for basics: read:projects, read:tasks, read:time_entries, write:time_entries. + Admin users receive admin scope; regular users receive broad read/write API scopes. """ current_app.logger.info( "POST /api/v1/auth/login from %s", @@ -239,12 +239,12 @@ def auth_login(): if not user or not user.check_password(password): return jsonify({"error": "Invalid username or password"}), 401 - scopes = "read:projects,read:tasks,read:time_entries,write:time_entries" + scopes = "admin:all" if user.is_admin else "read:*,write:*" expiry_days = current_app.config.get("API_TOKEN_DEFAULT_EXPIRY_DAYS", 90) api_token, plain_token = ApiToken.create_token( user_id=user.id, - name=f"Mobile app - {user.username}", - description="Token issued via mobile/app login", + name=f"App login - {user.username}", + description="Token issued via desktop/mobile app login", scopes=scopes, expires_days=expiry_days if expiry_days else None, )