Skip to content
This repository was archived by the owner on Jul 6, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 32 additions & 14 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -179,23 +179,41 @@ jobs:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3

# Retry the base-image build: mcr.microsoft.com/dotnet/* intermittently returns
# HTTP 403 on the manifest HEAD under load. A single transient blip here used to
# fail the whole Trivy job, which silently skipped build-push -> release -> CD.
# Retry up to 3x with backoff before treating it as a real failure.
- name: Build backend image for security scan
uses: docker/build-push-action@v6
with:
context: backend
file: devops/docker/backend/Dockerfile
load: true
push: false
tags: loopless-backend:security
shell: bash
run: |
attempt=1; max=3
until docker buildx build \
--file devops/docker/backend/Dockerfile \
--load --tag loopless-backend:security \
backend; do
if [ "$attempt" -ge "$max" ]; then
echo "::error::backend security image build failed after ${max} attempts"
exit 1
fi
echo "::warning::build attempt ${attempt} failed (likely transient registry 403); retrying in 20s..."
attempt=$((attempt + 1)); sleep 20
done

- name: Build frontend image for security scan
uses: docker/build-push-action@v6
with:
context: frontend/client
file: devops/docker/frontend/Dockerfile
load: true
push: false
tags: loopless-frontend:security
shell: bash
run: |
attempt=1; max=3
until docker buildx build \
--file devops/docker/frontend/Dockerfile \
--load --tag loopless-frontend:security \
frontend/client; do
if [ "$attempt" -ge "$max" ]; then
echo "::error::frontend security image build failed after ${max} attempts"
exit 1
fi
echo "::warning::build attempt ${attempt} failed (likely transient registry 403); retrying in 20s..."
attempt=$((attempt + 1)); sleep 20
done

- name: Run Trivy filesystem scan (SARIF)
uses: aquasecurity/trivy-action@v0.36.0
Expand Down
2 changes: 1 addition & 1 deletion backend/data/import/loopless-realm.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
"refreshTokenMaxReuse": 0,
"accessTokenLifespan": 300,
"accessTokenLifespanForImplicitFlow": 900,
"ssoSessionIdleTimeout": 1800,
"ssoSessionIdleTimeout": 3600,
"ssoSessionMaxLifespan": 36000,
"ssoSessionIdleTimeoutRememberMe": 0,
"ssoSessionMaxLifespanRememberMe": 0,
Expand Down
3 changes: 3 additions & 0 deletions backend/src/Loopless.Api/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,9 @@
await db.Database.MigrateAsync();
}

// Idempotently ensure the bootstrap admin account (no-op unless Seed:Admin* configured).
await Loopless.Infrastructure.Persistence.Seeds.AdminSeeder.SeedAsync(app.Services);

app.UseMiddleware<CorrelationIdMiddleware>();
app.UseExceptionHandler();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,12 @@ public async Task<AuthResponse> Handle(GitHubSsoCommand request, CancellationTok
}
}

// GitHub SSO / first-broker-login does NOT assign Keycloak realm roles, so the
// user's JWT would lack their role and every role-gated endpoint (standups,
// profile edit, ...) returns 403. Sync the realm role to the DB role on each
// SSO login (idempotent). The new role is picked up on the next token refresh.
await keycloak.AssignRoleAsync(userInfo.Sub, user.Role, cancellationToken);

return new AuthResponse(
token.AccessToken,
token.ExpiresIn,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
using Loopless.Application.Common.Exceptions;
using Loopless.Application.Interfaces;
using Loopless.Domain.Entities;
using Loopless.Domain.Enums;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;

namespace Loopless.Infrastructure.Persistence.Seeds;

/// <summary>
/// Idempotently ensures a bootstrap admin account exists (Keycloak user with the admin
/// realm role + a DB mirror with Role=Admin), driven by config:
/// Seed:AdminEmail, Seed:AdminPassword (required), Seed:AdminName (optional).
/// No-op when not configured. Runs at startup; never throws (logs + retries next boot).
/// </summary>
public static class AdminSeeder
{
public static async Task SeedAsync(IServiceProvider services, CancellationToken cancellationToken = default)
{
using var scope = services.CreateScope();
var sp = scope.ServiceProvider;
var config = sp.GetRequiredService<IConfiguration>();
var logger = sp.GetRequiredService<ILoggerFactory>().CreateLogger("AdminSeeder");

var email = config["Seed:AdminEmail"]?.Trim().ToLowerInvariant();
var password = config["Seed:AdminPassword"];
var name = config["Seed:AdminName"];
if (string.IsNullOrWhiteSpace(name)) name = "Loopless Admin";

if (string.IsNullOrWhiteSpace(email) || string.IsNullOrWhiteSpace(password))
return; // Not configured — nothing to seed.

var db = sp.GetRequiredService<AppDbContext>();
var keycloak = sp.GetRequiredService<IKeycloakAdminClient>();

try
{
var existing = await db.Users
.IgnoreQueryFilters()
.FirstOrDefaultAsync(u => u.Email == email, cancellationToken);

if (existing is not null)
{
var changed = false;
if (existing.Role != UserRole.Admin)
{
existing.Role = UserRole.Admin;
existing.UpdatedAt = DateTimeOffset.UtcNow;
changed = true;
}
if (existing.IsDeleted)
{
existing.IsDeleted = false;
changed = true;
}
if (changed) await db.SaveChangesAsync(cancellationToken);

if (!string.IsNullOrEmpty(existing.KeycloakId))
await keycloak.AssignRoleAsync(existing.KeycloakId, UserRole.Admin, cancellationToken);

logger.LogInformation("AdminSeeder: ensured admin role for existing user {Email}.", email);
return;
}

string keycloakId;
try
{
keycloakId = await keycloak.CreateUserAsync(email, name, password, UserRole.Admin, cancellationToken);
}
catch (ConflictException)
{
logger.LogWarning(
"AdminSeeder: Keycloak user {Email} exists but has no DB row; manual link required.", email);
return;
}

db.Users.Add(new User
{
Email = email,
Name = name,
Role = UserRole.Admin,
KeycloakId = keycloakId,
});
await db.SaveChangesAsync(cancellationToken);

logger.LogInformation("AdminSeeder: created admin user {Email}.", email);
}
catch (Exception ex)
{
// Never crash startup over seeding — retried on the next boot.
logger.LogError(ex, "AdminSeeder failed; will retry on next startup.");
}
}
}
3 changes: 3 additions & 0 deletions devops/helm/loopless/values.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,9 @@ config:
Cors__AllowedOrigins__1: "https://admin.project-01.gjirafa.dev"
# SMTP not configured in prod yet -> disable the unread-email job to avoid 5-min error noise.
FeatureManagement__EmailNotifications: "false"
# Bootstrap admin (AdminSeeder). Email is non-secret here; the PASSWORD must live in
# loopless-secrets as Seed__AdminPassword (never commit it). Seeder is a no-op if unset.
Seed__AdminEmail: "enes.admin@loopless.dev"
# --- Log shipping to in-cluster ELK (DO-5/6/7). appsettings keeps WriteTo[0]=Console;
# index 1 overrides the default Loki sink with the Http->Logstash sink the
# devops/helm logstash.conf pipeline expects (HTTP input on :5044).
Expand Down
9 changes: 8 additions & 1 deletion devops/k8s/project-01/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,8 @@ $PAT = 'ghp_your-github-token'
$GRAFANA = 'NEW-grafana-admin-password'
$SLACK = 'https://hooks.slack.com/services/XXX/YYY/ZZZ'
$DISCORD = 'https://discord.com/api/webhooks/XXX/YYY'
# --- Bootstrap admin (AdminSeeder). Seed__AdminEmail is in values.yaml; password is secret. ---
$ADMINPASS = 'NEW-admin-password'

kubectl delete secret loopless-secrets -n project-01 --ignore-not-found
kubectl create secret generic loopless-secrets -n project-01 `
Expand All @@ -69,9 +71,14 @@ kubectl create secret generic loopless-secrets -n project-01 `
--from-literal=GitHub__Token="$PAT" `
--from-literal=GRAFANA_ADMIN_PASSWORD="$GRAFANA" `
--from-literal=SLACK_WEBHOOK_URL="$SLACK" `
--from-literal=DISCORD_WEBHOOK_URL="$DISCORD"
--from-literal=DISCORD_WEBHOOK_URL="$DISCORD" `
--from-literal=Seed__AdminPassword="$ADMINPASS"
```

> To add the admin password to an EXISTING secret without recreating it:
> `kubectl patch secret loopless-secrets -n project-01 -p '{"stringData":{"Seed__AdminPassword":"<admin-password>"}}'`
> then restart the backend so `AdminSeeder` runs: `kubectl rollout restart deploy/loopless-backend -n project-01`.

Using variables guarantees the values that must match actually match: `POSTGRES_PASSWORD` ==
the password inside `ConnectionStrings__PostgreSQL`; the MinIO key/secret == `Storage__*`.

Expand Down
2 changes: 1 addition & 1 deletion devops/keycloak/loopless-realm.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
"refreshTokenMaxReuse": 0,
"accessTokenLifespan": 300,
"accessTokenLifespanForImplicitFlow": 900,
"ssoSessionIdleTimeout": 1800,
"ssoSessionIdleTimeout": 3600,
"ssoSessionMaxLifespan": 36000,
"ssoSessionIdleTimeoutRememberMe": 0,
"ssoSessionMaxLifespanRememberMe": 0,
Expand Down
5 changes: 4 additions & 1 deletion frontend/client/app/(protected)/discover/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,10 @@ export default function DiscoverPage() {

// Freelancer project discovery
const projectDiscoverQuery = useDiscoverProjects();
const discoverProjects = projectDiscoverQuery.data ?? [];
const discoverProjects = useMemo(
() => projectDiscoverQuery.data ?? [],
[projectDiscoverQuery.data],
);
const filteredProjects = useMemo(() => {
if (!debouncedQuery) return discoverProjects;
const q = debouncedQuery.toLowerCase();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,9 @@ export default function EnterpriseOnboardingPage() {
}
}, [role, router]);

// React Hook Form's watch() returns a non-memoizable function; the React Compiler
// intentionally skips this component. This is a known, accepted RHF limitation.
// eslint-disable-next-line react-hooks/incompatible-library
const selectedCompanySize = watch("companySize");
const descriptionValue = watch("description") ?? "";

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ export function ProjectDiscoverCard({
<div className="flex items-start justify-between gap-3">
<div className="flex items-center gap-3">
{project.ownerAvatarUrl ? (
// eslint-disable-next-line @next/next/no-img-element -- user-uploaded avatar from arbitrary host; next/image remote config not set
<img
src={project.ownerAvatarUrl}
alt={project.ownerName}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,7 @@ export function EnterpriseProfileCard({ profile, isSelf }: Props) {
{/* Avatar (square) */}
<div className="group relative h-24 w-24 shrink-0">
{avatarPreview || profile.avatarUrl ? (
// eslint-disable-next-line @next/next/no-img-element -- user-uploaded avatar / local preview blob; next/image remote config not set
<img
src={avatarPreview ?? profile.avatarUrl!}
alt={companyName}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,7 @@ export function FreelancerProfileCard({ profile, isSelf }: Props) {
{/* Avatar */}
<div className="group relative h-24 w-24 shrink-0">
{avatarPreview || profile.avatarUrl ? (
// eslint-disable-next-line @next/next/no-img-element -- user-uploaded avatar / local preview blob; next/image remote config not set
<img
src={avatarPreview ?? profile.avatarUrl!}
alt={profile.name}
Expand Down
5 changes: 5 additions & 0 deletions frontend/client/components/providers/RealtimeProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { createContext, useContext, useMemo, type ReactNode } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { useNotifications } from "@/hooks/useNotifications";
import { useMessaging } from "@/hooks/useMessaging";
import { useTokenRefresh } from "@/hooks/useTokenRefresh";
import { useNotificationStore } from "@/stores/notificationStore";
import { useAuthStore } from "@/stores/authStore";

Expand All @@ -29,6 +30,10 @@ export function useRealtime(): RealtimeContextValue {
export function RealtimeProvider({ children }: { children: ReactNode }) {
const token = useAuthStore((s) => s.token);

// Keep the session alive while the app is open (idle keep-alive). Safe to call
// unconditionally — it no-ops until a token exists.
useTokenRefresh();

// Don't open hub connections until authenticated (they need a bearer token).
if (!token) {
return <>{children}</>;
Expand Down
2 changes: 1 addition & 1 deletion frontend/client/components/ui/GettingStartedChecklist.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,7 @@ export function GettingStartedBadge() {
);
if (item.done || !item.href) {
return (
<li key={item.id} aria-disabled={item.done}>
<li key={item.id}>
{inner}
</li>
);
Expand Down
14 changes: 12 additions & 2 deletions frontend/client/components/ui/NotificationBell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -61,10 +61,20 @@ export function NotificationBell() {
} = useNotificationStore();
const { markNotificationRead } = useRealtime();

// Mark read both locally (instant) and on the server (persists across refresh).
// Mark read locally (instant), in the query cache (so a refetch of cached pages
// doesn't re-hydrate the bell with stale isRead=false), and on the server.
const persistRead = (id: string) => {
markRead(id);
void markNotificationRead(id).catch(() => {});
queryClient.setQueriesData<{ items: NotificationDto[]; totalCount: number }>(
{ queryKey: ["notifications"] },
(old) =>
old
? { ...old, items: old.items.map((n) => (n.id === id ? { ...n, isRead: true } : n)) }
: old,
);
void markNotificationRead(id).catch(() => {
queryClient.invalidateQueries({ queryKey: ["notifications"] });
});
};

const { data, isFetching } = useQuery({
Expand Down
1 change: 1 addition & 0 deletions frontend/client/components/ui/ProfileMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ export function ProfileMenu({ name, avatarUrl, profileHref, variant }: Props) {
}
>
{avatarUrl ? (
// eslint-disable-next-line @next/next/no-img-element -- user-uploaded avatar from arbitrary host; next/image remote config not set
<img
src={avatarUrl}
alt={name}
Expand Down
14 changes: 14 additions & 0 deletions frontend/client/eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,20 @@ const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
eslintConfigPrettier,
{
rules: {
// Honor the `_` prefix as the "intentionally unused" convention.
"@typescript-eslint/no-unused-vars": [
"warn",
{
argsIgnorePattern: "^_",
varsIgnorePattern: "^_",
caughtErrorsIgnorePattern: "^_",
destructuredArrayIgnorePattern: "^_",
},
],
},
},
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
Expand Down
13 changes: 7 additions & 6 deletions frontend/client/hooks/useGettingStartedChecklist.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,13 +27,16 @@ export function useGettingStartedChecklist() {

const [dismissed, setDismissed] = useState(false);
useEffect(() => {
// Read persisted dismissal only after mount to avoid an SSR hydration mismatch.
// eslint-disable-next-line react-hooks/set-state-in-effect
setDismissed(localStorage.getItem(CHECKLIST_DISMISS_KEY) === "1");
}, []);

const isWithin7Days = useMemo(() => {
if (!user?.createdAt) return true;
return Date.now() - new Date(user.createdAt).getTime() < SEVEN_DAYS_MS;
}, [user?.createdAt]);
// Capture "now" once at mount via a lazy initializer (allowed to be impure);
// the comparison itself is pure, so it's safe to derive during render.
const [now] = useState(() => Date.now());
const isWithin7Days =
!user?.createdAt || now - new Date(user.createdAt).getTime() < SEVEN_DAYS_MS;

const visible = !dismissed && isWithin7Days && !!user;

Expand Down Expand Up @@ -63,7 +66,6 @@ export function useGettingStartedChecklist() {
queryKey: ["matching", "discover"],
});
return entries.some(([, data]) => (data?.pages?.[0]?.totalCount ?? 0) > 0);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [qc]);

const hasInvitedFreelancer = useMemo(() => {
Expand All @@ -77,7 +79,6 @@ export function useGettingStartedChecklist() {
Array.isArray(data) &&
data.length > 0,
);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [qc]);

const items: ChecklistItem[] = useMemo(() => {
Expand Down
Loading
Loading