diff --git a/backend/src/CCE.Api.Common/Auth/CceJwtAuthRegistration.cs b/backend/src/CCE.Api.Common/Auth/CceJwtAuthRegistration.cs
index f61d0b36..28080699 100644
--- a/backend/src/CCE.Api.Common/Auth/CceJwtAuthRegistration.cs
+++ b/backend/src/CCE.Api.Common/Auth/CceJwtAuthRegistration.cs
@@ -1,8 +1,11 @@
-using System.Text;
+using System.Text;
+using CCE.Api.Common.Results;
using CCE.Application.Identity.Auth.Common;
+using CCE.Application.Messages;
using CCE.Infrastructure.Identity;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.JwtBearer;
+using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.IdentityModel.Tokens;
@@ -65,6 +68,8 @@ public static IServiceCollection AddCceJwtAuth(
};
// SignalR browser WebSocket clients can't set the Authorization header — they pass the JWT
// via ?access_token=. Accept it for hub requests so the hub authenticates over WebSockets.
+ // OnChallenge/OnForbidden write the standard CCE error envelope instead of the default
+ // empty 401/403 body, keeping the WWW-Authenticate header on 401 (RFC 6750).
jwt.Events = new JwtBearerEvents
{
OnMessageReceived = context =>
@@ -76,6 +81,23 @@ public static IServiceCollection AddCceJwtAuth(
context.Token = accessToken;
}
return Task.CompletedTask;
+ },
+ OnChallenge = async context =>
+ {
+ context.HandleResponse();
+ context.Response.Headers.WWWAuthenticate = "Bearer";
+ await EnvelopeWriter.WriteAsync(
+ context.HttpContext,
+ StatusCodes.Status401Unauthorized,
+ MessageKeys.General.UNAUTHORIZED,
+ context.AuthenticateFailure?.Message).ConfigureAwait(false);
+ },
+ OnForbidden = async context =>
+ {
+ await EnvelopeWriter.WriteAsync(
+ context.HttpContext,
+ StatusCodes.Status403Forbidden,
+ MessageKeys.General.FORBIDDEN).ConfigureAwait(false);
}
};
});
diff --git a/backend/src/CCE.Api.Common/Auth/DevAuthEndpoints.cs b/backend/src/CCE.Api.Common/Auth/DevAuthEndpoints.cs
index fdd71c3e..c8fad951 100644
--- a/backend/src/CCE.Api.Common/Auth/DevAuthEndpoints.cs
+++ b/backend/src/CCE.Api.Common/Auth/DevAuthEndpoints.cs
@@ -3,6 +3,7 @@
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
+using HttpResults = Microsoft.AspNetCore.Http.Results;
namespace CCE.Api.Common.Auth;
@@ -28,7 +29,7 @@ public static IEndpointRouteBuilder MapDevAuthEndpoints(this IEndpointRouteBuild
var roleValue = role ?? "cce-admin";
if (!DevAuthHandler.RoleToUserId.ContainsKey(roleValue))
{
- return Results.BadRequest(new
+ return HttpResults.BadRequest(new
{
error = $"Unknown dev role '{roleValue}'.",
validRoles = DevAuthHandler.RoleToUserId.Keys,
@@ -47,15 +48,15 @@ public static IEndpointRouteBuilder MapDevAuthEndpoints(this IEndpointRouteBuild
// Redirect to returnUrl if relative + safe; else home.
if (!string.IsNullOrEmpty(returnUrl) && returnUrl.StartsWith('/'))
{
- return Results.Redirect(returnUrl);
+ return HttpResults.Redirect(returnUrl);
}
- return Results.Redirect("/");
+ return HttpResults.Redirect("/");
}).AllowAnonymous().WithName("DevSignIn");
dev.MapPost("/sign-out", (HttpContext ctx) =>
{
ctx.Response.Cookies.Delete(DevAuthHandler.DevCookieName);
- return Results.Ok(new { signedOut = true });
+ return HttpResults.Ok(new { signedOut = true });
}).AllowAnonymous().WithName("DevSignOut");
dev.MapGet("/whoami", (HttpContext ctx) =>
@@ -63,7 +64,7 @@ public static IEndpointRouteBuilder MapDevAuthEndpoints(this IEndpointRouteBuild
var name = ctx.User.Identity?.Name ?? "(anonymous)";
var roles = ctx.User.FindAll("roles").Select(c => c.Value).ToArray();
var sub = ctx.User.FindFirst("sub")?.Value ?? "(none)";
- return Results.Ok(new { name, sub, roles });
+ return HttpResults.Ok(new { name, sub, roles });
}).AllowAnonymous().WithName("DevWhoAmI");
// ─── Frontend-compat shims at /auth/* ───────────────────────────
@@ -82,13 +83,13 @@ public static IEndpointRouteBuilder MapDevAuthEndpoints(this IEndpointRouteBuild
}
var rurl = string.IsNullOrEmpty(returnUrl) || !returnUrl.StartsWith('/') ? "/" : returnUrl;
var target = $"/dev/sign-in?role={Uri.EscapeDataString(defaultRole)}&returnUrl={Uri.EscapeDataString(rurl)}";
- return Results.Redirect(target);
+ return HttpResults.Redirect(target);
}).AllowAnonymous().WithName("AuthLoginShim");
app.MapPost("/auth/logout", (HttpContext ctx) =>
{
ctx.Response.Cookies.Delete(DevAuthHandler.DevCookieName);
- return Results.Ok(new { signedOut = true });
+ return HttpResults.Ok(new { signedOut = true });
}).AllowAnonymous().WithName("AuthLogoutShim");
return app;
diff --git a/backend/src/CCE.Api.Common/CCE.Api.Common.csproj b/backend/src/CCE.Api.Common/CCE.Api.Common.csproj
index 05aba267..859a77a7 100644
--- a/backend/src/CCE.Api.Common/CCE.Api.Common.csproj
+++ b/backend/src/CCE.Api.Common/CCE.Api.Common.csproj
@@ -28,7 +28,6 @@
-
diff --git a/backend/src/CCE.Api.Common/Extensions/ResponseExtensions.cs b/backend/src/CCE.Api.Common/Extensions/ResponseExtensions.cs
index 1d51aedc..69f3f8fe 100644
--- a/backend/src/CCE.Api.Common/Extensions/ResponseExtensions.cs
+++ b/backend/src/CCE.Api.Common/Extensions/ResponseExtensions.cs
@@ -1,4 +1,4 @@
-using CCE.Api.Common.HttpResults;
+using CCE.Api.Common.Results;
using CCE.Application.Common;
using CCE.Domain.Common;
using Microsoft.AspNetCore.Http;
@@ -11,14 +11,14 @@ public static class ResponseExtensions
/// Maps a to a typed with correct HTTP status,
/// injecting traceId and timestamp, and registering Swashbuckle metadata.
///
- public static OkApiResponse ToHttpResult(this Response response)
+ public static OkApiResult ToHttpResult(this Response response)
=> new(response);
/// Shorthand for 201 Created with Swashbuckle metadata.
- public static CreatedApiResponse ToCreatedHttpResult(this Response response)
+ public static CreatedApiResult ToCreatedHttpResult(this Response response)
=> new(response);
/// Shorthand for 204 No Content with Swashbuckle metadata.
- public static NoContentApiResponse ToNoContentHttpResult(this Response response)
+ public static NoContentApiResult ToNoContentHttpResult(this Response response)
=> new(response);
}
diff --git a/backend/src/CCE.Api.Common/Extensions/ResultExtensions.cs b/backend/src/CCE.Api.Common/Extensions/ResultExtensions.cs
deleted file mode 100644
index d6a98b05..00000000
--- a/backend/src/CCE.Api.Common/Extensions/ResultExtensions.cs
+++ /dev/null
@@ -1,24 +0,0 @@
-using CCE.Api.Common.HttpResults;
-using CCE.Application.Common;
-using CCE.Domain.Common;
-using Microsoft.AspNetCore.Http;
-
-namespace CCE.Api.Common.Extensions;
-
-public static class ResultExtensions
-{
- ///
- /// Maps a to a typed with the correct HTTP status
- /// and registers Swashbuckle metadata.
- ///
- public static OkApiResult ToHttpResult(this Result result)
- => new(result);
-
- /// Shorthand for 201 Created with Swashbuckle metadata.
- public static CreatedApiResult ToCreatedHttpResult(this Result result)
- => new(result);
-
- /// Shorthand for 204 No Content (void commands) with Swashbuckle metadata.
- public static NoContentApiResult ToNoContentHttpResult(this Result result)
- => new(result);
-}
diff --git a/backend/src/CCE.Api.Common/Localization/Resources.yaml b/backend/src/CCE.Api.Common/Localization/Resources.yaml
index 29e1ecfa..f5c1af45 100644
--- a/backend/src/CCE.Api.Common/Localization/Resources.yaml
+++ b/backend/src/CCE.Api.Common/Localization/Resources.yaml
@@ -125,8 +125,8 @@ INTERNAL_ERROR:
en: "An unexpected error occurred"
BAD_REQUEST:
- ar: "عذرًا، البيانات المدخلة غير صحيحة"
- en: "Sorry, the entered data is invalid"
+ ar: "تعذر معالجة الطلب"
+ en: "The request could not be processed"
RESOURCE_NOT_FOUND_GENERIC:
ar: "المورد غير موجود"
@@ -140,6 +140,14 @@ DUPLICATE_VALUE:
ar: "القيمة موجودة بالفعل"
en: "Value already exists"
+RATE_LIMIT_EXCEEDED:
+ ar: "تم تجاوز الحد المسموح من الطلبات. يرجى المحاولة مرة أخرى لاحقًا"
+ en: "Too many requests. Please try again later."
+
+BUSINESS_RULE_VIOLATION:
+ ar: "تعذر إتمام العملية بسبب مخالفة قاعدة العمل"
+ en: "The operation could not be completed due to a business rule violation."
+
SCENARIO_NOT_FOUND:
ar: "السيناريو غير موجود"
en: "Scenario not found"
@@ -257,6 +265,18 @@ NOTIFICATION_TEMPLATE_UPDATED:
ar: "تم تحديث قالب الإشعار بنجاح"
en: "Notification template updated successfully"
+DEVICE_TOKEN_REGISTERED:
+ ar: "تم تسجيل الجهاز بنجاح"
+ en: "Device registered successfully"
+
+DEVICE_TOKEN_DELETED:
+ ar: "تم إلغاء تسجيل الجهاز بنجاح"
+ en: "Device unregistered successfully"
+
+DEVICE_TOKEN_NOT_FOUND:
+ ar: "رمز الجهاز غير موجود"
+ en: "Device token not found"
+
EVALUATION_NOT_FOUND:
ar: "التقييم غير موجود"
en: "Evaluation not found"
@@ -658,6 +678,14 @@ PASSWORD_NUMBER:
ar: "يجب أن تحتوي كلمة المرور على رقم على الأقل"
en: "Password must contain at least one number"
+PASSWORD_POLICY:
+ ar: "كلمة المرور يجب أن تتراوح بين 12 و20 حرفاً وتحتوي على أحرف كبيرة وصغيرة وأرقام"
+ en: "Password must be 12-20 characters and contain uppercase, lowercase, and numbers"
+
+PASSWORDS_MUST_MATCH:
+ ar: "كلمتا المرور غير متطابقتين"
+ en: "Passwords do not match"
+
# ─── General Errors ───
EXTERNAL_API_ERROR:
@@ -725,3 +753,69 @@ INTERACTIVE_MAP_NODE_DELETED:
ar: "تم حذف العنصر من الخريطة التفاعلية بنجاح"
en: "Interactive map node deleted successfully"
+# ─── Identity / Permissions / Claims ───
+
+ROLE_NOT_FOUND:
+ ar: "الدور غير موجود"
+ en: "Role not found"
+
+INVALID_RESET_TOKEN:
+ ar: "رمز إعادة تعيين كلمة المرور غير صالح أو منتهي الصلاحية"
+ en: "Password reset token is invalid or expired"
+
+EMAIL_CHANGE_FAILED:
+ ar: "حدث خطأ أثناء تغيير البريد الإلكتروني"
+ en: "An error occurred while changing the email address"
+
+AD_LOGIN_SUCCESS:
+ ar: "تم تسجيل الدخول عبر Active Directory بنجاح"
+ en: "Logged in via Active Directory successfully"
+
+PERMISSIONS_GRANTED:
+ ar: "تم منح الصلاحيات للدور بنجاح"
+ en: "Permissions granted to role successfully"
+
+PERMISSIONS_REVOKED:
+ ar: "تم سحب الصلاحيات من الدور بنجاح"
+ en: "Permissions revoked from role successfully"
+
+PERMISSIONS_UPDATED:
+ ar: "تم تحديث صلاحيات الدور بنجاح"
+ en: "Role permissions updated successfully"
+
+CLAIMS_GRANTED:
+ ar: "تم منح المطالبات للمستخدم بنجاح"
+ en: "Claims granted to user successfully"
+
+CLAIMS_REVOKED:
+ ar: "تم سحب المطالبات من المستخدم بنجاح"
+ en: "Claims revoked from user successfully"
+
+USER_CLAIMS_UPDATED:
+ ar: "تم تحديث مطالبات المستخدم بنجاح"
+ en: "User claims updated successfully"
+
+# ─── Verification ───
+
+OTP_UNAUTHORIZED:
+ ar: "غير مصرح لك بالتحقق من هذا الرمز"
+ en: "You are not authorized to verify this code"
+
+# ─── Content / Community / Platform Settings ───
+
+TAG_NOT_FOUND:
+ ar: "الوسم غير موجود"
+ en: "Tag not found"
+
+NEWSLETTER_SUBSCRIBED:
+ ar: "تم الاشتراك في النشرة البريدية بنجاح"
+ en: "Subscribed to newsletter successfully"
+
+TOPICS_LISTED:
+ ar: "تم جلب الموضوعات بنجاح"
+ en: "Topics listed successfully"
+
+SECTION_REORDERED:
+ ar: "تم إعادة ترتيب الأقسام بنجاح"
+ en: "Sections reordered successfully"
+
diff --git a/backend/src/CCE.Api.Common/Middleware/ExceptionHandlingMiddleware.cs b/backend/src/CCE.Api.Common/Middleware/ExceptionHandlingMiddleware.cs
index 354e17b3..2dec20f2 100644
--- a/backend/src/CCE.Api.Common/Middleware/ExceptionHandlingMiddleware.cs
+++ b/backend/src/CCE.Api.Common/Middleware/ExceptionHandlingMiddleware.cs
@@ -1,14 +1,13 @@
-using CCE.Application.Common;
+using CCE.Api.Common.Results;
using CCE.Application.Localization;
using CCE.Application.Messages;
using CCE.Domain.Common;
using FluentValidation;
using Microsoft.AspNetCore.Http;
+using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
-using System.Diagnostics;
using System.Text.Json;
-using System.Text.Json.Serialization;
namespace CCE.Api.Common.Middleware;
@@ -29,109 +28,72 @@ public async Task InvokeAsync(HttpContext context)
{
await _next(context).ConfigureAwait(false);
}
+ catch (OperationCanceledException)
+ {
+ // Client disconnected — not a server error.
+ }
catch (ValidationException ex)
{
await WriteValidationResultAsync(context, ex).ConfigureAwait(false);
}
catch (ConcurrencyException ex)
{
- await WriteErrorAsync(context, StatusCodes.Status409Conflict,
- "CONCURRENCY_CONFLICT", MessageType.Conflict, ex.Message).ConfigureAwait(false);
+ await EnvelopeWriter.WriteAsync(context, StatusCodes.Status409Conflict,
+ MessageKeys.General.CONCURRENCY_CONFLICT, ex.Message).ConfigureAwait(false);
}
catch (DuplicateException ex)
{
- await WriteErrorAsync(context, StatusCodes.Status409Conflict,
- "DUPLICATE_VALUE", MessageType.Conflict, ex.Message).ConfigureAwait(false);
+ await EnvelopeWriter.WriteAsync(context, StatusCodes.Status409Conflict,
+ MessageKeys.General.DUPLICATE_VALUE, ex.Message).ConfigureAwait(false);
}
catch (DomainException ex)
{
- await WriteErrorAsync(context, StatusCodes.Status400BadRequest,
- "BAD_REQUEST", MessageType.BusinessRule, ex.Message).ConfigureAwait(false);
+ await EnvelopeWriter.WriteAsync(context, StatusCodes.Status422UnprocessableEntity,
+ MessageKeys.General.BUSINESS_RULE_VIOLATION, ex.Message).ConfigureAwait(false);
+ }
+ catch (UnauthorizedAccessException ex)
+ {
+ _logger.LogInformation(ex, "Unauthorized access");
+ await EnvelopeWriter.WriteAsync(context, StatusCodes.Status401Unauthorized,
+ MessageKeys.General.UNAUTHORIZED).ConfigureAwait(false);
}
catch (System.Collections.Generic.KeyNotFoundException ex)
{
- await WriteErrorAsync(context, StatusCodes.Status404NotFound,
- "RESOURCE_NOT_FOUND_GENERIC", MessageType.NotFound, ex.Message).ConfigureAwait(false);
+ await EnvelopeWriter.WriteAsync(context, StatusCodes.Status404NotFound,
+ MessageKeys.General.RESOURCE_NOT_FOUND_GENERIC, ex.Message).ConfigureAwait(false);
}
catch (Exception ex)
{
_logger.LogError(ex, "Unhandled exception");
- await WriteErrorAsync(context, StatusCodes.Status500InternalServerError,
- "INTERNAL_ERROR", MessageType.Internal, null).ConfigureAwait(false);
+ await EnvelopeWriter.WriteAsync(context, StatusCodes.Status500InternalServerError,
+ MessageKeys.General.INTERNAL_ERROR).ConfigureAwait(false);
}
}
- private static async Task WriteErrorAsync(
- HttpContext ctx, int statusCode, string domainKey, MessageType type, string? fallbackMessage)
- {
- var l = ctx.RequestServices.GetService();
- var msg = l?.GetString(domainKey) ?? fallbackMessage ?? "خطأ";
- var code = SystemCodeMap.ToSystemCode(domainKey);
-
- var envelope = new
- {
- success = false,
- code,
- message = msg,
- data = (object?)null,
- errors = Array.Empty