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:
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
60
app/src/main/java/com/runicgateway/app/core/auth/Session.kt
Normal file
60
app/src/main/java/com/runicgateway/app/core/auth/Session.kt
Normal 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))
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
19
app/src/main/java/com/runicgateway/app/core/net/Http.kt
Normal file
19
app/src/main/java/com/runicgateway/app/core/net/Http.kt
Normal 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"
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
40
app/src/main/java/com/runicgateway/app/data/api/AuthApi.kt
Normal file
40
app/src/main/java/com/runicgateway/app/data/api/AuthApi.kt
Normal 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
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
22
app/src/main/java/com/runicgateway/app/di/StorageModule.kt
Normal file
22
app/src/main/java/com/runicgateway/app/di/StorageModule.kt
Normal 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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
184
app/src/main/java/com/runicgateway/app/ui/auth/LoginScreen.kt
Normal file
184
app/src/main/java/com/runicgateway/app/ui/auth/LoginScreen.kt
Normal 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
|
||||||
|
}
|
||||||
101
app/src/main/java/com/runicgateway/app/ui/auth/LoginViewModel.kt
Normal file
101
app/src/main/java/com/runicgateway/app/ui/auth/LoginViewModel.kt
Normal 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) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
59
app/src/main/java/com/runicgateway/app/ui/navigation/Menu.kt
Normal file
59
app/src/main/java/com/runicgateway/app/ui/navigation/Menu.kt
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
|
||||||
|
|||||||
@@ -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) }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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" }
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user