17 Commits

Author SHA1 Message Date
f0a3b6c03e Merge pull request 'fix(nav): show the player game-data groups to staff' (#24) from fix/staff-player-menu into main
All checks were successful
SonarQube / analysis (push) Successful in 56s
Release APK / release (push) Successful in 9m55s
Reviewed-on: #24
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-22 08:32:57 +00:00
3aeb295342 fix(nav): show the player game-data groups to staff
All checks were successful
PR Checks / android-build (pull_request) Successful in 6m8s
Staff are a superset of players (all player abilities plus their staff
tools), and the backend's player self-service surface is role-agnostic,
but MenuAccess.PLAYER gated "My characters/vendors/houses" on
role == player — so a signed-in admin/editor/moderator saw neither the
menu items nor, via the greyed personal streams, their own notification
options, even with linked characters.

Gate MenuAccess.PLAYER on isPlayer OR isStaff. The notifications screen
needs no change: once the backend returns the caller's linked accounts
(paired with RunicGateway/website), hasLinkedAccount resolves and the
personal streams enable themselves.

Tests: MenuAccessTest now asserts every staff role sees the player
game-data groups and a PLAYER entry, and an unrecognized role / anon
still cannot. Full unit suite passes.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-22 02:18:33 -05:00
03d4ef6fad Merge pull request 'feat(auth): trusted devices & recovery codes on the mobile client' (#23) from feature/trusted-devices-mfa into main
All checks were successful
SonarQube / analysis (push) Successful in 1m14s
Release APK / release (push) Successful in 9m37s
Reviewed-on: #23
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-22 06:26:54 +00:00
a1fa4901ef @
All checks were successful
PR Checks / android-build (pull_request) Successful in 6m22s
feat(auth): trusted devices & recovery codes on the mobile client

Consumes the merged backend trusted-device + MFA feature
(RunicGateway/website#93, docs#32) per docs/android/PLAN.md §4.1.1.

Login (POST /auth/mobile/login):
- "Trust this device" checkbox and a "use a recovery code instead"
  toggle on the 401 { totpRequired } step; sends trustDevice /
  recoveryCode / device_name and replays a stored X-Trust-Token.
- A returned trustToken is stored in a dedicated, username-scoped
  EncryptedSharedPreferences file (runic_trust, AES-256-GCM), separate
  from the session store so it deliberately SURVIVES logout — the token
  is only consulted at a fresh login, so clearing it there would make
  the feature a no-op. Cleared only on a Settings→Server switch,
  untrust-all, or server-side revocation. (Supersedes the handoff note
  that said clear-on-logout; matches the canonical rg_trust design.)

Account → Security:
- Trusted Devices screen: list / revoke one / untrust all / trust this
  device (persists the returned token).
- Recovery Codes screen: remaining count + password-stepped regenerate
  with a show-once copy/share display; the one-time batch from enabling
  2FA is also surfaced on the account screen.

Login-time trust cap (trustLimitReached) is surfaced + resolved on the
Trusted Devices screen rather than a blocking login modal, since the
native login has already issued the session.

Tests: DTO decode for all new wire shapes + AccountRepository logic
(the 409 cap-body parse, revoke, recovery). 154 unit tests pass;
assembleDebug clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NgyHnrNa8WwG3doxvxjuCr
@
2026-07-22 00:41:56 -05:00
befbc01670 Merge pull request 'fix(shard): decode in-game serials as hex strings, not numbers' (#22) from fix/shard-serial-decode into main
All checks were successful
SonarQube / analysis (push) Successful in 52s
Release APK / release (push) Successful in 9m7s
Reviewed-on: #22
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-22 01:16:54 +00:00
1a14d47d5c fix(shard): decode in-game serials as hex strings, not numbers
All checks were successful
PR Checks / android-build (pull_request) Successful in 5m44s
The public shard board DTOs typed in-game serials (and actor webId) as
Long, but the wire protocol (docs/link/INTEGRATION.md §1) sends them as
opaque hex strings ("0x1A2B"). The website returns board payloads
verbatim, so a guild leader / champ / governor carrying a hex serial
threw JsonDecodingException out of the Retrofit converter and crashed the
app on the Guilds/Champs/Governors boards. The API is the source of
truth, so the DTOs are corrected to match it.

- ActorDto.serial/webId, ChampDto.serial, HouseDto.serial,
  OnlineStaffDto.serial: Long -> String
- champ.remove / house.decay live frames now read serial via stringField;
  longField returned null on a hex serial, silently dropping every board
  removal and live IDOC update
- safeApiCall now catches SerializationException -> ErrorKind.SERVER, so
  any future contract drift degrades to a retry-able error instead of a
  crash (defense in depth)
- DTO + result tests updated to the real hex-string wire shapes

AI-assisted: authored with Claude Code (Opus 4.8).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NgyHnrNa8WwG3doxvxjuCr
2026-07-21 20:09:01 -05:00
d6d966882b Merge pull request 'feat: M10 — native SSO fixes + staff operations' (#21) from feat/m10-native-sso-fix into main
All checks were successful
SonarQube / analysis (push) Successful in 52s
Reviewed-on: #21
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-21 21:38:46 +00:00
422892f1ed feat(sso): single "Sign in with SSO" button with a native provider picker
All checks were successful
PR Checks / android-build (pull_request) Successful in 6m10s
Release APK / release (push) Successful in 9m26s
Collapse the per-provider login buttons into one "Sign in with SSO" entry. With a
single configured provider it launches straight through; with several it opens a
native ModalBottomSheet picker (driven by the discovery list the app already
fetches — no website chooser page, no Google SDK). Each row opens the Custom-Tab
bridge for that provider.

Also make the login screen dismiss reliably after any sign-in: the LOGIN
destination now pops as soon as the shared session becomes SignedIn, not only via
the login VM's local flag — the deep-link/recomposition timing of the Custom-Tab
return could otherwise leave the login screen up even though the session was
established.

Verified on emulator with two providers: the picker lists both, completing SSO via
one signs in and returns to Home (exchange 200, session persisted). lint + build green.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-21 16:28:32 -05:00
7f876377f0 feat(admin): moderation + support queue (M10 Phase 3, part 3)
The final two staff groups, both admin/moderator (MODERATOR menu access; StaffGate
now takes a role predicate). Over the shard write plane `/admin/shard/*`:
- Moderation: kick / ban / unban an account + broadcast a system message
  (AdminModerationScreen form + AdminModerationViewModel guarded actions).
- Support queue: list open help pages, reply (optionally closing), close
  (AdminSupportScreen + AdminSupportViewModel).

These need a live sidecar; offline they degrade cleanly (a clear error on writes,
an empty queue on the list) — never a crash (§7). AdminApi/AdminDto/AdminRepository
extended with the shard-op + help-page endpoints.

Verified on emulator: both entries appear for an admin (drawer now scrolls through
all four staff items); moderation broadcast returns a clean failure with the shard
offline; the support queue shows its empty state. assembleDebug + lint green.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-21 16:04:50 -05:00
c5596845c1 feat(admin): staff content — news posts + wiki taxonomy (M10 Phase 3, part 2)
Second staff group over the existing /admin routes (any staff role; bearer-authed,
role re-checked every request). AdminApi/AdminDto/AdminRepository gain posts
(list/create/publish-toggle/delete) and wiki taxonomy (list categories + tags,
create/delete category). AdminContentScreen is a two-tab screen (Posts | Wiki) with
create dialogs; the CMS block/hero editor stays out of scope. Admin wiki DTOs are
prefixed (AdminWikiCategoryDto/AdminWikiTagDto) to avoid colliding with the public
wiki DTOs.

Verified on emulator against the dev backend: posts list with published/draft pills;
publish/unpublish flips the DB row with live reload; create a news post; create +
delete a wiki category (confirmed in MariaDB). assembleDebug + lint green.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-21 15:55:48 -05:00
ac99a012b0 feat(admin): staff nav + dashboard/site-mode (M10 Phase 3, part 1)
Add the staff-operations surface scaffolding and the first group. Session gains
isStaff/isModerator/isAdmin; the menu gains STAFF (admin/editor/moderator) and
MODERATOR (admin/moderator) access levels, plus a StaffGate mirroring PlayerGate.

Dashboard group (over the existing /api/v1/admin, bearer-authed, role re-checked
every request): AdminApi/AdminDto/AdminRepository for GET /admin/dashboard and
PUT /admin/site-mode; AdminDashboardScreen shows site mode, summary counts, and
recent admin activity, with an admin-only maintenance/live toggle.

Verified on emulator against the dev backend: an admin sees the Dashboard entry
(a player does not); counts + audit log render from real data; the site-mode
toggle flips /public/status to maintenance and back to live. MenuAccessTest +2
(8 total), assembleDebug + lint green.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-21 15:37:05 -05:00
44d039d2a0 fix(nav): make the navigation drawer scrollable
The ModalDrawerSheet stacked all items in a non-scrolling column. A signed-in
session adds My account, Notifications, and the three player groups (11 nav items
+ sign-out + change-server), which overflows the drawer height on shorter screens
or larger display-size / font-scale settings — clipping the lower entries
(Notifications among them) so they can't be reached. Wrap the drawer content in a
verticalScroll column so every entry is reachable regardless of screen height.

Verified on-device: a signed-in player sees Home…My houses + Sign out + Change
server, with Notifications present and its screen reachable.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-21 15:18:53 -05:00
0f93f3dcd3 fix(sso): make native SSO discovery legible and survive process death
On-device, the native SSO buttons never appeared and the flow dumped users on
the desktop website login (which can't deep-link a mobile session back), so it
hung. Two app-side causes:

1. Discovery conflated "no providers" with "call failed" (ssoProviders() returned
   emptyList() on any error) and the screen then showed a dead website-login
   hand-off. Now ssoProviders() returns Available/None/Unavailable, retries once,
   and the login screen renders native provider buttons, a loading hint, or a
   retry — never the website login fallback (removed, along with WebsiteUrls.login).

2. The pending {state, verifier} lived only in memory, so a Custom-Tab-induced
   process eviction lost it and the exchange failed STATE_MISMATCH. Persist it via
   a new encrypted PendingSsoStore (EncryptedSharedPreferences, mirrors the token
   store), cleared the moment the callback is consumed so replays still fail closed.

SsoAuthManager stays framework-free (store behind an interface). +1 test proving a
fresh manager on the persisted store completes (process-death sim); 15/15 SSO tests
pass, lint + assembleDebug green (JDK21, -Pksp.incremental=false).

Verified end-to-end against the local site via the dev stub IdP: player and admin
both sign in natively and receive the correct role.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-21 15:04:53 -05:00
c69d704881 Merge pull request 'fix(security): declare explicit network security config to forbid cleartext' (#20) from fix/manifest-cleartext-traffic into main
All checks were successful
SonarQube / analysis (push) Successful in 1m4s
Reviewed-on: #20
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-21 05:25:36 +00:00
26b8eecde6 fix(security): declare explicit network security config to forbid cleartext
All checks were successful
PR Checks / android-build (pull_request) Successful in 10m30s
The app is purely an HTTPS API client, but the manifest left
usesCleartextTraffic implicit, which SonarQube S5332 flags (cleartext is
implicitly permitted on older Android and a merged library manifest could
re-enable it). Add an explicit network security config:

- main/release: base-config cleartextTrafficPermitted="false" (no cleartext).
- debug override (app/src/debug/res/xml): re-permits cleartext to loopback
  (127.0.0.1/localhost) only, for local dev against http://127.0.0.1:3000.

This mirrors ServerUrl's rule (HTTPS required in release, HTTP allowed in
debug via allowInsecureHttp = BuildConfig.DEBUG) at the platform socket
layer. It also fixes a latent gap: at targetSdk 28+ the platform default
already blocks cleartext, so the debug loopback path only actually works
with the explicit domain-config now added.

Docs updated in RunicGateway/docs (android/PLAN.md M1).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-20 23:39:12 -05:00
f729b772fc Merge pull request 'ci(sonarqube): add non-blocking SonarQube analysis (project key Runic-Gateway-Android-app)' (#19) from ci/sonarqube-fix-project-key into main
All checks were successful
SonarQube / analysis (push) Successful in 57s
Reviewed-on: #19
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-21 04:32:35 +00:00
402d750138 ci(sonarqube): add non-blocking SonarQube analysis on push to main
All checks were successful
PR Checks / android-build (pull_request) Successful in 10m42s
Mirrors the website repo's setup: a source-based scan of app/src/main
(Kotlin) that reports to the self-hosted SonarQube server after merge,
never gating PRs.

Uses the existing SonarQube project key Runic-Gateway-Android-app (the
server rejects re-creating a case-variant key). Supersedes #18.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-20 23:20:35 -05:00
57 changed files with 3430 additions and 119 deletions

View File

@@ -8,7 +8,7 @@
# Prerequisites (one-time, in the Gitea UI — Repo → Settings → Actions): # Prerequisites (one-time, in the Gitea UI — Repo → Settings → Actions):
# • Secret SONAR_TOKEN — a SonarQube "Analysis" token generated at # • Secret SONAR_TOKEN — a SonarQube "Analysis" token generated at
# My Account → Security in SonarQube for the # My Account → Security in SonarQube for the
# runic-gateway-android-app project (or a global one). # Runic-Gateway-Android-app project (or a global one).
# • Variable SONAR_HOST_URL — the SonarQube base URL on your LAN, e.g. # • Variable SONAR_HOST_URL — the SonarQube base URL on your LAN, e.g.
# http://192.168.0.56:9000 # http://192.168.0.56:9000
# (kept as a variable, not committed, so the internal address stays out of git.) # (kept as a variable, not committed, so the internal address stays out of git.)

View File

@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- SPDX-License-Identifier: GPL-3.0-or-later -->
<!--
Debug-only override of the main network_security_config.xml. Keeps the secure
base posture (no cleartext) but re-permits cleartext to loopback so debug builds
can reach a local website backend at http://127.0.0.1:3000 / http://localhost:3000
(ServerUrl allows plain HTTP only when allowInsecureHttp = BuildConfig.DEBUG).
Because the platform default already blocks cleartext at targetSdk 28+, this
domain-config is what actually makes the debug local-dev path work at runtime.
This file is compiled only into debug builds; release builds use the main
source set's config and permit no cleartext at all.
-->
<network-security-config>
<base-config cleartextTrafficPermitted="false" />
<domain-config cleartextTrafficPermitted="true">
<domain includeSubdomains="false">127.0.0.1</domain>
<domain includeSubdomains="false">localhost</domain>
</domain-config>
</network-security-config>

View File

@@ -20,6 +20,7 @@
android:fullBackupContent="@xml/backup_rules" android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher" android:icon="@mipmap/ic_launcher"
android:label="@string/app_name" android:label="@string/app_name"
android:networkSecurityConfig="@xml/network_security_config"
android:roundIcon="@mipmap/ic_launcher_round" android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true" android:supportsRtl="true"
android:theme="@style/Theme.RunicGateway"> android:theme="@style/Theme.RunicGateway">

View File

@@ -0,0 +1,34 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.core.auth
import android.os.Build
import javax.inject.Inject
import javax.inject.Singleton
/**
* Supplies a friendly label for this device, sent as `device_name` at login so a
* trusted-device / active-session row is recognizable in the account lists
* (TRUSTED_DEVICES_MFA.md). Behind an interface so the auth repository stays free of
* `android.os.Build` and unit-testable on the JVM.
*/
fun interface DeviceNameProvider {
/** A human label like "Google Pixel 8", or null if nothing meaningful is available. */
fun deviceName(): String?
}
/** Production impl: manufacturer + model from [Build] (e.g. "Samsung SM-S918B"). */
@Singleton
class BuildDeviceNameProvider @Inject constructor() : DeviceNameProvider {
override fun deviceName(): String? {
val manufacturer = Build.MANUFACTURER?.trim().orEmpty()
val model = Build.MODEL?.trim().orEmpty()
val label = when {
model.isEmpty() -> manufacturer
manufacturer.isEmpty() || model.startsWith(manufacturer, ignoreCase = true) -> model
else -> "$manufacturer $model"
}.replaceFirstChar { if (it.isLowerCase()) it.titlecase() else it.toString() }
return label.take(100).ifBlank { null }
}
}

View File

@@ -0,0 +1,65 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.core.auth
import android.content.Context
import android.content.SharedPreferences
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
import javax.inject.Singleton
/**
* [TrustTokenStore] backed by its **own** EncryptedSharedPreferences file
* (Tink/AES-256-GCM), distinct from the session store so it is never wiped by
* [SessionManager.onSignedOut] — the trust token must outlive a logout to do its
* job (TRUSTED_DEVICES_MFA.md). The token is stored alongside the username it was
* minted for so [tokenFor] only returns it for a matching login.
*
* The prefs handle is lazy so a device that never trusts pays the keystore cost
* only if a token is actually stored or read.
*/
@Singleton
class EncryptedTrustTokenStore @Inject constructor(
@param:ApplicationContext private val context: Context,
) : TrustTokenStore {
private val prefs: SharedPreferences by lazy {
val masterKey = MasterKey.Builder(context)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()
EncryptedSharedPreferences.create(
context,
PREFS_NAME,
masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
)
}
override fun tokenFor(username: String): String? {
val token = prefs.getString(KEY_TOKEN, null) ?: return null
val owner = prefs.getString(KEY_USERNAME, null) ?: return null
// Case-insensitive: usernames are matched case-insensitively server-side.
return if (owner.equals(username, ignoreCase = true)) token else null
}
override fun save(username: String, token: String) {
prefs.edit()
.putString(KEY_TOKEN, token)
.putString(KEY_USERNAME, username)
.apply()
}
override fun clear() {
prefs.edit().clear().apply()
}
private companion object {
const val PREFS_NAME = "runic_trust"
const val KEY_TOKEN = "trust_token"
const val KEY_USERNAME = "trust_username"
}
}

View File

@@ -16,6 +16,15 @@ data class SessionUser(
val role: Role, val role: Role,
) { ) {
val isPlayer: Boolean get() = role == Role.PLAYER val isPlayer: Boolean get() = role == Role.PLAYER
/** Any staff role (moderator/editor/admin) — the staff-operations surface (§1, M10). */
val isStaff: Boolean get() = role.isStaff
/** Admin or moderator — moderation actions + the support queue (`modAccess`). */
val isModerator: Boolean get() = role == Role.ADMIN || role == Role.MODERATOR
/** Admin only — site-mode and other `adminOnly` controls. */
val isAdmin: Boolean get() = role == Role.ADMIN
} }
/** /**

View File

@@ -0,0 +1,31 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.core.auth
/**
* At-rest home for the opaque trusted-device token (TRUSTED_DEVICES_MFA.md). It is
* the native analogue of the web `rg_trust` cookie: a device that holds a valid
* token skips the TOTP step on its next login (never the password).
*
* Deliberately **separate** from [TokenStore] and untouched by session teardown —
* the token must **survive logout and a dead-refresh sign-out**, because it is only
* ever consulted at a *fresh* login (exactly the moment after the session is gone).
* Clearing it there would make the feature a no-op. It is scoped to the username it
* was minted for so it is never replayed for a different account on a shared device,
* and is cleared only by an explicit untrust, a Settings → Server switch, or a
* server-side revocation (password change/reset, TOTP disable) that renders it dead.
*
* Tokens are sensitive, so the production impl uses EncryptedSharedPreferences —
* never plain prefs or logs. Kept behind an interface for an in-memory test fake.
*/
interface TrustTokenStore {
/** The stored trust token for [username], or null if this device isn't trusted for them. */
fun tokenFor(username: String): String?
/** Persist [token] as the trust token for [username] (overwrites any prior one). */
fun save(username: String, token: String)
/** Drop the trust token — untrust-all and the Settings → Server hard reset. */
fun clear()
}

View File

@@ -0,0 +1,61 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.core.auth.sso
import android.content.Context
import android.content.SharedPreferences
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
import javax.inject.Singleton
/**
* [PendingSsoStore] backed by Jetpack Security's [EncryptedSharedPreferences]
* (Tink/AES-256-GCM), so the PKCE verifier is encrypted at rest for the brief
* window a flow is in progress. Separate prefs file from the session token store —
* this holds only the transient SSO handshake, cleared as soon as the callback is
* consumed. Lazy, so a device that never signs in via SSO pays no keystore cost.
*/
@Singleton
class EncryptedPendingSsoStore @Inject constructor(
@param:ApplicationContext private val context: Context,
) : PendingSsoStore {
private val prefs: SharedPreferences by lazy {
val masterKey = MasterKey.Builder(context)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()
EncryptedSharedPreferences.create(
context,
PREFS_NAME,
masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
)
}
override fun save(state: String, verifier: String) {
prefs.edit()
.putString(KEY_STATE, state)
.putString(KEY_VERIFIER, verifier)
.apply()
}
override fun load(): PendingSso? {
val state = prefs.getString(KEY_STATE, null) ?: return null
val verifier = prefs.getString(KEY_VERIFIER, null) ?: return null
return PendingSso(state = state, verifier = verifier)
}
override fun clear() {
prefs.edit().clear().apply()
}
private companion object {
const val PREFS_NAME = "runic_sso_pending"
const val KEY_STATE = "state"
const val KEY_VERIFIER = "verifier"
}
}

View File

@@ -0,0 +1,24 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.core.auth.sso
/**
* Persists the in-flight SSO `{state, verifier}` (PKCE Layer B + CSRF state) across
* the Custom-Tab round trip so the exchange survives process death — a low-memory
* device can evict the app while the Custom Tab is foreground, and the callback then
* returns to a fresh process (PLAN.md §4.2). Kept behind an interface so
* [SsoAuthManager] stays framework-free and unit-tests on the JVM with a fake.
*
* Exactly one flow is pending at a time; [save] overwrites any prior. The verifier
* is a bearer-equivalent secret for the one-time code, so the production impl
* ([EncryptedPendingSsoStore]) encrypts it at rest, mirroring the token store.
*/
interface PendingSsoStore {
fun save(state: String, verifier: String)
fun load(): PendingSso?
fun clear()
}
/** The stashed CSRF state + PKCE verifier for the current SSO attempt. */
data class PendingSso(val state: String, val verifier: String)

View File

@@ -13,7 +13,6 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import java.io.IOException import java.io.IOException
import java.util.concurrent.atomic.AtomicReference
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
@@ -34,20 +33,22 @@ import javax.inject.Singleton
* parses the callback `Uri` (the Android edge) and hands the raw params here, * parses the callback `Uri` (the Android edge) and hands the raw params here,
* so this class stays free of framework types and unit-tests on the JVM. * so this class stays free of framework types and unit-tests on the JVM.
* *
* The pending `{state, verifier}` lives only in memory: if the process is killed * The pending `{state, verifier}` is persisted via [PendingSsoStore] (encrypted at
* while the Custom Tab is foreground it is lost and the exchange **fails closed** * rest), so the exchange survives the process being evicted while the Custom Tab is
* (the user simply retries) — never a security downgrade. * foreground — the callback can land in a fresh process and still complete. It is
* cleared the moment [complete] consumes it, so a lost/duplicate callback still
* **fails closed** as [Failure.STATE_MISMATCH] rather than double-exchanging.
* *
* Threading: [buildStartUrl] runs on the UI thread; [complete] runs on the * Threading: [buildStartUrl] runs on the UI thread; [complete] runs on the
* activity's coroutine scope after a deep link. The pending holder is an * activity's coroutine scope after a deep link. [outcome] is a [StateFlow], so a
* [AtomicReference] and [outcome] a [StateFlow], so a ViewModel/activity recreation * ViewModel/activity recreation while the Custom Tab is open cannot drop a result.
* while the Custom Tab is open cannot drop a result.
*/ */
@Singleton @Singleton
class SsoAuthManager @Inject constructor( class SsoAuthManager @Inject constructor(
private val ssoApi: SsoApi, private val ssoApi: SsoApi,
private val sessionManager: SessionManager, private val sessionManager: SessionManager,
private val baseUrlHolder: BaseUrlHolder, private val baseUrlHolder: BaseUrlHolder,
private val pendingStore: PendingSsoStore,
) { ) {
/** Why an SSO attempt ended, for a friendly inline message on the login screen. */ /** Why an SSO attempt ended, for a friendly inline message on the login screen. */
@@ -75,10 +76,6 @@ class SsoAuthManager @Inject constructor(
data class Failed(val reason: Failure) : Outcome data class Failed(val reason: Failure) : Outcome
} }
private data class Pending(val state: String, val verifier: String)
private val pending = AtomicReference<Pending?>(null)
/** /**
* The host this build baked an App Link intent-filter for (`BuildConfig.APP_LINK_HOST`, * The host this build baked an App Link intent-filter for (`BuildConfig.APP_LINK_HOST`,
* empty on the generic multi-tenant build — see docs/android/APP_LINKS.md). * empty on the generic multi-tenant build — see docs/android/APP_LINKS.md).
@@ -97,16 +94,16 @@ class SsoAuthManager @Inject constructor(
/** /**
* Build the `/auth/mobile/sso/start` URL for [providerId] and stash the pending * Build the `/auth/mobile/sso/start` URL for [providerId] and stash the pending
* PKCE verifier + CSRF state. Returns null when no shard site is configured yet * PKCE verifier + CSRF state (persisted so it survives process death). Returns
* (the caller then keeps the website hand-off fallback). Also resets [outcome] * null when no shard site is configured yet. Also resets [outcome] to
* to [Outcome.Idle] so a stale prior result can't fire against the new attempt. * [Outcome.Idle] so a stale prior result can't fire against the new attempt.
*/ */
fun buildStartUrl(providerId: String): String? { fun buildStartUrl(providerId: String): String? {
val base = baseUrlHolder.current ?: return null val base = baseUrlHolder.current ?: return null
val verifier = Pkce.newVerifier() val verifier = Pkce.newVerifier()
val challenge = Pkce.challengeOf(verifier) val challenge = Pkce.challengeOf(verifier)
val state = Pkce.newState() val state = Pkce.newState()
pending.set(Pending(state = state, verifier = verifier)) pendingStore.save(state = state, verifier = verifier)
_outcome.value = Outcome.Idle _outcome.value = Outcome.Idle
return base.newBuilder() return base.newBuilder()
.addPathSegments("api/v1/auth/mobile/sso/start") .addPathSegments("api/v1/auth/mobile/sso/start")
@@ -158,7 +155,8 @@ class SsoAuthManager @Inject constructor(
* single-uses the code). * single-uses the code).
*/ */
suspend fun complete(state: String?, code: String?, error: String?) { suspend fun complete(state: String?, code: String?, error: String?) {
val stashed = pending.getAndSet(null) val stashed = pendingStore.load()
pendingStore.clear()
// CSRF: the callback must echo the exact state we generated at /start. // CSRF: the callback must echo the exact state we generated at /start.
if (stashed == null || state.isNullOrEmpty() || state != stashed.state) { if (stashed == null || state.isNullOrEmpty() || state != stashed.state) {

View File

@@ -4,6 +4,7 @@
package com.runicgateway.app.core.result package com.runicgateway.app.core.result
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
import kotlinx.serialization.SerializationException
import retrofit2.HttpException import retrofit2.HttpException
import java.io.IOException import java.io.IOException
@@ -37,6 +38,16 @@ inline fun <T, R> ApiResult<T>.map(transform: (T) -> R): ApiResult<R> = when (th
* Run a suspending Retrofit call and normalize every outcome into an [ApiResult]. * Run a suspending Retrofit call and normalize every outcome into an [ApiResult].
* Coroutine cancellation is rethrown so structured concurrency still works — it * Coroutine cancellation is rethrown so structured concurrency still works — it
* is control flow, not a network failure. * is control flow, not a network failure.
*
* A body the app can't decode (a field whose type/shape doesn't match its DTO, e.g.
* a live-shaped `guild.update` snapshot carrying an unexpected value) throws a
* [SerializationException] out of the Retrofit converter. That is a broken contract
* with the backend, not a bug to crash on: the request completed but the response is
* unusable — an invalid upstream response — so it is surfaced as a server-side error
* (`502` → [ErrorKind.SERVER]) the screen renders as "something went wrong, retry",
* exactly the graceful-degradation the layer promises (never throw for an expected
* failure). Without this catch the exception escapes the collecting coroutine and
* takes down the whole app.
*/ */
suspend fun <T> safeApiCall(block: suspend () -> T): ApiResult<T> = try { suspend fun <T> safeApiCall(block: suspend () -> T): ApiResult<T> = try {
ApiResult.Ok(block()) ApiResult.Ok(block())
@@ -46,4 +57,9 @@ suspend fun <T> safeApiCall(block: suspend () -> T): ApiResult<T> = try {
ApiResult.HttpError(e.code(), e.message()) ApiResult.HttpError(e.code(), e.message())
} catch (e: IOException) { } catch (e: IOException) {
ApiResult.NetworkError(e) ApiResult.NetworkError(e)
} catch (e: SerializationException) {
ApiResult.HttpError(MALFORMED_RESPONSE_STATUS, e.message)
} }
/** Synthetic status for a 2xx body the app couldn't decode — an invalid upstream response. */
private const val MALFORMED_RESPONSE_STATUS = 502

View File

@@ -26,12 +26,8 @@ class WebsiteUrls @Inject constructor(
/** Forgot / reset password (the flow built on the backend before app work, §8). */ /** Forgot / reset password (the flow built on the backend before app work, §8). */
fun forgotPassword(): String? = resolve(FORGOT) fun forgotPassword(): String? = resolve(FORGOT)
/** The website login page — carries the SSO provider buttons (§4.2). */
fun login(): String? = resolve(LOGIN)
private companion object { private companion object {
const val REGISTER = "account/register" const val REGISTER = "account/register"
const val FORGOT = "account/forgot" const val FORGOT = "account/forgot"
const val LOGIN = "account/login"
} }
} }

View File

@@ -0,0 +1,97 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.data.api
import com.runicgateway.app.data.api.dto.AdminDashboardDto
import com.runicgateway.app.data.api.dto.AdminPostDto
import com.runicgateway.app.data.api.dto.BanRequest
import com.runicgateway.app.data.api.dto.BroadcastRequest
import com.runicgateway.app.data.api.dto.KickRequest
import com.runicgateway.app.data.api.dto.PageRespondRequest
import com.runicgateway.app.data.api.dto.PostCreateRequest
import com.runicgateway.app.data.api.dto.PublishRequest
import com.runicgateway.app.data.api.dto.SiteModeRequest
import com.runicgateway.app.data.api.dto.SiteModeStateDto
import com.runicgateway.app.data.api.dto.SupportPageDto
import com.runicgateway.app.data.api.dto.UnbanRequest
import com.runicgateway.app.data.api.dto.AdminWikiCategoryDto
import com.runicgateway.app.data.api.dto.WikiCategoryRequest
import com.runicgateway.app.data.api.dto.AdminWikiTagDto
import retrofit2.Response
import retrofit2.http.Body
import retrofit2.http.DELETE
import retrofit2.http.GET
import retrofit2.http.PATCH
import retrofit2.http.PUT
import retrofit2.http.POST
import retrofit2.http.Path
/**
* The M10 staff-operations surface over `/api/v1/admin/…` (PLAN.md §1, §6.4). On
* the authed client — every call carries the bearer, and the backend re-checks the
* caller's role on every request (`staffOnly` / `modAccess` / `adminOnly`), so a
* demoted user is refused server-side even if a stale menu still showed the entry.
*
* Grows one group at a time (dashboard first); moderation, support, and content
* endpoints are added with their screens.
*/
interface AdminApi {
/** `GET /admin/dashboard` — summary counts + site mode (any staff role). */
@GET("api/v1/admin/dashboard")
suspend fun dashboard(): AdminDashboardDto
/** `PUT /admin/site-mode` — switch live/maintenance (admin only; 403 otherwise). */
@PUT("api/v1/admin/site-mode")
suspend fun setSiteMode(@Body body: SiteModeRequest): SiteModeStateDto
// ── Content: news posts (any staff role) ──────────────────────────────
@GET("api/v1/admin/posts")
suspend fun posts(): List<AdminPostDto>
@POST("api/v1/admin/posts")
suspend fun createPost(@Body body: PostCreateRequest): AdminPostDto
@PATCH("api/v1/admin/posts/{id}/publish")
suspend fun publishPost(@Path("id") id: Long, @Body body: PublishRequest): AdminPostDto
@DELETE("api/v1/admin/posts/{id}")
suspend fun deletePost(@Path("id") id: Long): Response<Unit>
// ── Content: wiki taxonomy (any staff role) ───────────────────────────
@GET("api/v1/admin/wiki/categories")
suspend fun wikiCategories(): List<AdminWikiCategoryDto>
@POST("api/v1/admin/wiki/categories")
suspend fun createWikiCategory(@Body body: WikiCategoryRequest): AdminWikiCategoryDto
@DELETE("api/v1/admin/wiki/categories/{id}")
suspend fun deleteWikiCategory(@Path("id") id: Long): Response<Unit>
@GET("api/v1/admin/wiki/tags")
suspend fun wikiTags(): List<AdminWikiTagDto>
// ── Moderation: shard write plane (admin/moderator) ───────────────────
@POST("api/v1/admin/shard/kick")
suspend fun kick(@Body body: KickRequest): Response<Unit>
@POST("api/v1/admin/shard/ban")
suspend fun ban(@Body body: BanRequest): Response<Unit>
@POST("api/v1/admin/shard/unban")
suspend fun unban(@Body body: UnbanRequest): Response<Unit>
@POST("api/v1/admin/shard/broadcast")
suspend fun broadcast(@Body body: BroadcastRequest): Response<Unit>
// ── Support queue: help pages (admin/moderator) ───────────────────────
@GET("api/v1/admin/shard/pages")
suspend fun supportPages(): List<SupportPageDto>
@POST("api/v1/admin/shard/pages/{id}/respond")
suspend fun respondPage(@Path("id") id: String, @Body body: PageRespondRequest): Response<Unit>
@POST("api/v1/admin/shard/pages/{id}/close")
suspend fun closePage(@Path("id") id: String): Response<Unit>
}

View File

@@ -10,6 +10,7 @@ import com.runicgateway.app.data.api.dto.MobileTokenResponse
import retrofit2.Response import retrofit2.Response
import retrofit2.http.Body import retrofit2.http.Body
import retrofit2.http.GET import retrofit2.http.GET
import retrofit2.http.Header
import retrofit2.http.Headers import retrofit2.http.Headers
import retrofit2.http.POST import retrofit2.http.POST
@@ -27,9 +28,15 @@ import retrofit2.http.POST
interface AuthApi { interface AuthApi {
// Literal header value required by Retrofit @Headers; matches Http.NO_SESSION_HEADER. // Literal header value required by Retrofit @Headers; matches Http.NO_SESSION_HEADER.
// [trustToken] rides the `X-Trust-Token` header (TRUSTED_DEVICES_MFA.md): a valid
// token bound to this user lets the server skip the TOTP step. Retrofit omits the
// header entirely when it is null, so an untrusted device sends nothing.
@Headers("X-Runic-No-Session: 1") @Headers("X-Runic-No-Session: 1")
@POST("api/v1/auth/mobile/login") @POST("api/v1/auth/mobile/login")
suspend fun login(@Body body: MobileLoginRequest): Response<MobileTokenResponse> suspend fun login(
@Body body: MobileLoginRequest,
@Header("X-Trust-Token") trustToken: String? = null,
): Response<MobileTokenResponse>
@POST("api/v1/auth/mobile/logout") @POST("api/v1/auth/mobile/logout")
suspend fun logout(@Body body: MobileLogoutRequest): Response<Unit> suspend fun logout(@Body body: MobileLogoutRequest): Response<Unit>

View File

@@ -7,11 +7,21 @@ import com.runicgateway.app.data.api.dto.ChangePasswordRequest
import com.runicgateway.app.data.api.dto.ChangeUsernameRequest import com.runicgateway.app.data.api.dto.ChangeUsernameRequest
import com.runicgateway.app.data.api.dto.LinkedIdentityDto import com.runicgateway.app.data.api.dto.LinkedIdentityDto
import com.runicgateway.app.data.api.dto.PlayerAccountDto import com.runicgateway.app.data.api.dto.PlayerAccountDto
import com.runicgateway.app.data.api.dto.RecoveryCodesDto
import com.runicgateway.app.data.api.dto.RecoveryGenerateRequest
import com.runicgateway.app.data.api.dto.RecoveryStatusDto
import com.runicgateway.app.data.api.dto.RevokedCountDto
import com.runicgateway.app.data.api.dto.RevokedFlagDto
import com.runicgateway.app.data.api.dto.TotpCodeRequest import com.runicgateway.app.data.api.dto.TotpCodeRequest
import com.runicgateway.app.data.api.dto.TotpSetupDto import com.runicgateway.app.data.api.dto.TotpSetupDto
import com.runicgateway.app.data.api.dto.TotpStateDto import com.runicgateway.app.data.api.dto.TotpStateDto
import com.runicgateway.app.data.api.dto.TrustDeviceRequest
import com.runicgateway.app.data.api.dto.TrustDeviceResultDto
import com.runicgateway.app.data.api.dto.TrustedDeviceDto
import com.runicgateway.app.data.api.dto.UsernameResponse import com.runicgateway.app.data.api.dto.UsernameResponse
import retrofit2.Response
import retrofit2.http.Body import retrofit2.http.Body
import retrofit2.http.DELETE
import retrofit2.http.GET import retrofit2.http.GET
import retrofit2.http.HTTP import retrofit2.http.HTTP
import retrofit2.http.PATCH import retrofit2.http.PATCH
@@ -52,4 +62,28 @@ interface MeApi {
// path template explicit alongside the provider argument. // path template explicit alongside the provider argument.
@HTTP(method = "DELETE", path = "api/v1/auth/me/account/identities/{provider}") @HTTP(method = "DELETE", path = "api/v1/auth/me/account/identities/{provider}")
suspend fun unlinkIdentity(@Path("provider") provider: String): Unit suspend fun unlinkIdentity(@Path("provider") provider: String): Unit
// ── Trusted devices (TRUSTED_DEVICES_MFA.md) — devices allowed to skip TOTP ──
@GET("api/v1/auth/me/trusted-devices")
suspend fun trustedDevices(): List<TrustedDeviceDto>
// Raw [Response] so the caller can read the `409 { error, devices }` cap body,
// which a thrown HttpException would discard.
@POST("api/v1/auth/me/trusted-devices")
suspend fun trustThisDevice(@Body body: TrustDeviceRequest): Response<TrustDeviceResultDto>
@DELETE("api/v1/auth/me/trusted-devices/{id}")
suspend fun revokeTrustedDevice(@Path("id") id: Long): RevokedFlagDto
@DELETE("api/v1/auth/me/trusted-devices")
suspend fun revokeAllTrustedDevices(): RevokedCountDto
// ── Recovery (backup) codes ──────────────────────────────────────────────
@GET("api/v1/auth/me/account/recovery-codes/status")
suspend fun recoveryCodesStatus(): RecoveryStatusDto
@POST("api/v1/auth/me/account/recovery-codes/generate")
suspend fun generateRecoveryCodes(@Body body: RecoveryGenerateRequest): RecoveryCodesDto
} }

View File

@@ -55,9 +55,78 @@ data class TotpSetupDto(
@Serializable @Serializable
data class TotpCodeRequest(val code: String) data class TotpCodeRequest(val code: String)
/** Result of enabling/disabling 2FA. */ /**
* Result of enabling/disabling 2FA. Enabling also returns the freshly generated
* single-use [recoveryCodes] **once** (null on disable and for older backends) — the
* app shows them for the user to save and never persists them.
*/
@Serializable @Serializable
data class TotpStateDto(val totp_enabled: Boolean = false) data class TotpStateDto(
val totp_enabled: Boolean = false,
val recoveryCodes: List<String>? = null,
)
// ── Trusted devices & recovery codes (TRUSTED_DEVICES_MFA.md) ───────────────
/**
* An active trusted device (`GET /auth/me/trusted-devices`): a browser/app allowed
* to skip the TOTP step at login. Never carries the token. Timestamps are ISO-8601
* strings shown as-is (advisory display).
*/
@Serializable
data class TrustedDeviceDto(
val id: Long = 0,
val platform: String? = null,
val deviceName: String? = null,
val userAgent: String? = null,
val createdAt: String? = null,
val lastUsedAt: String? = null,
val expiresAt: String? = null,
)
/** `POST /auth/me/trusted-devices` body — an optional friendly label. */
@Serializable
data class TrustDeviceRequest(val deviceName: String? = null)
/**
* `POST /auth/me/trusted-devices` success (native): the opaque [trustToken] to store
* and replay via `X-Trust-Token`. Web receives the token as a cookie and no body token.
*/
@Serializable
data class TrustDeviceResultDto(
val trusted: Boolean = false,
val trustToken: String? = null,
)
/**
* `409 { error: "trusted_device_limit", devices }` from a trust attempt at the cap —
* the app lists [devices] and asks the user to revoke one, then retry.
*/
@Serializable
data class TrustedDeviceLimitDto(
val error: String? = null,
val devices: List<TrustedDeviceDto> = emptyList(),
)
/** `DELETE /auth/me/trusted-devices/:id` — idempotent single-revoke result. */
@Serializable
data class RevokedFlagDto(val revoked: Boolean = false)
/** `DELETE /auth/me/trusted-devices` — count of devices untrusted ("untrust all"). */
@Serializable
data class RevokedCountDto(val revoked: Int = 0)
/** `GET /auth/me/account/recovery-codes/status` — remaining unused count only. */
@Serializable
data class RecoveryStatusDto(val remaining: Int = 0)
/** `POST /auth/me/account/recovery-codes/generate` body — password step-up. */
@Serializable
data class RecoveryGenerateRequest(val currentPassword: String? = null)
/** A fresh single-use recovery-code batch, returned **once** (generate + totp enable). */
@Serializable
data class RecoveryCodesDto(val recoveryCodes: List<String> = emptyList())
/** A linked external identity (`GET /auth/me/account/identities`). */ /** A linked external identity (`GET /auth/me/account/identities`). */
@Serializable @Serializable

View File

@@ -0,0 +1,179 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.data.api.dto
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonElement
/**
* Wire shapes for the M10 staff-operations surface over `/api/v1/admin/…` (PLAN.md
* §1, §6.4). These are consumed only by the staff screens (dashboard, moderation,
* support, content); every DTO ignores unknown keys (NetworkModule's lenient Json)
* so additive backend fields stay safe. Nothing here is auto-provisioned or secret.
*/
/** `GET /admin/dashboard` — the staff landing summary. */
@Serializable
data class AdminDashboardDto(
@SerialName("site_mode") val siteMode: String = "live",
@SerialName("last_change") val lastChange: SiteModeChangeDto = SiteModeChangeDto(),
val counts: AdminCountsDto = AdminCountsDto(),
@SerialName("recent_activity") val recentActivity: List<AdminActivityDto> = emptyList(),
)
@Serializable
data class SiteModeChangeDto(
val at: String? = null,
val by: String? = null,
)
@Serializable
data class AdminCountsDto(
/** Post counts keyed by DB category (`news`, `five_on_friday`, …). */
val posts: Map<String, Int> = emptyMap(),
val users: Int = 0,
)
/** One row of the recent admin-activity log. `detail` is provider-shaped JSON. */
@Serializable
data class AdminActivityDto(
val id: Long = 0,
val username: String? = null,
val action: String = "",
val detail: JsonElement? = null,
@SerialName("created_at") val createdAt: String? = null,
)
/** `PUT /admin/site-mode` request + response. */
@Serializable
data class SiteModeRequest(val mode: String)
@Serializable
data class SiteModeStateDto(
@SerialName("site_mode") val siteMode: String = "live",
@SerialName("changed_at") val changedAt: String? = null,
@SerialName("changed_by") val changedBy: String? = null,
)
// ── Content: news posts ───────────────────────────────────────────────────
/**
* A post row from `GET /admin/posts` (all posts, incl. unpublished — unlike the
* public feed). `published` is a 0/1 flag (MariaDB tinyint), exposed as [isPublished].
*/
@Serializable
data class AdminPostDto(
val id: Long,
val category: String = "",
val title: String = "",
val slug: String? = null,
val excerpt: String? = null,
val body: String? = null,
@SerialName("image_url") val imageUrl: String? = null,
val published: Int = 0,
@SerialName("published_at") val publishedAt: String? = null,
@SerialName("created_at") val createdAt: String? = null,
) {
val isPublished: Boolean get() = published != 0
}
/** `POST/PUT /admin/posts` body. `category` is a URL category the backend maps
* (news | five-on-friday | newsletter | screenshots). */
@Serializable
data class PostCreateRequest(
val category: String,
val title: String,
val excerpt: String? = null,
val body: String? = null,
@SerialName("image_url") val imageUrl: String? = null,
val published: Boolean = false,
)
/** `PATCH /admin/posts/:id/publish` body. */
@Serializable
data class PublishRequest(val published: Boolean)
// ── Content: wiki taxonomy ────────────────────────────────────────────────
/** A wiki category from `GET /admin/wiki/categories` (with page counts). */
@Serializable
data class AdminWikiCategoryDto(
val id: Long,
val slug: String = "",
val title: String = "",
val description: String? = null,
@SerialName("sort_order") val sortOrder: Int? = null,
@SerialName("page_count") val pageCount: Int? = null,
@SerialName("published_count") val publishedCount: Int? = null,
)
/** `POST /admin/wiki/categories` body. */
@Serializable
data class WikiCategoryRequest(
val slug: String,
val title: String,
val description: String? = null,
@SerialName("sort_order") val sortOrder: Int? = null,
)
/** A wiki tag from `GET /admin/wiki/tags` (tags derive from pages; read-only here). */
@Serializable
data class AdminWikiTagDto(
val id: Long,
val slug: String = "",
val label: String = "",
@SerialName("published_count") val publishedCount: Int? = null,
)
// ── Moderation (admin/moderator; shard write plane) ───────────────────────
/** `POST /admin/shard/kick` — at least one of account/serial. */
@Serializable
data class KickRequest(val account: String? = null, val serial: String? = null)
/** `POST /admin/shard/ban` — account/serial + optional duration (0/absent = indefinite). */
@Serializable
data class BanRequest(
val account: String? = null,
val serial: String? = null,
@SerialName("durationSec") val durationSec: Long? = null,
val reason: String? = null,
)
/** `POST /admin/shard/unban`. */
@Serializable
data class UnbanRequest(val account: String)
/** `POST /admin/shard/broadcast` — a system message to everyone online. */
@Serializable
data class BroadcastRequest(val text: String, val hue: Int? = null)
// ── Support queue (admin/moderator; help pages) ───────────────────────────
/**
* One open help page from `GET /admin/shard/pages` (INTEGRATION.md §4). `pageId`
* is the sender's in-game serial (the `:id` for respond/close). Permissive — the
* shard-state fields beyond these (coords, timing) are ignored.
*/
@Serializable
data class SupportPageDto(
@SerialName("pageId") val pageId: String = "",
val type: String? = null,
val message: String? = null,
val handled: Boolean? = null,
val handler: String? = null,
val sender: SupportActorDto? = null,
)
/** The page's sender (actor object); [account] present when the character is linked. */
@Serializable
data class SupportActorDto(
val name: String? = null,
val account: String? = null,
)
/** `POST /admin/shard/pages/:id/respond` — reply, optionally closing the page. */
@Serializable
data class PageRespondRequest(val message: String, val close: Boolean = false)

View File

@@ -12,12 +12,23 @@ import kotlinx.serialization.Serializable
* safe (§8, recorded for M1). * safe (§8, recorded for M1).
*/ */
/** `POST /auth/mobile/login` body. [code] is only sent on the 2FA retry. */ /**
* `POST /auth/mobile/login` body (trusted-devices contract, TRUSTED_DEVICES_MFA.md).
* [code] is only sent on the 2FA retry; [recoveryCode] is its single-use fallback
* (sent instead of [code]). [trustDevice] asks the server to remember this device so
* future logins skip the second factor — on success the response carries a
* [MobileTokenResponse.trustToken] the app stores and replays via `X-Trust-Token`.
* [device_name] labels the resulting trusted-device / session row (snake_case to
* match the backend field exactly).
*/
@Serializable @Serializable
data class MobileLoginRequest( data class MobileLoginRequest(
val username: String, val username: String,
val password: String, val password: String,
val code: String? = null, val code: String? = null,
val recoveryCode: String? = null,
val trustDevice: Boolean? = null,
val device_name: String? = null,
) )
/** `POST /auth/mobile/refresh` body. */ /** `POST /auth/mobile/refresh` body. */
@@ -34,6 +45,11 @@ data class MobileLogoutRequest(
/** /**
* Success payload from login and refresh: the token pair, the access lifetime * Success payload from login and refresh: the token pair, the access lifetime
* (a zeit/ms duration string, e.g. "15m"), and the safe (secret-stripped) user. * (a zeit/ms duration string, e.g. "15m"), and the safe (secret-stripped) user.
*
* Login additionally carries the trusted-device outcome when `trustDevice` was set:
* [trustToken] is the opaque token to persist + replay (present only when the trust
* was accepted), or [trustLimitReached] + [devices] when the per-user cap blocked it
* (the login itself still succeeded). Refresh never sets these.
*/ */
@Serializable @Serializable
data class MobileTokenResponse( data class MobileTokenResponse(
@@ -41,6 +57,9 @@ data class MobileTokenResponse(
val refreshToken: String, val refreshToken: String,
val expiresIn: String? = null, val expiresIn: String? = null,
val user: SafeUserDto, val user: SafeUserDto,
val trustToken: String? = null,
val trustLimitReached: Boolean = false,
val devices: List<TrustedDeviceDto> = emptyList(),
) )
/** The minimal, non-sensitive user the app needs to render + gate the menu (§5). */ /** The minimal, non-sensitive user the app needs to render + gate the menu (§5). */

View File

@@ -14,8 +14,8 @@ import kotlinx.serialization.json.JsonObject
* `CharacterSheet.jsx` / `GameAccounts.jsx` and `docs/link/INTEGRATION.md` §5). * `CharacterSheet.jsx` / `GameAccounts.jsx` and `docs/link/INTEGRATION.md` §5).
* Presentation is text-only for v1 (no item icons / paperdoll). * Presentation is text-only for v1 (no item icons / paperdoll).
* *
* In-game serials are hex strings (e.g. "0x24C"), unlike the numeric serials on * In-game serials are hex strings (e.g. "0x24C"), the same opaque-key form used on
* the public boards — these are separate endpoints with separate shapes. * the public boards (`ShardDto.ActorDto`/`ChampDto`/`HouseDto`) — never numbers.
*/ */
// ── Game-account linking ───────────────────────────────────────────────────── // ── Game-account linking ─────────────────────────────────────────────────────

View File

@@ -15,13 +15,18 @@ import kotlinx.serialization.json.JsonObject
* `*.update` frames on `/public/shard/stream` decode into these same DTOs. * `*.update` frames on `/public/shard/stream` decode into these same DTOs.
*/ */
/** A game actor (player/leader/governor) as embedded in board payloads. */ /**
* A game actor (player/leader/governor) as embedded in board payloads. Per the wire
* spec (`docs/link/INTEGRATION.md` §1), in-game [serial]s are opaque hex-string keys
* (e.g. `"0x1A2B"`), never numbers, and [webId] is the linked site-user id as a
* string (e.g. `"9931"`) — both are decoded as strings, not parsed.
*/
@Serializable @Serializable
data class ActorDto( data class ActorDto(
val serial: Long? = null, val serial: String? = null,
val name: String? = null, val name: String? = null,
val acct: String? = null, val acct: String? = null,
val webId: Long? = null, val webId: String? = null,
) { ) {
/** Best display label for this actor. */ /** Best display label for this actor. */
val label: String get() = name ?: acct ?: "Someone" val label: String get() = name ?: acct ?: "Someone"
@@ -73,7 +78,7 @@ data class FeedEventDto(
*/ */
@Serializable @Serializable
data class OnlineStaffDto( data class OnlineStaffDto(
val serial: Long? = null, val serial: String? = null,
val name: String? = null, val name: String? = null,
val map: String? = null, val map: String? = null,
val x: Int? = null, val x: Int? = null,
@@ -87,7 +92,7 @@ data class OnlineStaffDto(
*/ */
@Serializable @Serializable
data class HouseDto( data class HouseDto(
val serial: Long = 0, val serial: String = "",
val name: String? = null, val name: String? = null,
val region: String? = null, val region: String? = null,
val map: String? = null, val map: String? = null,
@@ -104,7 +109,7 @@ data class HouseDto(
*/ */
@Serializable @Serializable
data class ChampDto( data class ChampDto(
val serial: Long = 0, val serial: String = "",
val category: String? = null, val category: String? = null,
val type: String? = null, val type: String? = null,
val name: String? = null, val name: String? = null,

View File

@@ -10,10 +10,19 @@ import com.runicgateway.app.data.api.dto.ChangePasswordRequest
import com.runicgateway.app.data.api.dto.ChangeUsernameRequest import com.runicgateway.app.data.api.dto.ChangeUsernameRequest
import com.runicgateway.app.data.api.dto.LinkedIdentityDto import com.runicgateway.app.data.api.dto.LinkedIdentityDto
import com.runicgateway.app.data.api.dto.PlayerAccountDto import com.runicgateway.app.data.api.dto.PlayerAccountDto
import com.runicgateway.app.data.api.dto.RecoveryCodesDto
import com.runicgateway.app.data.api.dto.RecoveryGenerateRequest
import com.runicgateway.app.data.api.dto.RecoveryStatusDto
import com.runicgateway.app.data.api.dto.TotpCodeRequest import com.runicgateway.app.data.api.dto.TotpCodeRequest
import com.runicgateway.app.data.api.dto.TotpSetupDto import com.runicgateway.app.data.api.dto.TotpSetupDto
import com.runicgateway.app.data.api.dto.TotpStateDto import com.runicgateway.app.data.api.dto.TotpStateDto
import com.runicgateway.app.data.api.dto.TrustDeviceRequest
import com.runicgateway.app.data.api.dto.TrustedDeviceDto
import com.runicgateway.app.data.api.dto.TrustedDeviceLimitDto
import com.runicgateway.app.data.api.dto.UsernameResponse import com.runicgateway.app.data.api.dto.UsernameResponse
import kotlinx.coroutines.CancellationException
import kotlinx.serialization.json.Json
import java.io.IOException
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
@@ -26,6 +35,7 @@ import javax.inject.Singleton
@Singleton @Singleton
class AccountRepository @Inject constructor( class AccountRepository @Inject constructor(
private val api: MeApi, private val api: MeApi,
private val json: Json,
) { ) {
suspend fun getAccount(): ApiResult<PlayerAccountDto> = safeApiCall { api.getAccount() } suspend fun getAccount(): ApiResult<PlayerAccountDto> = safeApiCall { api.getAccount() }
@@ -48,4 +58,63 @@ class AccountRepository @Inject constructor(
suspend fun unlinkIdentity(provider: String): ApiResult<Unit> = suspend fun unlinkIdentity(provider: String): ApiResult<Unit> =
safeApiCall { api.unlinkIdentity(provider) } safeApiCall { api.unlinkIdentity(provider) }
// ── Trusted devices (TRUSTED_DEVICES_MFA.md) ───────────────────────────
suspend fun trustedDevices(): ApiResult<List<TrustedDeviceDto>> =
safeApiCall { api.trustedDevices() }
/** The distinct outcomes of trusting the current device — the cap is a first-class case. */
sealed interface TrustOutcome {
/** Trusted; [trustToken] is the opaque token to persist (native). */
data class Trusted(val trustToken: String?) : TrustOutcome
/** At the per-user cap — [devices] must be pruned before retrying. */
data class LimitReached(val devices: List<TrustedDeviceDto>) : TrustOutcome
data object NetworkError : TrustOutcome
data object ServerError : TrustOutcome
}
/**
* Trust the current device. Reads the raw response so the `409 { error, devices }`
* cap body survives (a thrown [retrofit2.HttpException] would discard it).
*/
suspend fun trustThisDevice(deviceName: String? = null): TrustOutcome {
val response = try {
api.trustThisDevice(TrustDeviceRequest(deviceName))
} catch (e: CancellationException) {
throw e
} catch (_: IOException) {
return TrustOutcome.NetworkError
} catch (_: Exception) {
return TrustOutcome.ServerError
}
if (response.isSuccessful) {
return TrustOutcome.Trusted(response.body()?.trustToken)
}
if (response.code() == 409) {
val devices = runCatching {
val raw = response.errorBody()?.string()
if (raw.isNullOrBlank()) emptyList()
else json.decodeFromString<TrustedDeviceLimitDto>(raw).devices
}.getOrDefault(emptyList())
return TrustOutcome.LimitReached(devices)
}
return TrustOutcome.ServerError
}
suspend fun revokeTrustedDevice(id: Long): ApiResult<Boolean> =
safeApiCall { api.revokeTrustedDevice(id).revoked }
suspend fun revokeAllTrustedDevices(): ApiResult<Int> =
safeApiCall { api.revokeAllTrustedDevices().revoked }
// ── Recovery (backup) codes ────────────────────────────────────────────
suspend fun recoveryCodesStatus(): ApiResult<RecoveryStatusDto> =
safeApiCall { api.recoveryCodesStatus() }
/** Regenerate the single-use codes (password step-up). Returned once — never stored. */
suspend fun generateRecoveryCodes(currentPassword: String?): ApiResult<RecoveryCodesDto> =
safeApiCall { api.generateRecoveryCodes(RecoveryGenerateRequest(currentPassword)) }
} }

View File

@@ -0,0 +1,94 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.data.repository
import com.runicgateway.app.core.result.ApiResult
import com.runicgateway.app.core.result.safeApiCall
import com.runicgateway.app.data.api.AdminApi
import com.runicgateway.app.data.api.dto.AdminDashboardDto
import com.runicgateway.app.data.api.dto.AdminPostDto
import com.runicgateway.app.data.api.dto.BanRequest
import com.runicgateway.app.data.api.dto.BroadcastRequest
import com.runicgateway.app.data.api.dto.KickRequest
import com.runicgateway.app.data.api.dto.PageRespondRequest
import com.runicgateway.app.data.api.dto.PostCreateRequest
import com.runicgateway.app.data.api.dto.PublishRequest
import com.runicgateway.app.data.api.dto.SiteModeRequest
import com.runicgateway.app.data.api.dto.SiteModeStateDto
import com.runicgateway.app.data.api.dto.SupportPageDto
import com.runicgateway.app.data.api.dto.UnbanRequest
import com.runicgateway.app.data.api.dto.AdminWikiCategoryDto
import com.runicgateway.app.data.api.dto.WikiCategoryRequest
import com.runicgateway.app.data.api.dto.AdminWikiTagDto
import retrofit2.HttpException
import retrofit2.Response
import javax.inject.Inject
import javax.inject.Singleton
/**
* The M10 staff-operations data source over `/api/v1/admin/…` (PLAN.md §1, §6.4).
* Every call returns a typed [ApiResult] so a screen renders a clean error/retry
* rather than crashing — a `403` (role lost since the menu rendered) and a `503`
* (shard/sidecar offline for the shard-write actions) are both expected outcomes
* the UI handles, never thrown. Role is authoritative on the server.
*/
@Singleton
class AdminRepository @Inject constructor(
private val api: AdminApi,
) {
suspend fun dashboard(): ApiResult<AdminDashboardDto> = safeApiCall { api.dashboard() }
suspend fun setSiteMode(mode: String): ApiResult<SiteModeStateDto> =
safeApiCall { api.setSiteMode(SiteModeRequest(mode)) }
// ── Content: news posts ───────────────────────────────────────────────
suspend fun posts(): ApiResult<List<AdminPostDto>> = safeApiCall { api.posts() }
suspend fun createPost(body: PostCreateRequest): ApiResult<AdminPostDto> =
safeApiCall { api.createPost(body) }
suspend fun setPostPublished(id: Long, published: Boolean): ApiResult<AdminPostDto> =
safeApiCall { api.publishPost(id, PublishRequest(published)) }
suspend fun deletePost(id: Long): ApiResult<Unit> = safeApiCall { api.deletePost(id).requireOk() }
// ── Content: wiki taxonomy ────────────────────────────────────────────
suspend fun wikiCategories(): ApiResult<List<AdminWikiCategoryDto>> = safeApiCall { api.wikiCategories() }
suspend fun createWikiCategory(body: WikiCategoryRequest): ApiResult<AdminWikiCategoryDto> =
safeApiCall { api.createWikiCategory(body) }
suspend fun deleteWikiCategory(id: Long): ApiResult<Unit> =
safeApiCall { api.deleteWikiCategory(id).requireOk() }
suspend fun wikiTags(): ApiResult<List<AdminWikiTagDto>> = safeApiCall { api.wikiTags() }
// ── Moderation: shard write plane ─────────────────────────────────────
suspend fun kick(account: String?, serial: String?): ApiResult<Unit> =
safeApiCall { api.kick(KickRequest(account, serial)).requireOk() }
suspend fun ban(account: String?, serial: String?, durationSec: Long?, reason: String?): ApiResult<Unit> =
safeApiCall { api.ban(BanRequest(account, serial, durationSec, reason)).requireOk() }
suspend fun unban(account: String): ApiResult<Unit> =
safeApiCall { api.unban(UnbanRequest(account)).requireOk() }
suspend fun broadcast(text: String, hue: Int?): ApiResult<Unit> =
safeApiCall { api.broadcast(BroadcastRequest(text, hue)).requireOk() }
// ── Support queue: help pages ─────────────────────────────────────────
suspend fun supportPages(): ApiResult<List<SupportPageDto>> = safeApiCall { api.supportPages() }
suspend fun respondPage(id: String, message: String, close: Boolean): ApiResult<Unit> =
safeApiCall { api.respondPage(id, PageRespondRequest(message, close)).requireOk() }
suspend fun closePage(id: String): ApiResult<Unit> =
safeApiCall { api.closePage(id).requireOk() }
/** Turn a bodyless [Response] into a thrown [HttpException] on a non-2xx, so
* [safeApiCall] can fold it into an [ApiResult.HttpError] like every other call. */
private fun Response<Unit>.requireOk() {
if (!isSuccessful) throw HttpException(this)
}
}

View File

@@ -3,7 +3,9 @@
*/ */
package com.runicgateway.app.data.repository package com.runicgateway.app.data.repository
import com.runicgateway.app.core.auth.DeviceNameProvider
import com.runicgateway.app.core.auth.SessionManager import com.runicgateway.app.core.auth.SessionManager
import com.runicgateway.app.core.auth.TrustTokenStore
import com.runicgateway.app.core.push.PushManager import com.runicgateway.app.core.push.PushManager
import com.runicgateway.app.data.api.AuthApi import com.runicgateway.app.data.api.AuthApi
import com.runicgateway.app.data.api.SsoApi import com.runicgateway.app.data.api.SsoApi
@@ -12,7 +14,9 @@ import com.runicgateway.app.data.api.dto.MobileLogoutRequest
import com.runicgateway.app.data.api.dto.MobileTokenResponse import com.runicgateway.app.data.api.dto.MobileTokenResponse
import com.runicgateway.app.data.api.dto.SsoProviderDto import com.runicgateway.app.data.api.dto.SsoProviderDto
import com.runicgateway.app.data.api.dto.TotpRequiredError import com.runicgateway.app.data.api.dto.TotpRequiredError
import com.runicgateway.app.data.api.dto.TrustedDeviceDto
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.delay
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import retrofit2.Response import retrofit2.Response
import java.io.IOException import java.io.IOException
@@ -31,25 +35,58 @@ class AuthRepository @Inject constructor(
private val ssoApi: SsoApi, private val ssoApi: SsoApi,
private val sessionManager: SessionManager, private val sessionManager: SessionManager,
private val pushManager: PushManager, private val pushManager: PushManager,
private val trustTokenStore: TrustTokenStore,
private val deviceNameProvider: DeviceNameProvider,
private val json: Json, private val json: Json,
) { ) {
/** The three outcomes of SSO provider discovery, so the login screen can tell a
* shard that offers no SSO ([None]) apart from a discovery that failed
* ([Unavailable], offer a retry) — the old "empty on any failure" conflation hid
* a broken call behind a dead website hand-off (§4.2). */
sealed interface SsoDiscovery {
/** At least one enabled provider — render a native button per entry. */
data class Available(val providers: List<SsoProviderDto>) : SsoDiscovery
/** Discovery succeeded but the shard has no SSO providers configured. */
data object None : SsoDiscovery
/** The discovery call failed (offline / server error) — surface a retry. */
data object Unavailable : SsoDiscovery
}
/** /**
* The shard's enabled SSO providers for the native login buttons (§4.2). Public * Discover the shard's enabled SSO providers for the native login buttons (§4.2).
* discovery, never secrets. Returns an empty list on any failure — the login * Public discovery, never secrets. Retries once before reporting [Unavailable],
* screen then keeps the website hand-off fallback rather than showing nothing. * so a single transient blip doesn't strand the user.
*/ */
suspend fun ssoProviders(): List<SsoProviderDto> = try { suspend fun ssoProviders(): SsoDiscovery {
ssoApi.providers() var lastFailed = false
repeat(2) { attempt ->
try {
val providers = ssoApi.providers()
return if (providers.isEmpty()) SsoDiscovery.None else SsoDiscovery.Available(providers)
} catch (e: CancellationException) { } catch (e: CancellationException) {
throw e throw e
} catch (_: Exception) { } catch (_: Exception) {
emptyList() lastFailed = true
if (attempt == 0) delay(DISCOVERY_RETRY_DELAY_MS)
}
}
return if (lastFailed) SsoDiscovery.Unavailable else SsoDiscovery.None
} }
/** Outcome of a login attempt (§4.1). */ /** Outcome of a login attempt (§4.1). */
sealed interface LoginResult { sealed interface LoginResult {
data object Success : LoginResult /**
* Signed in. [trustLimitReached] is true when "trust this device" was asked
* for but the per-user cap blocked it (the login still succeeded, but no trust
* token was issued); [devices] then lists the trusted devices to manage.
*/
data class Success(
val trustLimitReached: Boolean = false,
val devices: List<TrustedDeviceDto> = emptyList(),
) : LoginResult
/** The account has 2FA on — reveal the code field and resubmit with a code. */ /** The account has 2FA on — reveal the code field and resubmit with a code. */
data object TotpRequired : LoginResult data object TotpRequired : LoginResult
@@ -65,9 +102,32 @@ class AuthRepository @Inject constructor(
data object NetworkError : LoginResult data object NetworkError : LoginResult
} }
suspend fun login(username: String, password: String, code: String? = null): LoginResult { /**
* Native login (TRUSTED_DEVICES_MFA.md). A stored trust token bound to [username]
* rides the `X-Trust-Token` header so a trusted device skips the TOTP step. A
* second factor is either a [code] (TOTP) or a single-use [recoveryCode]. With
* [trustDevice], the server may return a fresh trust token to persist for next time.
*/
suspend fun login(
username: String,
password: String,
code: String? = null,
recoveryCode: String? = null,
trustDevice: Boolean = false,
): LoginResult {
val storedTrustToken = trustTokenStore.tokenFor(username)
val response: Response<MobileTokenResponse> = try { val response: Response<MobileTokenResponse> = try {
authApi.login(MobileLoginRequest(username = username, password = password, code = code)) authApi.login(
MobileLoginRequest(
username = username,
password = password,
code = code,
recoveryCode = recoveryCode,
trustDevice = trustDevice.takeIf { it },
device_name = if (trustDevice) deviceNameProvider.deviceName() else null,
),
trustToken = storedTrustToken,
)
} catch (e: CancellationException) { } catch (e: CancellationException) {
throw e throw e
} catch (_: IOException) { } catch (_: IOException) {
@@ -76,8 +136,14 @@ class AuthRepository @Inject constructor(
if (response.isSuccessful) { if (response.isSuccessful) {
val body = response.body() ?: return LoginResult.ServerError val body = response.body() ?: return LoginResult.ServerError
// Persist a freshly minted trust token (scoped to this account) so the next
// login skips the second factor — it deliberately outlives logout.
body.trustToken?.let { trustTokenStore.save(username, it) }
sessionManager.onSignedIn(body.accessToken, body.refreshToken, body.user) sessionManager.onSignedIn(body.accessToken, body.refreshToken, body.user)
return LoginResult.Success return LoginResult.Success(
trustLimitReached = body.trustLimitReached,
devices = body.devices,
)
} }
return when (response.code()) { return when (response.code()) {
@@ -87,6 +153,20 @@ class AuthRepository @Inject constructor(
} }
} }
/**
* Persist a trust token minted by the self-service "trust this device" action
* (Account → Trusted Devices), scoped to [username] exactly like the login path.
*/
fun saveTrustToken(username: String, token: String) = trustTokenStore.save(username, token)
/**
* Drop the locally stored trust token so this device stops skipping the TOTP step
* (used after "untrust all" and on a Settings → Server switch). Server-side
* revocation makes any surviving token inert anyway — the next login just prompts
* for the code — so this is a client-side cleanliness step, never load-bearing.
*/
fun clearTrustToken() = trustTokenStore.clear()
/** /**
* Revoke this session (or, with [allDevices], every session) and clear local * Revoke this session (or, with [allDevices], every session) and clear local
* tokens (§4.3). Best-effort: the local session is torn down even if the * tokens (§4.3). Best-effort: the local session is torn down even if the
@@ -140,4 +220,8 @@ class AuthRepository @Inject constructor(
} catch (_: Exception) { } catch (_: Exception) {
false false
} }
private companion object {
const val DISCOVERY_RETRY_DELAY_MS = 400L
}
} }

View File

@@ -4,6 +4,7 @@
package com.runicgateway.app.data.repository package com.runicgateway.app.data.repository
import com.runicgateway.app.core.auth.SessionManager import com.runicgateway.app.core.auth.SessionManager
import com.runicgateway.app.core.auth.TrustTokenStore
import com.runicgateway.app.core.net.BaseUrlHolder import com.runicgateway.app.core.net.BaseUrlHolder
import com.runicgateway.app.core.net.ServerUrl import com.runicgateway.app.core.net.ServerUrl
import com.runicgateway.app.core.prefs.ServerPreferences import com.runicgateway.app.core.prefs.ServerPreferences
@@ -26,6 +27,7 @@ class ConnectionRepository @Inject constructor(
private val prefs: ServerPreferences, private val prefs: ServerPreferences,
private val baseUrlHolder: BaseUrlHolder, private val baseUrlHolder: BaseUrlHolder,
private val sessionManager: SessionManager, private val sessionManager: SessionManager,
private val trustTokenStore: TrustTokenStore,
private val pushManager: com.runicgateway.app.core.push.PushManager, private val pushManager: com.runicgateway.app.core.push.PushManager,
private val config: com.runicgateway.app.core.AppConfig, private val config: com.runicgateway.app.core.AppConfig,
) { ) {
@@ -106,6 +108,9 @@ class ConnectionRepository @Inject constructor(
} }
pushManager.setNtfyUrl(null) pushManager.setNtfyUrl(null)
sessionManager.onSignedOut() sessionManager.onSignedOut()
// The trust token is bound to the old host — drop it so we don't replay it
// against a different shard (it survives a plain logout, but not a host switch).
trustTokenStore.clear()
prefs.clear() prefs.clear()
baseUrlHolder.set(null) baseUrlHolder.set(null)
} }

View File

@@ -14,6 +14,7 @@ import com.runicgateway.app.core.net.UserAgentInterceptor
import com.runicgateway.app.data.api.AuthApi import com.runicgateway.app.data.api.AuthApi
import com.runicgateway.app.data.api.AuthRefreshApi import com.runicgateway.app.data.api.AuthRefreshApi
import com.runicgateway.app.data.api.MeApi import com.runicgateway.app.data.api.MeApi
import com.runicgateway.app.data.api.AdminApi
import com.runicgateway.app.data.api.NotificationsApi import com.runicgateway.app.data.api.NotificationsApi
import com.runicgateway.app.data.api.PlayerShardApi import com.runicgateway.app.data.api.PlayerShardApi
import com.runicgateway.app.data.api.PublicApi import com.runicgateway.app.data.api.PublicApi
@@ -119,6 +120,11 @@ object NetworkModule {
fun provideNotificationsApi(retrofit: Retrofit): NotificationsApi = fun provideNotificationsApi(retrofit: Retrofit): NotificationsApi =
retrofit.create(NotificationsApi::class.java) retrofit.create(NotificationsApi::class.java)
/** Staff operations (§1, §6.4, M10) — bearer-authed; the server re-checks role every call. */
@Provides
@Singleton
fun provideAdminApi(retrofit: Retrofit): AdminApi = retrofit.create(AdminApi::class.java)
/** /**
* Token refresh runs on its own **bare** client — UA + host retargeting only, * Token refresh runs on its own **bare** client — UA + host retargeting only,
* no auth interceptor and no authenticator — so a refresh can never recurse * no auth interceptor and no authenticator — so a refresh can never recurse

View File

@@ -3,15 +3,21 @@
*/ */
package com.runicgateway.app.di package com.runicgateway.app.di
import com.runicgateway.app.core.auth.BuildDeviceNameProvider
import com.runicgateway.app.core.auth.DeviceNameProvider
import com.runicgateway.app.core.auth.EncryptedTokenStore import com.runicgateway.app.core.auth.EncryptedTokenStore
import com.runicgateway.app.core.auth.EncryptedTrustTokenStore
import com.runicgateway.app.core.auth.TokenStore import com.runicgateway.app.core.auth.TokenStore
import com.runicgateway.app.core.auth.TrustTokenStore
import com.runicgateway.app.core.auth.sso.EncryptedPendingSsoStore
import com.runicgateway.app.core.auth.sso.PendingSsoStore
import dagger.Binds import dagger.Binds
import dagger.Module import dagger.Module
import dagger.hilt.InstallIn import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton import javax.inject.Singleton
/** Binds the at-rest token store to its EncryptedSharedPreferences impl (§4.3). */ /** Binds the at-rest stores to their EncryptedSharedPreferences impls (§4.3). */
@Module @Module
@InstallIn(SingletonComponent::class) @InstallIn(SingletonComponent::class)
abstract class StorageModule { abstract class StorageModule {
@@ -19,4 +25,17 @@ abstract class StorageModule {
@Binds @Binds
@Singleton @Singleton
abstract fun bindTokenStore(impl: EncryptedTokenStore): TokenStore abstract fun bindTokenStore(impl: EncryptedTokenStore): TokenStore
@Binds
@Singleton
abstract fun bindPendingSsoStore(impl: EncryptedPendingSsoStore): PendingSsoStore
/** The trusted-device token store — its own encrypted file, outlives session teardown. */
@Binds
@Singleton
abstract fun bindTrustTokenStore(impl: EncryptedTrustTokenStore): TrustTokenStore
@Binds
@Singleton
abstract fun bindDeviceNameProvider(impl: BuildDeviceNameProvider): DeviceNameProvider
} }

View File

@@ -3,9 +3,12 @@
*/ */
package com.runicgateway.app.ui package com.runicgateway.app.ui
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Menu import androidx.compose.material.icons.filled.Menu
@@ -48,6 +51,8 @@ import com.runicgateway.app.core.auth.Session
import com.runicgateway.app.data.api.dto.BrandDto import com.runicgateway.app.data.api.dto.BrandDto
import com.runicgateway.app.ui.auth.AccountScreen import com.runicgateway.app.ui.auth.AccountScreen
import com.runicgateway.app.ui.auth.LoginScreen import com.runicgateway.app.ui.auth.LoginScreen
import com.runicgateway.app.ui.auth.RecoveryCodesScreen
import com.runicgateway.app.ui.auth.TrustedDevicesScreen
import com.runicgateway.app.ui.auth.roleLabelRes import com.runicgateway.app.ui.auth.roleLabelRes
import com.runicgateway.app.ui.contact.ContactScreen import com.runicgateway.app.ui.contact.ContactScreen
import com.runicgateway.app.ui.home.HomeScreen import com.runicgateway.app.ui.home.HomeScreen
@@ -56,6 +61,10 @@ import com.runicgateway.app.ui.navigation.Routes
import com.runicgateway.app.ui.navigation.visibleEntries import com.runicgateway.app.ui.navigation.visibleEntries
import com.runicgateway.app.ui.news.NewsScreen import com.runicgateway.app.ui.news.NewsScreen
import com.runicgateway.app.ui.news.PostScreen import com.runicgateway.app.ui.news.PostScreen
import com.runicgateway.app.ui.admin.AdminContentScreen
import com.runicgateway.app.ui.admin.AdminDashboardScreen
import com.runicgateway.app.ui.admin.AdminModerationScreen
import com.runicgateway.app.ui.admin.AdminSupportScreen
import com.runicgateway.app.ui.notifications.NotificationsScreen import com.runicgateway.app.ui.notifications.NotificationsScreen
import com.runicgateway.app.ui.page.PageScreen import com.runicgateway.app.ui.page.PageScreen
import com.runicgateway.app.ui.player.CharacterSheetScreen import com.runicgateway.app.ui.player.CharacterSheetScreen
@@ -78,6 +87,7 @@ private val TOP_LEVEL_ROUTES = setOf(
Routes.HOME, Routes.NEWS, Routes.WIKI, Routes.SHARD, Routes.CONTACT, Routes.PAGE, Routes.ACCOUNT, Routes.HOME, Routes.NEWS, Routes.WIKI, Routes.SHARD, Routes.CONTACT, Routes.PAGE, Routes.ACCOUNT,
Routes.NOTIFICATIONS, Routes.NOTIFICATIONS,
Routes.PLAYER_CHARACTERS, Routes.PLAYER_VENDORS, Routes.PLAYER_HOUSES, Routes.PLAYER_CHARACTERS, Routes.PLAYER_VENDORS, Routes.PLAYER_HOUSES,
Routes.ADMIN_DASHBOARD, Routes.ADMIN_CONTENT, Routes.ADMIN_MODERATION, Routes.ADMIN_SUPPORT,
) )
/** /**
@@ -134,6 +144,11 @@ fun RunicApp(
selectedTextColor = MaterialTheme.colorScheme.onSecondaryContainer, selectedTextColor = MaterialTheme.colorScheme.onSecondaryContainer,
unselectedTextColor = MaterialTheme.colorScheme.onSurface, unselectedTextColor = MaterialTheme.colorScheme.onSurface,
) )
// Scroll the drawer: a signed-in session adds Account, Notifications, and
// the player groups, and the full list overflows a phone's drawer height —
// without this the lower entries (Notifications included) are clipped and
// unreachable. See RunicGateway M10.
Column(Modifier.verticalScroll(rememberScrollState())) {
Spacer(Modifier.height(12.dp)) Spacer(Modifier.height(12.dp))
Text( Text(
text = brand?.name?.takeIf { it.isNotBlank() } ?: stringResource(R.string.app_name), text = brand?.name?.takeIf { it.isNotBlank() } ?: stringResource(R.string.app_name),
@@ -190,6 +205,7 @@ fun RunicApp(
modifier = Modifier.padding(NavigationDrawerItemDefaults.ItemPadding), modifier = Modifier.padding(NavigationDrawerItemDefaults.ItemPadding),
) )
} }
}
}, },
) { ) {
Scaffold( Scaffold(
@@ -306,8 +322,16 @@ private fun RunicNavHost(
ContactScreen() ContactScreen()
} }
composable(Routes.LOGIN) { composable(Routes.LOGIN) {
// Leave the login screen as soon as the session is established — whether by
// password or the SSO bridge. Keying off the shared session (not just the
// login VM's local flag) makes this robust to the deep-link/recomposition
// timing of the Custom-Tab return, which the LoginScreen callback alone can miss.
if (session is Session.SignedIn) {
LaunchedEffect(Unit) { navController.popBackStack(Routes.LOGIN, inclusive = true) }
} else {
LoginScreen(onSignedIn = { navController.popBackStack() }) LoginScreen(onSignedIn = { navController.popBackStack() })
} }
}
composable(Routes.ACCOUNT) { composable(Routes.ACCOUNT) {
// Only meaningful while signed in; a sign-out (here or from the drawer) // Only meaningful while signed in; a sign-out (here or from the drawer)
// sends the user home rather than leaving a stale identity on screen. // sends the user home rather than leaving a stale identity on screen.
@@ -317,12 +341,27 @@ private fun RunicNavHost(
roleLabel = stringResource(roleLabelRes(s.user.role)), roleLabel = stringResource(roleLabelRes(s.user.role)),
onSignOut = onSignOut, onSignOut = onSignOut,
onSignOutEverywhere = onSignOutEverywhere, onSignOutEverywhere = onSignOutEverywhere,
onOpenTrustedDevices = { navController.navigate(Routes.ACCOUNT_TRUSTED_DEVICES) },
onOpenRecoveryCodes = { navController.navigate(Routes.ACCOUNT_RECOVERY_CODES) },
) )
Session.SignedOut -> LaunchedEffect(Unit) { Session.SignedOut -> LaunchedEffect(Unit) {
navController.navigateTopLevel(Routes.HOME) navController.navigateTopLevel(Routes.HOME)
} }
} }
} }
composable(Routes.ACCOUNT_TRUSTED_DEVICES) {
// Signed-in only; a drop (sign-out/demotion) sends the user home (§4.3).
when (session) {
is Session.SignedIn -> TrustedDevicesScreen()
Session.SignedOut -> LaunchedEffect(Unit) { navController.navigateTopLevel(Routes.HOME) }
}
}
composable(Routes.ACCOUNT_RECOVERY_CODES) {
when (session) {
is Session.SignedIn -> RecoveryCodesScreen()
Session.SignedOut -> LaunchedEffect(Unit) { navController.navigateTopLevel(Routes.HOME) }
}
}
composable(Routes.NOTIFICATIONS) { composable(Routes.NOTIFICATIONS) {
// Signed-in only; a sign-out (or demotion) sends the user home rather than // Signed-in only; a sign-out (or demotion) sends the user home rather than
// leaving stale settings up. The backend gates every call regardless (§5). // leaving stale settings up. The backend gates every call regardless (§5).
@@ -352,6 +391,24 @@ private fun RunicNavHost(
composable(Routes.PLAYER_HOUSES) { composable(Routes.PLAYER_HOUSES) {
PlayerGate(session, navController) { MyHousesScreen() } PlayerGate(session, navController) { MyHousesScreen() }
} }
// ── Staff operations (§1, §6.4, M10) — reached from the staff menu section.
// The backend re-checks role on every /admin/… call; these gates only mirror
// the menu's visibility so a signed-out/demoted user isn't left on a stale screen.
composable(Routes.ADMIN_DASHBOARD) {
StaffGate(session, navController) {
AdminDashboardScreen(isAdmin = (session as? Session.SignedIn)?.user?.isAdmin == true)
}
}
composable(Routes.ADMIN_CONTENT) {
StaffGate(session, navController) { AdminContentScreen() }
}
composable(Routes.ADMIN_MODERATION) {
StaffGate(session, navController, require = { it.isModerator }) { AdminModerationScreen() }
}
composable(Routes.ADMIN_SUPPORT) {
StaffGate(session, navController, require = { it.isModerator }) { AdminSupportScreen() }
}
} }
} }
@@ -373,6 +430,23 @@ private fun PlayerGate(
} }
} }
/**
* The staff-operations analogue of [PlayerGate] (§1, M10): render [content] only for
* a signed-in staff account; a signed-out/demoted session (caught on resume, §4.3) is
* sent home rather than left on a stale admin screen. The backend is the authority —
* every `/admin/…` call re-checks role — so this only mirrors the menu's visibility.
*/
@Composable
private fun StaffGate(
session: Session,
navController: NavHostController,
require: (com.runicgateway.app.core.auth.SessionUser) -> Boolean = { it.isStaff },
content: @Composable () -> Unit,
) {
val ok = (session as? Session.SignedIn)?.user?.let(require) == true
if (ok) content() else LaunchedEffect(Unit) { navController.navigateTopLevel(Routes.HOME) }
}
/** Navigate to a top-level menu destination: single instance, reset to it. */ /** Navigate to a top-level menu destination: single instance, reset to it. */
private fun NavHostController.navigateTopLevel(route: String) { private fun NavHostController.navigateTopLevel(route: String) {
navigate(route) { navigate(route) {

View File

@@ -0,0 +1,292 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.ui.admin
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Card
import androidx.compose.material3.FilterChip
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Switch
import androidx.compose.material3.Tab
import androidx.compose.material3.TabRow
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.runicgateway.app.R
import com.runicgateway.app.data.api.dto.AdminPostDto
import com.runicgateway.app.data.api.dto.AdminWikiCategoryDto
import com.runicgateway.app.data.api.dto.AdminWikiTagDto
import com.runicgateway.app.ui.UiState
import com.runicgateway.app.ui.components.ErrorView
import com.runicgateway.app.ui.components.LoadingView
import com.runicgateway.app.ui.components.PillTone
import com.runicgateway.app.ui.components.StatusPill
/**
* The staff content screen (PLAN.md §1, M10): news posts and wiki taxonomy, in two
* tabs. Create/publish/delete over the existing `/admin/posts` + `/admin/wiki/…`
* routes; the CMS block/hero editor stays out of scope. Any staff role; the server
* re-checks on every call.
*/
@Composable
fun AdminContentScreen(
modifier: Modifier = Modifier,
viewModel: AdminContentViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsStateWithLifecycle()
var tab by rememberSaveable { mutableIntStateOf(0) }
var showNewPost by rememberSaveable { mutableStateOf(false) }
var showNewCategory by rememberSaveable { mutableStateOf(false) }
Column(modifier.fillMaxSize()) {
TabRow(selectedTabIndex = tab) {
Tab(selected = tab == 0, onClick = { tab = 0 }, text = { Text(stringResource(R.string.admin_content_tab_posts)) })
Tab(selected = tab == 1, onClick = { tab = 1 }, text = { Text(stringResource(R.string.admin_content_tab_wiki)) })
}
state.feedback?.let {
Text(
text = stringResource(it.messageRes),
style = MaterialTheme.typography.bodySmall,
color = if (it.ok) MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.error,
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 6.dp),
)
}
when (tab) {
0 -> PostsTab(
state = state.posts,
busy = state.busy,
onNew = { showNewPost = true },
onToggle = viewModel::togglePublish,
onDelete = viewModel::deletePost,
onRetry = viewModel::loadPosts,
)
else -> WikiTab(
state = state.categories,
tags = state.tags,
busy = state.busy,
onNew = { showNewCategory = true },
onDelete = viewModel::deleteCategory,
onRetry = viewModel::loadWiki,
)
}
}
if (showNewPost) {
NewPostDialog(
categories = viewModel.postCategories,
onDismiss = { showNewPost = false },
onCreate = { cat, title, excerpt, body, published ->
viewModel.createPost(cat, title, excerpt, body, published)
showNewPost = false
},
)
}
if (showNewCategory) {
NewCategoryDialog(
onDismiss = { showNewCategory = false },
onCreate = { slug, title, desc, sort ->
viewModel.createCategory(slug, title, desc, sort)
showNewCategory = false
},
)
}
}
@Composable
private fun PostsTab(
state: UiState<List<AdminPostDto>>,
busy: Boolean,
onNew: () -> Unit,
onToggle: (AdminPostDto) -> Unit,
onDelete: (Long) -> Unit,
onRetry: () -> Unit,
) {
when (state) {
is UiState.Loading -> LoadingView()
is UiState.Error -> ErrorView(state.kind, onRetry = onRetry)
is UiState.Success -> LazyColumn(Modifier.fillMaxSize().padding(16.dp)) {
item {
OutlinedButton(onClick = onNew, enabled = !busy, modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp)) {
Text(stringResource(R.string.admin_content_new_post))
}
}
items(state.data, key = { it.id }) { post ->
Card(Modifier.fillMaxWidth().padding(vertical = 6.dp)) {
Column(Modifier.padding(12.dp)) {
Text(post.title, style = MaterialTheme.typography.bodyLarge)
Spacer(Modifier.height(4.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
StatusPill(
text = if (post.isPublished) stringResource(R.string.admin_content_published)
else stringResource(R.string.admin_content_draft),
tone = if (post.isPublished) PillTone.Success else PillTone.Neutral,
)
Spacer(Modifier.width(8.dp))
Text(post.category, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
Row(Modifier.fillMaxWidth().padding(top = 8.dp), horizontalArrangement = Arrangement.End) {
TextButton(onClick = { onToggle(post) }, enabled = !busy) {
Text(
stringResource(
if (post.isPublished) R.string.admin_content_unpublish else R.string.admin_content_publish,
),
)
}
TextButton(onClick = { onDelete(post.id) }, enabled = !busy) {
Text(stringResource(R.string.admin_content_delete), color = MaterialTheme.colorScheme.error)
}
}
}
}
}
}
}
}
@Composable
private fun WikiTab(
state: UiState<List<AdminWikiCategoryDto>>,
tags: List<AdminWikiTagDto>,
busy: Boolean,
onNew: () -> Unit,
onDelete: (Long) -> Unit,
onRetry: () -> Unit,
) {
when (state) {
is UiState.Loading -> LoadingView()
is UiState.Error -> ErrorView(state.kind, onRetry = onRetry)
is UiState.Success -> LazyColumn(Modifier.fillMaxSize().padding(16.dp)) {
item {
OutlinedButton(onClick = onNew, enabled = !busy, modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp)) {
Text(stringResource(R.string.admin_content_new_category))
}
}
items(state.data, key = { it.id }) { cat ->
Card(Modifier.fillMaxWidth().padding(vertical = 6.dp)) {
Column(Modifier.padding(12.dp)) {
Text(cat.title, style = MaterialTheme.typography.bodyLarge)
Text(
text = stringResource(R.string.admin_content_cat_meta, cat.slug, cat.pageCount ?: 0),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Row(Modifier.fillMaxWidth().padding(top = 8.dp), horizontalArrangement = Arrangement.End) {
TextButton(onClick = { onDelete(cat.id) }, enabled = !busy) {
Text(stringResource(R.string.admin_content_delete), color = MaterialTheme.colorScheme.error)
}
}
}
}
}
if (tags.isNotEmpty()) {
item {
HorizontalDivider(Modifier.padding(vertical = 12.dp))
Text(
stringResource(R.string.admin_content_tags, tags.joinToString(", ") { it.label }),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
}
@Composable
private fun NewPostDialog(
categories: List<String>,
onDismiss: () -> Unit,
onCreate: (category: String, title: String, excerpt: String, body: String, published: Boolean) -> Unit,
) {
var category by rememberSaveable { mutableStateOf(categories.first()) }
var title by rememberSaveable { mutableStateOf("") }
var excerpt by rememberSaveable { mutableStateOf("") }
var body by rememberSaveable { mutableStateOf("") }
var published by rememberSaveable { mutableStateOf(false) }
AlertDialog(
onDismissRequest = onDismiss,
confirmButton = {
TextButton(onClick = { onCreate(category, title, excerpt, body, published) }) {
Text(stringResource(R.string.admin_content_create))
}
},
dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.action_cancel)) } },
title = { Text(stringResource(R.string.admin_content_new_post)) },
text = {
Column {
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
categories.forEach { c ->
FilterChip(selected = category == c, onClick = { category = c }, label = { Text(c) })
}
}
OutlinedTextField(value = title, onValueChange = { title = it }, singleLine = true, label = { Text(stringResource(R.string.admin_content_field_title)) }, modifier = Modifier.fillMaxWidth().padding(top = 8.dp))
OutlinedTextField(value = excerpt, onValueChange = { excerpt = it }, label = { Text(stringResource(R.string.admin_content_field_excerpt)) }, modifier = Modifier.fillMaxWidth().padding(top = 8.dp))
OutlinedTextField(value = body, onValueChange = { body = it }, label = { Text(stringResource(R.string.admin_content_field_body)) }, modifier = Modifier.fillMaxWidth().padding(top = 8.dp))
Row(Modifier.fillMaxWidth().padding(top = 8.dp), verticalAlignment = Alignment.CenterVertically) {
Text(stringResource(R.string.admin_content_publish_now), modifier = Modifier.weight(1f))
Switch(checked = published, onCheckedChange = { published = it })
}
}
},
)
}
@Composable
private fun NewCategoryDialog(
onDismiss: () -> Unit,
onCreate: (slug: String, title: String, description: String, sortOrder: Int?) -> Unit,
) {
var slug by rememberSaveable { mutableStateOf("") }
var title by rememberSaveable { mutableStateOf("") }
var description by rememberSaveable { mutableStateOf("") }
var sort by rememberSaveable { mutableStateOf("") }
AlertDialog(
onDismissRequest = onDismiss,
confirmButton = {
TextButton(onClick = { onCreate(slug, title, description, sort.toIntOrNull()) }) {
Text(stringResource(R.string.admin_content_create))
}
},
dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.action_cancel)) } },
title = { Text(stringResource(R.string.admin_content_new_category)) },
text = {
Column {
OutlinedTextField(value = slug, onValueChange = { slug = it }, singleLine = true, label = { Text(stringResource(R.string.admin_content_field_slug)) }, modifier = Modifier.fillMaxWidth())
OutlinedTextField(value = title, onValueChange = { title = it }, singleLine = true, label = { Text(stringResource(R.string.admin_content_field_title)) }, modifier = Modifier.fillMaxWidth().padding(top = 8.dp))
OutlinedTextField(value = description, onValueChange = { description = it }, label = { Text(stringResource(R.string.admin_content_field_description)) }, modifier = Modifier.fillMaxWidth().padding(top = 8.dp))
OutlinedTextField(value = sort, onValueChange = { sort = it.filter(Char::isDigit) }, singleLine = true, label = { Text(stringResource(R.string.admin_content_field_sort)) }, modifier = Modifier.fillMaxWidth().padding(top = 8.dp))
}
},
)
}

View File

@@ -0,0 +1,140 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.ui.admin
import androidx.annotation.StringRes
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.runicgateway.app.R
import com.runicgateway.app.core.result.ApiResult
import com.runicgateway.app.data.api.dto.AdminPostDto
import com.runicgateway.app.data.api.dto.PostCreateRequest
import com.runicgateway.app.data.api.dto.AdminWikiCategoryDto
import com.runicgateway.app.data.api.dto.WikiCategoryRequest
import com.runicgateway.app.data.api.dto.AdminWikiTagDto
import com.runicgateway.app.data.repository.AdminRepository
import com.runicgateway.app.ui.UiState
import com.runicgateway.app.ui.toUiState
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import javax.inject.Inject
/**
* Drives the staff content screen (PLAN.md §1, M10): news posts (list, create,
* publish/unpublish, delete) and wiki taxonomy (list categories/tags, create/delete
* category). Any staff role reaches these (`staffOnly`); the full CMS block/hero
* editor stays out of scope. Reads go through the typed [AdminRepository] (§7).
*/
@HiltViewModel
class AdminContentViewModel @Inject constructor(
private val admin: AdminRepository,
) : ViewModel() {
/** The valid URL categories the backend maps (posts.model CATEGORY_MAP keys). */
val postCategories = listOf("news", "five-on-friday", "newsletter", "screenshots")
data class Feedback(val ok: Boolean, @param:StringRes val messageRes: Int)
data class State(
val posts: UiState<List<AdminPostDto>> = UiState.Loading,
val categories: UiState<List<AdminWikiCategoryDto>> = UiState.Loading,
val tags: List<AdminWikiTagDto> = emptyList(),
val busy: Boolean = false,
val feedback: Feedback? = null,
)
private val _state = MutableStateFlow(State())
val state: StateFlow<State> = _state.asStateFlow()
init {
loadPosts()
loadWiki()
}
fun clearFeedback() = _state.update { it.copy(feedback = null) }
fun loadPosts() {
_state.update { it.copy(posts = UiState.Loading) }
viewModelScope.launch { _state.update { it.copy(posts = admin.posts().toUiState()) } }
}
fun loadWiki() {
_state.update { it.copy(categories = UiState.Loading) }
viewModelScope.launch {
_state.update { it.copy(categories = admin.wikiCategories().toUiState()) }
when (val tags = admin.wikiTags()) {
is ApiResult.Ok -> _state.update { it.copy(tags = tags.data) }
else -> Unit // tags are secondary; leave the last list on a failure
}
}
}
fun togglePublish(post: AdminPostDto) = mutate(onSuccess = ::loadPosts) {
admin.setPostPublished(post.id, !post.isPublished).asFeedback(R.string.admin_content_post_updated)
}
fun deletePost(id: Long) = mutate(onSuccess = ::loadPosts) {
admin.deletePost(id).asFeedback(R.string.admin_content_post_deleted)
}
fun createPost(category: String, title: String, excerpt: String, body: String, published: Boolean) {
if (title.isBlank()) {
_state.update { it.copy(feedback = Feedback(false, R.string.admin_content_title_required)) }
return
}
mutate(onSuccess = ::loadPosts) {
admin.createPost(
PostCreateRequest(
category = category,
title = title.trim(),
excerpt = excerpt.ifBlank { null },
body = body.ifBlank { null },
published = published,
),
).asFeedback(R.string.admin_content_post_created)
}
}
fun createCategory(slug: String, title: String, description: String, sortOrder: Int?) {
if (slug.isBlank() || title.isBlank()) {
_state.update { it.copy(feedback = Feedback(false, R.string.admin_content_cat_fields_required)) }
return
}
mutate(onSuccess = ::loadWiki) {
admin.createWikiCategory(
WikiCategoryRequest(slug.trim(), title.trim(), description.ifBlank { null }, sortOrder),
).asFeedback(R.string.admin_content_cat_created)
}
}
fun deleteCategory(id: Long) = mutate(onSuccess = ::loadWiki) {
admin.deleteWikiCategory(id).asFeedback(R.string.admin_content_cat_deleted)
}
// ── Shared mutation plumbing ──────────────────────────────────────────
/** Run a write: set busy + clear feedback, then on completion set the feedback
* banner and, only if it succeeded, run [onSuccess] (a targeted reload). */
private fun mutate(onSuccess: () -> Unit = {}, block: suspend () -> Feedback) {
if (_state.value.busy) return
_state.update { it.copy(busy = true, feedback = null) }
viewModelScope.launch {
val feedback = block()
if (feedback.ok) onSuccess()
_state.update { it.copy(busy = false, feedback = feedback) }
}
}
/** Map an [ApiResult] to a [Feedback], with role/permission-aware failure copy. */
private fun ApiResult<*>.asFeedback(@StringRes okRes: Int): Feedback = when (this) {
is ApiResult.Ok -> Feedback(true, okRes)
is ApiResult.HttpError ->
Feedback(false, if (status == 403) R.string.admin_forbidden else R.string.admin_action_failed)
is ApiResult.NetworkError -> Feedback(false, R.string.error_network)
}
}

View File

@@ -0,0 +1,169 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.ui.admin
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.runicgateway.app.R
import com.runicgateway.app.data.api.dto.AdminDashboardDto
import com.runicgateway.app.ui.UiState
import com.runicgateway.app.ui.components.ErrorView
import com.runicgateway.app.ui.components.LoadingView
import com.runicgateway.app.ui.components.PillTone
import com.runicgateway.app.ui.components.SectionLabel
import com.runicgateway.app.ui.components.StatusPill
/**
* The staff dashboard (PLAN.md §1, M10): site mode + a site-mode toggle (admins
* only), summary counts, and recent admin activity. Read-only for moderators/editors;
* only [isAdmin] callers see the maintenance switch, and the server enforces it too.
*/
@Composable
fun AdminDashboardScreen(
isAdmin: Boolean,
modifier: Modifier = Modifier,
viewModel: AdminDashboardViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsStateWithLifecycle()
when (val ds = state.dashboard) {
is UiState.Loading -> LoadingView(modifier)
is UiState.Error -> ErrorView(ds.kind, onRetry = viewModel::load, modifier = modifier)
is UiState.Success -> DashboardContent(
data = ds.data,
isAdmin = isAdmin,
switching = state.switching,
feedbackRes = state.feedback?.messageRes,
onSetMode = viewModel::setSiteMode,
modifier = modifier,
)
}
}
@Composable
private fun DashboardContent(
data: AdminDashboardDto,
isAdmin: Boolean,
switching: Boolean,
feedbackRes: Int?,
onSetMode: (String) -> Unit,
modifier: Modifier = Modifier,
) {
val live = data.siteMode.equals("live", ignoreCase = true)
Column(
modifier = modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(20.dp),
) {
// ── Site status ──────────────────────────────────────────────
SectionLabel(stringResource(R.string.admin_dashboard_site))
Spacer(Modifier.height(8.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
StatusPill(
text = if (live) stringResource(R.string.admin_site_live) else stringResource(R.string.admin_site_maintenance),
tone = if (live) PillTone.Success else PillTone.Warning,
)
data.lastChange.by?.takeIf { it.isNotBlank() }?.let { by ->
Spacer(Modifier.width(12.dp))
Text(
text = stringResource(R.string.admin_site_changed_by, by),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
if (isAdmin) {
Spacer(Modifier.height(12.dp))
Button(
onClick = { onSetMode(if (live) "maintenance" else "live") },
enabled = !switching,
modifier = Modifier.fillMaxWidth(),
) {
if (switching) {
CircularProgressIndicator(strokeWidth = 2.dp, modifier = Modifier.height(20.dp))
} else {
Text(
stringResource(
if (live) R.string.admin_site_switch_maintenance else R.string.admin_site_switch_live,
),
)
}
}
}
feedbackRes?.let {
Spacer(Modifier.height(8.dp))
Text(
text = stringResource(it),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
// ── Counts ───────────────────────────────────────────────────
Spacer(Modifier.height(24.dp))
SectionLabel(stringResource(R.string.admin_dashboard_counts))
Spacer(Modifier.height(8.dp))
StatRow(stringResource(R.string.admin_count_users), data.counts.users.toString())
val totalPosts = data.counts.posts.values.sum()
StatRow(stringResource(R.string.admin_count_posts), totalPosts.toString())
data.counts.posts.forEach { (category, count) ->
StatRow("· $category", count.toString())
}
// ── Recent activity ──────────────────────────────────────────
if (data.recentActivity.isNotEmpty()) {
Spacer(Modifier.height(24.dp))
SectionLabel(stringResource(R.string.admin_dashboard_recent_activity))
Spacer(Modifier.height(8.dp))
data.recentActivity.forEach { row ->
Column(Modifier.fillMaxWidth().padding(vertical = 6.dp)) {
Text(row.action, style = MaterialTheme.typography.bodyMedium)
val meta = listOfNotNull(row.username, row.createdAt).joinToString(" · ")
if (meta.isNotBlank()) {
Text(
text = meta,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
}
}
@Composable
private fun StatRow(label: String, value: String) {
Row(
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(label, style = MaterialTheme.typography.bodyMedium)
Text(value, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}

View File

@@ -0,0 +1,91 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.ui.admin
import androidx.annotation.StringRes
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.runicgateway.app.R
import com.runicgateway.app.core.result.ApiResult
import com.runicgateway.app.data.api.dto.AdminDashboardDto
import com.runicgateway.app.data.repository.AdminRepository
import com.runicgateway.app.ui.UiState
import com.runicgateway.app.ui.toUiState
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import javax.inject.Inject
/**
* Drives the staff dashboard (PLAN.md §1, M10): summary counts + the site-mode
* toggle. The mode switch is admin-only server-side (`adminOnly`); the screen only
* offers it to admins, but a `403` is still handled cleanly if a moderator reaches
* it. Everything is read through the typed [AdminRepository] (§7).
*/
@HiltViewModel
class AdminDashboardViewModel @Inject constructor(
private val admin: AdminRepository,
) : ViewModel() {
data class Feedback(val ok: Boolean, @param:StringRes val messageRes: Int)
data class State(
val dashboard: UiState<AdminDashboardDto> = UiState.Loading,
/** True while a site-mode switch is in flight (disables the control). */
val switching: Boolean = false,
val feedback: Feedback? = null,
)
private val _state = MutableStateFlow(State())
val state: StateFlow<State> = _state.asStateFlow()
init {
load()
}
fun load() {
_state.update { it.copy(dashboard = UiState.Loading) }
viewModelScope.launch {
_state.update { it.copy(dashboard = admin.dashboard().toUiState()) }
}
}
fun clearFeedback() = _state.update { it.copy(feedback = null) }
/** Switch the site between "live" and "maintenance" (admin only). */
fun setSiteMode(mode: String) {
if (_state.value.switching) return
_state.update { it.copy(switching = true, feedback = null) }
viewModelScope.launch {
when (val result = admin.setSiteMode(mode)) {
is ApiResult.Ok -> {
// Reflect the new mode locally, then refresh the full summary.
val current = _state.value.dashboard
if (current is UiState.Success) {
_state.update {
it.copy(dashboard = UiState.Success(current.data.copy(siteMode = result.data.siteMode)))
}
}
_state.update { it.copy(switching = false, feedback = Feedback(true, R.string.admin_site_mode_updated)) }
load()
}
is ApiResult.HttpError ->
_state.update {
it.copy(
switching = false,
feedback = Feedback(
false,
if (result.status == 403) R.string.admin_forbidden else R.string.admin_action_failed,
),
)
}
is ApiResult.NetworkError ->
_state.update { it.copy(switching = false, feedback = Feedback(false, R.string.error_network)) }
}
}
}
}

View File

@@ -0,0 +1,92 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.ui.admin
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.runicgateway.app.R
import com.runicgateway.app.ui.components.SectionLabel
/**
* The moderation screen (PLAN.md §1, M10): kick / ban / unban an account and
* broadcast, over `/admin/shard/…` (admin/moderator). A live sidecar is required;
* offline, actions return a clean "shard offline" message. Fields are entered here;
* the [AdminModerationViewModel] performs the guarded action.
*/
@Composable
fun AdminModerationScreen(
modifier: Modifier = Modifier,
viewModel: AdminModerationViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsStateWithLifecycle()
var account by rememberSaveable { mutableStateOf("") }
var serial by rememberSaveable { mutableStateOf("") }
var reason by rememberSaveable { mutableStateOf("") }
var duration by rememberSaveable { mutableStateOf("") }
var broadcast by rememberSaveable { mutableStateOf("") }
val busy = state.busy
Column(
modifier = modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(20.dp),
) {
state.feedback?.let {
Text(
text = stringResource(it.messageRes),
style = MaterialTheme.typography.bodySmall,
color = if (it.ok) MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.error,
modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp),
)
}
// ── Account actions ──────────────────────────────────────────────
SectionLabel(stringResource(R.string.admin_mod_account_action))
OutlinedTextField(value = account, onValueChange = { account = it }, singleLine = true, label = { Text(stringResource(R.string.admin_mod_account)) }, modifier = Modifier.fillMaxWidth().padding(top = 8.dp))
OutlinedTextField(value = serial, onValueChange = { serial = it }, singleLine = true, label = { Text(stringResource(R.string.admin_mod_serial)) }, modifier = Modifier.fillMaxWidth().padding(top = 8.dp))
OutlinedTextField(value = reason, onValueChange = { reason = it }, label = { Text(stringResource(R.string.admin_mod_reason)) }, modifier = Modifier.fillMaxWidth().padding(top = 8.dp))
OutlinedTextField(value = duration, onValueChange = { duration = it.filter(Char::isDigit) }, singleLine = true, label = { Text(stringResource(R.string.admin_mod_duration)) }, modifier = Modifier.fillMaxWidth().padding(top = 8.dp))
Row(Modifier.fillMaxWidth().padding(top = 12.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedButton(onClick = { viewModel.kick(account, serial) }, enabled = !busy, modifier = Modifier.weight(1f)) {
Text(stringResource(R.string.admin_mod_kick))
}
Button(onClick = { viewModel.ban(account, serial, duration.toLongOrNull(), reason) }, enabled = !busy, modifier = Modifier.weight(1f)) {
Text(stringResource(R.string.admin_mod_ban))
}
OutlinedButton(onClick = { viewModel.unban(account) }, enabled = !busy, modifier = Modifier.weight(1f)) {
Text(stringResource(R.string.admin_mod_unban))
}
}
// ── Broadcast ────────────────────────────────────────────────────
Spacer(Modifier.height(24.dp))
SectionLabel(stringResource(R.string.admin_mod_broadcast_section))
OutlinedTextField(value = broadcast, onValueChange = { broadcast = it }, label = { Text(stringResource(R.string.admin_mod_broadcast_text)) }, modifier = Modifier.fillMaxWidth().padding(top = 8.dp))
Button(onClick = { viewModel.broadcast(broadcast, null) }, enabled = !busy, modifier = Modifier.fillMaxWidth().padding(top = 12.dp)) {
Text(stringResource(R.string.admin_mod_broadcast))
}
}
}

View File

@@ -0,0 +1,89 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.ui.admin
import androidx.annotation.StringRes
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.runicgateway.app.R
import com.runicgateway.app.core.result.ApiResult
import com.runicgateway.app.data.repository.AdminRepository
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import javax.inject.Inject
/**
* Drives the moderation actions (PLAN.md §1, M10): kick / ban / unban an account
* and broadcast a system message, over the shard write plane (`/admin/shard/…`,
* admin/moderator). These need a live sidecar — when the shard is offline the call
* fails and the screen shows a clean error, never a crash (§7). The form fields live
* in the screen; this VM owns only the busy + feedback state and the actions.
*/
@HiltViewModel
class AdminModerationViewModel @Inject constructor(
private val admin: AdminRepository,
) : ViewModel() {
data class Feedback(val ok: Boolean, @param:StringRes val messageRes: Int)
data class State(val busy: Boolean = false, val feedback: Feedback? = null)
private val _state = MutableStateFlow(State())
val state: StateFlow<State> = _state.asStateFlow()
fun clearFeedback() = _state.update { it.copy(feedback = null) }
fun kick(account: String, serial: String) {
if (account.isBlank() && serial.isBlank()) return badTarget()
run(R.string.admin_mod_kicked) { admin.kick(account.ifBlank { null }, serial.ifBlank { null }) }
}
fun ban(account: String, serial: String, durationSec: Long?, reason: String) {
if (account.isBlank() && serial.isBlank()) return badTarget()
run(R.string.admin_mod_banned) {
admin.ban(account.ifBlank { null }, serial.ifBlank { null }, durationSec, reason.ifBlank { null })
}
}
fun unban(account: String) {
if (account.isBlank()) return badTarget()
run(R.string.admin_mod_unbanned) { admin.unban(account.trim()) }
}
fun broadcast(text: String, hue: Int?) {
if (text.isBlank()) {
_state.update { it.copy(feedback = Feedback(false, R.string.admin_mod_text_required)) }
return
}
run(R.string.admin_mod_broadcasted) { admin.broadcast(text.trim(), hue) }
}
private fun badTarget() {
_state.update { it.copy(feedback = Feedback(false, R.string.admin_mod_target_required)) }
}
private fun run(@StringRes okRes: Int, block: suspend () -> ApiResult<Unit>) {
if (_state.value.busy) return
_state.update { it.copy(busy = true, feedback = null) }
viewModelScope.launch {
val feedback = when (val r = block()) {
is ApiResult.Ok -> Feedback(true, okRes)
is ApiResult.HttpError -> Feedback(
false,
when (r.status) {
403 -> R.string.admin_forbidden
503 -> R.string.admin_mod_shard_offline
else -> R.string.admin_action_failed
},
)
is ApiResult.NetworkError -> Feedback(false, R.string.error_network)
}
_state.update { it.copy(busy = false, feedback = feedback) }
}
}
}

View File

@@ -0,0 +1,147 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.ui.admin
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Card
import androidx.compose.material3.Checkbox
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.runicgateway.app.R
import com.runicgateway.app.data.api.dto.SupportPageDto
import com.runicgateway.app.ui.UiState
import com.runicgateway.app.ui.components.EmptyView
import com.runicgateway.app.ui.components.ErrorView
import com.runicgateway.app.ui.components.LoadingView
/**
* The support (help-page) queue (PLAN.md §1, M10): open tickets with reply/close,
* over `/admin/shard/pages…` (admin/moderator). Empty when there are no open pages
* (or the shard is offline); every read/write degrades cleanly (§7).
*/
@Composable
fun AdminSupportScreen(
modifier: Modifier = Modifier,
viewModel: AdminSupportViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsStateWithLifecycle()
var replyTo by remember { mutableStateOf<SupportPageDto?>(null) }
Column(modifier.fillMaxSize()) {
state.feedback?.let {
Text(
text = stringResource(it.messageRes),
style = MaterialTheme.typography.bodySmall,
color = if (it.ok) MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.error,
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 6.dp),
)
}
when (val s = state.pages) {
is UiState.Loading -> LoadingView()
is UiState.Error -> ErrorView(s.kind, onRetry = viewModel::load)
is UiState.Success ->
if (s.data.isEmpty()) {
EmptyView(stringResource(R.string.admin_support_empty))
} else {
LazyColumn(Modifier.fillMaxSize().padding(16.dp)) {
items(s.data, key = { it.pageId }) { page ->
SupportPageCard(
page = page,
busy = state.busy,
onReply = { replyTo = page },
onClose = { viewModel.close(page.pageId) },
)
}
}
}
}
}
replyTo?.let { page ->
RespondDialog(
page = page,
onDismiss = { replyTo = null },
onSend = { message, close ->
viewModel.respond(page.pageId, message, close)
replyTo = null
},
)
}
}
@Composable
private fun SupportPageCard(
page: SupportPageDto,
busy: Boolean,
onReply: () -> Unit,
onClose: () -> Unit,
) {
Card(Modifier.fillMaxWidth().padding(vertical = 6.dp)) {
Column(Modifier.padding(12.dp)) {
val who = page.sender?.name ?: page.sender?.account ?: page.pageId
Text(
text = listOfNotNull(page.type, who).joinToString(" · "),
style = MaterialTheme.typography.bodyLarge,
)
page.message?.takeIf { it.isNotBlank() }?.let {
Spacer(Modifier.height(4.dp))
Text(it, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
Row(Modifier.fillMaxWidth().padding(top = 8.dp), horizontalArrangement = Arrangement.End) {
TextButton(onClick = onReply, enabled = !busy) { Text(stringResource(R.string.admin_support_reply)) }
TextButton(onClick = onClose, enabled = !busy) { Text(stringResource(R.string.admin_support_close)) }
}
}
}
}
@Composable
private fun RespondDialog(
page: SupportPageDto,
onDismiss: () -> Unit,
onSend: (message: String, close: Boolean) -> Unit,
) {
var message by rememberSaveable { mutableStateOf("") }
var alsoClose by rememberSaveable { mutableStateOf(true) }
AlertDialog(
onDismissRequest = onDismiss,
confirmButton = { TextButton(onClick = { onSend(message, alsoClose) }) { Text(stringResource(R.string.admin_support_send)) } },
dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.action_cancel)) } },
title = { Text(stringResource(R.string.admin_support_reply)) },
text = {
Column {
OutlinedTextField(value = message, onValueChange = { message = it }, label = { Text(stringResource(R.string.admin_support_message)) }, modifier = Modifier.fillMaxWidth())
Row(Modifier.fillMaxWidth().padding(top = 8.dp), verticalAlignment = Alignment.CenterVertically) {
Checkbox(checked = alsoClose, onCheckedChange = { alsoClose = it })
Text(stringResource(R.string.admin_support_close_after))
}
}
},
)
}

View File

@@ -0,0 +1,87 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.ui.admin
import androidx.annotation.StringRes
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.runicgateway.app.R
import com.runicgateway.app.core.result.ApiResult
import com.runicgateway.app.data.api.dto.SupportPageDto
import com.runicgateway.app.data.repository.AdminRepository
import com.runicgateway.app.ui.UiState
import com.runicgateway.app.ui.toUiState
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import javax.inject.Inject
/**
* Drives the support (help-page) queue (PLAN.md §1, M10): list open pages, reply
* (optionally closing), and close, over `/admin/shard/pages…` (admin/moderator).
* The list is served from shard state — empty when no tickets (or the shard is
* offline); writes need a live sidecar and fail cleanly otherwise (§7).
*/
@HiltViewModel
class AdminSupportViewModel @Inject constructor(
private val admin: AdminRepository,
) : ViewModel() {
data class Feedback(val ok: Boolean, @param:StringRes val messageRes: Int)
data class State(
val pages: UiState<List<SupportPageDto>> = UiState.Loading,
val busy: Boolean = false,
val feedback: Feedback? = null,
)
private val _state = MutableStateFlow(State())
val state: StateFlow<State> = _state.asStateFlow()
init {
load()
}
fun clearFeedback() = _state.update { it.copy(feedback = null) }
fun load() {
_state.update { it.copy(pages = UiState.Loading) }
viewModelScope.launch { _state.update { it.copy(pages = admin.supportPages().toUiState()) } }
}
fun respond(id: String, message: String, close: Boolean) {
if (message.isBlank()) {
_state.update { it.copy(feedback = Feedback(false, R.string.admin_support_message_required)) }
return
}
mutate(R.string.admin_support_responded) { admin.respondPage(id, message.trim(), close) }
}
fun close(id: String) = mutate(R.string.admin_support_closed) { admin.closePage(id) }
private fun mutate(@StringRes okRes: Int, block: suspend () -> ApiResult<Unit>) {
if (_state.value.busy) return
_state.update { it.copy(busy = true, feedback = null) }
viewModelScope.launch {
val feedback = when (val r = block()) {
is ApiResult.Ok -> Feedback(true, okRes)
is ApiResult.HttpError -> Feedback(
false,
when (r.status) {
403 -> R.string.admin_forbidden
404 -> R.string.admin_support_unknown_page
503 -> R.string.admin_mod_shard_offline
else -> R.string.admin_action_failed
},
)
is ApiResult.NetworkError -> Feedback(false, R.string.error_network)
}
if (feedback.ok) load()
_state.update { it.copy(busy = false, feedback = feedback) }
}
}
}

View File

@@ -65,6 +65,8 @@ fun AccountScreen(
roleLabel: String, roleLabel: String,
onSignOut: () -> Unit, onSignOut: () -> Unit,
onSignOutEverywhere: () -> Unit, onSignOutEverywhere: () -> Unit,
onOpenTrustedDevices: () -> Unit,
onOpenRecoveryCodes: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
viewModel: AccountViewModel = hiltViewModel(), viewModel: AccountViewModel = hiltViewModel(),
) { ) {
@@ -78,10 +80,15 @@ fun AccountScreen(
) { ) {
IdentityCard(username = username, roleLabel = roleLabel) IdentityCard(username = username, roleLabel = roleLabel)
// One-time recovery codes surfaced right after enabling 2FA — save them now.
state.recoveryCodesOnce?.let { codes ->
RecoveryCodesShowOnceCard(codes, onDismiss = viewModel::dismissRecoveryCodes)
}
when (val account = state.account) { when (val account = state.account) {
is UiState.Loading -> LoadingView(Modifier.padding(top = 32.dp)) is UiState.Loading -> LoadingView(Modifier.padding(top = 32.dp))
is UiState.Error -> ErrorView(account.kind, onRetry = viewModel::load, modifier = Modifier.padding(top = 32.dp)) is UiState.Error -> ErrorView(account.kind, onRetry = viewModel::load, modifier = Modifier.padding(top = 32.dp))
is UiState.Success -> AccountSections(account.data, state, viewModel) is UiState.Success -> AccountSections(account.data, state, viewModel, onOpenTrustedDevices, onOpenRecoveryCodes)
} }
HorizontalDivider(Modifier.padding(vertical = 20.dp)) HorizontalDivider(Modifier.padding(vertical = 20.dp))
@@ -117,13 +124,34 @@ private fun AccountSections(
account: PlayerAccountDto, account: PlayerAccountDto,
state: AccountViewModel.State, state: AccountViewModel.State,
viewModel: AccountViewModel, viewModel: AccountViewModel,
onOpenTrustedDevices: () -> Unit,
onOpenRecoveryCodes: () -> Unit,
) { ) {
UsernameSection(account, state, viewModel) UsernameSection(account, state, viewModel)
PasswordSection(account, state, viewModel) PasswordSection(account, state, viewModel)
TwoFactorSection(account, state, viewModel) TwoFactorSection(account, state, viewModel)
SecuritySection(onOpenTrustedDevices, onOpenRecoveryCodes)
IdentitiesSection(state, viewModel) IdentitiesSection(state, viewModel)
} }
/**
* Links to the dedicated trusted-device and recovery-code screens
* (TRUSTED_DEVICES_MFA.md). Kept simple — the management UX lives on those screens.
*/
@Composable
private fun SecuritySection(onOpenTrustedDevices: () -> Unit, onOpenRecoveryCodes: () -> Unit) {
SectionCard(R.string.account_security_title) {
OutlinedButton(
onClick = onOpenTrustedDevices,
modifier = Modifier.fillMaxWidth().padding(top = 12.dp),
) { Text(stringResource(R.string.account_security_trusted_devices)) }
OutlinedButton(
onClick = onOpenRecoveryCodes,
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
) { Text(stringResource(R.string.account_security_recovery_codes)) }
}
}
@Composable @Composable
private fun SectionCard(@StringRes titleRes: Int, content: @Composable () -> Unit) { private fun SectionCard(@StringRes titleRes: Int, content: @Composable () -> Unit) {
Card(Modifier.fillMaxWidth().padding(top = 12.dp)) { Card(Modifier.fillMaxWidth().padding(top = 12.dp)) {

View File

@@ -49,6 +49,8 @@ class AccountViewModel @Inject constructor(
val busy: Boolean = false, val busy: Boolean = false,
/** The pending TOTP enrollment (QR shown) between setup and enable. */ /** The pending TOTP enrollment (QR shown) between setup and enable. */
val totpSetup: TotpSetupDto? = null, val totpSetup: TotpSetupDto? = null,
/** The single-use recovery codes returned once when 2FA was just enabled. */
val recoveryCodesOnce: List<String>? = null,
val feedback: Feedback? = null, val feedback: Feedback? = null,
) )
@@ -122,9 +124,12 @@ class AccountViewModel @Inject constructor(
if (_state.value.busy) return if (_state.value.busy) return
_state.update { it.copy(busy = true, feedback = null) } _state.update { it.copy(busy = true, feedback = null) }
viewModelScope.launch { viewModelScope.launch {
when (accountRepository.totpEnable(code.trim())) { when (val result = accountRepository.totpEnable(code.trim())) {
is ApiResult.Ok -> { is ApiResult.Ok -> {
_state.update { it.copy(totpSetup = null) } // 2FA enable returns the fresh recovery-code batch once — surface it.
_state.update {
it.copy(totpSetup = null, recoveryCodesOnce = result.data.recoveryCodes?.takeIf(List<String>::isNotEmpty))
}
finish(Section.TOTP, true, R.string.account_totp_enabled) finish(Section.TOTP, true, R.string.account_totp_enabled)
reloadAccount() reloadAccount()
} }
@@ -134,6 +139,9 @@ class AccountViewModel @Inject constructor(
} }
} }
/** Dismiss the one-time recovery-code batch shown after enabling 2FA. */
fun dismissRecoveryCodes() = _state.update { it.copy(recoveryCodesOnce = null) }
fun disableTotp(code: String) { fun disableTotp(code: String) {
if (_state.value.busy) return if (_state.value.busy) return
_state.update { it.copy(busy = true, feedback = null) } _state.update { it.copy(busy = true, feedback = null) }

View File

@@ -5,8 +5,11 @@ package com.runicgateway.app.ui.auth
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
@@ -14,15 +17,22 @@ import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.Checkbox
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
@@ -115,6 +125,24 @@ fun LoginScreen(
) )
if (state.totpRequired) { if (state.totpRequired) {
if (state.useRecoveryCode) {
OutlinedTextField(
value = state.recoveryCode,
onValueChange = viewModel::onRecoveryCodeChange,
singleLine = true,
enabled = !state.submitting,
label = { Text(stringResource(R.string.login_recovery_code)) },
supportingText = { Text(stringResource(R.string.login_recovery_hint)) },
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Password,
imeAction = ImeAction.Go,
),
keyboardActions = KeyboardActions(onGo = { viewModel.submit() }),
modifier = Modifier
.fillMaxWidth()
.padding(top = 12.dp),
)
} else {
OutlinedTextField( OutlinedTextField(
value = state.code, value = state.code,
onValueChange = viewModel::onCodeChange, onValueChange = viewModel::onCodeChange,
@@ -133,6 +161,39 @@ fun LoginScreen(
) )
} }
// Toggle between authenticator code and a single-use recovery code.
TextButton(
onClick = { viewModel.onUseRecoveryCodeChange(!state.useRecoveryCode) },
enabled = !state.submitting,
modifier = Modifier.align(Alignment.Start),
) {
Text(
stringResource(
if (state.useRecoveryCode) R.string.login_use_totp_instead
else R.string.login_use_recovery_instead,
),
)
}
// "Trust this device" → skip the 2FA step on future logins here.
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.padding(top = 4.dp),
) {
Checkbox(
checked = state.trustDevice,
onCheckedChange = viewModel::onTrustDeviceChange,
enabled = !state.submitting,
)
Text(
text = stringResource(R.string.login_trust_device),
style = MaterialTheme.typography.bodyMedium,
)
}
}
state.error?.let { err -> state.error?.let { err ->
Text( Text(
text = stringResource(loginErrorRes(err)), text = stringResource(loginErrorRes(err)),
@@ -163,27 +224,46 @@ fun LoginScreen(
} }
} }
// ── Native SSO (§4.2, M9): a button per enabled provider that opens the // ── Native SSO (§4.2, M9): a single "Sign in with SSO" button that opens the
// Custom-Tab bridge and returns the user signed in. Falls back to the // Custom-Tab bridge. With one provider it launches straight through; with
// website login hand-off when the shard exposes no providers. // several it presents a native picker (below). No website-login fallback —
if (state.ssoProviders.isNotEmpty()) { // that page can't deep-link the session back; a failed discovery offers a retry.
state.ssoProviders.forEach { provider -> var showSsoPicker by remember { mutableStateOf(false) }
when {
state.ssoProviders.isNotEmpty() -> {
OutlinedButton( OutlinedButton(
onClick = { viewModel.onSsoProviderClick(provider) }, onClick = {
val providers = state.ssoProviders
if (providers.size == 1) viewModel.onSsoProviderClick(providers.first())
else showSsoPicker = true
},
enabled = !state.submitting, enabled = !state.submitting,
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(top = 12.dp), .padding(top = 12.dp),
) { ) {
Text(stringResource(R.string.login_sso_provider, provider.name)) Text(stringResource(R.string.login_sso_button))
} }
} }
} else {
viewModel.ssoLoginUrl?.let { url -> state.ssoDiscovering -> {
TextButton(onClick = { WebHandoff.open(context, url) }) { Text(
Text(stringResource(R.string.login_sso)) text = stringResource(R.string.login_sso_loading),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 12.dp),
)
}
state.ssoUnavailable -> {
TextButton(
onClick = { viewModel.discoverSsoProviders() },
modifier = Modifier.padding(top = 4.dp),
) {
Text(stringResource(R.string.login_sso_retry))
} }
} }
// else: discovery succeeded with no providers — this shard offers no SSO.
} }
// ── Website hand-offs (§4.2): open the site's own pages in a Custom Tab ── // ── Website hand-offs (§4.2): open the site's own pages in a Custom Tab ──
@@ -198,6 +278,53 @@ fun LoginScreen(
Text(stringResource(R.string.login_forgot)) Text(stringResource(R.string.login_forgot))
} }
} }
if (showSsoPicker) {
SsoProviderPicker(
providers = state.ssoProviders,
onDismiss = { showSsoPicker = false },
onPick = { provider ->
showSsoPicker = false
viewModel.onSsoProviderClick(provider)
},
)
}
}
}
/**
* The native provider picker (§4.2): a bottom sheet listing the shard's enabled SSO
* providers so a single "Sign in with SSO" button can serve several IdPs without a
* website chooser page. Each row opens the Custom-Tab bridge for that provider.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun SsoProviderPicker(
providers: List<com.runicgateway.app.data.api.dto.SsoProviderDto>,
onDismiss: () -> Unit,
onPick: (com.runicgateway.app.data.api.dto.SsoProviderDto) -> Unit,
) {
ModalBottomSheet(onDismissRequest = onDismiss, sheetState = rememberModalBottomSheetState()) {
Text(
text = stringResource(R.string.login_sso_pick_title),
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(horizontal = 24.dp, vertical = 8.dp),
)
providers.forEach { provider ->
TextButton(
onClick = { onPick(provider) },
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 2.dp),
) {
Text(
text = stringResource(R.string.login_sso_provider, provider.name),
modifier = Modifier.fillMaxWidth(),
textAlign = TextAlign.Start,
)
}
}
Spacer(Modifier.height(24.dp)) // clears the gesture inset at the sheet's bottom
} }
} }

View File

@@ -10,6 +10,7 @@ import com.runicgateway.app.core.web.WebsiteUrls
import com.runicgateway.app.data.api.dto.SsoProviderDto import com.runicgateway.app.data.api.dto.SsoProviderDto
import com.runicgateway.app.data.repository.AuthRepository import com.runicgateway.app.data.repository.AuthRepository
import com.runicgateway.app.data.repository.AuthRepository.LoginResult import com.runicgateway.app.data.repository.AuthRepository.LoginResult
import com.runicgateway.app.data.repository.AuthRepository.SsoDiscovery
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@@ -38,13 +39,23 @@ class LoginViewModel @Inject constructor(
val username: String = "", val username: String = "",
val password: String = "", val password: String = "",
val code: String = "", val code: String = "",
/** A single-use recovery code, entered instead of [code] when [useRecoveryCode]. */
val recoveryCode: String = "",
/** True once the account is known to have 2FA on — reveal the code field. */ /** True once the account is known to have 2FA on — reveal the code field. */
val totpRequired: Boolean = false, val totpRequired: Boolean = false,
/** "Enter a recovery code instead" — swap the TOTP field for the recovery field. */
val useRecoveryCode: Boolean = false,
/** "Trust this device" — skip the 2FA step on future logins (TRUSTED_DEVICES_MFA.md). */
val trustDevice: Boolean = false,
val submitting: Boolean = false, val submitting: Boolean = false,
val error: LoginError? = null, val error: LoginError? = null,
val signedIn: Boolean = false, val signedIn: Boolean = false,
/** The shard's enabled SSO providers (§4.2); empty → website hand-off fallback. */ /** The shard's enabled SSO providers (§4.2); empty until discovery resolves. */
val ssoProviders: List<SsoProviderDto> = emptyList(), val ssoProviders: List<SsoProviderDto> = emptyList(),
/** True while discovery is in flight — the screen shows a spinner, not an empty gap. */
val ssoDiscovering: Boolean = true,
/** True when discovery failed (offline/server) — offer a retry rather than a dead end. */
val ssoUnavailable: Boolean = false,
/** A `/auth/mobile/sso/start` URL the screen should open in a Custom Tab, once. */ /** A `/auth/mobile/sso/start` URL the screen should open in a Custom Tab, once. */
val ssoLaunchUrl: String? = null, val ssoLaunchUrl: String? = null,
) )
@@ -53,11 +64,7 @@ class LoginViewModel @Inject constructor(
val state: StateFlow<UiState> = _state.asStateFlow() val state: StateFlow<UiState> = _state.asStateFlow()
init { init {
// Discover the native SSO providers to render buttons for (§4.2). discoverSsoProviders()
viewModelScope.launch {
val providers = authRepository.ssoProviders()
if (providers.isNotEmpty()) _state.update { it.copy(ssoProviders = providers) }
}
// Consume the SSO bridge outcome: a returned callback completes here even if // Consume the SSO bridge outcome: a returned callback completes here even if
// this ViewModel was recreated while the Custom Tab was foreground (§4.2). // this ViewModel was recreated while the Custom Tab was foreground (§4.2).
viewModelScope.launch { viewModelScope.launch {
@@ -82,11 +89,43 @@ class LoginViewModel @Inject constructor(
fun onCodeChange(value: String) = fun onCodeChange(value: String) =
_state.update { it.copy(code = value.filter(Char::isDigit).take(8), error = null) } _state.update { it.copy(code = value.filter(Char::isDigit).take(8), error = null) }
/** Recovery codes are alphanumeric; keep it permissive, just trim length + noise. */
fun onRecoveryCodeChange(value: String) =
_state.update { it.copy(recoveryCode = value.filterNot(Char::isWhitespace).take(32), error = null) }
fun onTrustDeviceChange(value: Boolean) = _state.update { it.copy(trustDevice = value) }
/** Toggle between the TOTP field and the recovery-code field on the 2FA step. */
fun onUseRecoveryCodeChange(value: Boolean) =
_state.update { it.copy(useRecoveryCode = value, error = null) }
val registerUrl: String? get() = websiteUrls.register() val registerUrl: String? get() = websiteUrls.register()
val forgotPasswordUrl: String? get() = websiteUrls.forgotPassword() val forgotPasswordUrl: String? get() = websiteUrls.forgotPassword()
/** Website login hand-off — the fallback when native SSO discovery is empty (§4.2). */ /**
val ssoLoginUrl: String? get() = websiteUrls.login() * Discover the shard's native SSO providers (§4.2). A failure surfaces a retry
* affordance instead of the old dead website-login hand-off, which was never
* mobile-formatted and could not deep-link the session back.
*/
fun discoverSsoProviders() {
_state.update { it.copy(ssoDiscovering = true, ssoUnavailable = false) }
viewModelScope.launch {
when (val result = authRepository.ssoProviders()) {
is SsoDiscovery.Available ->
_state.update {
it.copy(ssoProviders = result.providers, ssoDiscovering = false, ssoUnavailable = false)
}
SsoDiscovery.None ->
_state.update {
it.copy(ssoProviders = emptyList(), ssoDiscovering = false, ssoUnavailable = false)
}
SsoDiscovery.Unavailable ->
_state.update {
it.copy(ssoProviders = emptyList(), ssoDiscovering = false, ssoUnavailable = true)
}
}
}
}
/** /**
* Begin a native SSO flow for [provider]: mint PKCE + state and surface the * Begin a native SSO flow for [provider]: mint PKCE + state and surface the
@@ -118,26 +157,42 @@ class LoginViewModel @Inject constructor(
_state.update { it.copy(error = LoginError.INVALID_CREDENTIALS) } _state.update { it.copy(error = LoginError.INVALID_CREDENTIALS) }
return return
} }
// If 2FA is being requested, a code must accompany the resubmit. // If 2FA is being requested, the chosen second factor must accompany the resubmit.
if (s.totpRequired && s.code.isBlank()) { if (s.totpRequired) {
val factor = if (s.useRecoveryCode) s.recoveryCode else s.code
if (factor.isBlank()) {
_state.update { it.copy(error = LoginError.BAD_CODE) } _state.update { it.copy(error = LoginError.BAD_CODE) }
return return
} }
}
_state.update { it.copy(submitting = true, error = null) } _state.update { it.copy(submitting = true, error = null) }
viewModelScope.launch { viewModelScope.launch {
val code = s.code.trim().takeIf { it.isNotBlank() } // Only one second factor is sent; the recovery toggle picks which.
when (authRepository.login(s.username.trim(), s.password, code)) { val code = s.code.trim().takeIf { it.isNotBlank() && !s.useRecoveryCode }
LoginResult.Success -> val recoveryCode = s.recoveryCode.trim().takeIf { it.isNotBlank() && s.useRecoveryCode }
val result = authRepository.login(
username = s.username.trim(),
password = s.password,
code = code,
recoveryCode = recoveryCode,
trustDevice = s.trustDevice,
)
when (result) {
is LoginResult.Success ->
// The trusted-device cap (result.trustLimitReached) is an edge case:
// login succeeded but the device wasn't remembered. It's surfaced +
// managed on the Trusted Devices screen rather than blocking sign-in.
_state.update { it.copy(submitting = false, signedIn = true) } _state.update { it.copy(submitting = false, signedIn = true) }
LoginResult.TotpRequired -> LoginResult.TotpRequired ->
// Reveal the code field; a wrong code re-lands here as BAD_CODE. // Reveal the 2FA fields; a wrong code/recovery code re-lands here as BAD_CODE.
_state.update { _state.update {
val hadFactor = if (it.useRecoveryCode) it.recoveryCode.isNotBlank() else it.code.isNotBlank()
it.copy( it.copy(
submitting = false, submitting = false,
totpRequired = true, totpRequired = true,
error = if (it.code.isNotBlank()) LoginError.BAD_CODE else null, error = if (hadFactor) LoginError.BAD_CODE else null,
) )
} }

View File

@@ -0,0 +1,153 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.ui.auth
import android.content.Intent
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.runicgateway.app.R
import com.runicgateway.app.ui.UiState
/**
* Account → Recovery Codes (TRUSTED_DEVICES_MFA.md): shows the remaining count and a
* password-stepped regenerate that reveals a fresh single-use batch **once**. The
* codes are shown only in memory — copy or share them before leaving; they are never
* stored on the device.
*/
@Composable
fun RecoveryCodesScreen(
modifier: Modifier = Modifier,
viewModel: RecoveryCodesViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsStateWithLifecycle()
var currentPassword by rememberSaveable { mutableStateOf("") }
Column(
modifier = modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(16.dp),
) {
Text(stringResource(R.string.recovery_codes_title), style = MaterialTheme.typography.titleLarge)
Text(
stringResource(R.string.recovery_codes_subtitle),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 4.dp),
)
val remainingText = when (val r = state.remaining) {
is UiState.Success -> stringResource(R.string.recovery_codes_remaining, r.data)
is UiState.Error -> stringResource(R.string.recovery_codes_remaining_unknown)
UiState.Loading -> stringResource(R.string.recovery_codes_remaining_loading)
}
Text(remainingText, style = MaterialTheme.typography.bodyLarge, modifier = Modifier.padding(top = 16.dp))
state.freshCodes?.let { codes ->
RecoveryCodesShowOnceCard(codes, onDismiss = { viewModel.dismissFreshCodes(); currentPassword = "" })
}
OutlinedTextField(
value = currentPassword,
onValueChange = { currentPassword = it },
singleLine = true,
enabled = !state.busy,
label = { Text(stringResource(R.string.account_password_current)) },
supportingText = { Text(stringResource(R.string.recovery_codes_password_hint)) },
visualTransformation = PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
modifier = Modifier.fillMaxWidth().padding(top = 20.dp),
)
state.error?.let { err ->
Text(
text = stringResource(err),
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(top = 12.dp),
)
}
Button(
onClick = { viewModel.regenerate(currentPassword) },
enabled = !state.busy,
modifier = Modifier.fillMaxWidth().padding(top = 16.dp),
) { Text(stringResource(R.string.recovery_codes_regenerate)) }
}
}
/**
* A show-once display of a freshly generated recovery-code batch, with copy/share and
* a dismiss. Shared by this screen and the "2FA just enabled" surface on AccountScreen.
*/
@Composable
fun RecoveryCodesShowOnceCard(codes: List<String>, onDismiss: () -> Unit) {
val context = LocalContext.current
val clipboard = LocalClipboardManager.current
val joined = remember(codes) { codes.joinToString("\n") }
Card(Modifier.fillMaxWidth().padding(top = 16.dp)) {
Column(Modifier.padding(16.dp)) {
Text(stringResource(R.string.recovery_codes_new_title), style = MaterialTheme.typography.titleMedium)
Text(
stringResource(R.string.recovery_codes_new_hint),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 4.dp),
)
codes.forEach { code ->
Text(
code,
style = MaterialTheme.typography.bodyLarge.copy(fontFamily = FontFamily.Monospace),
modifier = Modifier.padding(top = 8.dp),
)
}
Row(Modifier.fillMaxWidth().padding(top = 16.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedButton(
onClick = { clipboard.setText(AnnotatedString(joined)) },
) { Text(stringResource(R.string.recovery_codes_copy)) }
OutlinedButton(
onClick = {
val send = Intent(Intent.ACTION_SEND).apply {
type = "text/plain"
putExtra(Intent.EXTRA_TEXT, joined)
}
context.startActivity(Intent.createChooser(send, null))
},
) { Text(stringResource(R.string.recovery_codes_share)) }
Button(onClick = onDismiss) { Text(stringResource(R.string.recovery_codes_done)) }
}
}
}
}

View File

@@ -0,0 +1,84 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.ui.auth
import androidx.annotation.StringRes
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.runicgateway.app.R
import com.runicgateway.app.core.result.ApiResult
import com.runicgateway.app.data.repository.AccountRepository
import com.runicgateway.app.ui.UiState
import com.runicgateway.app.ui.toUiState
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import javax.inject.Inject
/**
* Drives Account → Recovery Codes (TRUSTED_DEVICES_MFA.md): the remaining-count
* status and a password-stepped regenerate that surfaces a fresh single-use batch
* **once** (never persisted). The freshly generated codes live only in memory until
* the user leaves the screen or dismisses them.
*/
@HiltViewModel
class RecoveryCodesViewModel @Inject constructor(
private val accountRepository: AccountRepository,
) : ViewModel() {
data class State(
/** Remaining unused codes (the status endpoint). */
val remaining: UiState<Int> = UiState.Loading,
/** A just-generated batch to show once, or null. Cleared on dismiss/leave. */
val freshCodes: List<String>? = null,
val busy: Boolean = false,
@param:StringRes val error: Int? = null,
)
private val _state = MutableStateFlow(State())
val state: StateFlow<State> = _state.asStateFlow()
init {
load()
}
fun load() {
_state.update { it.copy(remaining = UiState.Loading) }
viewModelScope.launch {
_state.update { it.copy(remaining = accountRepository.recoveryCodesStatus().toUiState().map { s -> s.remaining }) }
}
}
/** Regenerate the codes; [currentPassword] is required for accounts that have one. */
fun regenerate(currentPassword: String?) {
if (_state.value.busy) return
_state.update { it.copy(busy = true, error = null, freshCodes = null) }
viewModelScope.launch {
when (val result = accountRepository.generateRecoveryCodes(currentPassword?.takeIf { it.isNotBlank() })) {
is ApiResult.Ok -> {
_state.update { it.copy(busy = false, freshCodes = result.data.recoveryCodes) }
// Refresh the remaining count to reflect the new batch.
_state.update { it.copy(remaining = accountRepository.recoveryCodesStatus().toUiState().map { s -> s.remaining }) }
}
is ApiResult.HttpError ->
_state.update { it.copy(busy = false, error = R.string.recovery_codes_error) }
is ApiResult.NetworkError ->
_state.update { it.copy(busy = false, error = R.string.error_network) }
}
}
}
/** Drop the shown-once batch from memory (user saved them / navigated away). */
fun dismissFreshCodes() = _state.update { it.copy(freshCodes = null) }
}
/** Map an [UiState] success value (local helper mirroring ApiResult.map). */
private inline fun <T, R> UiState<T>.map(transform: (T) -> R): UiState<R> = when (this) {
is UiState.Success -> UiState.Success(transform(data))
is UiState.Loading -> UiState.Loading
is UiState.Error -> this
}

View File

@@ -0,0 +1,136 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.ui.auth
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.runicgateway.app.R
import com.runicgateway.app.data.api.dto.TrustedDeviceDto
import com.runicgateway.app.ui.UiState
import com.runicgateway.app.ui.components.ErrorView
import com.runicgateway.app.ui.components.LoadingView
/**
* Account → Trusted Devices (TRUSTED_DEVICES_MFA.md): the devices allowed to skip
* the TOTP step at login. Trust the current device, revoke one, or untrust all. The
* server re-checks ownership on every call; this screen just renders the outcomes.
*/
@Composable
fun TrustedDevicesScreen(
modifier: Modifier = Modifier,
viewModel: TrustedDevicesViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsStateWithLifecycle()
Column(
modifier = modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(16.dp),
) {
Text(
stringResource(R.string.trusted_devices_title),
style = MaterialTheme.typography.titleLarge,
)
Text(
stringResource(R.string.trusted_devices_subtitle),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 4.dp),
)
state.feedback?.let { fb ->
Text(
text = stringResource(fb.messageRes),
color = if (fb.ok) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(top = 12.dp),
)
}
when (val devices = state.devices) {
is UiState.Loading -> LoadingView(Modifier.padding(top = 32.dp))
is UiState.Error -> ErrorView(devices.kind, onRetry = viewModel::load, modifier = Modifier.padding(top = 32.dp))
is UiState.Success -> {
if (devices.data.isEmpty()) {
Text(
stringResource(R.string.trusted_devices_empty),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 24.dp),
)
} else {
devices.data.forEach { device ->
TrustedDeviceRow(device, state.busy, onRevoke = { viewModel.revoke(device.id) })
}
}
HorizontalDivider(Modifier.padding(vertical = 20.dp))
Button(
onClick = viewModel::trustThisDevice,
enabled = !state.busy,
modifier = Modifier.fillMaxWidth(),
) { Text(stringResource(R.string.trusted_devices_trust_this)) }
if (devices.data.isNotEmpty()) {
OutlinedButton(
onClick = viewModel::revokeAll,
enabled = !state.busy,
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
) { Text(stringResource(R.string.trusted_devices_untrust_all)) }
}
}
}
}
}
@Composable
private fun TrustedDeviceRow(device: TrustedDeviceDto, busy: Boolean, onRevoke: () -> Unit) {
Card(Modifier.fillMaxWidth().padding(top = 12.dp)) {
Row(
Modifier.fillMaxWidth().padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Column(Modifier.weight(1f)) {
Text(
text = device.deviceName?.takeIf { it.isNotBlank() }
?: device.platform?.replaceFirstChar { it.uppercase() }
?: stringResource(R.string.trusted_devices_unknown),
style = MaterialTheme.typography.bodyLarge,
)
device.lastUsedAt?.let {
Text(
stringResource(R.string.trusted_devices_last_used, it),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
TextButton(onClick = onRevoke, enabled = !busy) {
Text(stringResource(R.string.trusted_devices_revoke))
}
}
}
}

View File

@@ -0,0 +1,125 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.ui.auth
import androidx.annotation.StringRes
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.runicgateway.app.R
import com.runicgateway.app.core.auth.DeviceNameProvider
import com.runicgateway.app.core.result.ApiResult
import com.runicgateway.app.data.api.dto.TrustedDeviceDto
import com.runicgateway.app.data.repository.AccountRepository
import com.runicgateway.app.data.repository.AccountRepository.TrustOutcome
import com.runicgateway.app.data.repository.AuthRepository
import com.runicgateway.app.ui.UiState
import com.runicgateway.app.ui.toUiState
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import javax.inject.Inject
/**
* Drives the Trusted Devices screen (TRUSTED_DEVICES_MFA.md): list the devices
* allowed to skip the TOTP step, trust the current one (persisting the returned
* token via [AuthRepository]), revoke one, and untrust all. The trust action folds
* the `409` cap into a first-class [Feedback] telling the user to revoke one first.
*/
@HiltViewModel
class TrustedDevicesViewModel @Inject constructor(
private val accountRepository: AccountRepository,
private val authRepository: AuthRepository,
private val sessionManager: com.runicgateway.app.core.auth.SessionManager,
private val deviceNameProvider: DeviceNameProvider,
) : ViewModel() {
/** A one-shot result banner shown above the list. */
data class Feedback(val ok: Boolean, @param:StringRes val messageRes: Int)
data class State(
val devices: UiState<List<TrustedDeviceDto>> = UiState.Loading,
val busy: Boolean = false,
val feedback: Feedback? = null,
)
private val _state = MutableStateFlow(State())
val state: StateFlow<State> = _state.asStateFlow()
init {
load()
}
fun load() {
_state.update { it.copy(devices = UiState.Loading) }
viewModelScope.launch {
_state.update { it.copy(devices = accountRepository.trustedDevices().toUiState()) }
}
}
/** Trust the current device; persist the returned token so future logins skip 2FA. */
fun trustThisDevice() {
if (_state.value.busy) return
_state.update { it.copy(busy = true, feedback = null) }
viewModelScope.launch {
when (val outcome = accountRepository.trustThisDevice(deviceNameProvider.deviceName())) {
is TrustOutcome.Trusted -> {
// Bind the fresh token to the signed-in username (mirrors the login path).
val username = sessionManager.state.value.let {
(it as? com.runicgateway.app.core.auth.Session.SignedIn)?.user?.username
}
if (outcome.trustToken != null && username != null) {
authRepository.saveTrustToken(username, outcome.trustToken)
}
finish(true, R.string.trusted_devices_trusted)
reload()
}
is TrustOutcome.LimitReached -> finish(false, R.string.trusted_devices_limit)
TrustOutcome.NetworkError -> finish(false, R.string.error_network)
TrustOutcome.ServerError -> finish(false, R.string.trusted_devices_error)
}
}
}
fun revoke(id: Long) {
if (_state.value.busy) return
_state.update { it.copy(busy = true, feedback = null) }
viewModelScope.launch {
when (accountRepository.revokeTrustedDevice(id)) {
is ApiResult.Ok -> {
finish(true, R.string.trusted_devices_revoked)
reload()
}
else -> finish(false, R.string.trusted_devices_error)
}
}
}
fun revokeAll() {
if (_state.value.busy) return
_state.update { it.copy(busy = true, feedback = null) }
viewModelScope.launch {
when (accountRepository.revokeAllTrustedDevices()) {
is ApiResult.Ok -> {
// Every device is untrusted now, including this one — drop the local token.
authRepository.clearTrustToken()
finish(true, R.string.trusted_devices_revoked_all)
reload()
}
else -> finish(false, R.string.trusted_devices_error)
}
}
}
fun clearFeedback() = _state.update { it.copy(feedback = null) }
private suspend fun reload() {
_state.update { it.copy(devices = accountRepository.trustedDevices().toUiState()) }
}
private fun finish(ok: Boolean, @StringRes messageRes: Int) =
_state.update { it.copy(busy = false, feedback = Feedback(ok, messageRes)) }
}

View File

@@ -21,8 +21,19 @@ enum class MenuAccess {
/** Visible to any signed-in account (§5, "My Account"). */ /** Visible to any signed-in account (§5, "My Account"). */
SIGNED_IN, SIGNED_IN,
/** Visible only to a player — the linked game-data groups (§6.3). */ /**
* The linked game-data groups (§6.3). Visible to any player **or** staff:
* staff are a superset of players (all player abilities plus their staff
* tools), and the backend's player self-service surface is role-agnostic, so
* a signed-in admin/editor/moderator sees + uses their own characters too.
*/
PLAYER, PLAYER,
/** Visible to any staff role (admin/editor/moderator) — the M10 staff surface (§1). */
STAFF,
/** Visible to admin/moderator — moderation actions + the support queue (§1, M10). */
MODERATOR,
} }
data class MenuEntry( data class MenuEntry(
@@ -48,6 +59,11 @@ val APP_MENU: List<MenuEntry> = listOf(
MenuEntry(Routes.PLAYER_CHARACTERS, R.string.menu_my_characters, MenuAccess.PLAYER), MenuEntry(Routes.PLAYER_CHARACTERS, R.string.menu_my_characters, MenuAccess.PLAYER),
MenuEntry(Routes.PLAYER_VENDORS, R.string.menu_my_vendors, MenuAccess.PLAYER), MenuEntry(Routes.PLAYER_VENDORS, R.string.menu_my_vendors, MenuAccess.PLAYER),
MenuEntry(Routes.PLAYER_HOUSES, R.string.menu_my_houses, MenuAccess.PLAYER), MenuEntry(Routes.PLAYER_HOUSES, R.string.menu_my_houses, MenuAccess.PLAYER),
// Staff operations (§1, M10) — revealed for staff roles; the backend re-checks every call.
MenuEntry(Routes.ADMIN_DASHBOARD, R.string.menu_admin_dashboard, MenuAccess.STAFF),
MenuEntry(Routes.ADMIN_CONTENT, R.string.menu_admin_content, MenuAccess.STAFF),
MenuEntry(Routes.ADMIN_MODERATION, R.string.menu_admin_moderation, MenuAccess.MODERATOR),
MenuEntry(Routes.ADMIN_SUPPORT, R.string.menu_admin_support, MenuAccess.MODERATOR),
) )
/** /**
@@ -59,6 +75,8 @@ fun visibleEntries(entries: List<MenuEntry>, session: Session): List<MenuEntry>
when (entry.access) { when (entry.access) {
MenuAccess.PUBLIC -> true MenuAccess.PUBLIC -> true
MenuAccess.SIGNED_IN -> session is Session.SignedIn MenuAccess.SIGNED_IN -> session is Session.SignedIn
MenuAccess.PLAYER -> session is Session.SignedIn && session.user.isPlayer MenuAccess.PLAYER -> session is Session.SignedIn && (session.user.isPlayer || session.user.isStaff)
MenuAccess.STAFF -> session is Session.SignedIn && session.user.isStaff
MenuAccess.MODERATOR -> session is Session.SignedIn && session.user.isModerator
} }
} }

View File

@@ -18,6 +18,10 @@ object Routes {
const val LOGIN = "login" const val LOGIN = "login"
const val ACCOUNT = "account" const val ACCOUNT = "account"
/** MFA management, reached from Account (TRUSTED_DEVICES_MFA.md). Signed-in only. */
const val ACCOUNT_TRUSTED_DEVICES = "account/trusted-devices"
const val ACCOUNT_RECOVERY_CODES = "account/recovery-codes"
/** Opt-in push notification settings (§11, signed-in). */ /** Opt-in push notification settings (§11, signed-in). */
const val NOTIFICATIONS = "notifications" const val NOTIFICATIONS = "notifications"
@@ -38,6 +42,13 @@ object Routes {
/** A single character sheet by in-game (hex) serial. */ /** A single character sheet by in-game (hex) serial. */
const val PLAYER_CHAR = "player/char/{serial}" const val PLAYER_CHAR = "player/char/{serial}"
/** Staff operations (§1, §6.4, M10). Gated to staff roles by the menu access level;
* the backend re-checks role on every `/admin/…` call. */
const val ADMIN_DASHBOARD = "admin/dashboard"
const val ADMIN_MODERATION = "admin/moderation"
const val ADMIN_SUPPORT = "admin/support"
const val ADMIN_CONTENT = "admin/content"
/** CMS page by slug (e.g. the conventional "about" page, mirrored from the site nav). */ /** CMS page by slug (e.g. the conventional "about" page, mirrored from the site nav). */
const val PAGE = "page/{slug}" const val PAGE = "page/{slug}"

View File

@@ -69,7 +69,9 @@ class ChampsViewModel @Inject constructor(
private fun applyFrame(frame: ShardStreamEvent.Frame) { private fun applyFrame(frame: ShardStreamEvent.Frame) {
when (frame.kind) { when (frame.kind) {
"champ.update" -> repository.champFrame(frame.data)?.let { board.upsert(it) } "champ.update" -> repository.champFrame(frame.data)?.let { board.upsert(it) }
"champ.remove" -> FrameFields.longField(frame.data, "serial")?.let { board.remove(it.toString()) } // Serial is an opaque hex-string key ("0x…"), not a number — read as a
// string (reading it as a Long silently dropped every champ.remove).
"champ.remove" -> FrameFields.stringField(frame.data, "serial")?.let { board.remove(it) }
else -> return else -> return
} }
// Only republish when the board actually changed (Success state only). // Only republish when the board actually changed (Success state only).

View File

@@ -69,7 +69,9 @@ class HousesViewModel @Inject constructor(
private fun applyFrame(frame: ShardStreamEvent.Frame) { private fun applyFrame(frame: ShardStreamEvent.Frame) {
if (frame.kind != "house.decay") return if (frame.kind != "house.decay") return
val serial = FrameFields.longField(frame.data, "serial") ?: return // Serials are opaque hex-string keys ("0x…"), not numbers — read as a string
// (reading it as a Long silently dropped every live IDOC update).
val serial = FrameFields.stringField(frame.data, "serial") ?: return
// `to` is the new decay stage; only IDOC belongs on the public board. // `to` is the new decay stage; only IDOC belongs on the public board.
val stage = FrameFields.stringField(frame.data, "to") val stage = FrameFields.stringField(frame.data, "to")
?: FrameFields.stringField(frame.data, "stage") ?: FrameFields.stringField(frame.data, "stage")

View File

@@ -46,10 +46,92 @@
<string name="menu_my_characters">My characters</string> <string name="menu_my_characters">My characters</string>
<string name="menu_my_vendors">My vendors</string> <string name="menu_my_vendors">My vendors</string>
<string name="menu_my_houses">My houses</string> <string name="menu_my_houses">My houses</string>
<string name="menu_admin_dashboard">Dashboard</string>
<string name="menu_admin_content">Content</string>
<string name="menu_admin_moderation">Moderation</string>
<string name="menu_admin_support">Support queue</string>
<string name="menu_sign_in">Sign in</string> <string name="menu_sign_in">Sign in</string>
<string name="menu_sign_out">Sign out</string> <string name="menu_sign_out">Sign out</string>
<string name="menu_change_server">Change server</string> <string name="menu_change_server">Change server</string>
<!-- ── Staff operations (§1, M10) ──────────────────────────────────── -->
<string name="admin_dashboard_site">Site</string>
<string name="admin_dashboard_counts">Counts</string>
<string name="admin_dashboard_recent_activity">Recent activity</string>
<string name="admin_site_live">Live</string>
<string name="admin_site_maintenance">Maintenance</string>
<string name="admin_site_switch_maintenance">Switch to maintenance</string>
<string name="admin_site_switch_live">Switch to live</string>
<string name="admin_site_changed_by">by %1$s</string>
<string name="admin_site_mode_updated">Site mode updated.</string>
<string name="admin_count_users">Users</string>
<string name="admin_count_posts">Posts</string>
<string name="admin_forbidden">You don\'t have permission for that action.</string>
<string name="admin_action_failed">That action couldn\'t be completed. Please try again.</string>
<string name="action_cancel">Cancel</string>
<!-- Staff content (posts + wiki) -->
<string name="admin_content_tab_posts">Posts</string>
<string name="admin_content_tab_wiki">Wiki</string>
<string name="admin_content_new_post">New post</string>
<string name="admin_content_new_category">New category</string>
<string name="admin_content_create">Create</string>
<string name="admin_content_published">Published</string>
<string name="admin_content_draft">Draft</string>
<string name="admin_content_publish">Publish</string>
<string name="admin_content_unpublish">Unpublish</string>
<string name="admin_content_delete">Delete</string>
<string name="admin_content_publish_now">Publish now</string>
<string name="admin_content_field_title">Title</string>
<string name="admin_content_field_excerpt">Excerpt</string>
<string name="admin_content_field_body">Body</string>
<string name="admin_content_field_slug">Slug</string>
<string name="admin_content_field_description">Description</string>
<string name="admin_content_field_sort">Sort order</string>
<!-- %1$s slug, %2$d page count -->
<string name="admin_content_cat_meta">%1$s · %2$d pages</string>
<!-- %1$s comma-separated tag labels -->
<string name="admin_content_tags">Tags: %1$s</string>
<string name="admin_content_post_created">Post created.</string>
<string name="admin_content_post_updated">Post updated.</string>
<string name="admin_content_post_deleted">Post deleted.</string>
<string name="admin_content_cat_created">Category created.</string>
<string name="admin_content_cat_deleted">Category deleted.</string>
<string name="admin_content_title_required">A title is required.</string>
<string name="admin_content_cat_fields_required">Slug and title are required.</string>
<!-- Staff moderation (shard write plane) -->
<string name="admin_mod_account_action">Account action</string>
<string name="admin_mod_account">Account</string>
<string name="admin_mod_serial">Serial (0x…)</string>
<string name="admin_mod_reason">Reason (ban)</string>
<string name="admin_mod_duration">Ban duration (seconds; blank = indefinite)</string>
<string name="admin_mod_kick">Kick</string>
<string name="admin_mod_ban">Ban</string>
<string name="admin_mod_unban">Unban</string>
<string name="admin_mod_broadcast_section">Broadcast</string>
<string name="admin_mod_broadcast_text">Message to everyone online</string>
<string name="admin_mod_broadcast">Broadcast</string>
<string name="admin_mod_kicked">Account kicked.</string>
<string name="admin_mod_banned">Account banned.</string>
<string name="admin_mod_unbanned">Ban cleared.</string>
<string name="admin_mod_broadcasted">Message broadcast.</string>
<string name="admin_mod_target_required">Enter an account or serial.</string>
<string name="admin_mod_text_required">Enter a message to broadcast.</string>
<string name="admin_mod_shard_offline">The shard is offline — the action couldn\'t be delivered.</string>
<!-- Staff support queue -->
<string name="admin_support_empty">No open help pages.</string>
<string name="admin_support_reply">Reply</string>
<string name="admin_support_close">Close</string>
<string name="admin_support_send">Send</string>
<string name="admin_support_message">Reply message</string>
<string name="admin_support_close_after">Close the page after replying</string>
<string name="admin_support_responded">Reply sent.</string>
<string name="admin_support_closed">Page closed.</string>
<string name="admin_support_message_required">Enter a reply message.</string>
<string name="admin_support_unknown_page">That page is no longer in the queue.</string>
<!-- ── Auth: login (§4.1) ──────────────────────────────────────────── --> <!-- ── Auth: login (§4.1) ──────────────────────────────────────────── -->
<string name="login_title">Sign in</string> <string name="login_title">Sign in</string>
<string name="login_subtitle">Sign in with your shard account.</string> <string name="login_subtitle">Sign in with your shard account.</string>
@@ -60,9 +142,13 @@
<string name="login_button">Sign in</string> <string name="login_button">Sign in</string>
<string name="login_register">Create an account</string> <string name="login_register">Create an account</string>
<string name="login_forgot">Forgot your password?</string> <string name="login_forgot">Forgot your password?</string>
<string name="login_sso">Sign in with Google or Discord (on the website)</string> <!-- Single SSO entry point; the picker lists the shard's providers (native SSO, M9/M10). -->
<!-- %1$s is the provider name, e.g. "Google" or "Discord" (native SSO, M9). --> <string name="login_sso_button">Sign in with SSO</string>
<string name="login_sso_pick_title">Choose a sign-in provider</string>
<!-- %1$s is the provider name, e.g. "Google" or "Discord". -->
<string name="login_sso_provider">Sign in with %1$s</string> <string name="login_sso_provider">Sign in with %1$s</string>
<string name="login_sso_loading">Loading sign-in options…</string>
<string name="login_sso_retry">Couldn\'t load sign-in options. Tap to retry.</string>
<string name="login_error_credentials">Incorrect username or password.</string> <string name="login_error_credentials">Incorrect username or password.</string>
<string name="login_error_code">That code didn\'t match. Try the current code.</string> <string name="login_error_code">That code didn\'t match. Try the current code.</string>
<string name="login_error_rate_limited">Too many attempts. Please try again shortly.</string> <string name="login_error_rate_limited">Too many attempts. Please try again shortly.</string>
@@ -115,6 +201,49 @@
<string name="account_identity_unlinked">Account unlinked.</string> <string name="account_identity_unlinked">Account unlinked.</string>
<string name="account_identity_error">Couldn\'t unlink that account.</string> <string name="account_identity_error">Couldn\'t unlink that account.</string>
<!-- ── Trusted devices & recovery codes (TRUSTED_DEVICES_MFA.md) ─────── -->
<!-- Login 2FA step -->
<string name="login_recovery_code">Recovery code</string>
<string name="login_recovery_hint">Enter one of your single-use backup codes.</string>
<string name="login_use_recovery_instead">Use a recovery code instead</string>
<string name="login_use_totp_instead">Use your authenticator code instead</string>
<string name="login_trust_device">Trust this device (skip codes for 30 days)</string>
<!-- Account: security section -->
<string name="account_security_title">Security</string>
<string name="account_security_trusted_devices">Trusted devices</string>
<string name="account_security_recovery_codes">Recovery codes</string>
<!-- Trusted devices screen -->
<string name="trusted_devices_title">Trusted devices</string>
<string name="trusted_devices_subtitle">These devices can skip the authentication code at sign-in for 30 days.</string>
<string name="trusted_devices_empty">No trusted devices yet.</string>
<string name="trusted_devices_unknown">Unknown device</string>
<string name="trusted_devices_last_used">Last used %1$s</string>
<string name="trusted_devices_revoke">Revoke</string>
<string name="trusted_devices_trust_this">Trust this device</string>
<string name="trusted_devices_untrust_all">Untrust all devices</string>
<string name="trusted_devices_trusted">This device is now trusted.</string>
<string name="trusted_devices_revoked">Device revoked.</string>
<string name="trusted_devices_revoked_all">All devices untrusted.</string>
<string name="trusted_devices_limit">You\'ve reached the trusted-device limit. Revoke one, then try again.</string>
<string name="trusted_devices_error">Something went wrong. Please try again.</string>
<!-- Recovery codes screen -->
<string name="recovery_codes_title">Recovery codes</string>
<string name="recovery_codes_subtitle">Single-use backup codes let you sign in if you lose your authenticator.</string>
<string name="recovery_codes_remaining">%1$d codes remaining</string>
<string name="recovery_codes_remaining_loading">Checking remaining codes…</string>
<string name="recovery_codes_remaining_unknown">Couldn\'t load the remaining count.</string>
<string name="recovery_codes_password_hint">Enter your current password to generate a new set.</string>
<string name="recovery_codes_regenerate">Generate new codes</string>
<string name="recovery_codes_error">Couldn\'t generate codes. Check your password and that two-factor is on.</string>
<string name="recovery_codes_new_title">Your new recovery codes</string>
<string name="recovery_codes_new_hint">Save these now — they\'re shown only once and each works a single time.</string>
<string name="recovery_codes_copy">Copy</string>
<string name="recovery_codes_share">Share</string>
<string name="recovery_codes_done">Done</string>
<!-- ── Player: game-account linking (§6.3) ─────────────────────────── --> <!-- ── Player: game-account linking (§6.3) ─────────────────────────── -->
<string name="player_link_title">Link your game account</string> <string name="player_link_title">Link your game account</string>
<string name="player_link_hint">In game, type [link to get a one-time code, then enter it here to see your characters, vendors and houses.</string> <string name="player_link_hint">In game, type [link to get a one-time code, then enter it here to see your characters, vendors and houses.</string>

View File

@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- SPDX-License-Identifier: GPL-3.0-or-later -->
<!--
The app is purely an HTTPS API client of a shard's website backend, so the base
posture forbids all cleartext (HTTP) traffic. This makes explicit what minSdk 29 /
targetSdk 35 already default to, satisfies the "usesCleartextTraffic implicitly
enabled" scanner finding, and stops any merged library manifest from re-enabling
cleartext. It also mirrors ServerUrl's release-build rule (HTTPS required) at the
platform socket layer — defense in depth.
The debug variant overrides this file (app/src/debug/res/xml/) to re-permit
cleartext to loopback only, for local dev against http://127.0.0.1:3000.
-->
<network-security-config>
<base-config cleartextTrafficPermitted="false" />
</network-security-config>

View File

@@ -37,6 +37,14 @@ class SsoAuthManagerTest {
override fun clear() { stored = null } override fun clear() { stored = null }
} }
/** In-memory stand-in for the encrypted pending-SSO store (survives across
* manager instances the way the on-disk store survives process death). */
private class FakePendingSsoStore(var pending: PendingSso? = null) : PendingSsoStore {
override fun save(state: String, verifier: String) { pending = PendingSso(state, verifier) }
override fun load(): PendingSso? = pending
override fun clear() { pending = null }
}
/** Records the exchange it was called with and returns a scripted response. */ /** Records the exchange it was called with and returns a scripted response. */
private class FakeSsoApi( private class FakeSsoApi(
private val exchangeResult: () -> Response<MobileTokenResponse>, private val exchangeResult: () -> Response<MobileTokenResponse>,
@@ -67,10 +75,11 @@ class SsoAuthManagerTest {
api: SsoApi, api: SsoApi,
session: SessionManager, session: SessionManager,
base: String? = "https://shard.example.com/", base: String? = "https://shard.example.com/",
store: PendingSsoStore = FakePendingSsoStore(),
): SsoAuthManager { ): SsoAuthManager {
val holder = BaseUrlHolder() val holder = BaseUrlHolder()
if (base != null) holder.set(base.toHttpUrl()) if (base != null) holder.set(base.toHttpUrl())
return SsoAuthManager(api, session, holder) return SsoAuthManager(api, session, holder, store)
} }
/** Build a start URL and pull the generated `state` back out of it. */ /** Build a start URL and pull the generated `state` back out of it. */
@@ -135,6 +144,25 @@ class SsoAuthManagerTest {
assertEquals(SsoAuthManager.Outcome.Failed(SsoAuthManager.Failure.STATE_MISMATCH), mgr.outcome.value) assertEquals(SsoAuthManager.Outcome.Failed(SsoAuthManager.Failure.STATE_MISMATCH), mgr.outcome.value)
} }
@Test fun `pending survives process death — a fresh manager on the same store completes`() = runTest {
// Persist the pending on one instance, then throw that instance away.
val store = FakePendingSsoStore()
val session = SessionManager(FakeTokenStore())
val started = managerWith(FakeSsoApi { Response.success(tokenPair()) }, session, store = store)
val state = startAndState(started)
// A brand-new manager (simulating the app relaunched after eviction) reads the
// persisted pending and completes the exchange — the old in-memory holder would
// have lost it and failed STATE_MISMATCH.
val api = FakeSsoApi { Response.success(tokenPair()) }
val revived = managerWith(api, session, store = store)
revived.complete(state = state, code = "auth-code-1", error = null)
assertEquals(1, api.exchangeCalls)
assertTrue(session.state.value is Session.SignedIn)
assertEquals(SsoAuthManager.Outcome.Success, revived.outcome.value)
}
@Test fun `error callback maps to a declined sign-in and does not exchange`() = runTest { @Test fun `error callback maps to a declined sign-in and does not exchange`() = runTest {
val api = FakeSsoApi { Response.success(tokenPair()) } val api = FakeSsoApi { Response.success(tokenPair()) }
val mgr = managerWith(api, SessionManager(FakeTokenStore())) val mgr = managerWith(api, SessionManager(FakeTokenStore()))

View File

@@ -35,6 +35,20 @@ class ApiResultTest {
assertTrue(result is ApiResult.NetworkError) assertTrue(result is ApiResult.NetworkError)
} }
/**
* A body the app can't decode (a field whose type doesn't match its DTO) throws a
* [SerializationException] out of the Retrofit converter. It must degrade to a
* server-side error the UI renders, not escape and crash the app — the guild-board
* crash this fixes. `502` folds to [ui.ErrorKind.SERVER] via `toUiState`.
*/
@Test fun serializationExceptionBecomesServerError() = runTest {
val result = safeApiCall {
throw kotlinx.serialization.SerializationException("Unexpected symbol 'm' at path: \$[0].members")
}
assertTrue(result is ApiResult.HttpError)
assertEquals(502, (result as ApiResult.HttpError).status)
}
@Test fun cancellationIsRethrown() = runTest { @Test fun cancellationIsRethrown() = runTest {
assertThrows(CancellationException::class.java) { assertThrows(CancellationException::class.java) {
kotlinx.coroutines.runBlocking { kotlinx.coroutines.runBlocking {

View File

@@ -54,6 +54,66 @@ class AccountDtoTest {
assertTrue(json.decodeFromString<TotpStateDto>("""{"totp_enabled":true}""").totp_enabled) assertTrue(json.decodeFromString<TotpStateDto>("""{"totp_enabled":true}""").totp_enabled)
} }
@Test fun totpEnableCarriesOneTimeRecoveryCodes() {
// Enabling 2FA now returns the fresh single-use batch once (TRUSTED_DEVICES_MFA.md).
val dto = json.decodeFromString<TotpStateDto>(
"""{"totp_enabled":true,"recoveryCodes":["aaaa-1111","bbbb-2222"]}""",
)
assertTrue(dto.totp_enabled)
assertEquals(listOf("aaaa-1111", "bbbb-2222"), dto.recoveryCodes)
}
@Test fun totpStateDisableHasNoRecoveryCodes() {
// Disable (and older backends) omit the field — must decode to null, not crash.
val dto = json.decodeFromString<TotpStateDto>("""{"totp_enabled":false}""")
assertFalse(dto.totp_enabled)
assertEquals(null, dto.recoveryCodes)
}
@Test fun trustedDeviceDecodes() {
val dto = json.decodeFromString<TrustedDeviceDto>(
"""{"id":5,"platform":"mobile","deviceName":"Pixel 8","userAgent":"RunicGatewayApp/1.0",
"createdAt":"2026-07-20T10:00:00Z","lastUsedAt":"2026-07-22T09:00:00Z",
"expiresAt":"2026-08-19T10:00:00Z"}""",
)
assertEquals(5L, dto.id)
assertEquals("mobile", dto.platform)
assertEquals("Pixel 8", dto.deviceName)
assertEquals("2026-07-22T09:00:00Z", dto.lastUsedAt)
}
@Test fun trustDeviceResultCarriesNativeToken() {
val dto = json.decodeFromString<TrustDeviceResultDto>(
"""{"trusted":true,"trustToken":"opaque-token-abc"}""",
)
assertTrue(dto.trusted)
assertEquals("opaque-token-abc", dto.trustToken)
}
@Test fun trustedDeviceLimitDecodesDevices() {
val dto = json.decodeFromString<TrustedDeviceLimitDto>(
"""{"error":"trusted_device_limit","devices":[
{"id":1,"platform":"web","deviceName":"Firefox"},
{"id":2,"platform":"mobile","deviceName":"Pixel"}]}""",
)
assertEquals("trusted_device_limit", dto.error)
assertEquals(2, dto.devices.size)
assertEquals(2L, dto.devices[1].id)
}
@Test fun recoveryStatusAndCodesDecode() {
assertEquals(7, json.decodeFromString<RecoveryStatusDto>("""{"remaining":7}""").remaining)
val codes = json.decodeFromString<RecoveryCodesDto>(
"""{"recoveryCodes":["c1","c2","c3"]}""",
)
assertEquals(3, codes.recoveryCodes.size)
}
@Test fun revokedResultsDecode() {
assertTrue(json.decodeFromString<RevokedFlagDto>("""{"revoked":true}""").revoked)
assertEquals(4, json.decodeFromString<RevokedCountDto>("""{"revoked":4}""").revoked)
}
@Test fun linkedIdentityDecodes() { @Test fun linkedIdentityDecodes() {
val dto = json.decodeFromString<LinkedIdentityDto>( val dto = json.decodeFromString<LinkedIdentityDto>(
"""{"provider":"discord","email":"u@example.com","linked_at":"2026-07-19T22:00:00Z"}""", """{"provider":"discord","email":"u@example.com","linked_at":"2026-07-19T22:00:00Z"}""",

View File

@@ -68,4 +68,35 @@ class AuthDtoTest {
assertNull(dto.expiresIn) assertNull(dto.expiresIn)
assertEquals("admin", dto.user.role) assertEquals("admin", dto.user.role)
} }
@Test fun loginCarriesTrustTokenWhenDeviceTrusted() {
// trustDevice accepted → an opaque token to persist + replay (TRUSTED_DEVICES_MFA.md).
val dto = json.decodeFromString<MobileTokenResponse>(
"""{"accessToken":"a","refreshToken":"r","user":{"id":3,"username":"c","role":"player"},
"trustToken":"opaque-abc"}""",
)
assertEquals("opaque-abc", dto.trustToken)
assertFalse(dto.trustLimitReached)
}
@Test fun loginSignalsTrustLimitWithDevices() {
// At the cap: login still succeeds, but no token; the device list is returned.
val dto = json.decodeFromString<MobileTokenResponse>(
"""{"accessToken":"a","refreshToken":"r","user":{"id":3,"username":"c","role":"player"},
"trustLimitReached":true,"devices":[{"id":1,"platform":"mobile","deviceName":"Old"}]}""",
)
assertNull(dto.trustToken)
assertTrue(dto.trustLimitReached)
assertEquals(1, dto.devices.size)
}
@Test fun loginWithoutTrustFieldsDefaultsCleanly() {
// A normal (no-trust) login omits every trust field — must not crash or mis-flag.
val dto = json.decodeFromString<MobileTokenResponse>(
"""{"accessToken":"a","refreshToken":"r","user":{"id":4,"username":"d","role":"player"}}""",
)
assertNull(dto.trustToken)
assertFalse(dto.trustLimitReached)
assertTrue(dto.devices.isEmpty())
}
} }

View File

@@ -43,25 +43,31 @@ class ShardDtoTest {
} }
@Test fun champUpdateFrameDecodesWithKindAndExtras() { @Test fun champUpdateFrameDecodesWithKindAndExtras() {
// A live champ.update frame: has `kind`, `serial`, and category extras. The // A live champ.update frame: has `kind`, a hex-string `serial` (INTEGRATION.md
// `kind` field is ignored (not on the DTO) and the extras decode. // §1 — serials are opaque hex keys, never numbers), and category extras. The
// `kind`/`rank`/`autoRestart` fields are ignored (not on the DTO); extras decode.
val dto = json.decodeFromString<ChampDto>( val dto = json.decodeFromString<ChampDto>(
"""{"kind":"champ.update","serial":12345,"category":"champion","name":"Barracoon", """{"kind":"champ.update","serial":"0x40012345","category":"champion","name":"Barracoon",
"status":"active","active":true,"level":10,"maxKills":250,"kills":120, "status":"active","active":true,"level":10,"rank":3,"maxKills":250,"kills":120,
"bossUp":false,"map":"Felucca","x":5571,"y":1379,"z":0,"t":1721426400000}""", "autoRestart":true,"bossUp":false,"map":"Felucca","x":5571,"y":1379,"z":0,"t":1721426400000}""",
) )
assertEquals(12345L, dto.serial) assertEquals("0x40012345", dto.serial)
assertEquals("champion", dto.category) assertEquals("champion", dto.category)
assertEquals(120, dto.kills) assertEquals(120, dto.kills)
assertTrue(dto.active) assertTrue(dto.active)
} }
@Test fun guildFrameDecodesLeaderActor() { @Test fun guildFrameDecodesLeaderActor() {
// The leader actor carries a hex-string serial and a string webId (the linked
// site-user id) — the exact wire shape from INTEGRATION.md §7.
val dto = json.decodeFromString<GuildDto>( val dto = json.decodeFromString<GuildDto>(
"""{"kind":"guild.update","id":7,"name":"Knights","abbr":"KNT","members":12, """{"kind":"guild.update","id":7,"name":"Knights","abbr":"KNT","members":12,
"online":3,"alliance":"Light","leader":{"serial":1,"name":"Arthur","acct":"art"}}""", "online":3,"alliance":"Light",
"leader":{"serial":"0x1A2B","name":"Arthur","acct":"art","webId":"9931","player":true}}""",
) )
assertEquals(7L, dto.id) assertEquals(7L, dto.id)
assertEquals("0x1A2B", dto.leader?.serial)
assertEquals("9931", dto.leader?.webId)
assertEquals("Arthur", dto.leader?.label) assertEquals("Arthur", dto.leader?.label)
assertEquals(12, dto.members) assertEquals(12, dto.members)
} }
@@ -86,13 +92,21 @@ class ShardDtoTest {
@Test fun houseDecodesPublicIdocShape() { @Test fun houseDecodesPublicIdocShape() {
val dto = json.decodeFromString<HouseDto>( val dto = json.decodeFromString<HouseDto>(
"""{"serial":999,"name":"Tower","region":"Britain","map":"Felucca", """{"serial":"0x40001234","name":"Tower","region":"Britain","map":"Felucca",
"x":1,"y":2,"z":3,"isIdoc":true}""", "x":1,"y":2,"z":3,"isIdoc":true}""",
) )
assertEquals(999L, dto.serial) assertEquals("0x40001234", dto.serial)
assertTrue(dto.isIdoc) assertTrue(dto.isIdoc)
} }
@Test fun onlineStaffDecodesHexSerial() {
val dto = json.decodeFromString<OnlineStaffDto>(
"""{"serial":"0x24C","name":"Darrow"}""",
)
assertEquals("0x24C", dto.serial)
assertEquals("Darrow", dto.name)
}
@Test fun actorLabelFallsBackToAcctThenSomeone() { @Test fun actorLabelFallsBackToAcctThenSomeone() {
assertEquals("bob", ActorDto(acct = "bob").label) assertEquals("bob", ActorDto(acct = "bob").label)
assertEquals("Someone", ActorDto().label) assertEquals("Someone", ActorDto().label)

View File

@@ -0,0 +1,120 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.data.repository
import com.runicgateway.app.core.result.ApiResult
import com.runicgateway.app.data.api.MeApi
import com.runicgateway.app.data.api.dto.ChangePasswordRequest
import com.runicgateway.app.data.api.dto.ChangeUsernameRequest
import com.runicgateway.app.data.api.dto.LinkedIdentityDto
import com.runicgateway.app.data.api.dto.PlayerAccountDto
import com.runicgateway.app.data.api.dto.RecoveryCodesDto
import com.runicgateway.app.data.api.dto.RecoveryGenerateRequest
import com.runicgateway.app.data.api.dto.RecoveryStatusDto
import com.runicgateway.app.data.api.dto.RevokedCountDto
import com.runicgateway.app.data.api.dto.RevokedFlagDto
import com.runicgateway.app.data.api.dto.TotpCodeRequest
import com.runicgateway.app.data.api.dto.TotpSetupDto
import com.runicgateway.app.data.api.dto.TotpStateDto
import com.runicgateway.app.data.api.dto.TrustDeviceRequest
import com.runicgateway.app.data.api.dto.TrustDeviceResultDto
import com.runicgateway.app.data.api.dto.TrustedDeviceDto
import com.runicgateway.app.data.api.dto.UsernameResponse
import com.runicgateway.app.data.repository.AccountRepository.TrustOutcome
import kotlinx.coroutines.test.runTest
import kotlinx.serialization.json.Json
import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.ResponseBody.Companion.toResponseBody
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import retrofit2.Response
/**
* [AccountRepository] trusted-device + recovery logic (TRUSTED_DEVICES_MFA.md) over a
* fake [MeApi]. The interesting case is the `409` cap: the device list must survive
* into a typed [TrustOutcome.LimitReached] rather than being lost as a bare error.
*/
class AccountTrustedDevicesTest {
private val json = Json {
ignoreUnknownKeys = true
explicitNulls = false
coerceInputValues = true
}
/** A fake MeApi; only the trusted-device/recovery methods under test are wired. */
private open class FakeMeApi(
var trustResponse: Response<TrustDeviceResultDto>? = null,
var devices: List<TrustedDeviceDto> = emptyList(),
var revokeFlag: Boolean = true,
var revokeCount: Int = 0,
var remaining: Int = 0,
var generated: List<String> = emptyList(),
) : MeApi {
override suspend fun trustedDevices(): List<TrustedDeviceDto> = devices
override suspend fun trustThisDevice(body: TrustDeviceRequest): Response<TrustDeviceResultDto> =
trustResponse ?: Response.success(TrustDeviceResultDto(trusted = true, trustToken = "t"))
override suspend fun revokeTrustedDevice(id: Long): RevokedFlagDto = RevokedFlagDto(revokeFlag)
override suspend fun revokeAllTrustedDevices(): RevokedCountDto = RevokedCountDto(revokeCount)
override suspend fun recoveryCodesStatus(): RecoveryStatusDto = RecoveryStatusDto(remaining)
override suspend fun generateRecoveryCodes(body: RecoveryGenerateRequest): RecoveryCodesDto =
RecoveryCodesDto(generated)
// Unused by these tests.
override suspend fun getAccount(): PlayerAccountDto = PlayerAccountDto()
override suspend fun changeUsername(body: ChangeUsernameRequest): UsernameResponse = UsernameResponse()
override suspend fun changePassword(body: ChangePasswordRequest) = Unit
override suspend fun totpSetup(): TotpSetupDto = TotpSetupDto()
override suspend fun totpEnable(body: TotpCodeRequest): TotpStateDto = TotpStateDto()
override suspend fun totpDisable(body: TotpCodeRequest): TotpStateDto = TotpStateDto()
override suspend fun identities(): List<LinkedIdentityDto> = emptyList()
override suspend fun unlinkIdentity(provider: String) = Unit
}
private fun repo(api: MeApi) = AccountRepository(api, json)
@Test fun trustThisDeviceReturnsToken() = runTest {
val api = FakeMeApi(trustResponse = Response.success(TrustDeviceResultDto(true, "opaque-xyz")))
val outcome = repo(api).trustThisDevice("Pixel")
assertTrue(outcome is TrustOutcome.Trusted)
assertEquals("opaque-xyz", (outcome as TrustOutcome.Trusted).trustToken)
}
@Test fun trustThisDeviceParsesCapDevicesFrom409() = runTest {
val body = """{"error":"trusted_device_limit","devices":[
{"id":1,"platform":"web","deviceName":"Firefox"},
{"id":2,"platform":"mobile","deviceName":"Pixel"}]}"""
.toResponseBody("application/json".toMediaTypeOrNull())
val api = FakeMeApi(trustResponse = Response.error(409, body))
val outcome = repo(api).trustThisDevice(null)
assertTrue(outcome is TrustOutcome.LimitReached)
val devices = (outcome as TrustOutcome.LimitReached).devices
assertEquals(2, devices.size)
assertEquals("Pixel", devices[1].deviceName)
}
@Test fun trustThisDeviceOtherErrorIsServerError() = runTest {
val body = """{"message":"boom"}""".toResponseBody("application/json".toMediaTypeOrNull())
val api = FakeMeApi(trustResponse = Response.error(500, body))
assertTrue(repo(api).trustThisDevice(null) is TrustOutcome.ServerError)
}
@Test fun revokeMapsFlagAndCount() = runTest {
val revoked = repo(FakeMeApi(revokeFlag = true)).revokeTrustedDevice(9)
assertTrue(revoked is ApiResult.Ok && revoked.data)
val all = repo(FakeMeApi(revokeCount = 3)).revokeAllTrustedDevices()
assertTrue(all is ApiResult.Ok && all.data == 3)
}
@Test fun recoveryStatusAndGenerateMap() = runTest {
val status = repo(FakeMeApi(remaining = 6)).recoveryCodesStatus()
assertTrue(status is ApiResult.Ok && status.data.remaining == 6)
val gen = repo(FakeMeApi(generated = listOf("a", "b"))).generateRecoveryCodes("pw")
assertTrue(gen is ApiResult.Ok)
assertEquals(listOf("a", "b"), (gen as ApiResult.Ok).data.recoveryCodes)
}
}

View File

@@ -37,12 +37,16 @@ class MenuAccessTest {
assertTrue(visible.contains(Routes.HOME)) assertTrue(visible.contains(Routes.HOME))
} }
@Test fun staffSeeAccountButNoPlayerOnlyGroups() { @Test fun staffSeeThePlayerGameDataGroups() {
val visible = routes(signedIn(Role.EDITOR)) // Staff are a superset of players: every staff role sees the PLAYER-access
assertTrue(visible.contains(Routes.ACCOUNT)) // game-data groups too (their own linked characters, via the role-agnostic
// No PLAYER-access entry (the M4 game-data groups) leaks to staff. // /player self-service surface), on top of their staff entries.
val playerOnly = APP_MENU.filter { it.access == MenuAccess.PLAYER }.map { it.route } val playerGroups = APP_MENU.filter { it.access == MenuAccess.PLAYER }.map { it.route }
assertTrue(playerOnly.none { visible.contains(it) }) for (role in listOf(Role.ADMIN, Role.EDITOR, Role.MODERATOR)) {
val visible = routes(signedIn(role))
assertTrue("$role should see Account", visible.contains(Routes.ACCOUNT))
assertTrue("$role should see the player game-data groups", playerGroups.all { visible.contains(it) })
}
} }
@Test fun publicEntryCountIsStableAcrossSessions() { @Test fun publicEntryCountIsStableAcrossSessions() {
@@ -58,10 +62,33 @@ class MenuAccessTest {
} }
@Test fun playerAccessGatedFunction() { @Test fun playerAccessGatedFunction() {
// A synthetic PLAYER-gated entry is visible to a player, hidden from staff/anon. // A PLAYER-gated entry is visible to a player AND to every staff role
// (staff superset), hidden only from an unrecognized role and anon.
val entries = listOf(MenuEntry("game", 0, MenuAccess.PLAYER)) val entries = listOf(MenuEntry("game", 0, MenuAccess.PLAYER))
assertTrue(visibleEntries(entries, signedIn(Role.PLAYER)).isNotEmpty()) for (role in listOf(Role.PLAYER, Role.ADMIN, Role.EDITOR, Role.MODERATOR)) {
assertTrue(visibleEntries(entries, signedIn(Role.ADMIN)).isEmpty()) assertTrue("$role should see a PLAYER entry", visibleEntries(entries, signedIn(role)).isNotEmpty())
}
assertTrue(visibleEntries(entries, signedIn(Role.UNKNOWN)).isEmpty())
assertTrue(visibleEntries(entries, Session.SignedOut).isEmpty())
}
@Test fun staffSeeTheAdminDashboardButPlayersDoNot() {
// STAFF entries (M10) show for every staff role, never for a player or anon.
for (role in listOf(Role.ADMIN, Role.EDITOR, Role.MODERATOR)) {
assertTrue("$role should see the dashboard", routes(signedIn(role)).contains(Routes.ADMIN_DASHBOARD))
}
assertFalse(routes(signedIn(Role.PLAYER)).contains(Routes.ADMIN_DASHBOARD))
assertFalse(routes(Session.SignedOut).contains(Routes.ADMIN_DASHBOARD))
}
@Test fun moderatorAccessIsAdminAndModeratorOnly() {
// A synthetic MODERATOR-gated entry (moderation / support) is visible to
// admin + moderator, but NOT editor, player, or anon.
val entries = listOf(MenuEntry("mod", 0, MenuAccess.MODERATOR))
assertTrue(visibleEntries(entries, signedIn(Role.ADMIN)).isNotEmpty())
assertTrue(visibleEntries(entries, signedIn(Role.MODERATOR)).isNotEmpty())
assertTrue(visibleEntries(entries, signedIn(Role.EDITOR)).isEmpty())
assertTrue(visibleEntries(entries, signedIn(Role.PLAYER)).isEmpty())
assertTrue(visibleEntries(entries, Session.SignedOut).isEmpty()) assertTrue(visibleEntries(entries, Session.SignedOut).isEmpty())
} }
} }

View File

@@ -1,9 +1,9 @@
# SonarQube analysis config for the Android-app repo. # SonarQube analysis config for the Android-app repo.
# Consumed by the scanner in .gitea/workflows/sonarqube.yml on push to main. # Consumed by the scanner in .gitea/workflows/sonarqube.yml on push to main.
# The project key must match the one created in SonarQube (dashboard URL # The project key must match the one created in SonarQube (dashboard URL
# ?id=runic-gateway-android-app). # ?id=Runic-Gateway-Android-app).
sonar.projectKey=runic-gateway-android-app sonar.projectKey=Runic-Gateway-Android-app
sonar.projectName=runic gateway android app sonar.projectName=runic gateway android app
# Analysed application code. The single :app module's Kotlin sources. # Analysed application code. The single :app module's Kotlin sources.