Skip to content

Conversation

@BlackYps
Copy link
Contributor

@BlackYps BlackYps commented Nov 13, 2025

Short bans can lead to users not even noticing the ban, because it has expired before they log in again. This is bad because it is still important that players get feedback when they broke the rules. It also limits moderators in the minimum ban length they can reasonably apply.
When we notify users that there is a now expired ban, we can close the information gap. This way we also enable moderators to reliably message players that should receive a warning, by issuing a short-duration ban.

Summary by CodeRabbit

  • New Features

    • Missed-ban detection: users are notified if a ban occurred while they were away, showing reason and localized start/end times.
  • Bug Fixes / UX

    • Login flow surfaces a clear, localized missed-ban notice and prompts re-login.
    • Login button no longer submits on Enter; ban expiration shown in locale-aware format.
    • Error message preserves line breaks; error text alignment adjusted.
  • Documentation

    • Added localized messages for missed-ban text.
  • Tests

    • Added/updated tests covering missed-ban timing and login outcomes.

@coderabbitai
Copy link

coderabbitai bot commented Nov 13, 2025

Walkthrough

Detects global bans created after a user's last login and surfaces them as LoginResult.MissedBan. Adds ban creation timestamps, last-login lookup, nullable repository ID handling, UI and Hydra mapping for the new variant, and test updates for time-based scenarios.

Changes

Cohort / File(s) Summary
Core login logic
src/main/kotlin/com/faforever/userservice/backend/account/LoginService.kt
Added LoginResult.MissedBan data class; obtain lastLogin via loginLogRepository.findLastLoginTime(user.id) (converted to OffsetDateTime); added findMissedGlobalBan(user, lastLogin): Ban?; check missed ban before ownership verification; updated findActiveGlobalBan call to banRepository.findGlobalBansByPlayerId(user.id).
Ban domain & repository
src/main/kotlin/com/faforever/userservice/backend/domain/Ban.kt
Added createTime: OffsetDateTime to Ban; changed BanRepository.findGlobalBansByPlayerId(playerId: Int)findGlobalBansByPlayerId(playerId: Int?): List<Ban> and handle null by returning an empty list.
Login log repository
src/main/kotlin/com/faforever/userservice/backend/domain/LoginLog.kt
Added findLastLoginTime(userId: Int?): LocalDateTime? to fetch the latest login timestamp; returns null when userId is null.
Hydra integration
src/main/kotlin/com/faforever/userservice/backend/hydra/HydraService.kt
Map LoginResult.MissedBan to the same failed/recoverable login response handling as other recoverable failures.
UI / frontend
src/main/kotlin/com/faforever/userservice/ui/view/oauth2/LoginView.kt, frontend/themes/faforever/styles.css
Added locale-aware DateTimeFormatter and rendering for MissedBan (intro, reason, expiration lines, preserved line breaks); removed Enter shortcut on login button; removed text-align: center from .error CSS.
Internationalization
src/main/resources/i18n/messages.properties
Added ban.missed.intro and ban.missed keys for missed-ban messages.
Tests
src/test/kotlin/com/faforever/userservice/backend/domain/LoginServiceTest.kt
Updated Ban fixtures to include createTime; added/adjusted tests and mocks for findLastLoginTime and missed-ban scenarios; added cases asserting MissedBan vs successful login depending on ban timestamps and last-login logs.

Sequence Diagram(s)

sequenceDiagram
    participant User
    participant LoginService
    participant LoginLogRepo as LoginLogRepository
    participant BanRepo as BanRepository
    participant Hydra

    rect rgb(245,250,245)
    User->>LoginService: attemptLogin(credentials)
    LoginService->>LoginLogRepo: findLastLoginTime(user.id?)
    LoginService->>BanRepo: findActiveGlobalBan(user)
    alt Active global ban found
        BanRepo-->>LoginService: active Ban
        LoginService-->>User: LoginResult.ActiveGlobalBan
    else No active ban
        LoginService->>BanRepo: findMissedGlobalBan(user, lastLogin)
        alt Missed ban found
            BanRepo-->>LoginService: missed Ban (createTime, expiresAt, reason)
            LoginService-->>User: LoginResult.MissedBan(reason, startTime, endTime)
            note right of Hydra: MissedBan treated as recoverable failure
            LoginService->>Hydra: map to failed/recoverable response
        else No missed ban
            LoginService-->>User: LoginResult.Success
        end
    end
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

  • Focus review on time-window and boundary logic in findMissedGlobalBan (createTime, expiresAt vs lastLogin).
  • Verify timezone conversions when converting LocalDateTime (login log) to OffsetDateTime.
  • Inspect DB mapping for createTime and nullable playerId behaviour in BanRepository.
  • Review updated tests for correct mocks and coverage of missed-ban vs active-ban paths.

Poem

🐰 I hopped through logs beneath the moon,
found a ban that started late last June.
I tuck the start and end into a line,
nudge you to log in — all will be fine.
Hop on, try again, and you'll be soon.

Pre-merge checks and finishing touches

❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. You can run @coderabbitai generate docstrings to improve docstring coverage.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title directly and clearly describes the main change: notifying users about bans they missed during inactivity, which aligns with the PR's core objective of closing the information gap for expired bans.
✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

📜 Recent review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 03b404d and a4017c5.

📒 Files selected for processing (1)
  • src/main/kotlin/com/faforever/userservice/backend/account/LoginService.kt (4 hunks)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: test
🔇 Additional comments (3)
src/main/kotlin/com/faforever/userservice/backend/account/LoginService.kt (3)

43-47: LGTM! Well-structured MissedBan result type.

The new MissedBan data class appropriately captures the required information (reason, start/end times) and correctly implements RecoverableLoginFailure to enable notifying users of bans they missed during inactivity.


99-101: Good fix: Computing lastLogin before logging.

Computing the last login timestamp before calling logLogin correctly addresses the timing issue flagged in past reviews, ensuring the current login attempt doesn't interfere with missed-ban detection.


109-121: Missed-ban detection logic depends on helper fixes.

The control flow here is sound: checking for missed bans after active bans but before ownership verification. However, the underlying findMissedGlobalBan helper (lines 145-150) still needs the fixes flagged in past reviews regarding ban ordering, expiry checks, and recency windows.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (4)
src/main/resources/i18n/messages.properties (1)

56-57: Let translations own the full time range sentence

Right now the intro text ends in a bare “to”, with the end time appended in code. That makes it hard for other locales to reorder or format the sentence.

Consider letting the property take both start and end times:

-ban.missed.intro=You have been banned from FAForever from {0} to
+ban.missed.intro=You have been banned from FAForever from {0} to {1}

and pass both values from the UI. This keeps the full sentence inside the translation and improves i18n flexibility.

src/main/kotlin/com/faforever/userservice/ui/view/oauth2/LoginView.kt (1)

170-175: Centralize ban time formatting and keep i18n flexible

The MissedBan message is partly constructed in code and partly in translations, and relies on toString() for times:

  • ban.missed.intro gets only startTime; endTime is concatenated in Kotlin.
  • startTime/endTime are rendered via default toString(), unlike the explicit formatter used in the banned case.

To improve consistency and i18n, consider:

-            is LoginResult.MissedBan -> {
-                val intro = "${getTranslation("ban.missed.intro", loginError.startTime)} ${loginError.endTime}"
+            is LoginResult.MissedBan -> {
+                val intro = getTranslation(
+                    "ban.missed.intro",
+                    loginError.startTime,
+                    loginError.endTime,
+                )
                 val reason = "${getTranslation("ban.reason")} ${loginError.reason}"
                 val explanation = getTranslation("ban.missed")
                 "$intro $reason\n$explanation"
             }

paired with the updated ban.missed.intro property (from {0} to {1} as suggested in messages.properties). You can also optionally format the times with DateTimeFormatter for consistency with the existing banned message.

src/main/kotlin/com/faforever/userservice/backend/domain/Ban.kt (1)

13-15: Ban createTime and nullable lookup look appropriate

Adding createTime and exposing it via BanRepository.findGlobalBansByPlayerId(playerId: Int?) fits the MissedBan use case and keeps call sites simple by returning an empty list for null ids. If you like, you could use emptyList() instead of listOf() for clarity, but the current behavior is fine.

Also applies to: 44-45, 57-60

src/test/kotlin/com/faforever/userservice/backend/domain/LoginServiceTest.kt (1)

88-89: Tests cover key MissedBan scenarios and updated Ban constructor

The updated Ban instantiations with createTime and the two new tests (loginWithPreviouslyBannedUser, loginWithPreviouslyBannedUserAfterAcknowledgement) accurately express the new behavior: detect a recently expired ban when the last login predates it, and allow login after a post-ban login.

If you want the tests to be maximally deterministic, you could compute a single val now = LocalDateTime.now() / OffsetDateTime.now() per test and derive all offsets from that, but the current use of hours/days offsets is acceptable.

Also applies to: 102-103, 153-189, 191-216

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 5f329f5 and f8ea2c5.

📒 Files selected for processing (7)
  • src/main/kotlin/com/faforever/userservice/backend/account/LoginService.kt (3 hunks)
  • src/main/kotlin/com/faforever/userservice/backend/domain/Ban.kt (3 hunks)
  • src/main/kotlin/com/faforever/userservice/backend/domain/LoginLog.kt (1 hunks)
  • src/main/kotlin/com/faforever/userservice/backend/hydra/HydraService.kt (1 hunks)
  • src/main/kotlin/com/faforever/userservice/ui/view/oauth2/LoginView.kt (1 hunks)
  • src/main/resources/i18n/messages.properties (1 hunks)
  • src/test/kotlin/com/faforever/userservice/backend/domain/LoginServiceTest.kt (4 hunks)
🧰 Additional context used
🧬 Code graph analysis (1)
src/main/kotlin/com/faforever/userservice/ui/view/oauth2/LoginView.kt (1)
src/main/kotlin/com/faforever/userservice/backend/i18n/I18n.kt (1)
  • getTranslation (28-40)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: test
🔇 Additional comments (1)
src/main/kotlin/com/faforever/userservice/backend/hydra/HydraService.kt (1)

74-78: MissedBan mapping fits existing failure handling

Mapping LoginResult.MissedBan to LoginResponse.FailedLogin keeps it aligned with other recoverable login failures and allows the UI to surface the message without changing the Hydra error flow. Looks good.

Comment on lines 42 to 46
data class MissedBan(
val reason: String,
val startTime: LocalDateTime,
val endTime: OffsetDateTime,
) : RecoverableLoginFailure
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

MissedBan logic can silently fail due to ordering and log timing

The core MissedBan behavior has two structural problems:

  1. Ban selection is not guaranteed to pick the most recent relevant ban
val lastRelevantBan = banRepository.findGlobalBansByPlayerId(user.id)
    .firstOrNull { ... } ?: return null

Since the repository list has no explicit ordering, firstOrNull may return an older ban and ignore a more recent one that also matches the criteria. In multi-ban histories this can cause you to miss the ban the user actually “missed”.

  1. Current login can be treated as the “last login”

You call logLogin before computing MissedBan:

logLogin(usernameOrEmail, user, ip)

val activeGlobalBan = findActiveGlobalBan(user)
...
val missedGlobalBan = findMissedGlobalBan(user)

findMissedGlobalBan internally calls loginLogRepository.findLastLoginTime(user.id). With typical JPA behavior, the just-persisted LoginLog for this attempt may already be visible to that query, so lastLogin can equal “now”. That makes lastLogin.isBefore(lastRelevantBan.createTime) false and prevents MissedBan from ever being returned, even when the user truly missed a ban between logins.

To align the implementation with the intended behavior (“since their last login”), I recommend:

  • Compute the previous last-login timestamp before logging the current attempt.
  • Pass that value into findMissedGlobalBan.
  • Within findMissedGlobalBan, pick the newest relevant ban, not the first from the list.

For example:

@@
-        logLogin(usernameOrEmail, user, ip)
-
-        val activeGlobalBan = findActiveGlobalBan(user)
+        val lastLogin = loginLogRepository.findLastLoginTime(user.id)
+
+        logLogin(usernameOrEmail, user, ip)
+
+        val activeGlobalBan = findActiveGlobalBan(user)
@@
-        val missedGlobalBan = findMissedGlobalBan(user)
+        val missedGlobalBan = findMissedGlobalBan(user, lastLogin)
@@
-    private fun findActiveGlobalBan(user: User): Ban? =
-        banRepository.findGlobalBansByPlayerId(user.id)
-            .firstOrNull { it.isActive }
+    private fun findActiveGlobalBan(user: User): Ban? =
+        banRepository.findGlobalBansByPlayerId(user.id)
+            .firstOrNull { it.isActive }
@@
-    private fun findMissedGlobalBan(user: User): Ban? {
-        val lastRelevantBan = banRepository.findGlobalBansByPlayerId(user.id)
-            .firstOrNull { it.revokeTime == null && it.expiresAt != null &&
-                it.expiresAt!!.isAfter(OffsetDateTime.now().minusDays(90))} ?: return null
-
-        val lastLogin = loginLogRepository.findLastLoginTime(user.id)
-        return if (lastLogin == null || lastLogin.isBefore(lastRelevantBan.createTime))
-            lastRelevantBan else null
-    }
+    private fun findMissedGlobalBan(user: User, lastLogin: LocalDateTime?): Ban? {
+        val lastRelevantBan = banRepository.findGlobalBansByPlayerId(user.id)
+            .filter { ban ->
+                ban.revokeTime == null &&
+                    ban.expiresAt != null &&
+                    ban.expiresAt!!.isAfter(OffsetDateTime.now().minusDays(90))
+            }
+            .maxByOrNull { it.createTime }
+            ?: return null
+
+        return if (lastLogin == null || lastLogin.isBefore(lastRelevantBan.createTime)) {
+            lastRelevantBan
+        } else {
+            null
+        }
+    }

This keeps logging behavior intact while ensuring:

  • You compare against the previous login time.
  • You consider the most recent non-revoked, expiring ban in the last 90 days when deciding whether to return LoginResult.MissedBan.

Also applies to: 100-112, 130-133, 134-142

🤖 Prompt for AI Agents
In src/main/kotlin/com/faforever/userservice/backend/account/LoginService.kt
around lines 42-46 (and similarly at ranges 100-112, 130-133, 134-142), the
MissedBan logic can silently fail because you call logLogin before reading the
previous last-login and you pick the first ban from an unordered repository
result; fix by reading and storing the previous lastLogin
(loginLogRepository.findLastLoginTime(user.id)) before calling logLogin, pass
that previousLastLogin into findMissedGlobalBan, and change findMissedGlobalBan
to select the most recent relevant ban (e.g. sort by createTime or use
maxBy/createTime) and then compare that ban.createTime against the
previousLastLogin and ban revocation/expiry rules; apply the same change
wherever missed-ban detection occurs so the comparison uses the prior login
timestamp and the newest applicable ban.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 0

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/main/kotlin/com/faforever/userservice/ui/view/oauth2/LoginView.kt (1)

176-188: Fix endTime formatting to use loginError.endTime instead of loginError.startTime

Line 182 incorrectly formats both startTime and endTime from loginError.startTime, causing the ban end time to display the same as the start time. The LoginResult.MissedBan class has both startTime and endTime properties, so the fix is:

- val endTime = loginError.startTime.format(dateTimeFormatter)
+ val endTime = loginError.endTime.format(dateTimeFormatter)
♻️ Duplicate comments (1)
src/main/kotlin/com/faforever/userservice/backend/account/LoginService.kt (1)

135-151: MissedBan still depends on unordered ban list; newest relevant ban is not guaranteed

findMissedGlobalBan currently picks the first matching ban from findGlobalBansByPlayerId(user.id):

val lastRelevantBan = banRepository.findGlobalBansByPlayerId(user.id)
    .firstOrNull {
        it.revokeTime == null && it.expiresAt != null &&
            it.expiresAt!!.isAfter(OffsetDateTime.now().minusDays(90))
    } ?: return null

Because the repository query has no explicit ordering, the “first” ban is effectively undefined and may not be the most recent relevant ban. In a history with multiple bans matching these criteria, you can end up informing the user about an older one instead of the latest, or missing a newer one entirely.

To align with “last relevant ban” semantics, consider:

  • Selecting the newest matching ban by createTime, e.g.:
val lastRelevantBan = banRepository.findGlobalBansByPlayerId(user.id)
    .filter { ban ->
        ban.revokeTime == null &&
            ban.expiresAt != null &&
            ban.expiresAt!!.isAfter(OffsetDateTime.now().minusDays(90))
    }
    .maxByOrNull { it.createTime }
    ?: return null

The subsequent comparison against lastLogin can remain as-is.

🧹 Nitpick comments (2)
src/main/kotlin/com/faforever/userservice/ui/view/oauth2/LoginView.kt (1)

63-73: Locale-dependent formatter is nice, but consider null-safety of UI.getCurrent()

Using a localized DateTimeFormatter and white-space: pre-line is a good fit for multi-line MissedBan / UserBanned messages.

However, dateTimeFormatter is initialized with UI.getCurrent().locale at field init time. If this view were ever constructed off the UI thread (e.g. during certain tests or background usage), UI.getCurrent() could be null and cause an NPE.

If that scenario is possible in your stack, consider lazily resolving the formatter when needed, with a safe fallback:

private fun uiDateTimeFormatter(): DateTimeFormatter {
    val locale = UI.getCurrent()?.locale ?: Locale.getDefault()
    return DateTimeFormatterBuilder()
        .append(DateTimeFormatter.ofLocalizedDate(FormatStyle.LONG))
        .appendLiteral(" ")
        .append(DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT))
        .toFormatter(locale)
}

Then call uiDateTimeFormatter() where you format timestamps.

Also applies to: 75-80, 209-217

src/main/kotlin/com/faforever/userservice/backend/domain/Ban.kt (1)

43-45: Ban createTime and nullable player ID handling look good (minor type nit)

Adding a non-null createTime and making findGlobalBansByPlayerId gracefully handle null IDs fits the MissedBan logic and avoids repeated null checks in callers.

There is a small asymmetry where Ban.playerId is Long but the repository method parameter is Int?. It works via implicit widening, but if you ever touch this area again it may be worth aligning these types (e.g., Long?) for clarity and to avoid subtle mapping surprises.

Also applies to: 56-59

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between f1b6072 and ef0bcda.

📒 Files selected for processing (7)
  • frontend/themes/faforever/styles.css (0 hunks)
  • src/main/kotlin/com/faforever/userservice/backend/account/LoginService.kt (3 hunks)
  • src/main/kotlin/com/faforever/userservice/backend/domain/Ban.kt (2 hunks)
  • src/main/kotlin/com/faforever/userservice/backend/domain/LoginLog.kt (1 hunks)
  • src/main/kotlin/com/faforever/userservice/ui/view/oauth2/LoginView.kt (5 hunks)
  • src/main/resources/i18n/messages.properties (1 hunks)
  • src/test/kotlin/com/faforever/userservice/backend/domain/LoginServiceTest.kt (4 hunks)
💤 Files with no reviewable changes (1)
  • frontend/themes/faforever/styles.css
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/main/resources/i18n/messages.properties
🧰 Additional context used
🧬 Code graph analysis (1)
src/main/kotlin/com/faforever/userservice/ui/view/oauth2/LoginView.kt (1)
src/main/kotlin/com/faforever/userservice/backend/i18n/I18n.kt (1)
  • getTranslation (28-40)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: test
🔇 Additional comments (4)
src/main/kotlin/com/faforever/userservice/backend/domain/LoginLog.kt (1)

41-46: findLastLoginTime semantics now match its name

The repository now explicitly orders by createTime desc and handles null user IDs up front, so callers reliably get the most recent login attempt or null.

This aligns well with MissedBan’s “since last login” semantics.

src/test/kotlin/com/faforever/userservice/backend/domain/LoginServiceTest.kt (1)

88-101: Tests correctly cover MissedBan and post-acknowledgement flows

The updated Ban constructors and the two new scenarios:

  • loginWithPreviouslyBannedUser (lastLogin before ban.createTime → MissedBan)
  • loginWithPreviouslyBannedUserAfterAcknowledgement (lastLogin after ban.createTime → SuccessfulLogin)

accurately reflect the intended timing behavior and guard against regressions in MissedBan detection. Using now().minus… with hour/day offsets should be robust enough in practice.

Also applies to: 114-127, 162-186, 188-240

src/main/kotlin/com/faforever/userservice/backend/account/LoginService.kt (2)

38-47: MissedBan result shape looks appropriate for UI messaging

Defining LoginResult.MissedBan with reason, startTime, and non-null endTime matches the UI’s need to show a time-bounded ban the user never saw. Requiring a non-null endTime is consistent with how findMissedGlobalBan filters only bans with expiresAt != null.


86-101: Good: last-login is read before logging the current attempt

Moving val lastLogin = loginLogRepository.findLastLoginTime(user.id) before logLogin(...) ensures the MissedBan comparison uses the previous login attempt and not the one being processed now. That fixes the earlier risk where the current attempt could be treated as “last login” and prevent MissedBan from ever triggering.

The subsequent MissedBan creation correctly passes reason, createTime, and expiresAt!! from the selected Ban.

Also applies to: 107-116

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (1)
src/main/kotlin/com/faforever/userservice/backend/account/LoginService.kt (1)

110-114: Consider avoiding non-null assertion on expiresAt.

Line 113 uses missedGlobalBan.expiresAt!!, which is safe given the filter on line 142 checks expiresAt != null. However, Kotlin's smart cast should eliminate the need for !! if the type system recognizes the null check.

If smart cast doesn't work due to mutability concerns, consider using let:

-            return LoginResult.MissedBan(
-                missedGlobalBan.reason,
-                missedGlobalBan.createTime,
-                missedGlobalBan.expiresAt!!,
-            )
+            missedGlobalBan.expiresAt?.let { expiresAt ->
+                return LoginResult.MissedBan(
+                    missedGlobalBan.reason,
+                    missedGlobalBan.createTime,
+                    expiresAt,
+                )
+            }

This is defensive but makes the null safety explicit without assertions.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between ef0bcda and 20205fd.

📒 Files selected for processing (2)
  • src/main/kotlin/com/faforever/userservice/backend/account/LoginService.kt (3 hunks)
  • src/main/kotlin/com/faforever/userservice/ui/view/oauth2/LoginView.kt (5 hunks)
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/main/kotlin/com/faforever/userservice/ui/view/oauth2/LoginView.kt
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: test
🔇 Additional comments (2)
src/main/kotlin/com/faforever/userservice/backend/account/LoginService.kt (2)

42-46: LGTM! Well-structured data class.

The MissedBan data class appropriately captures the essential information needed to inform users about a ban they missed during inactivity.


98-98: Good fix - addresses timing concern from previous review.

Retrieving lastLogin before logging the current attempt ensures the missed-ban detection compares against the user's previous login timestamp rather than the current one.

Comment on lines 107 to 115
val missedGlobalBan = findMissedGlobalBan(user, lastLogin ?: LocalDateTime.now().minusDays(90))
if (missedGlobalBan != null) {
LOG.debug("User '{}' missed a ban {} and needs to be informed about it", usernameOrEmail, missedGlobalBan)
return LoginResult.MissedBan(
missedGlobalBan.reason,
missedGlobalBan.createTime,
missedGlobalBan.expiresAt!!,
)
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Missed-ban selection needs ordering and recency constraints.

The current implementation has three issues:

  1. firstOrNull doesn't guarantee the most recent ban (line 141)

    • banRepository.findGlobalBansByPlayerId() returns an unordered list
    • When multiple bans match, you may surface an older, less relevant one
  2. No explicit check that the ban has expired

    • The logic relies on findActiveGlobalBan catching active bans first
    • Adding an explicit expiresAt < now check makes intent clearer and prevents edge cases
  3. No recency window for expired bans (line 142-143)

    • A ban that expired years ago could still be shown
    • The 90-day fallback on line 107 only applies when lastLogin is null

Apply this diff to select the most recent relevant ban and enforce a 90-day recency window:

-    private fun findMissedGlobalBan(user: User, lastLogin: LocalDateTime): Ban? {
-        return banRepository.findGlobalBansByPlayerId(user.id)
-            .firstOrNull {
-                it.revokeTime == null && it.expiresAt != null && it.createTime.toLocalDateTime().isAfter(lastLogin)
-            }
-    }
+    private fun findMissedGlobalBan(user: User, lastLogin: LocalDateTime): Ban? {
+        val now = OffsetDateTime.now()
+        return banRepository.findGlobalBansByPlayerId(user.id)
+            .filter {
+                it.revokeTime == null &&
+                    it.expiresAt != null &&
+                    it.expiresAt.isBefore(now) &&
+                    it.expiresAt.isAfter(now.minusDays(90)) &&
+                    it.createTime.toLocalDateTime().isAfter(lastLogin)
+            }
+            .maxByOrNull { it.createTime }
+    }

This ensures:

  • You pick the newest qualifying ban (via maxByOrNull)
  • The ban has actually expired (expiresAt < now)
  • The ban expired within the last 90 days (still relevant)
  • The ban was created after the user's last login (they missed it)

Based on learnings

Also applies to: 139-144

🤖 Prompt for AI Agents
In src/main/kotlin/com/faforever/userservice/backend/account/LoginService.kt
around lines 107-115 (and similarly 139-144), the missed-ban selection must be
tightened: replace the current firstOrNull selection with filtering to only bans
that have an expiresAt timestamp before now, whose expiresAt is within 90 days
of now, and whose createTime is after the user's lastLogin (use lastLogin ?:
now.minusDays(90) as the cutoff), then pick the most recent qualifying ban with
maxByOrNull on createTime; return that ban as the MissedBan. Ensure you use a
consistent now = LocalDateTime.now() and null-safety for expiresAt when applying
the comparisons so only truly expired, recent, and post-login bans are
considered.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 0

🧹 Nitpick comments (3)
src/test/kotlin/com/faforever/userservice/backend/domain/LoginServiceTest.kt (3)

88-100: Ban test fixtures updated correctly; consider a shared builder/helper

The added OffsetDateTime.now() argument for createTime in these Ban instances looks correct and keeps existing semantics for “currently banned” and “perma banned” users. However, the long positional argument list is now repeated across multiple tests and will become brittle if Ban gains more fields again.

Consider extracting small helper/builders (e.g., activeGlobalBan(...), permaGlobalBan(...)) or using named factory functions so tests remain readable and resilient to future Ban shape changes.

Also applies to: 114-126


167-186: loginWithUnbannedUser effectively models a revoked ban; optional naming/fixture tweak

This test correctly exercises the case where a global ban exists in history but is no longer active (revoked one day ago) and verifies that login succeeds without hitting MissedBan.

If you want to make the intent clearer for future readers, consider:

  • Renaming the test to something like loginWithRevokedGlobalBan (or similar), and/or
  • Introducing a small helper like revokedBan() to distinguish “revoked” from “expired” bans at the call site.

Purely cosmetic, the current test is logically sound.


188-213: Missed-ban behavior is well covered across time-based scenarios; minor suggestions on magic durations

The four new tests nicely cover the main MissedBan matrix:

  • loginAfterMissedBanExpired: user’s last login is before createTime, ban expired recently → LoginResult.MissedBan.
  • loginAfterKnownBanExpired: last login occurred while the ban was active → normal SuccessfulLogin after expiry.
  • loginAfterRecentBanExpiredWithFailedLoginLog: no login logs but a recent expired global ban → MissedBan.
  • loginAfterOldBanExpiredWithFailedLoginLog: no login logs and a very old expired ban (99/100 days) → SuccessfulLogin.

This aligns well with the PR’s intent to only surface bans that were plausibly “missed” and still relevant.

Two optional maintainability tweaks you might consider:

  1. Avoid hard-coded “old vs recent” cutoffs in tests

    If LoginService uses a specific constant/Duration to decide what counts as an “old” missed ban, it may be worth exposing that (or a derived value) so tests don’t rely on implicit numbers like 99/100 days. That keeps tests in sync if the threshold changes.

  2. Document the time relationships

    A brief comment in each test summarizing the intended timeline (e.g., “ban created 1 day ago, expired 1 hour ago, last login 2 days ago → user never saw it”) would help future maintainers quickly validate the scenarios without re-deriving the timeline from the offsets.

Functionally these tests look solid; these are just minor readability/maintenance improvements.

Also applies to: 216-240, 243-267, 270-294

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 20205fd and a8d8d2e.

📒 Files selected for processing (1)
  • src/test/kotlin/com/faforever/userservice/backend/domain/LoginServiceTest.kt (4 hunks)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: test

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant