Merge pull request 'feat(m3): native auth — login+TOTP, token storage, refresh, access-level menu' (#8) from feat/m3-auth into main

Reviewed-on: #8
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
This commit is contained in:
2026-07-20 00:19:48 +00:00
29 changed files with 1625 additions and 21 deletions

View File

@@ -93,6 +93,9 @@ dependencies {
implementation(libs.androidx.datastore.preferences) implementation(libs.androidx.datastore.preferences)
implementation(libs.androidx.security.crypto) implementation(libs.androidx.security.crypto)
// Web hand-off (Chrome Custom Tabs) for register / invite / reset / SSO (§4.2)
implementation(libs.androidx.browser)
// Images // Images
implementation(libs.coil.compose) implementation(libs.coil.compose)

View File

@@ -0,0 +1,73 @@
/*
* 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
/**
* [TokenStore] backed by Jetpack Security's [EncryptedSharedPreferences]
* (Tink/AES-256-GCM), so the token pair is encrypted at rest (PLAN.md §2, §4.3).
* The base URL stays in plain DataStore ([com.runicgateway.app.core.prefs.ServerPreferences]);
* only tokens live here.
*
* The prefs handle is created lazily so a first-launch device (no session yet)
* pays the keystore cost only once a user actually signs in.
*/
@Singleton
class EncryptedTokenStore @Inject constructor(
@param:ApplicationContext private val context: Context,
) : TokenStore {
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 load(): StoredSession? {
val access = prefs.getString(KEY_ACCESS, null) ?: return null
val refresh = prefs.getString(KEY_REFRESH, null) ?: return null
val username = prefs.getString(KEY_USERNAME, null) ?: return null
val role = prefs.getString(KEY_ROLE, null) ?: return null
val id = prefs.getLong(KEY_USER_ID, -1L)
if (id < 0) return null
return StoredSession(access, refresh, id, username, role)
}
override fun save(session: StoredSession) {
prefs.edit()
.putString(KEY_ACCESS, session.accessToken)
.putString(KEY_REFRESH, session.refreshToken)
.putLong(KEY_USER_ID, session.userId)
.putString(KEY_USERNAME, session.username)
.putString(KEY_ROLE, session.role)
.apply()
}
override fun clear() {
prefs.edit().clear().apply()
}
private companion object {
const val PREFS_NAME = "runic_session"
const val KEY_ACCESS = "access_token"
const val KEY_REFRESH = "refresh_token"
const val KEY_USER_ID = "user_id"
const val KEY_USERNAME = "username"
const val KEY_ROLE = "role"
}
}

View File

@@ -0,0 +1,60 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.core.auth
import com.runicgateway.app.data.api.dto.SafeUserDto
/**
* The signed-in identity the app carries (PLAN.md §4.3, §5). Role is *advisory*
* for menu rendering only — the backend re-checks every gated call, so the app
* treats a 403 as authoritative and never assumes access from this value.
*/
data class SessionUser(
val id: Long,
val username: String,
val role: Role,
) {
val isPlayer: Boolean get() = role == Role.PLAYER
}
/**
* The account roles the backend issues. The three staff roles are gated by
* capability, not rank (PLAN.md §5); [UNKNOWN] absorbs any future role so an
* additive backend change never crashes the menu.
*/
enum class Role(val wire: String) {
PLAYER("player"),
MODERATOR("moderator"),
EDITOR("editor"),
ADMIN("admin"),
UNKNOWN("");
val isStaff: Boolean get() = this == MODERATOR || this == EDITOR || this == ADMIN
companion object {
fun fromWire(value: String?): Role =
entries.firstOrNull { it.wire.equals(value, ignoreCase = true) } ?: UNKNOWN
}
}
/** The two auth states the UI observes. */
sealed interface Session {
data object SignedOut : Session
data class SignedIn(val user: SessionUser) : Session
}
internal fun SafeUserDto.toSessionUser(): SessionUser =
SessionUser(id = id, username = username, role = Role.fromWire(role))
internal fun SafeUserDto.toStored(accessToken: String, refreshToken: String): StoredSession =
StoredSession(
accessToken = accessToken,
refreshToken = refreshToken,
userId = id,
username = username,
role = role,
)
internal fun StoredSession.toSessionUser(): SessionUser =
SessionUser(id = userId, username = username, role = Role.fromWire(role))

View File

@@ -0,0 +1,99 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.core.auth
import com.runicgateway.app.data.api.dto.SafeUserDto
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import java.util.concurrent.atomic.AtomicReference
import javax.inject.Inject
import javax.inject.Singleton
/**
* The single source of truth for the current session (PLAN.md §4.3). It holds the
* in-memory token pair the network layer reads on every call, mirrors the
* signed-in identity into an observable [state] the UI + menu react to, and keeps
* the encrypted [TokenStore] in sync.
*
* Threading: [state] and the token holders are read from the UI thread and
* written from both coroutines (login/logout) and the OkHttp
* [com.runicgateway.app.core.net.TokenAuthenticator] dispatcher thread (silent
* refresh), so tokens live in [AtomicReference]s and the mutators are
* `@Synchronized` to keep the token pair and [state] consistent with each other.
*/
@Singleton
class SessionManager @Inject constructor(
private val store: TokenStore,
) {
private val accessRef = AtomicReference<String?>(null)
private val refreshRef = AtomicReference<String?>(null)
private val _state: MutableStateFlow<Session>
val state: StateFlow<Session>
init {
val restored = store.load()
if (restored != null) {
accessRef.set(restored.accessToken)
refreshRef.set(restored.refreshToken)
_state = MutableStateFlow(Session.SignedIn(restored.toSessionUser()))
} else {
_state = MutableStateFlow(Session.SignedOut)
}
state = _state.asStateFlow()
}
/** The bearer for the current request, or null when signed out. */
fun currentAccessToken(): String? = accessRef.get()
/** The refresh token the authenticator rotates, or null when signed out. */
fun currentRefreshToken(): String? = refreshRef.get()
val isSignedIn: Boolean get() = _state.value is Session.SignedIn
/** Establish a session from a successful login (§4.1). */
@Synchronized
fun onSignedIn(accessToken: String, refreshToken: String, user: SafeUserDto) {
accessRef.set(accessToken)
refreshRef.set(refreshToken)
store.save(user.toStored(accessToken, refreshToken))
_state.value = Session.SignedIn(user.toSessionUser())
}
/**
* Store a rotated token pair after a silent refresh (§4.3). Keeps the current
* user; if somehow signed out already, it is a no-op (the refresh raced a
* logout and must not resurrect the session).
*/
@Synchronized
fun onRefreshed(accessToken: String, refreshToken: String, user: SafeUserDto) {
if (_state.value !is Session.SignedIn) return
accessRef.set(accessToken)
refreshRef.set(refreshToken)
store.save(user.toStored(accessToken, refreshToken))
// Refresh may carry an updated role — reflect it so the menu stays honest.
_state.value = Session.SignedIn(user.toSessionUser())
}
/** Refresh the cached identity from a `/auth/me` re-validation (§4.3). */
@Synchronized
fun onUserRefreshed(user: SafeUserDto) {
val current = _state.value
if (current !is Session.SignedIn) return
val access = accessRef.get() ?: return
val refresh = refreshRef.get() ?: return
store.save(user.toStored(access, refresh))
_state.value = Session.SignedIn(user.toSessionUser())
}
/** Tear the session down — user logout, dead refresh, or a server switch (§3). */
@Synchronized
fun onSignedOut() {
accessRef.set(null)
refreshRef.set(null)
store.clear()
_state.value = Session.SignedOut
}
}

View File

@@ -0,0 +1,31 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.core.auth
/**
* The at-rest home for a signed-in session (PLAN.md §4.3): the access + refresh
* tokens plus the cached safe-user. Tokens are sensitive, so the production
* implementation stores them in EncryptedSharedPreferences — never plain
* DataStore or logs. Kept behind an interface so [SessionManager] is unit-testable
* against an in-memory fake.
*/
interface TokenStore {
/** The persisted session restored on launch, or null when signed out. */
fun load(): StoredSession?
/** Persist (overwrite) the current session atomically. */
fun save(session: StoredSession)
/** Wipe every stored token — sign-out and the Settings → Server hard reset (§3). */
fun clear()
}
/** A persisted session: the token pair and the non-sensitive user it belongs to. */
data class StoredSession(
val accessToken: String,
val refreshToken: String,
val userId: Long,
val username: String,
val role: String,
)

View File

@@ -0,0 +1,39 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.core.net
import com.runicgateway.app.core.auth.SessionManager
import okhttp3.Interceptor
import okhttp3.Response
import javax.inject.Inject
import javax.inject.Singleton
/**
* Attaches the current bearer access token to outbound calls (PLAN.md §4.1).
* Public endpoints simply carry a token the backend ignores; the credential
* endpoints (login/refresh) tag themselves [Http.NO_SESSION_HEADER] and are left
* bare so a credential `401` is never mistaken for an expired session. A request
* that already set its own Authorization (the authenticator's retry) is untouched.
*/
@Singleton
class AuthInterceptor @Inject constructor(
private val sessionManager: SessionManager,
) : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val request = chain.request()
if (request.header(Http.NO_SESSION_HEADER) != null) {
return chain.proceed(request)
}
if (request.header(Http.AUTHORIZATION) != null) {
return chain.proceed(request)
}
val token = sessionManager.currentAccessToken()
?: return chain.proceed(request)
val authed = request.newBuilder()
.header(Http.AUTHORIZATION, Http.bearer(token))
.build()
return chain.proceed(authed)
}
}

View File

@@ -0,0 +1,19 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.core.net
/** Shared HTTP constants for the auth layer (PLAN.md §4). */
object Http {
/**
* Marks the credential endpoints (login, refresh) that must run *without* a
* bearer and must never trigger the refresh-on-401 [TokenAuthenticator].
* [AuthInterceptor] sees it and skips attaching a token; the authenticator
* sees it on the failed request and declines to refresh. It is a harmless
* unknown header to the backend.
*/
const val NO_SESSION_HEADER = "X-Runic-No-Session"
const val AUTHORIZATION = "Authorization"
fun bearer(token: String): String = "Bearer $token"
}

View File

@@ -0,0 +1,86 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.core.net
import com.runicgateway.app.core.auth.SessionManager
import com.runicgateway.app.data.api.AuthRefreshApi
import com.runicgateway.app.data.api.dto.MobileRefreshRequest
import okhttp3.Authenticator
import okhttp3.Request
import okhttp3.Response
import okhttp3.Route
import java.io.IOException
import javax.inject.Inject
import javax.inject.Singleton
/**
* Transparently refreshes an expired access token on a bearer `401` and replays
* the request (PLAN.md §4.1, §4.3). Refresh tokens are single-use and rotated, so
* this is serialized behind a mutex: concurrent 401s trigger exactly one refresh
* and the losers reuse its result. A refresh that comes back `401` means the
* session is truly dead → sign out; a network error leaves the session intact so
* a later call can retry.
*
* The refresh call runs on [AuthRefreshApi] (its own bare client with no
* authenticator), so it can never recurse back into here.
*/
@Singleton
class TokenAuthenticator @Inject constructor(
private val sessionManager: SessionManager,
private val refreshApi: AuthRefreshApi,
) : Authenticator {
private val lock = Any()
override fun authenticate(route: Route?, response: Response): Request? {
val failed = response.request
// Credential endpoints (login/refresh) must never be "refreshed".
if (failed.header(Http.NO_SESSION_HEADER) != null) return null
// Give up after a single refresh+replay to avoid an auth loop.
if (priorResponseCount(response) >= 2) return null
val attemptedAuth = failed.header(Http.AUTHORIZATION)
synchronized(lock) {
// Another thread may have already refreshed while we waited on the lock.
val current = sessionManager.currentAccessToken()
if (current != null && Http.bearer(current) != attemptedAuth) {
return failed.retryWith(current)
}
val refreshToken = sessionManager.currentRefreshToken()
?: return null // already signed out
val refreshed = try {
refreshApi.refresh(MobileRefreshRequest(refreshToken)).execute()
} catch (_: IOException) {
// Transient — surface the original 401 but keep the session.
return null
}
val body = refreshed.body()
if (!refreshed.isSuccessful || body == null) {
// The refresh token is dead (401/expired/revoked) → session is over.
sessionManager.onSignedOut()
return null
}
sessionManager.onRefreshed(body.accessToken, body.refreshToken, body.user)
return failed.retryWith(body.accessToken)
}
}
private fun Request.retryWith(accessToken: String): Request =
newBuilder().header(Http.AUTHORIZATION, Http.bearer(accessToken)).build()
private fun priorResponseCount(response: Response): Int {
var count = 1
var prior = response.priorResponse
while (prior != null) {
count++
prior = prior.priorResponse
}
return count
}
}

View File

@@ -0,0 +1,32 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.core.web
import android.content.ActivityNotFoundException
import android.content.Context
import android.net.Uri
import androidx.browser.customtabs.CustomTabsIntent
/**
* Opens the website's own pages in a Chrome Custom Tab (PLAN.md §4.2):
* registration, invite acceptance, forgot/reset password, and SSO all stay
* website-handled, so the app hands off rather than rebuilding those flows. The
* user completes them in the browser and returns to sign in natively (§4.1).
*/
object WebHandoff {
/**
* Launch [url] in a Custom Tab. Returns false if no browser could handle it
* (extremely rare on Android) so the caller can surface a fallback.
*/
fun open(context: Context, url: String): Boolean = try {
CustomTabsIntent.Builder()
.setShowTitle(true)
.build()
.launchUrl(context, Uri.parse(url))
true
} catch (_: ActivityNotFoundException) {
false
}
}

View File

@@ -0,0 +1,37 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.core.web
import com.runicgateway.app.core.net.BaseUrlHolder
import javax.inject.Inject
import javax.inject.Singleton
/**
* Resolves the website's front-end page paths against the configured base URL,
* for the Custom-Tab hand-offs (PLAN.md §4.2). These are the React SPA routes
* (mirrored from `website/client` `App.jsx`), not API endpoints. Null before a
* shard site is configured.
*/
@Singleton
class WebsiteUrls @Inject constructor(
private val baseUrlHolder: BaseUrlHolder,
) {
private fun resolve(path: String): String? =
baseUrlHolder.current?.resolve(path)?.toString()
/** Create an account on the website. */
fun register(): String? = resolve(REGISTER)
/** Forgot / reset password (the flow built on the backend before app work, §8). */
fun forgotPassword(): String? = resolve(FORGOT)
/** The website login page — carries the SSO provider buttons (§4.2). */
fun login(): String? = resolve(LOGIN)
private companion object {
const val REGISTER = "account/register"
const val FORGOT = "account/forgot"
const val LOGIN = "account/login"
}
}

View File

@@ -0,0 +1,40 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.data.api
import com.runicgateway.app.data.api.dto.MeResponse
import com.runicgateway.app.data.api.dto.MobileLoginRequest
import com.runicgateway.app.data.api.dto.MobileLogoutRequest
import com.runicgateway.app.data.api.dto.MobileTokenResponse
import retrofit2.Response
import retrofit2.http.Body
import retrofit2.http.GET
import retrofit2.http.Headers
import retrofit2.http.POST
/**
* The native bearer-auth surface (PLAN.md §4.1). Login and logout run on the main
* OkHttp client; [com.runicgateway.app.core.net.AuthInterceptor] attaches the
* access token to logout + `/auth/me`, and [com.runicgateway.app.core.net.TokenAuthenticator]
* transparently refreshes on a `401`.
*
* Login is tagged [com.runicgateway.app.core.net.Http.NO_SESSION_HEADER] so it
* carries no bearer and a credential `401` (bad password / `totpRequired`) is not
* misread as an expired session. It returns a raw [Response] so the caller can
* inspect the status and parse the `{ totpRequired }` error body.
*/
interface AuthApi {
// Literal header value required by Retrofit @Headers; matches Http.NO_SESSION_HEADER.
@Headers("X-Runic-No-Session: 1")
@POST("api/v1/auth/mobile/login")
suspend fun login(@Body body: MobileLoginRequest): Response<MobileTokenResponse>
@POST("api/v1/auth/mobile/logout")
suspend fun logout(@Body body: MobileLogoutRequest): Response<Unit>
/** Current user — the app's authoritative role source, re-validated on resume (§4.3). */
@GET("api/v1/auth/me")
suspend fun me(): MeResponse
}

View File

@@ -0,0 +1,27 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.data.api
import com.runicgateway.app.data.api.dto.MobileRefreshRequest
import com.runicgateway.app.data.api.dto.MobileTokenResponse
import retrofit2.Call
import retrofit2.http.Body
import retrofit2.http.Headers
import retrofit2.http.POST
/**
* The token-rotation endpoint, isolated onto its own **bare** OkHttp client
* (no auth interceptor, no authenticator) so refreshing can never recurse
* through the very [com.runicgateway.app.core.net.TokenAuthenticator] that calls
* it (PLAN.md §4.3). It is a blocking [Call] because the authenticator runs on an
* OkHttp dispatcher thread, outside any coroutine, and executes it synchronously.
*
* Tagged `NO_SESSION` so it carries no stale bearer.
*/
interface AuthRefreshApi {
@Headers("X-Runic-No-Session: 1")
@POST("api/v1/auth/mobile/refresh")
fun refresh(@Body body: MobileRefreshRequest): Call<MobileTokenResponse>
}

View File

@@ -0,0 +1,67 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.data.api.dto
import kotlinx.serialization.Serializable
/**
* The mobile bearer-auth wire shapes (PLAN.md §4.1). Field names match the
* backend's `auth/mobile` controller and `/auth/me` exactly; every DTO ignores
* unknown keys (NetworkModule's lenient Json), so additive backend fields are
* safe (§8, recorded for M1).
*/
/** `POST /auth/mobile/login` body. [code] is only sent on the 2FA retry. */
@Serializable
data class MobileLoginRequest(
val username: String,
val password: String,
val code: String? = null,
)
/** `POST /auth/mobile/refresh` body. */
@Serializable
data class MobileRefreshRequest(val refreshToken: String)
/** `POST /auth/mobile/logout` body — revoke this session or (with [all]) every session. */
@Serializable
data class MobileLogoutRequest(
val refreshToken: String? = null,
val all: Boolean? = null,
)
/**
* 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.
*/
@Serializable
data class MobileTokenResponse(
val accessToken: String,
val refreshToken: String,
val expiresIn: String? = null,
val user: SafeUserDto,
)
/** The minimal, non-sensitive user the app needs to render + gate the menu (§5). */
@Serializable
data class SafeUserDto(
val id: Long,
val username: String,
val role: String,
)
/** `GET /auth/me` envelope — the role source, re-validated on resume (§4.3). */
@Serializable
data class MeResponse(val user: SafeUserDto)
/**
* The `401 { totpRequired: true }` body the single-request 2FA flow returns when
* an account has TOTP on and no/invalid code accompanied the login (§4.1). Parsed
* from the error body since it is not a 2xx response.
*/
@Serializable
data class TotpRequiredError(
val totpRequired: Boolean = false,
val message: String? = null,
)

View File

@@ -0,0 +1,115 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.data.repository
import com.runicgateway.app.core.auth.SessionManager
import com.runicgateway.app.data.api.AuthApi
import com.runicgateway.app.data.api.dto.MobileLoginRequest
import com.runicgateway.app.data.api.dto.MobileLogoutRequest
import com.runicgateway.app.data.api.dto.MobileTokenResponse
import com.runicgateway.app.data.api.dto.TotpRequiredError
import kotlinx.coroutines.CancellationException
import kotlinx.serialization.json.Json
import retrofit2.Response
import java.io.IOException
import javax.inject.Inject
import javax.inject.Singleton
/**
* Native username/password (+TOTP) auth — the app's only native credential flow
* (PLAN.md §4.1). It drives the [SessionManager]: a successful login establishes
* the session; logout revokes it. Registration/invite/reset/SSO are website
* hand-offs (§4.2), not here.
*/
@Singleton
class AuthRepository @Inject constructor(
private val authApi: AuthApi,
private val sessionManager: SessionManager,
private val json: Json,
) {
/** Outcome of a login attempt (§4.1). */
sealed interface LoginResult {
data object Success : LoginResult
/** The account has 2FA on — reveal the code field and resubmit with a code. */
data object TotpRequired : LoginResult
data object InvalidCredentials : LoginResult
/** Guarded by per-IP backoff → slowdown → hard cap; back off and retry. */
data object RateLimited : LoginResult
/** Any other server failure (5xx / unexpected). */
data object ServerError : LoginResult
/** No answer — offline, DNS, TLS, timeout. */
data object NetworkError : LoginResult
}
suspend fun login(username: String, password: String, code: String? = null): LoginResult {
val response: Response<MobileTokenResponse> = try {
authApi.login(MobileLoginRequest(username = username, password = password, code = code))
} catch (e: CancellationException) {
throw e
} catch (_: IOException) {
return LoginResult.NetworkError
}
if (response.isSuccessful) {
val body = response.body() ?: return LoginResult.ServerError
sessionManager.onSignedIn(body.accessToken, body.refreshToken, body.user)
return LoginResult.Success
}
return when (response.code()) {
401 -> if (isTotpRequired(response)) LoginResult.TotpRequired else LoginResult.InvalidCredentials
429 -> LoginResult.RateLimited
else -> LoginResult.ServerError
}
}
/**
* 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
* network call fails, so the user is always signed out locally.
*/
suspend fun logout(allDevices: Boolean = false) {
val refreshToken = sessionManager.currentRefreshToken()
try {
authApi.logout(MobileLogoutRequest(refreshToken = refreshToken, all = allDevices))
} catch (e: CancellationException) {
throw e
} catch (_: Exception) {
// Ignore — we still drop the local session below.
}
sessionManager.onSignedOut()
}
/**
* Re-validate the session against `GET /auth/me` on app resume (§4.3). A
* success refreshes the cached role (roles change server-side); a `401` that
* survives the silent refresh means the session is dead → sign out. Transient
* failures are ignored so a flaky network doesn't bounce the user.
*/
suspend fun revalidate() {
if (!sessionManager.isSignedIn) return
try {
val me = authApi.me()
sessionManager.onUserRefreshed(me.user)
} catch (e: CancellationException) {
throw e
} catch (e: retrofit2.HttpException) {
if (e.code() == 401) sessionManager.onSignedOut()
} catch (_: IOException) {
// Offline — keep the session; the next authed call will re-check.
}
}
private fun isTotpRequired(response: Response<*>): Boolean = try {
val raw = response.errorBody()?.string()
!raw.isNullOrBlank() && json.decodeFromString<TotpRequiredError>(raw).totpRequired
} catch (_: Exception) {
false
}
}

View File

@@ -3,6 +3,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.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
@@ -24,6 +25,7 @@ class ConnectionRepository @Inject constructor(
private val api: PublicApi, private val api: PublicApi,
private val prefs: ServerPreferences, private val prefs: ServerPreferences,
private val baseUrlHolder: BaseUrlHolder, private val baseUrlHolder: BaseUrlHolder,
private val sessionManager: SessionManager,
private val config: com.runicgateway.app.core.AppConfig, private val config: com.runicgateway.app.core.AppConfig,
) { ) {
@@ -81,10 +83,12 @@ class ConnectionRepository @Inject constructor(
} }
/** /**
* Hard reset for a Settings → Server switch (§3): clear the saved URL and * Hard reset for a Settings → Server switch (§3): sign out (clear stored
* deactivate it. Token/cache clearing joins here in M3 once sessions exist. * tokens), clear the saved URL, and deactivate it — the app returns to a
* signed-out state against the new host.
*/ */
suspend fun disconnect() { suspend fun disconnect() {
sessionManager.onSignedOut()
prefs.clear() prefs.clear()
baseUrlHolder.set(null) baseUrlHolder.set(null)
} }

View File

@@ -6,9 +6,13 @@ package com.runicgateway.app.di
import android.os.Build import android.os.Build
import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory
import com.runicgateway.app.BuildConfig import com.runicgateway.app.BuildConfig
import com.runicgateway.app.core.net.AuthInterceptor
import com.runicgateway.app.core.net.BaseUrlHolder import com.runicgateway.app.core.net.BaseUrlHolder
import com.runicgateway.app.core.net.HostSelectionInterceptor import com.runicgateway.app.core.net.HostSelectionInterceptor
import com.runicgateway.app.core.net.TokenAuthenticator
import com.runicgateway.app.core.net.UserAgentInterceptor import com.runicgateway.app.core.net.UserAgentInterceptor
import com.runicgateway.app.data.api.AuthApi
import com.runicgateway.app.data.api.AuthRefreshApi
import com.runicgateway.app.data.api.PublicApi import com.runicgateway.app.data.api.PublicApi
import dagger.Module import dagger.Module
import dagger.Provides import dagger.Provides
@@ -42,16 +46,25 @@ object NetworkModule {
return UserAgentInterceptor(ua) return UserAgentInterceptor(ua)
} }
/**
* The main client every API and the SSE stream ride on. Order: identify (UA),
* retarget onto the configured shard host, then attach the bearer; the
* [TokenAuthenticator] handles silent refresh-on-401 (§4.1). Logging sits last
* so it observes the final, authed request.
*/
@Provides @Provides
@Singleton @Singleton
fun provideOkHttpClient( fun provideOkHttpClient(
hostSelectionInterceptor: HostSelectionInterceptor, hostSelectionInterceptor: HostSelectionInterceptor,
userAgentInterceptor: UserAgentInterceptor, userAgentInterceptor: UserAgentInterceptor,
authInterceptor: AuthInterceptor,
tokenAuthenticator: TokenAuthenticator,
): OkHttpClient { ): OkHttpClient {
val builder = OkHttpClient.Builder() val builder = OkHttpClient.Builder()
// User-Agent first, then host retargeting, so both apply to every call.
.addInterceptor(userAgentInterceptor) .addInterceptor(userAgentInterceptor)
.addInterceptor(hostSelectionInterceptor) .addInterceptor(hostSelectionInterceptor)
.addInterceptor(authInterceptor)
.authenticator(tokenAuthenticator)
if (BuildConfig.DEBUG) { if (BuildConfig.DEBUG) {
builder.addInterceptor( builder.addInterceptor(
@@ -75,4 +88,33 @@ object NetworkModule {
@Provides @Provides
@Singleton @Singleton
fun providePublicApi(retrofit: Retrofit): PublicApi = retrofit.create(PublicApi::class.java) fun providePublicApi(retrofit: Retrofit): PublicApi = retrofit.create(PublicApi::class.java)
@Provides
@Singleton
fun provideAuthApi(retrofit: Retrofit): AuthApi = retrofit.create(AuthApi::class.java)
/**
* Token refresh runs on its own **bare** client — UA + host retargeting only,
* no auth interceptor and no authenticator — so a refresh can never recurse
* back through [TokenAuthenticator] (§4.3). This throwaway client/Retrofit is
* not exposed as a bean, so there is no ambiguous [OkHttpClient] binding.
*/
@Provides
@Singleton
fun provideAuthRefreshApi(
hostSelectionInterceptor: HostSelectionInterceptor,
userAgentInterceptor: UserAgentInterceptor,
json: Json,
): AuthRefreshApi {
val bareClient = OkHttpClient.Builder()
.addInterceptor(userAgentInterceptor)
.addInterceptor(hostSelectionInterceptor)
.build()
val retrofit = Retrofit.Builder()
.baseUrl(BaseUrlHolder.PLACEHOLDER_BASE_URL)
.client(bareClient)
.addConverterFactory(json.asConverterFactory("application/json".toMediaType()))
.build()
return retrofit.create(AuthRefreshApi::class.java)
}
} }

View File

@@ -0,0 +1,22 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.di
import com.runicgateway.app.core.auth.EncryptedTokenStore
import com.runicgateway.app.core.auth.TokenStore
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
/** Binds the at-rest token store to its EncryptedSharedPreferences impl (§4.3). */
@Module
@InstallIn(SingletonComponent::class)
abstract class StorageModule {
@Binds
@Singleton
abstract fun bindTokenStore(impl: EncryptedTokenStore): TokenStore
}

View File

@@ -4,7 +4,6 @@
package com.runicgateway.app.ui package com.runicgateway.app.ui
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
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.material.icons.Icons import androidx.compose.material.icons.Icons
@@ -15,6 +14,7 @@ import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalDrawerSheet import androidx.compose.material3.ModalDrawerSheet
import androidx.compose.material3.ModalNavigationDrawer import androidx.compose.material3.ModalNavigationDrawer
import androidx.compose.material3.NavigationDrawerItem import androidx.compose.material3.NavigationDrawerItem
@@ -24,11 +24,15 @@ import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.material3.rememberDrawerState import androidx.compose.material3.rememberDrawerState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.LifecycleResumeEffect
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavHostController import androidx.navigation.NavHostController
import androidx.navigation.NavType import androidx.navigation.NavType
import androidx.navigation.compose.NavHost import androidx.navigation.compose.NavHost
@@ -37,13 +41,20 @@ import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument import androidx.navigation.navArgument
import com.runicgateway.app.R import com.runicgateway.app.R
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.LoginScreen
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
import com.runicgateway.app.ui.navigation.APP_MENU
import com.runicgateway.app.ui.navigation.Routes import com.runicgateway.app.ui.navigation.Routes
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.page.PageScreen import com.runicgateway.app.ui.page.PageScreen
import com.runicgateway.app.ui.session.SessionViewModel
import com.runicgateway.app.ui.shard.ChampsScreen import com.runicgateway.app.ui.shard.ChampsScreen
import com.runicgateway.app.ui.shard.GovernorsScreen import com.runicgateway.app.ui.shard.GovernorsScreen
import com.runicgateway.app.ui.shard.GuildsScreen import com.runicgateway.app.ui.shard.GuildsScreen
@@ -54,27 +65,17 @@ import com.runicgateway.app.ui.wiki.WikiPageScreen
import com.runicgateway.app.ui.wiki.WikiScreen import com.runicgateway.app.ui.wiki.WikiScreen
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
/** A navigation menu entry (PLAN.md §5). For M1 every entry is public. */
private data class MenuEntry(val route: String, val labelRes: Int)
private val PUBLIC_MENU = listOf(
MenuEntry(Routes.HOME, R.string.menu_home),
MenuEntry(Routes.NEWS, R.string.menu_news),
MenuEntry(Routes.WIKI, R.string.menu_wiki),
MenuEntry(Routes.SHARD, R.string.menu_shard),
MenuEntry(Routes.page("about"), R.string.menu_about),
MenuEntry(Routes.CONTACT, R.string.menu_contact),
)
/** Destinations that show the drawer (hamburger); others show a back arrow. */ /** Destinations that show the drawer (hamburger); others show a back arrow. */
private val TOP_LEVEL_ROUTES = setOf( private val TOP_LEVEL_ROUTES = setOf(
Routes.HOME, Routes.NEWS, Routes.WIKI, Routes.SHARD, Routes.CONTACT, Routes.PAGE, Routes.HOME, Routes.NEWS, Routes.WIKI, Routes.SHARD, Routes.CONTACT, Routes.PAGE, Routes.ACCOUNT,
) )
/** /**
* The main app shell once a shard site is configured (PLAN.md §5): one shared, * The main app shell once a shard site is configured (PLAN.md §5): one shared,
* declarative navigation drawer over the public content graph, plus the * declarative, access-level navigation drawer whose entries are filtered by the
* Settings → Server switch. The signed-in menu groups and auth toggle join in M3. * current session, plus the Sign in / Sign out toggle and the Settings → Server
* switch. The signed-in role is re-validated against the backend on every resume
* (§4.3), so a server-side demotion drops menu access promptly.
*/ */
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@@ -82,14 +83,24 @@ fun RunicApp(
brand: BrandDto?, brand: BrandDto?,
onChangeServer: () -> Unit, onChangeServer: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
sessionViewModel: SessionViewModel = hiltViewModel(),
) { ) {
val navController = rememberNavController() val navController = rememberNavController()
val drawerState = rememberDrawerState(DrawerValue.Closed) val drawerState = rememberDrawerState(DrawerValue.Closed)
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val session by sessionViewModel.session.collectAsStateWithLifecycle()
// Re-validate the cached role each time the app returns to the foreground (§4.3).
LifecycleResumeEffect(Unit) {
sessionViewModel.revalidate()
onPauseOrDispose { }
}
val backStackEntry by navController.currentBackStackEntryAsState() val backStackEntry by navController.currentBackStackEntryAsState()
val currentRoute = backStackEntry?.destination?.route val currentRoute = backStackEntry?.destination?.route
val isTopLevel = currentRoute in TOP_LEVEL_ROUTES val isTopLevel = currentRoute in TOP_LEVEL_ROUTES
val entries = visibleEntries(APP_MENU, session)
ModalNavigationDrawer( ModalNavigationDrawer(
drawerState = drawerState, drawerState = drawerState,
@@ -99,12 +110,12 @@ fun RunicApp(
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),
style = androidx.compose.material3.MaterialTheme.typography.titleLarge, style = MaterialTheme.typography.titleLarge,
modifier = Modifier.padding(horizontal = 24.dp, vertical = 12.dp), modifier = Modifier.padding(horizontal = 24.dp, vertical = 12.dp),
) )
HorizontalDivider() HorizontalDivider()
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
PUBLIC_MENU.forEach { entry -> entries.forEach { entry ->
NavigationDrawerItem( NavigationDrawerItem(
label = { Text(stringResource(entry.labelRes)) }, label = { Text(stringResource(entry.labelRes)) },
selected = currentRoute == entry.route, selected = currentRoute == entry.route,
@@ -115,7 +126,29 @@ fun RunicApp(
modifier = Modifier.padding(NavigationDrawerItemDefaults.ItemPadding), modifier = Modifier.padding(NavigationDrawerItemDefaults.ItemPadding),
) )
} }
HorizontalDivider(Modifier.padding(vertical = 8.dp)) HorizontalDivider(Modifier.padding(vertical = 8.dp))
// Sign in / Sign out toggles on the session (§5).
val signInLabel = if (session is Session.SignedIn) {
R.string.menu_sign_out
} else {
R.string.menu_sign_in
}
NavigationDrawerItem(
label = { Text(stringResource(signInLabel)) },
selected = false,
onClick = {
scope.launch { drawerState.close() }
if (session is Session.SignedIn) {
sessionViewModel.signOut()
navController.navigateTopLevel(Routes.HOME)
} else {
navController.navigate(Routes.LOGIN)
}
},
modifier = Modifier.padding(NavigationDrawerItemDefaults.ItemPadding),
)
NavigationDrawerItem( NavigationDrawerItem(
label = { Text(stringResource(R.string.menu_change_server)) }, label = { Text(stringResource(R.string.menu_change_server)) },
selected = false, selected = false,
@@ -158,6 +191,9 @@ fun RunicApp(
RunicNavHost( RunicNavHost(
navController = navController, navController = navController,
brand = brand, brand = brand,
session = session,
onSignOut = { sessionViewModel.signOut() },
onSignOutEverywhere = { sessionViewModel.signOut(allDevices = true) },
modifier = Modifier.padding(innerPadding), modifier = Modifier.padding(innerPadding),
) )
} }
@@ -168,6 +204,9 @@ fun RunicApp(
private fun RunicNavHost( private fun RunicNavHost(
navController: NavHostController, navController: NavHostController,
brand: BrandDto?, brand: BrandDto?,
session: Session,
onSignOut: () -> Unit,
onSignOutEverywhere: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
NavHost( NavHost(
@@ -226,6 +265,24 @@ private fun RunicNavHost(
composable(Routes.CONTACT) { composable(Routes.CONTACT) {
ContactScreen() ContactScreen()
} }
composable(Routes.LOGIN) {
LoginScreen(onSignedIn = { navController.popBackStack() })
}
composable(Routes.ACCOUNT) {
// 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.
when (val s = session) {
is Session.SignedIn -> AccountScreen(
username = s.user.username,
roleLabel = stringResource(roleLabelRes(s.user.role)),
onSignOut = onSignOut,
onSignOutEverywhere = onSignOutEverywhere,
)
Session.SignedOut -> LaunchedEffect(Unit) {
navController.navigateTopLevel(Routes.HOME)
}
}
}
} }
} }

View File

@@ -0,0 +1,89 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.ui.auth
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Card
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.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.annotation.StringRes
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.runicgateway.app.R
import com.runicgateway.app.core.auth.Role
/**
* The signed-in account surface (PLAN.md §5, "My Account"). For the M3 functional
* pass it shows the identity + role and the sign-out controls; full self-service
* (change username/password, TOTP, linked identities via the `/auth/me` surface)
* lands in M4 (§6.3).
*/
@Composable
fun AccountScreen(
username: String,
roleLabel: String,
onSignOut: () -> Unit,
onSignOutEverywhere: () -> Unit,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier
.fillMaxSize()
.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Top,
) {
Card(modifier = Modifier.fillMaxWidth()) {
Column(Modifier.padding(20.dp)) {
Text(
text = username,
style = MaterialTheme.typography.titleLarge,
)
Text(
text = roleLabel,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 4.dp),
)
}
}
OutlinedButton(
onClick = onSignOut,
modifier = Modifier
.fillMaxWidth()
.padding(top = 24.dp),
) {
Text(stringResource(R.string.account_sign_out))
}
TextButton(
onClick = onSignOutEverywhere,
modifier = Modifier
.fillMaxWidth()
.padding(top = 4.dp),
) {
Text(stringResource(R.string.account_sign_out_all))
}
}
}
/** Human label for a role (advisory display only — §4.3). */
@StringRes
fun roleLabelRes(role: Role): Int = when (role) {
Role.PLAYER -> R.string.role_player
Role.MODERATOR -> R.string.role_moderator
Role.EDITOR -> R.string.role_editor
Role.ADMIN -> R.string.role_admin
Role.UNKNOWN -> R.string.role_unknown
}

View File

@@ -0,0 +1,184 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.ui.auth
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
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.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.style.TextAlign
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.core.web.WebHandoff
import com.runicgateway.app.ui.auth.LoginViewModel.LoginError
/**
* Native username/password (+TOTP) login (PLAN.md §4.1) — the app's only native
* credential screen. Registration, forgot-password, and SSO are website hand-offs
* opened in a Custom Tab (§4.2); the user completes them in the browser and
* returns here to sign in.
*/
@Composable
fun LoginScreen(
onSignedIn: () -> Unit,
modifier: Modifier = Modifier,
viewModel: LoginViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsStateWithLifecycle()
val context = LocalContext.current
LaunchedEffect(state.signedIn) {
if (state.signedIn) onSignedIn()
}
Column(
modifier = modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Text(
text = stringResource(R.string.login_title),
style = MaterialTheme.typography.headlineSmall,
textAlign = TextAlign.Center,
)
Text(
text = stringResource(R.string.login_subtitle),
style = MaterialTheme.typography.bodyMedium,
textAlign = TextAlign.Center,
modifier = Modifier.padding(top = 8.dp, bottom = 24.dp),
)
OutlinedTextField(
value = state.username,
onValueChange = viewModel::onUsernameChange,
singleLine = true,
enabled = !state.submitting,
label = { Text(stringResource(R.string.login_username)) },
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Text,
imeAction = ImeAction.Next,
),
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = state.password,
onValueChange = viewModel::onPasswordChange,
singleLine = true,
enabled = !state.submitting,
label = { Text(stringResource(R.string.login_password)) },
visualTransformation = PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Password,
imeAction = if (state.totpRequired) ImeAction.Next else ImeAction.Go,
),
keyboardActions = KeyboardActions(onGo = { viewModel.submit() }),
modifier = Modifier
.fillMaxWidth()
.padding(top = 12.dp),
)
if (state.totpRequired) {
OutlinedTextField(
value = state.code,
onValueChange = viewModel::onCodeChange,
singleLine = true,
enabled = !state.submitting,
label = { Text(stringResource(R.string.login_totp_code)) },
supportingText = { Text(stringResource(R.string.login_totp_hint)) },
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.NumberPassword,
imeAction = ImeAction.Go,
),
keyboardActions = KeyboardActions(onGo = { viewModel.submit() }),
modifier = Modifier
.fillMaxWidth()
.padding(top = 12.dp),
)
}
state.error?.let { err ->
Text(
text = stringResource(loginErrorRes(err)),
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodyMedium,
textAlign = TextAlign.Center,
modifier = Modifier
.fillMaxWidth()
.padding(top = 12.dp),
)
}
Button(
onClick = viewModel::submit,
enabled = !state.submitting,
modifier = Modifier
.fillMaxWidth()
.padding(top = 20.dp),
) {
if (state.submitting) {
CircularProgressIndicator(
strokeWidth = 2.dp,
modifier = Modifier.size(20.dp),
color = MaterialTheme.colorScheme.onPrimary,
)
} else {
Text(stringResource(R.string.login_button))
}
}
// ── Website hand-offs (§4.2): open the site's own pages in a Custom Tab ──
viewModel.registerUrl?.let { url ->
TextButton(
onClick = { WebHandoff.open(context, url) },
modifier = Modifier.padding(top = 16.dp),
) { Text(stringResource(R.string.login_register)) }
}
viewModel.forgotPasswordUrl?.let { url ->
TextButton(onClick = { WebHandoff.open(context, url) }) {
Text(stringResource(R.string.login_forgot))
}
}
viewModel.ssoLoginUrl?.let { url ->
TextButton(onClick = { WebHandoff.open(context, url) }) {
Text(stringResource(R.string.login_sso))
}
}
}
}
private fun loginErrorRes(error: LoginError): Int = when (error) {
LoginError.INVALID_CREDENTIALS -> R.string.login_error_credentials
LoginError.BAD_CODE -> R.string.login_error_code
LoginError.RATE_LIMITED -> R.string.login_error_rate_limited
LoginError.SERVER -> R.string.login_error_server
LoginError.NETWORK -> R.string.login_error_network
}

View File

@@ -0,0 +1,101 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.ui.auth
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.runicgateway.app.core.web.WebsiteUrls
import com.runicgateway.app.data.repository.AuthRepository
import com.runicgateway.app.data.repository.AuthRepository.LoginResult
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 native login screen (PLAN.md §4.1): username/password, single-request
* TOTP (the code field is revealed once the backend answers `totpRequired`), and
* friendly 429 backoff handling. Registration / forgot-password / SSO are website
* hand-offs whose URLs it exposes (§4.2).
*/
@HiltViewModel
class LoginViewModel @Inject constructor(
private val authRepository: AuthRepository,
private val websiteUrls: WebsiteUrls,
) : ViewModel() {
/** The transient error surfaced under the form after a failed attempt. */
enum class LoginError { INVALID_CREDENTIALS, BAD_CODE, RATE_LIMITED, SERVER, NETWORK }
data class UiState(
val username: String = "",
val password: String = "",
val code: String = "",
/** True once the account is known to have 2FA on — reveal the code field. */
val totpRequired: Boolean = false,
val submitting: Boolean = false,
val error: LoginError? = null,
val signedIn: Boolean = false,
)
private val _state = MutableStateFlow(UiState())
val state: StateFlow<UiState> = _state.asStateFlow()
fun onUsernameChange(value: String) = _state.update { it.copy(username = value, error = null) }
fun onPasswordChange(value: String) = _state.update { it.copy(password = value, error = null) }
fun onCodeChange(value: String) =
_state.update { it.copy(code = value.filter(Char::isDigit).take(8), error = null) }
val registerUrl: String? get() = websiteUrls.register()
val forgotPasswordUrl: String? get() = websiteUrls.forgotPassword()
val ssoLoginUrl: String? get() = websiteUrls.login()
fun submit() {
val s = _state.value
if (s.submitting) return
if (s.username.isBlank() || s.password.isBlank()) {
_state.update { it.copy(error = LoginError.INVALID_CREDENTIALS) }
return
}
// If 2FA is being requested, a code must accompany the resubmit.
if (s.totpRequired && s.code.isBlank()) {
_state.update { it.copy(error = LoginError.BAD_CODE) }
return
}
_state.update { it.copy(submitting = true, error = null) }
viewModelScope.launch {
val code = s.code.trim().takeIf { it.isNotBlank() }
when (authRepository.login(s.username.trim(), s.password, code)) {
LoginResult.Success ->
_state.update { it.copy(submitting = false, signedIn = true) }
LoginResult.TotpRequired ->
// Reveal the code field; a wrong code re-lands here as BAD_CODE.
_state.update {
it.copy(
submitting = false,
totpRequired = true,
error = if (it.code.isNotBlank()) LoginError.BAD_CODE else null,
)
}
LoginResult.InvalidCredentials ->
_state.update { it.copy(submitting = false, error = LoginError.INVALID_CREDENTIALS) }
LoginResult.RateLimited ->
_state.update { it.copy(submitting = false, error = LoginError.RATE_LIMITED) }
LoginResult.ServerError ->
_state.update { it.copy(submitting = false, error = LoginError.SERVER) }
LoginResult.NetworkError ->
_state.update { it.copy(submitting = false, error = LoginError.NETWORK) }
}
}
}
}

View File

@@ -0,0 +1,59 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.ui.navigation
import androidx.annotation.StringRes
import com.runicgateway.app.R
import com.runicgateway.app.core.auth.Session
/**
* One shared, declarative, access-level navigation definition (PLAN.md §5): a
* single list where each entry declares the minimum access it needs, filtered by
* the current session — not a pile of `if role ==` checks. The server stays the
* source of truth; a hidden item is a UX convenience and every gated call still
* enforces on the backend.
*/
enum class MenuAccess {
/** Visible to everyone, signed in or not. */
PUBLIC,
/** Visible to any signed-in account (§5, "My Account"). */
SIGNED_IN,
/** Visible only to a player (linked game data lands in M4, §6.3). */
PLAYER,
}
data class MenuEntry(
val route: String,
@param:StringRes val labelRes: Int,
val access: MenuAccess = MenuAccess.PUBLIC,
)
/**
* The full menu, in display order. Public content first, then the signed-in
* surfaces. Player game-data groups (My Characters / Vendors / Houses) join in M4.
*/
val APP_MENU: List<MenuEntry> = listOf(
MenuEntry(Routes.HOME, R.string.menu_home),
MenuEntry(Routes.NEWS, R.string.menu_news),
MenuEntry(Routes.WIKI, R.string.menu_wiki),
MenuEntry(Routes.SHARD, R.string.menu_shard),
MenuEntry(Routes.page("about"), R.string.menu_about),
MenuEntry(Routes.CONTACT, R.string.menu_contact),
MenuEntry(Routes.ACCOUNT, R.string.menu_account, MenuAccess.SIGNED_IN),
)
/**
* The entries the given [session] may see. Pure + side-effect-free so the access
* gating is unit-tested without Compose.
*/
fun visibleEntries(entries: List<MenuEntry>, session: Session): List<MenuEntry> =
entries.filter { entry ->
when (entry.access) {
MenuAccess.PUBLIC -> true
MenuAccess.SIGNED_IN -> session is Session.SignedIn
MenuAccess.PLAYER -> session is Session.SignedIn && session.user.isPlayer
}
}

View File

@@ -14,6 +14,10 @@ object Routes {
const val WIKI = "wiki" const val WIKI = "wiki"
const val CONTACT = "contact" const val CONTACT = "contact"
/** Native login (§4.1) and the signed-in account surface (§5). */
const val LOGIN = "login"
const val ACCOUNT = "account"
/** Public shard hub (§6.2). */ /** Public shard hub (§6.2). */
const val SHARD = "shard" const val SHARD = "shard"

View File

@@ -0,0 +1,39 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.ui.session
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.runicgateway.app.core.auth.Session
import com.runicgateway.app.core.auth.SessionManager
import com.runicgateway.app.data.repository.AuthRepository
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import javax.inject.Inject
/**
* Activity-scoped view of the current session for the app shell (PLAN.md §4.3,
* §5): the shared menu observes [session] to reveal signed-in groups + the
* sign-in/out toggle, and the shell drives resume re-validation and sign-out.
* Login itself lives in [com.runicgateway.app.ui.auth.LoginViewModel].
*/
@HiltViewModel
class SessionViewModel @Inject constructor(
sessionManager: SessionManager,
private val authRepository: AuthRepository,
) : ViewModel() {
val session: StateFlow<Session> = sessionManager.state
/** Re-validate the cached role against the backend on app resume. */
fun revalidate() {
viewModelScope.launch { authRepository.revalidate() }
}
/** Sign out of this session, or every session with [allDevices]. */
fun signOut(allDevices: Boolean = false) {
viewModelScope.launch { authRepository.logout(allDevices) }
}
}

View File

@@ -41,8 +41,38 @@
<string name="menu_shard">Shard</string> <string name="menu_shard">Shard</string>
<string name="menu_about">About</string> <string name="menu_about">About</string>
<string name="menu_contact">Contact</string> <string name="menu_contact">Contact</string>
<string name="menu_account">My account</string>
<string name="menu_sign_in">Sign in</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>
<!-- ── Auth: login (§4.1) ──────────────────────────────────────────── -->
<string name="login_title">Sign in</string>
<string name="login_subtitle">Sign in with your shard account.</string>
<string name="login_username">Username</string>
<string name="login_password">Password</string>
<string name="login_totp_code">Authentication code</string>
<string name="login_totp_hint">Enter the 6-digit code from your authenticator app.</string>
<string name="login_button">Sign in</string>
<string name="login_register">Create an account</string>
<string name="login_forgot">Forgot your password?</string>
<string name="login_sso">Sign in with Google or Discord (on the website)</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_rate_limited">Too many attempts. Please try again shortly.</string>
<string name="login_error_server">Something went wrong. Please try again.</string>
<string name="login_error_network">Can\'t reach the site. Check your connection and try again.</string>
<!-- ── Auth: account (§5, §6.3) ────────────────────────────────────── -->
<string name="account_title">My account</string>
<string name="account_sign_out">Sign out</string>
<string name="account_sign_out_all">Sign out on all devices</string>
<string name="role_player">Player</string>
<string name="role_moderator">Moderator</string>
<string name="role_editor">Editor</string>
<string name="role_admin">Administrator</string>
<string name="role_unknown">Signed in</string>
<!-- ── Home / status (§6.1) ────────────────────────────────────────── --> <!-- ── Home / status (§6.1) ────────────────────────────────────────── -->
<string name="home_status_live">Online</string> <string name="home_status_live">Online</string>
<string name="home_status_maintenance">Under maintenance</string> <string name="home_status_maintenance">Under maintenance</string>

View File

@@ -0,0 +1,101 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.core.auth
import com.runicgateway.app.data.api.dto.SafeUserDto
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* Session lifecycle over a fake [TokenStore] (PLAN.md §4.3): restore on launch,
* establish/rotate/tear-down, and the invariant that the in-memory token pair and
* the observable [Session] state always agree.
*/
class SessionManagerTest {
/** In-memory stand-in for EncryptedSharedPreferences. */
private class FakeTokenStore(var stored: StoredSession? = null) : TokenStore {
override fun load(): StoredSession? = stored
override fun save(session: StoredSession) { stored = session }
override fun clear() { stored = null }
}
private fun user(id: Long = 1, name: String = "alice", role: String = "player") =
SafeUserDto(id = id, username = name, role = role)
@Test fun startsSignedOutWithNoStoredSession() {
val mgr = SessionManager(FakeTokenStore())
assertEquals(Session.SignedOut, mgr.state.value)
assertNull(mgr.currentAccessToken())
assertNull(mgr.currentRefreshToken())
}
@Test fun restoresSignedInFromStore() {
val store = FakeTokenStore(
StoredSession("access-1", "refresh-1", 5, "bob", "admin"),
)
val mgr = SessionManager(store)
val state = mgr.state.value
assertTrue(state is Session.SignedIn)
assertEquals("bob", (state as Session.SignedIn).user.username)
assertEquals(Role.ADMIN, state.user.role)
assertEquals("access-1", mgr.currentAccessToken())
assertEquals("refresh-1", mgr.currentRefreshToken())
}
@Test fun signInPersistsAndExposesTokens() {
val store = FakeTokenStore()
val mgr = SessionManager(store)
mgr.onSignedIn("access-A", "refresh-A", user(role = "player"))
assertTrue(mgr.state.value is Session.SignedIn)
assertEquals("access-A", mgr.currentAccessToken())
assertEquals("refresh-A", mgr.currentRefreshToken())
// Persisted so a relaunch restores it.
assertEquals("refresh-A", store.stored?.refreshToken)
}
@Test fun refreshRotatesTokensWhileSignedIn() {
val mgr = SessionManager(FakeTokenStore())
mgr.onSignedIn("access-A", "refresh-A", user())
mgr.onRefreshed("access-B", "refresh-B", user())
assertEquals("access-B", mgr.currentAccessToken())
assertEquals("refresh-B", mgr.currentRefreshToken())
}
@Test fun refreshIsNoOpAfterSignOut() {
val mgr = SessionManager(FakeTokenStore())
mgr.onSignedIn("access-A", "refresh-A", user())
mgr.onSignedOut()
// A refresh that races a logout must not resurrect the session.
mgr.onRefreshed("access-B", "refresh-B", user())
assertEquals(Session.SignedOut, mgr.state.value)
assertNull(mgr.currentAccessToken())
}
@Test fun userRefreshUpdatesRoleKeepingTokens() {
val mgr = SessionManager(FakeTokenStore())
mgr.onSignedIn("access-A", "refresh-A", user(role = "player"))
// A /auth/me re-validation reports a promotion.
mgr.onUserRefreshed(user(role = "editor"))
val state = mgr.state.value as Session.SignedIn
assertEquals(Role.EDITOR, state.user.role)
assertEquals("access-A", mgr.currentAccessToken())
}
@Test fun signOutClearsStoreAndState() {
val store = FakeTokenStore()
val mgr = SessionManager(store)
mgr.onSignedIn("access-A", "refresh-A", user())
mgr.onSignedOut()
assertEquals(Session.SignedOut, mgr.state.value)
assertNull(store.stored)
}
}

View File

@@ -0,0 +1,71 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.data.api.dto
import kotlinx.serialization.json.Json
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* Decoding tests for the mobile auth wire shapes (PLAN.md §4.1). Field names come
* from the website's `auth/mobile` controller + `/auth/me`; the parser ignores
* unknown keys (additive backend fields, §8).
*/
class AuthDtoTest {
private val json = Json {
ignoreUnknownKeys = true
explicitNulls = false
coerceInputValues = true
}
@Test fun tokenResponseDecodesWithSafeUser() {
val dto = json.decodeFromString<MobileTokenResponse>(
"""{"accessToken":"aaa.bbb.ccc","refreshToken":"r-123","expiresIn":"15m",
"user":{"id":7,"username":"alice","role":"player"}}""",
)
assertEquals("aaa.bbb.ccc", dto.accessToken)
assertEquals("r-123", dto.refreshToken)
assertEquals("15m", dto.expiresIn)
assertEquals(7L, dto.user.id)
assertEquals("alice", dto.user.username)
assertEquals("player", dto.user.role)
}
@Test fun meResponseIgnoresExtraUserFields() {
// /auth/me returns the full safe user row — the app only needs id/username/role.
val dto = json.decodeFromString<MeResponse>(
"""{"user":{"id":1,"username":"staff","role":"editor","status":"active",
"createdAt":"2026-01-01T00:00:00Z","totp_enabled":true}}""",
)
assertEquals(1L, dto.user.id)
assertEquals("editor", dto.user.role)
}
@Test fun totpRequiredErrorDecodes() {
val dto = json.decodeFromString<TotpRequiredError>(
"""{"totpRequired":true,"message":"A verification code is required."}""",
)
assertTrue(dto.totpRequired)
}
@Test fun totpRequiredDefaultsFalseForPlainCredentialFailure() {
// A bad-password 401 has no totpRequired flag — must not read as a 2FA prompt.
val dto = json.decodeFromString<TotpRequiredError>(
"""{"message":"Incorrect username or password."}""",
)
assertFalse(dto.totpRequired)
}
@Test fun tokenResponseExpiresInOptional() {
val dto = json.decodeFromString<MobileTokenResponse>(
"""{"accessToken":"a","refreshToken":"r","user":{"id":2,"username":"bob","role":"admin"}}""",
)
assertNull(dto.expiresIn)
assertEquals("admin", dto.user.role)
}
}

View File

@@ -0,0 +1,67 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.ui.navigation
import com.runicgateway.app.core.auth.Role
import com.runicgateway.app.core.auth.Session
import com.runicgateway.app.core.auth.SessionUser
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* The declarative access-level menu filter (PLAN.md §5): one list gated by the
* current session, not scattered `if role ==` checks.
*/
class MenuAccessTest {
private fun signedIn(role: Role) =
Session.SignedIn(SessionUser(id = 1, username = "u", role = role))
private fun routes(session: Session) =
visibleEntries(APP_MENU, session).map { it.route }
@Test fun anonymousSeesOnlyPublicEntries() {
val visible = routes(Session.SignedOut)
assertTrue(visible.contains(Routes.HOME))
assertTrue(visible.contains(Routes.SHARD))
// Signed-in surfaces are hidden.
assertFalse(visible.contains(Routes.ACCOUNT))
}
@Test fun signedInPlayerSeesAccount() {
val visible = routes(signedIn(Role.PLAYER))
assertTrue(visible.contains(Routes.ACCOUNT))
assertTrue(visible.contains(Routes.HOME))
}
@Test fun staffSeeAccountButNoPlayerOnlyGroups() {
val visible = routes(signedIn(Role.EDITOR))
assertTrue(visible.contains(Routes.ACCOUNT))
// No PLAYER-access entry leaks to staff (none exist yet in M3; guard the rule).
val playerOnly = APP_MENU.filter { it.access == MenuAccess.PLAYER }.map { it.route }
assertTrue(playerOnly.none { visible.contains(it) })
}
@Test fun publicEntryCountIsStableAcrossSessions() {
val publicCount = APP_MENU.count { it.access == MenuAccess.PUBLIC }
assertEquals(publicCount, routes(Session.SignedOut).size)
}
@Test fun roleFromWireMapsKnownAndUnknown() {
assertEquals(Role.ADMIN, Role.fromWire("admin"))
assertEquals(Role.PLAYER, Role.fromWire("player"))
assertEquals(Role.UNKNOWN, Role.fromWire("superuser"))
assertEquals(Role.UNKNOWN, Role.fromWire(null))
}
@Test fun playerAccessGatedFunction() {
// A synthetic PLAYER-gated entry is visible to a player, hidden from staff/anon.
val entries = listOf(MenuEntry("game", 0, MenuAccess.PLAYER))
assertTrue(visibleEntries(entries, signedIn(Role.PLAYER)).isNotEmpty())
assertTrue(visibleEntries(entries, signedIn(Role.ADMIN)).isEmpty())
assertTrue(visibleEntries(entries, Session.SignedOut).isEmpty())
}
}

View File

@@ -32,6 +32,9 @@ retrofitSerializationConverter = "1.0.0"
datastore = "1.1.1" datastore = "1.1.1"
securityCrypto = "1.1.0-alpha06" securityCrypto = "1.1.0-alpha06"
# Web hand-off (Chrome Custom Tabs) — §4.2
browser = "1.8.0"
# Images # Images
coil = "2.7.0" coil = "2.7.0"
@@ -79,6 +82,9 @@ retrofit-kotlinx-serialization-converter = { group = "com.jakewharton.retrofit",
androidx-datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore" } androidx-datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore" }
androidx-security-crypto = { group = "androidx.security", name = "security-crypto", version.ref = "securityCrypto" } androidx-security-crypto = { group = "androidx.security", name = "security-crypto", version.ref = "securityCrypto" }
# Web hand-off
androidx-browser = { group = "androidx.browser", name = "browser", version.ref = "browser" }
# Images # Images
coil-compose = { group = "io.coil-kt", name = "coil-compose", version.ref = "coil" } coil-compose = { group = "io.coil-kt", name = "coil-compose", version.ref = "coil" }