From db3bbbf8d1603f60c38c1e90540f9123d74f5295 Mon Sep 17 00:00:00 2001 From: Enes1998 <104234112+Enes1998@users.noreply.github.com> Date: Sun, 7 Jun 2026 18:23:51 +0200 Subject: [PATCH] =?UTF-8?q?fix(app):=20batch-2=20prod=20fixes=20=E2=80=94?= =?UTF-8?q?=20SSO=20roles,=20remove-member,=20notifications,=20idle=20sess?= =?UTF-8?q?ion,=20admin=20seeder=20+=20CI/lint?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bug fixes: - Standup 403 (GitHub-SSO freelancer): GitHubSsoCommandHandler never assigned the Keycloak realm role (only email/pwd signup did) -> JWT lacked the role. Assign the realm role from the DB role on every SSO login (idempotent). - Remove-member button hidden: lib/api/projects.ts mapped owner from the wrong JSON keys (proj.ownerId/ownerName) instead of freelancerId/freelancerName, so isOwner was always false for everyone (also silently broke the invitations-tab gating). Fixed. - Notification bell badge persisted after click: the bell's useQuery re-hydrated the store with stale isRead=false on refetch. persistRead now optimistically updates the notifications query cache (+ invalidate-on-error) so reads stick. - Idle logout: added a proactive token keep-alive (useTokenRefresh, 4-min interval, visibility-gated; getValidAccessToken(force)) mounted in RealtimeProvider, and raised Keycloak ssoSessionIdleTimeout 1800 -> 3600 in both realm exports. Features: - Admin bootstrap: AdminSeeder (idempotent, config-driven Seed:AdminEmail/AdminPassword) ensures a Keycloak admin user + DB Role=Admin at startup. Seed__AdminEmail set in values.yaml; password supplied via loopless-secrets (not committed). Unblocks the reconcile-roles/backfill-embeddings admin endpoints. (Swagger UI is already live at https://api.project-01.gjirafa.dev/swagger — no change.) Also folded into this batch: - CI: retry the Trivy-job image builds on transient MCR 403 (was stashed). - Web: fix all 17 ESLint warnings (zero-warning lint; honor `_` prefix, memoize, justified disables, lazy-init purity, named default export). Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/ci.yml | 46 ++++++--- backend/data/import/loopless-realm.json | 2 +- backend/src/Loopless.Api/Program.cs | 3 + .../Auth/GitHubSso/GitHubSsoCommandHandler.cs | 6 ++ .../Persistence/Seeds/AdminSeeder.cs | 96 +++++++++++++++++++ devops/helm/loopless/values.yaml | 3 + devops/k8s/project-01/README.md | 9 +- devops/keycloak/loopless-realm.json | 2 +- .../client/app/(protected)/discover/page.tsx | 5 +- .../onboarding/enterprise/page.tsx | 3 + .../discovery/ProjectDiscoverCard.tsx | 1 + .../profile/EnterpriseProfileCard.tsx | 1 + .../profile/FreelancerProfileCard.tsx | 1 + .../components/providers/RealtimeProvider.tsx | 5 + .../components/ui/GettingStartedChecklist.tsx | 2 +- .../client/components/ui/NotificationBell.tsx | 14 ++- frontend/client/components/ui/ProfileMenu.tsx | 1 + frontend/client/eslint.config.mjs | 14 +++ .../hooks/useGettingStartedChecklist.ts | 13 +-- frontend/client/hooks/useTokenRefresh.ts | 28 ++++++ frontend/client/lib/api/projects.ts | 4 +- frontend/client/lib/auth/token.ts | 6 +- frontend/client/next.config.mjs | 4 +- 23 files changed, 237 insertions(+), 32 deletions(-) create mode 100644 backend/src/Loopless.Infrastructure/Persistence/Seeds/AdminSeeder.cs create mode 100644 frontend/client/hooks/useTokenRefresh.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 66de301..45d2ce8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/backend/data/import/loopless-realm.json b/backend/data/import/loopless-realm.json index 2f1f1fb..6e895a0 100644 --- a/backend/data/import/loopless-realm.json +++ b/backend/data/import/loopless-realm.json @@ -7,7 +7,7 @@ "refreshTokenMaxReuse": 0, "accessTokenLifespan": 300, "accessTokenLifespanForImplicitFlow": 900, - "ssoSessionIdleTimeout": 1800, + "ssoSessionIdleTimeout": 3600, "ssoSessionMaxLifespan": 36000, "ssoSessionIdleTimeoutRememberMe": 0, "ssoSessionMaxLifespanRememberMe": 0, diff --git a/backend/src/Loopless.Api/Program.cs b/backend/src/Loopless.Api/Program.cs index 733e55d..72a9d24 100644 --- a/backend/src/Loopless.Api/Program.cs +++ b/backend/src/Loopless.Api/Program.cs @@ -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(); app.UseExceptionHandler(); diff --git a/backend/src/Loopless.Application/Features/Auth/GitHubSso/GitHubSsoCommandHandler.cs b/backend/src/Loopless.Application/Features/Auth/GitHubSso/GitHubSsoCommandHandler.cs index 6f1e1e4..8eb1a61 100644 --- a/backend/src/Loopless.Application/Features/Auth/GitHubSso/GitHubSsoCommandHandler.cs +++ b/backend/src/Loopless.Application/Features/Auth/GitHubSso/GitHubSsoCommandHandler.cs @@ -76,6 +76,12 @@ public async Task 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, diff --git a/backend/src/Loopless.Infrastructure/Persistence/Seeds/AdminSeeder.cs b/backend/src/Loopless.Infrastructure/Persistence/Seeds/AdminSeeder.cs new file mode 100644 index 0000000..8924cfa --- /dev/null +++ b/backend/src/Loopless.Infrastructure/Persistence/Seeds/AdminSeeder.cs @@ -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; + +/// +/// 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). +/// +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(); + var logger = sp.GetRequiredService().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(); + var keycloak = sp.GetRequiredService(); + + 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."); + } + } +} diff --git a/devops/helm/loopless/values.yaml b/devops/helm/loopless/values.yaml index a332839..635da10 100644 --- a/devops/helm/loopless/values.yaml +++ b/devops/helm/loopless/values.yaml @@ -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). diff --git a/devops/k8s/project-01/README.md b/devops/k8s/project-01/README.md index a4cd3f0..d54b95c 100644 --- a/devops/k8s/project-01/README.md +++ b/devops/k8s/project-01/README.md @@ -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 ` @@ -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":""}}'` +> 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__*`. diff --git a/devops/keycloak/loopless-realm.json b/devops/keycloak/loopless-realm.json index 88b9ec6..42848e9 100644 --- a/devops/keycloak/loopless-realm.json +++ b/devops/keycloak/loopless-realm.json @@ -7,7 +7,7 @@ "refreshTokenMaxReuse": 0, "accessTokenLifespan": 300, "accessTokenLifespanForImplicitFlow": 900, - "ssoSessionIdleTimeout": 1800, + "ssoSessionIdleTimeout": 3600, "ssoSessionMaxLifespan": 36000, "ssoSessionIdleTimeoutRememberMe": 0, "ssoSessionMaxLifespanRememberMe": 0, diff --git a/frontend/client/app/(protected)/discover/page.tsx b/frontend/client/app/(protected)/discover/page.tsx index 51533a3..c7f515d 100644 --- a/frontend/client/app/(protected)/discover/page.tsx +++ b/frontend/client/app/(protected)/discover/page.tsx @@ -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(); diff --git a/frontend/client/app/(protected)/onboarding/enterprise/page.tsx b/frontend/client/app/(protected)/onboarding/enterprise/page.tsx index a2059a3..bea7da2 100644 --- a/frontend/client/app/(protected)/onboarding/enterprise/page.tsx +++ b/frontend/client/app/(protected)/onboarding/enterprise/page.tsx @@ -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") ?? ""; diff --git a/frontend/client/components/discovery/ProjectDiscoverCard.tsx b/frontend/client/components/discovery/ProjectDiscoverCard.tsx index 5d1cc8e..ccad8f8 100644 --- a/frontend/client/components/discovery/ProjectDiscoverCard.tsx +++ b/frontend/client/components/discovery/ProjectDiscoverCard.tsx @@ -54,6 +54,7 @@ export function ProjectDiscoverCard({
{project.ownerAvatarUrl ? ( + // eslint-disable-next-line @next/next/no-img-element -- user-uploaded avatar from arbitrary host; next/image remote config not set {project.ownerName} {avatarPreview || profile.avatarUrl ? ( + // eslint-disable-next-line @next/next/no-img-element -- user-uploaded avatar / local preview blob; next/image remote config not set {companyName} {avatarPreview || profile.avatarUrl ? ( + // eslint-disable-next-line @next/next/no-img-element -- user-uploaded avatar / local preview blob; next/image remote config not set {profile.name} 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}; diff --git a/frontend/client/components/ui/GettingStartedChecklist.tsx b/frontend/client/components/ui/GettingStartedChecklist.tsx index 7acaeb8..546e631 100644 --- a/frontend/client/components/ui/GettingStartedChecklist.tsx +++ b/frontend/client/components/ui/GettingStartedChecklist.tsx @@ -138,7 +138,7 @@ export function GettingStartedBadge() { ); if (item.done || !item.href) { return ( -
  • +
  • {inner}
  • ); diff --git a/frontend/client/components/ui/NotificationBell.tsx b/frontend/client/components/ui/NotificationBell.tsx index f64f0e3..7fcbd1e 100644 --- a/frontend/client/components/ui/NotificationBell.tsx +++ b/frontend/client/components/ui/NotificationBell.tsx @@ -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({ diff --git a/frontend/client/components/ui/ProfileMenu.tsx b/frontend/client/components/ui/ProfileMenu.tsx index cee8fbd..1128686 100644 --- a/frontend/client/components/ui/ProfileMenu.tsx +++ b/frontend/client/components/ui/ProfileMenu.tsx @@ -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 {name} { + // 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; @@ -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(() => { @@ -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(() => { diff --git a/frontend/client/hooks/useTokenRefresh.ts b/frontend/client/hooks/useTokenRefresh.ts new file mode 100644 index 0000000..e433ea6 --- /dev/null +++ b/frontend/client/hooks/useTokenRefresh.ts @@ -0,0 +1,28 @@ +"use client"; + +import { useEffect } from "react"; +import { useAuthStore } from "@/stores/authStore"; +import { getValidAccessToken } from "@/lib/auth/token"; + +// Refresh well inside Keycloak's SSO idle timeout so an open-but-idle session never +// expires. Each refresh resets the idle clock server-side. +const REFRESH_INTERVAL_MS = 4 * 60 * 1000; + +/** + * Proactively refreshes the access token on an interval while the user is + * authenticated and the tab is visible. Without this, an idle user fires no + * requests, the token expires, the SSO idle timeout elapses, and the next action + * forces a logout. + */ +export function useTokenRefresh() { + const token = useAuthStore((s) => s.token); + + useEffect(() => { + if (!token) return; + const id = setInterval(() => { + if (typeof document !== "undefined" && document.visibilityState === "hidden") return; + void getValidAccessToken(true); + }, REFRESH_INTERVAL_MS); + return () => clearInterval(id); + }, [token]); +} diff --git a/frontend/client/lib/api/projects.ts b/frontend/client/lib/api/projects.ts index 47449cd..6ebbf7f 100644 --- a/frontend/client/lib/api/projects.ts +++ b/frontend/client/lib/api/projects.ts @@ -79,8 +79,8 @@ export async function fetchProject(id: string, signal?: AbortSignal): Promise { +export async function getValidAccessToken(force = false): Promise { const token = getAccessToken(); - if (!isExpiredSoon(token)) return token; + // `force` refreshes even a still-valid token — used by the idle keep-alive timer so + // the Keycloak SSO idle timeout never elapses while the app is open. + if (!force && !isExpiredSoon(token)) return token; try { const { useAuthStore } = await import("@/stores/authStore"); diff --git a/frontend/client/next.config.mjs b/frontend/client/next.config.mjs index 24cb2da..b91868b 100644 --- a/frontend/client/next.config.mjs +++ b/frontend/client/next.config.mjs @@ -17,10 +17,12 @@ const nextConfig = { // @next/bundle-analyzer is a devDependency and is NOT installed in the production // image (the Docker runner installs prod-only deps). Importing it at module top // crashes `next start`. Load it lazily — only when explicitly analyzing a build. -export default async () => { +const config = async () => { if (process.env.ANALYZE === "true") { const { default: withBundleAnalyzer } = await import("@next/bundle-analyzer"); return withBundleAnalyzer({ enabled: true })(nextConfig); } return nextConfig; }; + +export default config;