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.
+
+
+
+
My tickets
diff --git a/frontend/client/app/(protected)/messages/page.tsx b/frontend/client/app/(protected)/messages/page.tsx
index bae2dfc..b0818c6 100644
--- a/frontend/client/app/(protected)/messages/page.tsx
+++ b/frontend/client/app/(protected)/messages/page.tsx
@@ -25,7 +25,7 @@ const ChatView = dynamic(
import { useConversations } from "@/hooks/useConversations";
import { flattenMessageHistory, useMessageHistory } from "@/hooks/useMessageHistory";
import { useMessaging } from "@/hooks/useMessaging";
-import { sendMessage as apiSendMessage, markMessageRead } from "@/lib/api/messaging";
+import { sendMessage as apiSendMessage, markConversationRead } from "@/lib/api/messaging";
import { useAuthStore } from "@/stores/authStore";
import type {
ConversationDto,
@@ -155,31 +155,19 @@ export default function MessagesPage() {
};
}, []);
- // When a conversation is open, mark its unread incoming messages as read so the
- // message badge clears live (the only persisting path is PUT /messages/{id}/read).
- const markedReadRef = useRef>(new Set());
+ // Mark a conversation's incoming messages as read when it's opened, then
+ // refresh the list so the unread badge clears. Without this, unread counts
+ // never decrease after viewing.
useEffect(() => {
- if (!selectedId || !currentUserId) return;
- const unread = messages.filter(
- (m) =>
- m.senderId !== currentUserId &&
- !m.readAt &&
- !markedReadRef.current.has(m.id) &&
- !m.id.startsWith("live-") &&
- !m.id.startsWith("temp-"),
- );
- if (unread.length === 0) return;
- unread.forEach((m) => markedReadRef.current.add(m.id));
-
- // Optimistically clear this conversation's unread badge.
- queryClient.setQueryData(["messaging", "conversations"], (prev) =>
- prev?.map((c) => (c.id === selectedId ? { ...c, unreadCount: 0 } : c)),
- );
-
- Promise.allSettled(unread.map((m) => markMessageRead(m.id))).finally(() => {
- queryClient.invalidateQueries({ queryKey: ["messaging", "conversations"] });
- });
- }, [selectedId, currentUserId, messages, queryClient]);
+ if (!selectedId) return;
+ markConversationRead(selectedId)
+ .then(() => {
+ queryClient.invalidateQueries({ queryKey: ["messaging", "conversations"] });
+ })
+ .catch(() => {
+ // Non-critical; the badge will reconcile on the next fetch.
+ });
+ }, [selectedId, queryClient]);
const [inputByConv, setInputByConv] = useState>({});
const inputValue = selectedId ? (inputByConv[selectedId] ?? "") : "";
diff --git a/frontend/client/app/(protected)/projects/[id]/page.tsx b/frontend/client/app/(protected)/projects/[id]/page.tsx
index 3433755..3d0c178 100644
--- a/frontend/client/app/(protected)/projects/[id]/page.tsx
+++ b/frontend/client/app/(protected)/projects/[id]/page.tsx
@@ -2,7 +2,7 @@
import { use, useState } from "react";
import Link from "next/link";
-import { useRouter } from "next/navigation";
+import { useRouter, useSearchParams } from "next/navigation";
import { motion } from "framer-motion";
import {
ArrowLeft,
@@ -66,6 +66,10 @@ function getInitials(name: string): string {
.slice(0, 2);
}
+function isTab(value: string | null): value is Tab {
+ return value !== null && TABS.some((t) => t.key === value);
+}
+
export default function ProjectDashboardPage({
params,
}: {
@@ -73,8 +77,14 @@ export default function ProjectDashboardPage({
}) {
const { id } = use(params);
const router = useRouter();
+ const searchParams = useSearchParams();
const { user } = useAuthStore();
- const [activeTab, setActiveTab] = useState("overview");
+ // Deep-link support: notifications (e.g. "New Application") link to
+ // /projects/{id}?tab=invitations. Seed the initial tab from the query param.
+ const [activeTab, setActiveTab] = useState(() => {
+ const tabParam = searchParams.get("tab");
+ return isTab(tabParam) ? tabParam : "overview";
+ });
const [showEditForm, setShowEditForm] = useState(false);
const [showInviteModal, setShowInviteModal] = useState(false);
const [editForm, setEditForm] = useState({});
diff --git a/frontend/client/app/(protected)/settings/page.tsx b/frontend/client/app/(protected)/settings/page.tsx
index 9ddf35a..2805141 100644
--- a/frontend/client/app/(protected)/settings/page.tsx
+++ b/frontend/client/app/(protected)/settings/page.tsx
@@ -1,22 +1,16 @@
"use client";
-import { useRef, useState } from "react";
+import { useEffect, useState } from "react";
import Link from "next/link";
-import { Camera, ExternalLink, Loader2 } from "lucide-react";
-import { useMutation } from "@tanstack/react-query";
+import { Monitor, Moon, Sun } from "lucide-react";
+import { useTheme } from "next-themes";
import { AppNavigation } from "@/components/ui/AppNavigation";
import { apiClient } from "@/lib/api/client";
-import { useMe, useUpdateCurrentUser } from "@/hooks/useAuth";
-import { uploadAvatar } from "@/lib/api/profile";
+import { changePassword } from "@/lib/api/auth";
+import { changePasswordSchema } from "@/lib/schemas/auth";
+import { useMe } from "@/hooks/useAuth";
import { useAuthStore } from "@/stores/authStore";
-function resolveAvatar(raw: string | null | undefined): string | null {
- if (!raw) return null;
- if (raw.startsWith("http://") || raw.startsWith("https://")) return raw;
- const base = process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8080";
- return `${base}/api/v1/profile/avatars/${raw.split("/").pop()}`;
-}
-
export default function SettingsPage() {
const storeUser = useAuthStore((s) => s.user);
const meQuery = useMe();
@@ -32,19 +26,37 @@ export default function SettingsPage() {
+
+ {me?.id && (
+
+ Looking to update your name, avatar, or bio?{" "}
+
+ Edit your profile
+
+ .
+
+ )}
);
}
-function Section({ title, description, children }: { title: string; description?: string; children: React.ReactNode }) {
+function Section({
+ title,
+ description,
+ children,
+}: {
+ title: string;
+ description?: string;
+ children: React.ReactNode;
+}) {
return (
@@ -56,15 +68,6 @@ function Section({ title, description, children }: { title: string; description?
);
}
-function Field({ label, children }: { label: string; children: React.ReactNode }) {
- return (
-
- );
-}
-
function SaveStatus({ saving, saved, error }: { saving: boolean; saved: boolean; error: string | null }) {
if (saving) return Saving…;
if (saved) return Saved;
@@ -72,132 +75,156 @@ function SaveStatus({ saving, saved, error }: { saving: boolean; saved: boolean;
return null;
}
-function AccountSection({
- name: initialName,
- avatarUrl: initialAvatar,
- userId,
-}: {
- name: string;
- avatarUrl: string | null;
- userId: string | null;
-}) {
- const [name, setName] = useState(initialName);
- const [saved, setSaved] = useState(false);
- const [err, setErr] = useState(null);
- const [prevInitialName, setPrevInitialName] = useState(initialName);
- const updateUser = useUpdateCurrentUser();
- const fileRef = useRef(null);
+const THEME_OPTIONS = [
+ { value: "light", label: "Light", icon: Sun },
+ { value: "dark", label: "Dark", icon: Moon },
+ { value: "system", label: "System", icon: Monitor },
+] as const;
- if (prevInitialName !== initialName) {
- setPrevInitialName(initialName);
- setName(initialName);
- }
+function AppearanceSection() {
+ const { theme, setTheme } = useTheme();
+ const [mounted, setMounted] = useState(false);
+ // Avoid a hydration mismatch: the active theme is only known on the client.
+ // queueMicrotask defers the setState out of the effect body (matches ThemeToggle).
+ useEffect(() => {
+ queueMicrotask(() => setMounted(true));
+ }, []);
- const avatarMutation = useMutation({ mutationFn: (file: File) => uploadAvatar(file) });
+ return (
+
+
+ {THEME_OPTIONS.map(({ value, label, icon: Icon }) => {
+ const active = mounted && theme === value;
+ return (
+
+ );
+ })}
+
+
+ );
+}
+
+function SecuritySection() {
+ const [currentPassword, setCurrentPassword] = useState("");
+ const [newPassword, setNewPassword] = useState("");
+ const [confirmPassword, setConfirmPassword] = useState("");
+ const [saving, setSaving] = useState(false);
+ const [saved, setSaved] = useState(false);
+ const [error, setError] = useState(null);
+
+ const reset = () => {
+ setCurrentPassword("");
+ setNewPassword("");
+ setConfirmPassword("");
+ };
- const handleSave = async (e: React.FormEvent) => {
+ const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
- setErr(null);
+ setError(null);
setSaved(false);
- try {
- await updateUser.mutateAsync({ name: name.trim() });
- setSaved(true);
- setTimeout(() => setSaved(false), 2000);
- } catch (e) {
- setErr((e as { message?: string } | undefined)?.message ?? "Failed");
+
+ const parsed = changePasswordSchema.safeParse({
+ currentPassword,
+ newPassword,
+ confirmPassword,
+ });
+ if (!parsed.success) {
+ setError(parsed.error.issues[0]?.message ?? "Invalid input");
+ return;
}
- };
- const handleAvatar = async (file: File) => {
- setErr(null);
+ setSaving(true);
try {
- await avatarMutation.mutateAsync(file);
+ await changePassword(parsed.data.currentPassword, parsed.data.newPassword);
setSaved(true);
- setTimeout(() => setSaved(false), 2000);
+ reset();
+ setTimeout(() => setSaved(false), 2500);
} catch (e) {
- setErr((e as { message?: string } | undefined)?.message ?? "Avatar upload failed");
+ setError((e as { message?: string } | undefined)?.message ?? "Failed to change password");
+ } finally {
+ setSaving(false);
}
};
- const resolvedAvatar = resolveAvatar(initialAvatar);
-
return (
-
-