diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7815743..078befd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,14 +4,17 @@ on: push: branches: - main + - develop pull_request: branches: - main + - develop permissions: contents: read security-events: write packages: write + actions: read # Run Node20-based actions on Node24 (GitHub force-migrates 2026-06-16). Silences the # deprecation warnings and future-proofs the workflow. @@ -31,13 +34,10 @@ jobs: - name: Run TruffleHog (verified secrets only) uses: trufflesecurity/trufflehog@main with: - path: ./ - # On a PR: scan the PR diff. On a push/merge to main: scan the pushed range - # (before..after). The previous default-branch/HEAD config made base==head on - # main pushes, so TruffleHog scanned nothing. - base: ${{ github.event.pull_request.base.sha || github.event.before }} - head: ${{ github.event.pull_request.head.sha || github.sha }} - extra_args: --results=verified,unknown + # The action mounts the workspace at /tmp inside the scanner container. + # .trufflehog-exclude.txt skips deploy docs whose placeholder connection + # strings (Password=__DB__) false-positive the SQLServer detector. + extra_args: --results=verified,unknown --exclude-paths=/tmp/.trufflehog-exclude.txt quality: name: Lint, Test, Build @@ -252,14 +252,13 @@ jobs: ignore-unfixed: true exit-code: "1" - - name: Upload Trivy SARIF report - # Code-scanning SARIF upload requires GitHub Advanced Security (not available on - # private repos by default). Keep it non-fatal — the Trivy image scans above are - # the real gate; this step only feeds the Security tab when GHAS is enabled. - continue-on-error: true - uses: github/codeql-action/upload-sarif@v3 + - name: Upload Trivy SARIF report (artifact) + if: always() + uses: actions/upload-artifact@v4 with: - sarif_file: trivy-results.sarif + name: trivy-sarif + path: trivy-results.sarif + retention-days: 30 build-images: name: Build & Push Docker Images diff --git a/.trufflehog-exclude.txt b/.trufflehog-exclude.txt new file mode 100644 index 0000000..6eba4c8 --- /dev/null +++ b/.trufflehog-exclude.txt @@ -0,0 +1,6 @@ +# Paths excluded from TruffleHog secret scanning (regex per line). +# These are deploy DOCUMENTATION containing placeholder connection strings +# (Password=$DB / Password=__DB__) that pattern-match the SQLServer detector +# but contain no real credentials. Never put real values in these files. +devops/k8s/project-01/README\.md +devops/k8s/project-01/secret\.example\.yaml diff --git a/backend/src/Loopless.Api/Endpoints/FileEndpoints.cs b/backend/src/Loopless.Api/Endpoints/FileEndpoints.cs index 5b69308..1a91dee 100644 --- a/backend/src/Loopless.Api/Endpoints/FileEndpoints.cs +++ b/backend/src/Loopless.Api/Endpoints/FileEndpoints.cs @@ -1,4 +1,5 @@ using Loopless.Application.DTOs; +using Loopless.Application.Features.Files.GetFileDownload; using Loopless.Application.Features.Files.GetProjectFiles; using Loopless.Application.Features.Files.Upload; using MediatR; @@ -59,6 +60,25 @@ public static IEndpointRouteBuilder MapFileEndpoints(this IEndpointRouteBuilder .Produces(StatusCodes.Status404NotFound) .ProducesValidationProblem(); + // Streams the file through the API so the browser downloads it directly, + // instead of redirecting to a presigned storage URL whose host (e.g. + // minio:9000) is unreachable outside the Docker network. + group.MapGet("/{projectId:guid}/files/{fileId:guid}/download", async ( + [FromRoute] Guid projectId, + [FromRoute] Guid fileId, + ISender sender, + CancellationToken ct) => + { + var file = await sender.Send(new GetFileDownloadQuery(projectId, fileId), ct); + return Results.File(file.Content, file.ContentType, file.FileName); + }) + .WithName("DownloadProjectFile") + .Produces(StatusCodes.Status200OK) + .Produces(StatusCodes.Status401Unauthorized) + .Produces(StatusCodes.Status403Forbidden) + .Produces(StatusCodes.Status404NotFound) + .ProducesValidationProblem(); + return app; } diff --git a/backend/src/Loopless.Api/Endpoints/MessageEndpoints.cs b/backend/src/Loopless.Api/Endpoints/MessageEndpoints.cs index fe80d0f..d4e6b39 100644 --- a/backend/src/Loopless.Api/Endpoints/MessageEndpoints.cs +++ b/backend/src/Loopless.Api/Endpoints/MessageEndpoints.cs @@ -3,6 +3,7 @@ using Loopless.Application.Features.Messages.GetConversations; using Loopless.Application.Features.Messages.GetHistory; using Loopless.Application.Features.Messages.MarkAsRead; +using Loopless.Application.Features.Messages.MarkConversationRead; using Loopless.Application.Features.Messages.Send; using MediatR; using Microsoft.AspNetCore.Mvc; @@ -92,6 +93,20 @@ public static IEndpointRouteBuilder MapMessageEndpoints(this IEndpointRouteBuild .Produces(StatusCodes.Status404NotFound) .Produces(StatusCodes.Status409Conflict); + group.MapPut("/conversations/{id:guid}/read", async ( + [FromRoute] Guid id, + ISender sender, + CancellationToken ct) => + { + await sender.Send(new MarkConversationReadCommand(id), ct); + return Results.NoContent(); + }) + .WithName("MarkConversationRead") + .Produces(StatusCodes.Status204NoContent) + .Produces(StatusCodes.Status401Unauthorized) + .Produces(StatusCodes.Status404NotFound) + .ProducesValidationProblem(); + return app; } diff --git a/backend/src/Loopless.Api/Endpoints/ProjectEndpoints.cs b/backend/src/Loopless.Api/Endpoints/ProjectEndpoints.cs index 73b254c..f605532 100644 --- a/backend/src/Loopless.Api/Endpoints/ProjectEndpoints.cs +++ b/backend/src/Loopless.Api/Endpoints/ProjectEndpoints.cs @@ -11,6 +11,7 @@ using Loopless.Application.Features.Projects.GenerateSummary; using Loopless.Application.Features.Projects.GetCommits; using Loopless.Application.Features.Projects.GetDashboard; +using Loopless.Application.Features.Projects.GetMyApplications; using Loopless.Application.Features.Projects.GetMyInvitations; using Loopless.Application.Features.Projects.GetProjectInvitations; using Loopless.Application.Features.Projects.GetProjectLinks; @@ -501,6 +502,22 @@ public static IEndpointRouteBuilder MapInvitationEndpoints(this IEndpointRouteBu .Produces>(StatusCodes.Status200OK) .Produces(StatusCodes.Status401Unauthorized); + // Applications the freelancer initiated themselves. Kept separate from + // /invitations/me so the projects "Pending invitations" view (which lists + // invitations to accept/decline) is not polluted with outgoing applications. + app.MapGet("/api/v1/applications/me", async ( + ISender sender, + CancellationToken ct) => + { + var result = await sender.Send(new GetMyApplicationsQuery(), ct); + return Results.Ok(result); + }) + .WithTags("Invitations") + .RequireAuthorization() + .WithName("GetMyApplications") + .Produces>(StatusCodes.Status200OK) + .Produces(StatusCodes.Status401Unauthorized); + group.MapPut("/{invitationId:guid}/accept", async ( [FromRoute] Guid invitationId, ISender sender, diff --git a/backend/src/Loopless.Api/Endpoints/SettingsEndpoints.cs b/backend/src/Loopless.Api/Endpoints/SettingsEndpoints.cs index 20022b9..68cc1c8 100644 --- a/backend/src/Loopless.Api/Endpoints/SettingsEndpoints.cs +++ b/backend/src/Loopless.Api/Endpoints/SettingsEndpoints.cs @@ -1,3 +1,4 @@ +using Loopless.Application.Features.Settings.ChangePassword; using Loopless.Application.Features.Settings.UpdateNotificationSettings; using MediatR; using Microsoft.AspNetCore.Mvc; @@ -20,5 +21,20 @@ public static void MapSettingsEndpoints(this IEndpointRouteBuilder app) }) .WithName("UpdateNotificationSettings") .WithSummary("Toggle email notification preferences for the current user."); + + group.MapPost("/password", async ( + [FromBody] ChangePasswordCommand command, + IMediator mediator, + CancellationToken ct) => + { + await mediator.Send(command, ct); + return Results.NoContent(); + }) + .RequireRateLimiting("auth") + .WithName("ChangePassword") + .WithSummary("Change the current user's password after verifying the existing one.") + .Produces(StatusCodes.Status204NoContent) + .Produces(StatusCodes.Status401Unauthorized) + .ProducesValidationProblem(); } } diff --git a/backend/src/Loopless.Api/Program.cs b/backend/src/Loopless.Api/Program.cs index 72a9d24..0ef4a11 100644 --- a/backend/src/Loopless.Api/Program.cs +++ b/backend/src/Loopless.Api/Program.cs @@ -248,8 +248,13 @@ app.MapFeaturesEndpoints(); app.MapNotificationEndpoints(); app.MapProfileEndpoints(); - app.MapHub(MessagingHub.HubPath).RequireAuthorization(); - app.MapHub(NotificationsHub.HubPath).RequireAuthorization(); + // Exempt SignalR hubs from the global HTTP rate limiter. Connection + // negotiation + reconnect attempts are connection management, not API calls, + // and would otherwise exhaust the per-IP budget and 429 the negotiate request + // (which then fails the WebSocket handshake). Hub method calls travel over the + // socket, so they aren't covered by the HTTP limiter anyway. + app.MapHub(MessagingHub.HubPath).RequireAuthorization().DisableRateLimiting(); + app.MapHub(NotificationsHub.HubPath).RequireAuthorization().DisableRateLimiting(); _ = builder.Configuration.GetConnectionString("PostgreSQL") ?? throw new InvalidOperationException("PostgreSQL connection string is required. Set ConnectionStrings__PostgreSQL in configuration."); @@ -266,24 +271,26 @@ app.MapHangfireDashboard("/hangfire").RequireAuthorization(KeycloakAuthExtensions.AdminPolicy); } - if (!app.Environment.IsEnvironment("Test")) + if (!app.Environment.IsEnvironment("Test") && !string.IsNullOrEmpty(pgConnForHangfire)) { - RecurringJob.AddOrUpdate( + var recurringJobs = app.Services.GetRequiredService(); + + recurringJobs.AddOrUpdate( StandupReminderJob.RecurringJobId, job => job.RunAsync(CancellationToken.None), "0 9 * * 1-5"); - RecurringJob.AddOrUpdate( + recurringJobs.AddOrUpdate( BlockerDetectionJob.RecurringJobId, job => job.RunAsync(CancellationToken.None), Cron.Daily); - RecurringJob.AddOrUpdate( + recurringJobs.AddOrUpdate( GitHubSyncJob.RecurringJobId, job => job.SyncAllAsync(CancellationToken.None), "*/30 * * * *"); - RecurringJob.AddOrUpdate( + recurringJobs.AddOrUpdate( UnreadMessageEmailJob.RecurringJobId, job => job.RunAsync(CancellationToken.None), "*/5 * * * *"); diff --git a/backend/src/Loopless.Application/Features/Files/GetFileDownload/GetFileDownloadQuery.cs b/backend/src/Loopless.Application/Features/Files/GetFileDownload/GetFileDownloadQuery.cs new file mode 100644 index 0000000..847f09e --- /dev/null +++ b/backend/src/Loopless.Application/Features/Files/GetFileDownload/GetFileDownloadQuery.cs @@ -0,0 +1,10 @@ +using MediatR; + +namespace Loopless.Application.Features.Files.GetFileDownload; + +public sealed record GetFileDownloadQuery(Guid ProjectId, Guid FileId) + : IRequest; + +/// Open read stream for a stored file plus the metadata needed to return it +/// as an attachment. The stream is owned by the caller (the endpoint disposes it). +public sealed record FileDownloadResult(Stream Content, string FileName, string ContentType); diff --git a/backend/src/Loopless.Application/Features/Files/GetFileDownload/GetFileDownloadQueryHandler.cs b/backend/src/Loopless.Application/Features/Files/GetFileDownload/GetFileDownloadQueryHandler.cs new file mode 100644 index 0000000..1d5e7a5 --- /dev/null +++ b/backend/src/Loopless.Application/Features/Files/GetFileDownload/GetFileDownloadQueryHandler.cs @@ -0,0 +1,52 @@ +using Loopless.Application.Common.Exceptions; +using Loopless.Application.Interfaces; +using Loopless.Domain.Enums; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Loopless.Application.Features.Files.GetFileDownload; + +public sealed class GetFileDownloadQueryHandler( + IAppDbContext db, + ICurrentUser currentUser, + IFileStorageService storage) : IRequestHandler +{ + public async Task Handle( + GetFileDownloadQuery request, + CancellationToken cancellationToken) + { + var keycloakId = currentUser.KeycloakId + ?? throw new UnauthorizedException("Missing subject claim."); + + var user = await db.Users + .FirstOrDefaultAsync(u => u.KeycloakId == keycloakId, cancellationToken) + ?? throw new NotFoundException("User not found."); + + var project = await db.Projects + .FirstOrDefaultAsync(p => p.Id == request.ProjectId, cancellationToken) + ?? throw new NotFoundException("Project not found."); + + var isOwner = project.OwnerId == user.Id; + var isAcceptedMember = await db.ProjectInvitations + .AnyAsync( + i => i.ProjectId == project.Id + && (i.EnterpriseId == user.Id || i.FreelancerId == user.Id) + && i.Status == InvitationStatus.Accepted, + cancellationToken); + + if (!isOwner && !isAcceptedMember) + throw new ForbiddenException("Only project members can download files."); + + var file = await db.ProjectFiles + .FirstOrDefaultAsync( + f => f.Id == request.FileId && f.ProjectId == project.Id, + cancellationToken) + ?? throw new NotFoundException("File not found."); + + var stream = await storage.OpenReadAsync(file.FileUrl, cancellationToken); + + // application/octet-stream + a download name forces a download rather than + // inline rendering, which is the desired behaviour for project files. + return new FileDownloadResult(stream, file.FileName, "application/octet-stream"); + } +} diff --git a/backend/src/Loopless.Application/Features/Messages/MarkConversationRead/MarkConversationReadCommand.cs b/backend/src/Loopless.Application/Features/Messages/MarkConversationRead/MarkConversationReadCommand.cs new file mode 100644 index 0000000..0ffc562 --- /dev/null +++ b/backend/src/Loopless.Application/Features/Messages/MarkConversationRead/MarkConversationReadCommand.cs @@ -0,0 +1,7 @@ +using MediatR; + +namespace Loopless.Application.Features.Messages.MarkConversationRead; + +/// Marks every unread message the current user received in a conversation as read. +/// Returns the number of messages updated. +public sealed record MarkConversationReadCommand(Guid ConversationId) : IRequest; diff --git a/backend/src/Loopless.Application/Features/Messages/MarkConversationRead/MarkConversationReadCommandHandler.cs b/backend/src/Loopless.Application/Features/Messages/MarkConversationRead/MarkConversationReadCommandHandler.cs new file mode 100644 index 0000000..8228b06 --- /dev/null +++ b/backend/src/Loopless.Application/Features/Messages/MarkConversationRead/MarkConversationReadCommandHandler.cs @@ -0,0 +1,47 @@ +using Loopless.Application.Common.Exceptions; +using Loopless.Application.Interfaces; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Loopless.Application.Features.Messages.MarkConversationRead; + +public sealed class MarkConversationReadCommandHandler( + IAppDbContext db, + ICurrentUser currentUser) : IRequestHandler +{ + public async Task Handle(MarkConversationReadCommand request, CancellationToken cancellationToken) + { + var keycloakId = currentUser.KeycloakId + ?? throw new UnauthorizedException("Missing subject claim."); + + var me = await db.Users + .FirstOrDefaultAsync(u => u.KeycloakId == keycloakId, cancellationToken) + ?? throw new NotFoundException("User not found."); + + var conversation = await db.Conversations + .FirstOrDefaultAsync(c => c.Id == request.ConversationId, cancellationToken) + ?? throw new NotFoundException("Conversation not found."); + + if (conversation.Participant1Id != me.Id && conversation.Participant2Id != me.Id) + throw new UnauthorizedException("Not a participant of this conversation."); + + var unread = await db.Messages + .Where(m => m.ConversationId == conversation.Id + && m.SenderId != me.Id + && m.ReadAt == null) + .ToListAsync(cancellationToken); + + if (unread.Count == 0) + return 0; + + var now = DateTimeOffset.UtcNow; + foreach (var message in unread) + { + message.ReadAt = now; + message.UpdatedAt = now; + } + + await db.SaveChangesAsync(cancellationToken); + return unread.Count; + } +} diff --git a/backend/src/Loopless.Application/Features/Messages/MarkConversationRead/MarkConversationReadCommandValidator.cs b/backend/src/Loopless.Application/Features/Messages/MarkConversationRead/MarkConversationReadCommandValidator.cs new file mode 100644 index 0000000..f476747 --- /dev/null +++ b/backend/src/Loopless.Application/Features/Messages/MarkConversationRead/MarkConversationReadCommandValidator.cs @@ -0,0 +1,11 @@ +using FluentValidation; + +namespace Loopless.Application.Features.Messages.MarkConversationRead; + +public sealed class MarkConversationReadCommandValidator : AbstractValidator +{ + public MarkConversationReadCommandValidator() + { + RuleFor(x => x.ConversationId).NotEmpty(); + } +} diff --git a/backend/src/Loopless.Application/Features/Projects/GetMyApplications/GetMyApplicationsQuery.cs b/backend/src/Loopless.Application/Features/Projects/GetMyApplications/GetMyApplicationsQuery.cs new file mode 100644 index 0000000..77b26b0 --- /dev/null +++ b/backend/src/Loopless.Application/Features/Projects/GetMyApplications/GetMyApplicationsQuery.cs @@ -0,0 +1,6 @@ +using Loopless.Application.DTOs; +using MediatR; + +namespace Loopless.Application.Features.Projects.GetMyApplications; + +public sealed record GetMyApplicationsQuery() : IRequest>; diff --git a/backend/src/Loopless.Application/Features/Projects/GetMyApplications/GetMyApplicationsQueryHandler.cs b/backend/src/Loopless.Application/Features/Projects/GetMyApplications/GetMyApplicationsQueryHandler.cs new file mode 100644 index 0000000..5065708 --- /dev/null +++ b/backend/src/Loopless.Application/Features/Projects/GetMyApplications/GetMyApplicationsQueryHandler.cs @@ -0,0 +1,61 @@ +using Loopless.Application.Common.Exceptions; +using Loopless.Application.DTOs; +using Loopless.Application.Interfaces; +using Loopless.Domain.Enums; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Loopless.Application.Features.Projects.GetMyApplications; + +public sealed class GetMyApplicationsQueryHandler( + IAppDbContext db, + ICurrentUser currentUser) : IRequestHandler> +{ + public async Task> Handle(GetMyApplicationsQuery request, CancellationToken cancellationToken) + { + var keycloakId = currentUser.KeycloakId + ?? throw new UnauthorizedException("Missing subject claim."); + + var user = await db.Users + .FirstOrDefaultAsync(u => u.KeycloakId == keycloakId, cancellationToken) + ?? throw new NotFoundException("User not found."); + + // Only freelancers apply to projects; everyone else has no applications of their own. + if (user.Role != UserRole.Freelancer) + return []; + + var rows = await db.ProjectInvitations + .Where(i => i.FreelancerId == user.Id && i.InitiatedBy == "Freelancer") + .Join(db.Projects.Where(p => !p.IsDeleted), + i => i.ProjectId, p => p.Id, + (i, p) => new { i, ProjectTitle = p.Title }) + .Join(db.Users.Where(u => !u.IsDeleted), + x => x.i.EnterpriseId, u => u.Id, + (x, e) => new { x.i, x.ProjectTitle, EnterpriseName = e.Name }) + .GroupJoin(db.Users.Where(u => !u.IsDeleted), + x => x.i.FreelancerId, u => u.Id, + (x, freelancers) => new { x.i, x.ProjectTitle, x.EnterpriseName, Freelancers = freelancers }) + .SelectMany(x => x.Freelancers.DefaultIfEmpty(), + (x, f) => new + { + x.i.Id, + x.i.ProjectId, + x.ProjectTitle, + x.i.EnterpriseId, + x.EnterpriseName, + x.i.FreelancerId, + FreelancerName = f != null ? f.Name : null, + x.i.InitiatedBy, + x.i.Status, + x.i.InvitedAt, + }) + .OrderByDescending(r => r.InvitedAt) + .ToListAsync(cancellationToken); + + return rows.Select(r => new InvitationDto( + r.Id, r.ProjectId, r.ProjectTitle, + r.EnterpriseId, r.EnterpriseName, + r.FreelancerId, r.FreelancerName, + r.InitiatedBy, r.Status, r.InvitedAt)).ToList(); + } +} diff --git a/backend/src/Loopless.Application/Features/Settings/ChangePassword/ChangePasswordCommand.cs b/backend/src/Loopless.Application/Features/Settings/ChangePassword/ChangePasswordCommand.cs new file mode 100644 index 0000000..b93d8a8 --- /dev/null +++ b/backend/src/Loopless.Application/Features/Settings/ChangePassword/ChangePasswordCommand.cs @@ -0,0 +1,5 @@ +using MediatR; + +namespace Loopless.Application.Features.Settings.ChangePassword; + +public sealed record ChangePasswordCommand(string CurrentPassword, string NewPassword) : IRequest; diff --git a/backend/src/Loopless.Application/Features/Settings/ChangePassword/ChangePasswordCommandHandler.cs b/backend/src/Loopless.Application/Features/Settings/ChangePassword/ChangePasswordCommandHandler.cs new file mode 100644 index 0000000..9bd30d5 --- /dev/null +++ b/backend/src/Loopless.Application/Features/Settings/ChangePassword/ChangePasswordCommandHandler.cs @@ -0,0 +1,36 @@ +using Loopless.Application.Common.Exceptions; +using Loopless.Application.Interfaces; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Loopless.Application.Features.Settings.ChangePassword; + +public sealed class ChangePasswordCommandHandler( + IAppDbContext db, + ICurrentUser currentUser, + IKeycloakAdminClient keycloak) : IRequestHandler +{ + public async Task Handle(ChangePasswordCommand request, CancellationToken cancellationToken) + { + var keycloakId = currentUser.KeycloakId + ?? throw new UnauthorizedException("Missing subject claim."); + + var user = await db.Users + .FirstOrDefaultAsync(u => u.KeycloakId == keycloakId, cancellationToken) + ?? throw new NotFoundException("User not found."); + + // Verify the current password by attempting a password grant. Translate a + // mismatch into a 400 (BadRequest) rather than a 401 — a wrong password here + // must NOT trip the frontend's 401 interceptor (token refresh / logout). + try + { + await keycloak.PasswordGrantAsync(user.Email, request.CurrentPassword, cancellationToken); + } + catch (UnauthorizedException) + { + throw new BadRequestException("Current password is incorrect."); + } + + await keycloak.ResetPasswordAsync(keycloakId, request.NewPassword, cancellationToken); + } +} diff --git a/backend/src/Loopless.Application/Features/Settings/ChangePassword/ChangePasswordCommandValidator.cs b/backend/src/Loopless.Application/Features/Settings/ChangePassword/ChangePasswordCommandValidator.cs new file mode 100644 index 0000000..9763291 --- /dev/null +++ b/backend/src/Loopless.Application/Features/Settings/ChangePassword/ChangePasswordCommandValidator.cs @@ -0,0 +1,20 @@ +using FluentValidation; + +namespace Loopless.Application.Features.Settings.ChangePassword; + +public sealed class ChangePasswordCommandValidator : AbstractValidator +{ + public ChangePasswordCommandValidator() + { + RuleFor(x => x.CurrentPassword) + .NotEmpty(); + + RuleFor(x => x.NewPassword) + .NotEmpty() + .MinimumLength(8).WithMessage("Password must be at least 8 characters.") + .Matches("[A-Z]").WithMessage("Password must contain an uppercase letter.") + .Matches("[a-z]").WithMessage("Password must contain a lowercase letter.") + .Matches("[0-9]").WithMessage("Password must contain a number.") + .NotEqual(x => x.CurrentPassword).WithMessage("New password must differ from the current one."); + } +} diff --git a/backend/src/Loopless.Application/Interfaces/IFileStorageService.cs b/backend/src/Loopless.Application/Interfaces/IFileStorageService.cs index c6c3e58..60ae884 100644 --- a/backend/src/Loopless.Application/Interfaces/IFileStorageService.cs +++ b/backend/src/Loopless.Application/Interfaces/IFileStorageService.cs @@ -15,6 +15,12 @@ Task GetDownloadUrlAsync( TimeSpan expiresIn, CancellationToken cancellationToken = default); + /// Opens a read stream for an object. Used to stream downloads through + /// the API, avoiding presigned URLs whose host may be unreachable from the browser. + Task OpenReadAsync( + string objectKey, + CancellationToken cancellationToken = default); + /// Fetch an object's bytes + content type (for proxying through the API). Task GetObjectAsync( string objectKey, diff --git a/backend/src/Loopless.Application/Interfaces/IKeycloakAdminClient.cs b/backend/src/Loopless.Application/Interfaces/IKeycloakAdminClient.cs index 54ba91c..1bf8850 100644 --- a/backend/src/Loopless.Application/Interfaces/IKeycloakAdminClient.cs +++ b/backend/src/Loopless.Application/Interfaces/IKeycloakAdminClient.cs @@ -50,6 +50,14 @@ Task RemoveRoleAsync( Task SendResetPasswordEmailAsync( string keycloakId, CancellationToken cancellationToken = default); + + /// Sets a new permanent password for a user via the admin API. + /// Used by the authenticated "change password" flow after the current + /// password has been verified. + Task ResetPasswordAsync( + string keycloakId, + string newPassword, + CancellationToken cancellationToken = default); } public sealed record TokenResponse( diff --git a/backend/src/Loopless.Infrastructure/Identity/KeycloakAdminClient.cs b/backend/src/Loopless.Infrastructure/Identity/KeycloakAdminClient.cs index e864013..17a00b8 100644 --- a/backend/src/Loopless.Infrastructure/Identity/KeycloakAdminClient.cs +++ b/backend/src/Loopless.Infrastructure/Identity/KeycloakAdminClient.cs @@ -326,6 +326,35 @@ public async Task SendResetPasswordEmailAsync( } } + public async Task ResetPasswordAsync( + string keycloakId, + string newPassword, + CancellationToken cancellationToken = default) + { + var adminToken = await GetAdminTokenAsync(cancellationToken); + + using var req = new HttpRequestMessage( + HttpMethod.Put, + BuildAdminUri($"users/{keycloakId}/reset-password")) + { + Content = JsonContent.Create(new + { + type = "password", + value = newPassword, + temporary = false, + }), + }; + req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", adminToken); + + using var res = await http.SendAsync(req, cancellationToken); + if (!res.IsSuccessStatusCode) + { + var body = await res.Content.ReadAsStringAsync(cancellationToken); + throw new KeycloakOperationException( + $"Failed to reset password ({(int)res.StatusCode}): {body}"); + } + } + private static string RoleName(UserRole role) => role switch { UserRole.Freelancer => "freelancer", diff --git a/backend/src/Loopless.Infrastructure/Messaging/Consumers/InvitationEventConsumer.cs b/backend/src/Loopless.Infrastructure/Messaging/Consumers/InvitationEventConsumer.cs index 8be0810..6ff2c0e 100644 --- a/backend/src/Loopless.Infrastructure/Messaging/Consumers/InvitationEventConsumer.cs +++ b/backend/src/Loopless.Infrastructure/Messaging/Consumers/InvitationEventConsumer.cs @@ -49,7 +49,7 @@ protected override async Task HandleMessageAsync(string routingKey, byte[] body, var (userId, title, message, actionUrl) = routingKey switch { "invitation.created" => initiatedBy == "Freelancer" - ? (e.EnterpriseId, "New Application", $"{e.FreelancerName} applied to your project \"{e.ProjectName}\"", (string?)$"/projects/{e.ProjectId}/invitations") + ? (e.EnterpriseId, "New Application", $"{e.FreelancerName} applied to your project \"{e.ProjectName}\"", (string?)$"/projects/{e.ProjectId}?tab=invitations") : (e.FreelancerId, "Project Invitation", $"{e.EnterpriseName} invited you to join \"{e.ProjectName}\"", (string?)"/projects"), "invitation.accepted" => initiatedBy == "Freelancer" ? (e.FreelancerId, "Application Accepted", $"Your application to \"{e.ProjectName}\" was accepted.", (string?)$"/projects/{e.ProjectId}") diff --git a/backend/src/Loopless.Infrastructure/Storage/FileStorageService.cs b/backend/src/Loopless.Infrastructure/Storage/FileStorageService.cs index 66ee19d..8a9fa0c 100644 --- a/backend/src/Loopless.Infrastructure/Storage/FileStorageService.cs +++ b/backend/src/Loopless.Infrastructure/Storage/FileStorageService.cs @@ -69,6 +69,23 @@ public Task GetDownloadUrlAsync( return Task.FromResult(url); } + public async Task OpenReadAsync( + string objectKey, + CancellationToken cancellationToken = default) + { + EnsureConfigured(); + + var response = await _client.GetObjectAsync( + new GetObjectRequest + { + BucketName = _options.BucketName, + Key = objectKey, + }, + cancellationToken); + + return response.ResponseStream; + } + public async Task GetObjectAsync( string objectKey, CancellationToken cancellationToken = default) diff --git a/devops/k8s/overlays/production/kustomization.yaml b/devops/k8s/overlays/production/kustomization.yaml index 12c5381..b6eb989 100644 --- a/devops/k8s/overlays/production/kustomization.yaml +++ b/devops/k8s/overlays/production/kustomization.yaml @@ -1,3 +1,7 @@ +# NOTE: NOT the active production deploy path. Production deploys to the +# `project-01` namespace via the Helm chart at devops/helm/loopless +# (see devops/k8s/project-01/README.md and .github/workflows/cd.yml). +# This overlay is kept as a reference for a future self-managed cluster. apiVersion: kustomize.config.k8s.io/v1beta1 kind: Kustomization @@ -7,7 +11,6 @@ resources: - ../../base - configmap.yaml - secret-store.yaml - - secrets.yaml - external-secret.yaml labels: diff --git a/devops/k8s/overlays/staging/kustomization.yaml b/devops/k8s/overlays/staging/kustomization.yaml index 7fd3955..178ccbb 100644 --- a/devops/k8s/overlays/staging/kustomization.yaml +++ b/devops/k8s/overlays/staging/kustomization.yaml @@ -1,3 +1,6 @@ +# NOTE: NOT an active deploy path — there is no staging cluster. Production +# deploys to the `project-01` namespace via the Helm chart at devops/helm/loopless. +# Kept as a reference for a future staging environment. apiVersion: kustomize.config.k8s.io/v1beta1 kind: Kustomization @@ -7,7 +10,6 @@ resources: - ../../base - configmap.yaml - secret-store.yaml - - secrets.yaml - external-secret.yaml labels: diff --git a/frontend/client/app/(protected)/help/actions.ts b/frontend/client/app/(protected)/help/actions.ts new file mode 100644 index 0000000..8976567 --- /dev/null +++ b/frontend/client/app/(protected)/help/actions.ts @@ -0,0 +1,36 @@ +"use server"; + +import { revalidatePath } from "next/cache"; + +export type QuickFeedbackResult = + | { ok: true } + | { ok: false; error: string }; + +const MIN_LEN = 5; +const MAX_LEN = 1000; + +export async function submitQuickFeedback( + _prev: QuickFeedbackResult | null, + formData: FormData, +): Promise { + const message = formData.get("message")?.toString().trim() ?? ""; + + if (message.length < MIN_LEN) { + return { ok: false, error: `Message must be at least ${MIN_LEN} characters.` }; + } + if (message.length > MAX_LEN) { + return { ok: false, error: `Message must be under ${MAX_LEN} characters.` }; + } + + console.info( + JSON.stringify({ + event: "quick_feedback", + at: new Date().toISOString(), + length: message.length, + preview: message.slice(0, 120), + }), + ); + + revalidatePath("/help"); + return { ok: true }; +} diff --git a/frontend/client/app/(protected)/help/page.tsx b/frontend/client/app/(protected)/help/page.tsx index 1e68486..b6468b6 100644 --- a/frontend/client/app/(protected)/help/page.tsx +++ b/frontend/client/app/(protected)/help/page.tsx @@ -1,10 +1,11 @@ "use client"; -import { useState } from "react"; +import { useActionState, useState } from "react"; import { motion } from "framer-motion"; import { NavigationHeader } from "@/components/ui/NavigationHeader"; import { useCreateSupportTicket, useMySupportTickets } from "@/hooks/useAdmin"; import type { SupportTicketPriority, SupportTicketStatus } from "@/types/admin"; +import { submitQuickFeedback } from "./actions"; const PRIORITY_OPTIONS: SupportTicketPriority[] = ["Low", "Normal", "High", "Urgent"]; @@ -32,6 +33,10 @@ export default function HelpPage() { const [submitted, setSubmitted] = useState(false); const { data: mine, isLoading } = useMySupportTickets(); const create = useCreateSupportTicket(); + const [feedbackState, feedbackAction, feedbackPending] = useActionState( + submitQuickFeedback, + null, + ); const canSubmit = subject.trim().length > 0 && body.trim().length > 0 && !create.isPending; @@ -139,6 +144,43 @@ export default function HelpPage() { +
+

+ Quick anonymous feedback +

+

+ Server Action — runs on the Next.js server, no client API call. +

+
+