feat: M10 — native SSO fixes + staff operations #21
@@ -16,6 +16,15 @@ data class SessionUser(
|
|||||||
val role: Role,
|
val role: Role,
|
||||||
) {
|
) {
|
||||||
val isPlayer: Boolean get() = role == Role.PLAYER
|
val isPlayer: Boolean get() = role == Role.PLAYER
|
||||||
|
|
||||||
|
/** Any staff role (moderator/editor/admin) — the staff-operations surface (§1, M10). */
|
||||||
|
val isStaff: Boolean get() = role.isStaff
|
||||||
|
|
||||||
|
/** Admin or moderator — moderation actions + the support queue (`modAccess`). */
|
||||||
|
val isModerator: Boolean get() = role == Role.ADMIN || role == Role.MODERATOR
|
||||||
|
|
||||||
|
/** Admin only — site-mode and other `adminOnly` controls. */
|
||||||
|
val isAdmin: Boolean get() = role == Role.ADMIN
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
package com.runicgateway.app.core.auth.sso
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.SharedPreferences
|
||||||
|
import androidx.security.crypto.EncryptedSharedPreferences
|
||||||
|
import androidx.security.crypto.MasterKey
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [PendingSsoStore] backed by Jetpack Security's [EncryptedSharedPreferences]
|
||||||
|
* (Tink/AES-256-GCM), so the PKCE verifier is encrypted at rest for the brief
|
||||||
|
* window a flow is in progress. Separate prefs file from the session token store —
|
||||||
|
* this holds only the transient SSO handshake, cleared as soon as the callback is
|
||||||
|
* consumed. Lazy, so a device that never signs in via SSO pays no keystore cost.
|
||||||
|
*/
|
||||||
|
@Singleton
|
||||||
|
class EncryptedPendingSsoStore @Inject constructor(
|
||||||
|
@param:ApplicationContext private val context: Context,
|
||||||
|
) : PendingSsoStore {
|
||||||
|
|
||||||
|
private val prefs: SharedPreferences by lazy {
|
||||||
|
val masterKey = MasterKey.Builder(context)
|
||||||
|
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
|
||||||
|
.build()
|
||||||
|
EncryptedSharedPreferences.create(
|
||||||
|
context,
|
||||||
|
PREFS_NAME,
|
||||||
|
masterKey,
|
||||||
|
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
|
||||||
|
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun save(state: String, verifier: String) {
|
||||||
|
prefs.edit()
|
||||||
|
.putString(KEY_STATE, state)
|
||||||
|
.putString(KEY_VERIFIER, verifier)
|
||||||
|
.apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun load(): PendingSso? {
|
||||||
|
val state = prefs.getString(KEY_STATE, null) ?: return null
|
||||||
|
val verifier = prefs.getString(KEY_VERIFIER, null) ?: return null
|
||||||
|
return PendingSso(state = state, verifier = verifier)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun clear() {
|
||||||
|
prefs.edit().clear().apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val PREFS_NAME = "runic_sso_pending"
|
||||||
|
const val KEY_STATE = "state"
|
||||||
|
const val KEY_VERIFIER = "verifier"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
package com.runicgateway.app.core.auth.sso
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persists the in-flight SSO `{state, verifier}` (PKCE Layer B + CSRF state) across
|
||||||
|
* the Custom-Tab round trip so the exchange survives process death — a low-memory
|
||||||
|
* device can evict the app while the Custom Tab is foreground, and the callback then
|
||||||
|
* returns to a fresh process (PLAN.md §4.2). Kept behind an interface so
|
||||||
|
* [SsoAuthManager] stays framework-free and unit-tests on the JVM with a fake.
|
||||||
|
*
|
||||||
|
* Exactly one flow is pending at a time; [save] overwrites any prior. The verifier
|
||||||
|
* is a bearer-equivalent secret for the one-time code, so the production impl
|
||||||
|
* ([EncryptedPendingSsoStore]) encrypts it at rest, mirroring the token store.
|
||||||
|
*/
|
||||||
|
interface PendingSsoStore {
|
||||||
|
fun save(state: String, verifier: String)
|
||||||
|
fun load(): PendingSso?
|
||||||
|
fun clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The stashed CSRF state + PKCE verifier for the current SSO attempt. */
|
||||||
|
data class PendingSso(val state: String, val verifier: String)
|
||||||
@@ -13,7 +13,6 @@ import kotlinx.coroutines.flow.MutableStateFlow
|
|||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
import java.util.concurrent.atomic.AtomicReference
|
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
|
||||||
@@ -34,20 +33,22 @@ import javax.inject.Singleton
|
|||||||
* parses the callback `Uri` (the Android edge) and hands the raw params here,
|
* parses the callback `Uri` (the Android edge) and hands the raw params here,
|
||||||
* so this class stays free of framework types and unit-tests on the JVM.
|
* so this class stays free of framework types and unit-tests on the JVM.
|
||||||
*
|
*
|
||||||
* The pending `{state, verifier}` lives only in memory: if the process is killed
|
* The pending `{state, verifier}` is persisted via [PendingSsoStore] (encrypted at
|
||||||
* while the Custom Tab is foreground it is lost and the exchange **fails closed**
|
* rest), so the exchange survives the process being evicted while the Custom Tab is
|
||||||
* (the user simply retries) — never a security downgrade.
|
* foreground — the callback can land in a fresh process and still complete. It is
|
||||||
|
* cleared the moment [complete] consumes it, so a lost/duplicate callback still
|
||||||
|
* **fails closed** as [Failure.STATE_MISMATCH] rather than double-exchanging.
|
||||||
*
|
*
|
||||||
* Threading: [buildStartUrl] runs on the UI thread; [complete] runs on the
|
* Threading: [buildStartUrl] runs on the UI thread; [complete] runs on the
|
||||||
* activity's coroutine scope after a deep link. The pending holder is an
|
* activity's coroutine scope after a deep link. [outcome] is a [StateFlow], so a
|
||||||
* [AtomicReference] and [outcome] a [StateFlow], so a ViewModel/activity recreation
|
* ViewModel/activity recreation while the Custom Tab is open cannot drop a result.
|
||||||
* while the Custom Tab is open cannot drop a result.
|
|
||||||
*/
|
*/
|
||||||
@Singleton
|
@Singleton
|
||||||
class SsoAuthManager @Inject constructor(
|
class SsoAuthManager @Inject constructor(
|
||||||
private val ssoApi: SsoApi,
|
private val ssoApi: SsoApi,
|
||||||
private val sessionManager: SessionManager,
|
private val sessionManager: SessionManager,
|
||||||
private val baseUrlHolder: BaseUrlHolder,
|
private val baseUrlHolder: BaseUrlHolder,
|
||||||
|
private val pendingStore: PendingSsoStore,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
/** Why an SSO attempt ended, for a friendly inline message on the login screen. */
|
/** Why an SSO attempt ended, for a friendly inline message on the login screen. */
|
||||||
@@ -75,10 +76,6 @@ class SsoAuthManager @Inject constructor(
|
|||||||
data class Failed(val reason: Failure) : Outcome
|
data class Failed(val reason: Failure) : Outcome
|
||||||
}
|
}
|
||||||
|
|
||||||
private data class Pending(val state: String, val verifier: String)
|
|
||||||
|
|
||||||
private val pending = AtomicReference<Pending?>(null)
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The host this build baked an App Link intent-filter for (`BuildConfig.APP_LINK_HOST`,
|
* The host this build baked an App Link intent-filter for (`BuildConfig.APP_LINK_HOST`,
|
||||||
* empty on the generic multi-tenant build — see docs/android/APP_LINKS.md).
|
* empty on the generic multi-tenant build — see docs/android/APP_LINKS.md).
|
||||||
@@ -97,16 +94,16 @@ class SsoAuthManager @Inject constructor(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Build the `/auth/mobile/sso/start` URL for [providerId] and stash the pending
|
* Build the `/auth/mobile/sso/start` URL for [providerId] and stash the pending
|
||||||
* PKCE verifier + CSRF state. Returns null when no shard site is configured yet
|
* PKCE verifier + CSRF state (persisted so it survives process death). Returns
|
||||||
* (the caller then keeps the website hand-off fallback). Also resets [outcome]
|
* null when no shard site is configured yet. Also resets [outcome] to
|
||||||
* to [Outcome.Idle] so a stale prior result can't fire against the new attempt.
|
* [Outcome.Idle] so a stale prior result can't fire against the new attempt.
|
||||||
*/
|
*/
|
||||||
fun buildStartUrl(providerId: String): String? {
|
fun buildStartUrl(providerId: String): String? {
|
||||||
val base = baseUrlHolder.current ?: return null
|
val base = baseUrlHolder.current ?: return null
|
||||||
val verifier = Pkce.newVerifier()
|
val verifier = Pkce.newVerifier()
|
||||||
val challenge = Pkce.challengeOf(verifier)
|
val challenge = Pkce.challengeOf(verifier)
|
||||||
val state = Pkce.newState()
|
val state = Pkce.newState()
|
||||||
pending.set(Pending(state = state, verifier = verifier))
|
pendingStore.save(state = state, verifier = verifier)
|
||||||
_outcome.value = Outcome.Idle
|
_outcome.value = Outcome.Idle
|
||||||
return base.newBuilder()
|
return base.newBuilder()
|
||||||
.addPathSegments("api/v1/auth/mobile/sso/start")
|
.addPathSegments("api/v1/auth/mobile/sso/start")
|
||||||
@@ -158,7 +155,8 @@ class SsoAuthManager @Inject constructor(
|
|||||||
* single-uses the code).
|
* single-uses the code).
|
||||||
*/
|
*/
|
||||||
suspend fun complete(state: String?, code: String?, error: String?) {
|
suspend fun complete(state: String?, code: String?, error: String?) {
|
||||||
val stashed = pending.getAndSet(null)
|
val stashed = pendingStore.load()
|
||||||
|
pendingStore.clear()
|
||||||
|
|
||||||
// CSRF: the callback must echo the exact state we generated at /start.
|
// CSRF: the callback must echo the exact state we generated at /start.
|
||||||
if (stashed == null || state.isNullOrEmpty() || state != stashed.state) {
|
if (stashed == null || state.isNullOrEmpty() || state != stashed.state) {
|
||||||
|
|||||||
@@ -26,12 +26,8 @@ class WebsiteUrls @Inject constructor(
|
|||||||
/** Forgot / reset password (the flow built on the backend before app work, §8). */
|
/** Forgot / reset password (the flow built on the backend before app work, §8). */
|
||||||
fun forgotPassword(): String? = resolve(FORGOT)
|
fun forgotPassword(): String? = resolve(FORGOT)
|
||||||
|
|
||||||
/** The website login page — carries the SSO provider buttons (§4.2). */
|
|
||||||
fun login(): String? = resolve(LOGIN)
|
|
||||||
|
|
||||||
private companion object {
|
private companion object {
|
||||||
const val REGISTER = "account/register"
|
const val REGISTER = "account/register"
|
||||||
const val FORGOT = "account/forgot"
|
const val FORGOT = "account/forgot"
|
||||||
const val LOGIN = "account/login"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
97
app/src/main/java/com/runicgateway/app/data/api/AdminApi.kt
Normal file
97
app/src/main/java/com/runicgateway/app/data/api/AdminApi.kt
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
package com.runicgateway.app.data.api
|
||||||
|
|
||||||
|
import com.runicgateway.app.data.api.dto.AdminDashboardDto
|
||||||
|
import com.runicgateway.app.data.api.dto.AdminPostDto
|
||||||
|
import com.runicgateway.app.data.api.dto.BanRequest
|
||||||
|
import com.runicgateway.app.data.api.dto.BroadcastRequest
|
||||||
|
import com.runicgateway.app.data.api.dto.KickRequest
|
||||||
|
import com.runicgateway.app.data.api.dto.PageRespondRequest
|
||||||
|
import com.runicgateway.app.data.api.dto.PostCreateRequest
|
||||||
|
import com.runicgateway.app.data.api.dto.PublishRequest
|
||||||
|
import com.runicgateway.app.data.api.dto.SiteModeRequest
|
||||||
|
import com.runicgateway.app.data.api.dto.SiteModeStateDto
|
||||||
|
import com.runicgateway.app.data.api.dto.SupportPageDto
|
||||||
|
import com.runicgateway.app.data.api.dto.UnbanRequest
|
||||||
|
import com.runicgateway.app.data.api.dto.AdminWikiCategoryDto
|
||||||
|
import com.runicgateway.app.data.api.dto.WikiCategoryRequest
|
||||||
|
import com.runicgateway.app.data.api.dto.AdminWikiTagDto
|
||||||
|
import retrofit2.Response
|
||||||
|
import retrofit2.http.Body
|
||||||
|
import retrofit2.http.DELETE
|
||||||
|
import retrofit2.http.GET
|
||||||
|
import retrofit2.http.PATCH
|
||||||
|
import retrofit2.http.PUT
|
||||||
|
import retrofit2.http.POST
|
||||||
|
import retrofit2.http.Path
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The M10 staff-operations surface over `/api/v1/admin/…` (PLAN.md §1, §6.4). On
|
||||||
|
* the authed client — every call carries the bearer, and the backend re-checks the
|
||||||
|
* caller's role on every request (`staffOnly` / `modAccess` / `adminOnly`), so a
|
||||||
|
* demoted user is refused server-side even if a stale menu still showed the entry.
|
||||||
|
*
|
||||||
|
* Grows one group at a time (dashboard first); moderation, support, and content
|
||||||
|
* endpoints are added with their screens.
|
||||||
|
*/
|
||||||
|
interface AdminApi {
|
||||||
|
|
||||||
|
/** `GET /admin/dashboard` — summary counts + site mode (any staff role). */
|
||||||
|
@GET("api/v1/admin/dashboard")
|
||||||
|
suspend fun dashboard(): AdminDashboardDto
|
||||||
|
|
||||||
|
/** `PUT /admin/site-mode` — switch live/maintenance (admin only; 403 otherwise). */
|
||||||
|
@PUT("api/v1/admin/site-mode")
|
||||||
|
suspend fun setSiteMode(@Body body: SiteModeRequest): SiteModeStateDto
|
||||||
|
|
||||||
|
// ── Content: news posts (any staff role) ──────────────────────────────
|
||||||
|
@GET("api/v1/admin/posts")
|
||||||
|
suspend fun posts(): List<AdminPostDto>
|
||||||
|
|
||||||
|
@POST("api/v1/admin/posts")
|
||||||
|
suspend fun createPost(@Body body: PostCreateRequest): AdminPostDto
|
||||||
|
|
||||||
|
@PATCH("api/v1/admin/posts/{id}/publish")
|
||||||
|
suspend fun publishPost(@Path("id") id: Long, @Body body: PublishRequest): AdminPostDto
|
||||||
|
|
||||||
|
@DELETE("api/v1/admin/posts/{id}")
|
||||||
|
suspend fun deletePost(@Path("id") id: Long): Response<Unit>
|
||||||
|
|
||||||
|
// ── Content: wiki taxonomy (any staff role) ───────────────────────────
|
||||||
|
@GET("api/v1/admin/wiki/categories")
|
||||||
|
suspend fun wikiCategories(): List<AdminWikiCategoryDto>
|
||||||
|
|
||||||
|
@POST("api/v1/admin/wiki/categories")
|
||||||
|
suspend fun createWikiCategory(@Body body: WikiCategoryRequest): AdminWikiCategoryDto
|
||||||
|
|
||||||
|
@DELETE("api/v1/admin/wiki/categories/{id}")
|
||||||
|
suspend fun deleteWikiCategory(@Path("id") id: Long): Response<Unit>
|
||||||
|
|
||||||
|
@GET("api/v1/admin/wiki/tags")
|
||||||
|
suspend fun wikiTags(): List<AdminWikiTagDto>
|
||||||
|
|
||||||
|
// ── Moderation: shard write plane (admin/moderator) ───────────────────
|
||||||
|
@POST("api/v1/admin/shard/kick")
|
||||||
|
suspend fun kick(@Body body: KickRequest): Response<Unit>
|
||||||
|
|
||||||
|
@POST("api/v1/admin/shard/ban")
|
||||||
|
suspend fun ban(@Body body: BanRequest): Response<Unit>
|
||||||
|
|
||||||
|
@POST("api/v1/admin/shard/unban")
|
||||||
|
suspend fun unban(@Body body: UnbanRequest): Response<Unit>
|
||||||
|
|
||||||
|
@POST("api/v1/admin/shard/broadcast")
|
||||||
|
suspend fun broadcast(@Body body: BroadcastRequest): Response<Unit>
|
||||||
|
|
||||||
|
// ── Support queue: help pages (admin/moderator) ───────────────────────
|
||||||
|
@GET("api/v1/admin/shard/pages")
|
||||||
|
suspend fun supportPages(): List<SupportPageDto>
|
||||||
|
|
||||||
|
@POST("api/v1/admin/shard/pages/{id}/respond")
|
||||||
|
suspend fun respondPage(@Path("id") id: String, @Body body: PageRespondRequest): Response<Unit>
|
||||||
|
|
||||||
|
@POST("api/v1/admin/shard/pages/{id}/close")
|
||||||
|
suspend fun closePage(@Path("id") id: String): Response<Unit>
|
||||||
|
}
|
||||||
179
app/src/main/java/com/runicgateway/app/data/api/dto/AdminDto.kt
Normal file
179
app/src/main/java/com/runicgateway/app/data/api/dto/AdminDto.kt
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
package com.runicgateway.app.data.api.dto
|
||||||
|
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import kotlinx.serialization.json.JsonElement
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wire shapes for the M10 staff-operations surface over `/api/v1/admin/…` (PLAN.md
|
||||||
|
* §1, §6.4). These are consumed only by the staff screens (dashboard, moderation,
|
||||||
|
* support, content); every DTO ignores unknown keys (NetworkModule's lenient Json)
|
||||||
|
* so additive backend fields stay safe. Nothing here is auto-provisioned or secret.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** `GET /admin/dashboard` — the staff landing summary. */
|
||||||
|
@Serializable
|
||||||
|
data class AdminDashboardDto(
|
||||||
|
@SerialName("site_mode") val siteMode: String = "live",
|
||||||
|
@SerialName("last_change") val lastChange: SiteModeChangeDto = SiteModeChangeDto(),
|
||||||
|
val counts: AdminCountsDto = AdminCountsDto(),
|
||||||
|
@SerialName("recent_activity") val recentActivity: List<AdminActivityDto> = emptyList(),
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class SiteModeChangeDto(
|
||||||
|
val at: String? = null,
|
||||||
|
val by: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class AdminCountsDto(
|
||||||
|
/** Post counts keyed by DB category (`news`, `five_on_friday`, …). */
|
||||||
|
val posts: Map<String, Int> = emptyMap(),
|
||||||
|
val users: Int = 0,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** One row of the recent admin-activity log. `detail` is provider-shaped JSON. */
|
||||||
|
@Serializable
|
||||||
|
data class AdminActivityDto(
|
||||||
|
val id: Long = 0,
|
||||||
|
val username: String? = null,
|
||||||
|
val action: String = "",
|
||||||
|
val detail: JsonElement? = null,
|
||||||
|
@SerialName("created_at") val createdAt: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** `PUT /admin/site-mode` request + response. */
|
||||||
|
@Serializable
|
||||||
|
data class SiteModeRequest(val mode: String)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class SiteModeStateDto(
|
||||||
|
@SerialName("site_mode") val siteMode: String = "live",
|
||||||
|
@SerialName("changed_at") val changedAt: String? = null,
|
||||||
|
@SerialName("changed_by") val changedBy: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── Content: news posts ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A post row from `GET /admin/posts` (all posts, incl. unpublished — unlike the
|
||||||
|
* public feed). `published` is a 0/1 flag (MariaDB tinyint), exposed as [isPublished].
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class AdminPostDto(
|
||||||
|
val id: Long,
|
||||||
|
val category: String = "",
|
||||||
|
val title: String = "",
|
||||||
|
val slug: String? = null,
|
||||||
|
val excerpt: String? = null,
|
||||||
|
val body: String? = null,
|
||||||
|
@SerialName("image_url") val imageUrl: String? = null,
|
||||||
|
val published: Int = 0,
|
||||||
|
@SerialName("published_at") val publishedAt: String? = null,
|
||||||
|
@SerialName("created_at") val createdAt: String? = null,
|
||||||
|
) {
|
||||||
|
val isPublished: Boolean get() = published != 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `POST/PUT /admin/posts` body. `category` is a URL category the backend maps
|
||||||
|
* (news | five-on-friday | newsletter | screenshots). */
|
||||||
|
@Serializable
|
||||||
|
data class PostCreateRequest(
|
||||||
|
val category: String,
|
||||||
|
val title: String,
|
||||||
|
val excerpt: String? = null,
|
||||||
|
val body: String? = null,
|
||||||
|
@SerialName("image_url") val imageUrl: String? = null,
|
||||||
|
val published: Boolean = false,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** `PATCH /admin/posts/:id/publish` body. */
|
||||||
|
@Serializable
|
||||||
|
data class PublishRequest(val published: Boolean)
|
||||||
|
|
||||||
|
// ── Content: wiki taxonomy ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** A wiki category from `GET /admin/wiki/categories` (with page counts). */
|
||||||
|
@Serializable
|
||||||
|
data class AdminWikiCategoryDto(
|
||||||
|
val id: Long,
|
||||||
|
val slug: String = "",
|
||||||
|
val title: String = "",
|
||||||
|
val description: String? = null,
|
||||||
|
@SerialName("sort_order") val sortOrder: Int? = null,
|
||||||
|
@SerialName("page_count") val pageCount: Int? = null,
|
||||||
|
@SerialName("published_count") val publishedCount: Int? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** `POST /admin/wiki/categories` body. */
|
||||||
|
@Serializable
|
||||||
|
data class WikiCategoryRequest(
|
||||||
|
val slug: String,
|
||||||
|
val title: String,
|
||||||
|
val description: String? = null,
|
||||||
|
@SerialName("sort_order") val sortOrder: Int? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** A wiki tag from `GET /admin/wiki/tags` (tags derive from pages; read-only here). */
|
||||||
|
@Serializable
|
||||||
|
data class AdminWikiTagDto(
|
||||||
|
val id: Long,
|
||||||
|
val slug: String = "",
|
||||||
|
val label: String = "",
|
||||||
|
@SerialName("published_count") val publishedCount: Int? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── Moderation (admin/moderator; shard write plane) ───────────────────────
|
||||||
|
|
||||||
|
/** `POST /admin/shard/kick` — at least one of account/serial. */
|
||||||
|
@Serializable
|
||||||
|
data class KickRequest(val account: String? = null, val serial: String? = null)
|
||||||
|
|
||||||
|
/** `POST /admin/shard/ban` — account/serial + optional duration (0/absent = indefinite). */
|
||||||
|
@Serializable
|
||||||
|
data class BanRequest(
|
||||||
|
val account: String? = null,
|
||||||
|
val serial: String? = null,
|
||||||
|
@SerialName("durationSec") val durationSec: Long? = null,
|
||||||
|
val reason: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** `POST /admin/shard/unban`. */
|
||||||
|
@Serializable
|
||||||
|
data class UnbanRequest(val account: String)
|
||||||
|
|
||||||
|
/** `POST /admin/shard/broadcast` — a system message to everyone online. */
|
||||||
|
@Serializable
|
||||||
|
data class BroadcastRequest(val text: String, val hue: Int? = null)
|
||||||
|
|
||||||
|
// ── Support queue (admin/moderator; help pages) ───────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One open help page from `GET /admin/shard/pages` (INTEGRATION.md §4). `pageId`
|
||||||
|
* is the sender's in-game serial (the `:id` for respond/close). Permissive — the
|
||||||
|
* shard-state fields beyond these (coords, timing) are ignored.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class SupportPageDto(
|
||||||
|
@SerialName("pageId") val pageId: String = "",
|
||||||
|
val type: String? = null,
|
||||||
|
val message: String? = null,
|
||||||
|
val handled: Boolean? = null,
|
||||||
|
val handler: String? = null,
|
||||||
|
val sender: SupportActorDto? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** The page's sender (actor object); [account] present when the character is linked. */
|
||||||
|
@Serializable
|
||||||
|
data class SupportActorDto(
|
||||||
|
val name: String? = null,
|
||||||
|
val account: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** `POST /admin/shard/pages/:id/respond` — reply, optionally closing the page. */
|
||||||
|
@Serializable
|
||||||
|
data class PageRespondRequest(val message: String, val close: Boolean = false)
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
package com.runicgateway.app.data.repository
|
||||||
|
|
||||||
|
import com.runicgateway.app.core.result.ApiResult
|
||||||
|
import com.runicgateway.app.core.result.safeApiCall
|
||||||
|
import com.runicgateway.app.data.api.AdminApi
|
||||||
|
import com.runicgateway.app.data.api.dto.AdminDashboardDto
|
||||||
|
import com.runicgateway.app.data.api.dto.AdminPostDto
|
||||||
|
import com.runicgateway.app.data.api.dto.BanRequest
|
||||||
|
import com.runicgateway.app.data.api.dto.BroadcastRequest
|
||||||
|
import com.runicgateway.app.data.api.dto.KickRequest
|
||||||
|
import com.runicgateway.app.data.api.dto.PageRespondRequest
|
||||||
|
import com.runicgateway.app.data.api.dto.PostCreateRequest
|
||||||
|
import com.runicgateway.app.data.api.dto.PublishRequest
|
||||||
|
import com.runicgateway.app.data.api.dto.SiteModeRequest
|
||||||
|
import com.runicgateway.app.data.api.dto.SiteModeStateDto
|
||||||
|
import com.runicgateway.app.data.api.dto.SupportPageDto
|
||||||
|
import com.runicgateway.app.data.api.dto.UnbanRequest
|
||||||
|
import com.runicgateway.app.data.api.dto.AdminWikiCategoryDto
|
||||||
|
import com.runicgateway.app.data.api.dto.WikiCategoryRequest
|
||||||
|
import com.runicgateway.app.data.api.dto.AdminWikiTagDto
|
||||||
|
import retrofit2.HttpException
|
||||||
|
import retrofit2.Response
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The M10 staff-operations data source over `/api/v1/admin/…` (PLAN.md §1, §6.4).
|
||||||
|
* Every call returns a typed [ApiResult] so a screen renders a clean error/retry
|
||||||
|
* rather than crashing — a `403` (role lost since the menu rendered) and a `503`
|
||||||
|
* (shard/sidecar offline for the shard-write actions) are both expected outcomes
|
||||||
|
* the UI handles, never thrown. Role is authoritative on the server.
|
||||||
|
*/
|
||||||
|
@Singleton
|
||||||
|
class AdminRepository @Inject constructor(
|
||||||
|
private val api: AdminApi,
|
||||||
|
) {
|
||||||
|
suspend fun dashboard(): ApiResult<AdminDashboardDto> = safeApiCall { api.dashboard() }
|
||||||
|
|
||||||
|
suspend fun setSiteMode(mode: String): ApiResult<SiteModeStateDto> =
|
||||||
|
safeApiCall { api.setSiteMode(SiteModeRequest(mode)) }
|
||||||
|
|
||||||
|
// ── Content: news posts ───────────────────────────────────────────────
|
||||||
|
suspend fun posts(): ApiResult<List<AdminPostDto>> = safeApiCall { api.posts() }
|
||||||
|
|
||||||
|
suspend fun createPost(body: PostCreateRequest): ApiResult<AdminPostDto> =
|
||||||
|
safeApiCall { api.createPost(body) }
|
||||||
|
|
||||||
|
suspend fun setPostPublished(id: Long, published: Boolean): ApiResult<AdminPostDto> =
|
||||||
|
safeApiCall { api.publishPost(id, PublishRequest(published)) }
|
||||||
|
|
||||||
|
suspend fun deletePost(id: Long): ApiResult<Unit> = safeApiCall { api.deletePost(id).requireOk() }
|
||||||
|
|
||||||
|
// ── Content: wiki taxonomy ────────────────────────────────────────────
|
||||||
|
suspend fun wikiCategories(): ApiResult<List<AdminWikiCategoryDto>> = safeApiCall { api.wikiCategories() }
|
||||||
|
|
||||||
|
suspend fun createWikiCategory(body: WikiCategoryRequest): ApiResult<AdminWikiCategoryDto> =
|
||||||
|
safeApiCall { api.createWikiCategory(body) }
|
||||||
|
|
||||||
|
suspend fun deleteWikiCategory(id: Long): ApiResult<Unit> =
|
||||||
|
safeApiCall { api.deleteWikiCategory(id).requireOk() }
|
||||||
|
|
||||||
|
suspend fun wikiTags(): ApiResult<List<AdminWikiTagDto>> = safeApiCall { api.wikiTags() }
|
||||||
|
|
||||||
|
// ── Moderation: shard write plane ─────────────────────────────────────
|
||||||
|
suspend fun kick(account: String?, serial: String?): ApiResult<Unit> =
|
||||||
|
safeApiCall { api.kick(KickRequest(account, serial)).requireOk() }
|
||||||
|
|
||||||
|
suspend fun ban(account: String?, serial: String?, durationSec: Long?, reason: String?): ApiResult<Unit> =
|
||||||
|
safeApiCall { api.ban(BanRequest(account, serial, durationSec, reason)).requireOk() }
|
||||||
|
|
||||||
|
suspend fun unban(account: String): ApiResult<Unit> =
|
||||||
|
safeApiCall { api.unban(UnbanRequest(account)).requireOk() }
|
||||||
|
|
||||||
|
suspend fun broadcast(text: String, hue: Int?): ApiResult<Unit> =
|
||||||
|
safeApiCall { api.broadcast(BroadcastRequest(text, hue)).requireOk() }
|
||||||
|
|
||||||
|
// ── Support queue: help pages ─────────────────────────────────────────
|
||||||
|
suspend fun supportPages(): ApiResult<List<SupportPageDto>> = safeApiCall { api.supportPages() }
|
||||||
|
|
||||||
|
suspend fun respondPage(id: String, message: String, close: Boolean): ApiResult<Unit> =
|
||||||
|
safeApiCall { api.respondPage(id, PageRespondRequest(message, close)).requireOk() }
|
||||||
|
|
||||||
|
suspend fun closePage(id: String): ApiResult<Unit> =
|
||||||
|
safeApiCall { api.closePage(id).requireOk() }
|
||||||
|
|
||||||
|
/** Turn a bodyless [Response] into a thrown [HttpException] on a non-2xx, so
|
||||||
|
* [safeApiCall] can fold it into an [ApiResult.HttpError] like every other call. */
|
||||||
|
private fun Response<Unit>.requireOk() {
|
||||||
|
if (!isSuccessful) throw HttpException(this)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ import com.runicgateway.app.data.api.dto.MobileTokenResponse
|
|||||||
import com.runicgateway.app.data.api.dto.SsoProviderDto
|
import com.runicgateway.app.data.api.dto.SsoProviderDto
|
||||||
import com.runicgateway.app.data.api.dto.TotpRequiredError
|
import com.runicgateway.app.data.api.dto.TotpRequiredError
|
||||||
import kotlinx.coroutines.CancellationException
|
import kotlinx.coroutines.CancellationException
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
import retrofit2.Response
|
import retrofit2.Response
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
@@ -34,17 +35,40 @@ class AuthRepository @Inject constructor(
|
|||||||
private val json: Json,
|
private val json: Json,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
|
/** The three outcomes of SSO provider discovery, so the login screen can tell a
|
||||||
|
* shard that offers no SSO ([None]) apart from a discovery that failed
|
||||||
|
* ([Unavailable], offer a retry) — the old "empty on any failure" conflation hid
|
||||||
|
* a broken call behind a dead website hand-off (§4.2). */
|
||||||
|
sealed interface SsoDiscovery {
|
||||||
|
/** At least one enabled provider — render a native button per entry. */
|
||||||
|
data class Available(val providers: List<SsoProviderDto>) : SsoDiscovery
|
||||||
|
|
||||||
|
/** Discovery succeeded but the shard has no SSO providers configured. */
|
||||||
|
data object None : SsoDiscovery
|
||||||
|
|
||||||
|
/** The discovery call failed (offline / server error) — surface a retry. */
|
||||||
|
data object Unavailable : SsoDiscovery
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The shard's enabled SSO providers for the native login buttons (§4.2). Public
|
* Discover the shard's enabled SSO providers for the native login buttons (§4.2).
|
||||||
* discovery, never secrets. Returns an empty list on any failure — the login
|
* Public discovery, never secrets. Retries once before reporting [Unavailable],
|
||||||
* screen then keeps the website hand-off fallback rather than showing nothing.
|
* so a single transient blip doesn't strand the user.
|
||||||
*/
|
*/
|
||||||
suspend fun ssoProviders(): List<SsoProviderDto> = try {
|
suspend fun ssoProviders(): SsoDiscovery {
|
||||||
ssoApi.providers()
|
var lastFailed = false
|
||||||
} catch (e: CancellationException) {
|
repeat(2) { attempt ->
|
||||||
throw e
|
try {
|
||||||
} catch (_: Exception) {
|
val providers = ssoApi.providers()
|
||||||
emptyList()
|
return if (providers.isEmpty()) SsoDiscovery.None else SsoDiscovery.Available(providers)
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
throw e
|
||||||
|
} catch (_: Exception) {
|
||||||
|
lastFailed = true
|
||||||
|
if (attempt == 0) delay(DISCOVERY_RETRY_DELAY_MS)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return if (lastFailed) SsoDiscovery.Unavailable else SsoDiscovery.None
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Outcome of a login attempt (§4.1). */
|
/** Outcome of a login attempt (§4.1). */
|
||||||
@@ -140,4 +164,8 @@ class AuthRepository @Inject constructor(
|
|||||||
} catch (_: Exception) {
|
} catch (_: Exception) {
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val DISCOVERY_RETRY_DELAY_MS = 400L
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import com.runicgateway.app.core.net.UserAgentInterceptor
|
|||||||
import com.runicgateway.app.data.api.AuthApi
|
import com.runicgateway.app.data.api.AuthApi
|
||||||
import com.runicgateway.app.data.api.AuthRefreshApi
|
import com.runicgateway.app.data.api.AuthRefreshApi
|
||||||
import com.runicgateway.app.data.api.MeApi
|
import com.runicgateway.app.data.api.MeApi
|
||||||
|
import com.runicgateway.app.data.api.AdminApi
|
||||||
import com.runicgateway.app.data.api.NotificationsApi
|
import com.runicgateway.app.data.api.NotificationsApi
|
||||||
import com.runicgateway.app.data.api.PlayerShardApi
|
import com.runicgateway.app.data.api.PlayerShardApi
|
||||||
import com.runicgateway.app.data.api.PublicApi
|
import com.runicgateway.app.data.api.PublicApi
|
||||||
@@ -119,6 +120,11 @@ object NetworkModule {
|
|||||||
fun provideNotificationsApi(retrofit: Retrofit): NotificationsApi =
|
fun provideNotificationsApi(retrofit: Retrofit): NotificationsApi =
|
||||||
retrofit.create(NotificationsApi::class.java)
|
retrofit.create(NotificationsApi::class.java)
|
||||||
|
|
||||||
|
/** Staff operations (§1, §6.4, M10) — bearer-authed; the server re-checks role every call. */
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun provideAdminApi(retrofit: Retrofit): AdminApi = retrofit.create(AdminApi::class.java)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Token refresh runs on its own **bare** client — UA + host retargeting only,
|
* Token refresh runs on its own **bare** client — UA + host retargeting only,
|
||||||
* no auth interceptor and no authenticator — so a refresh can never recurse
|
* no auth interceptor and no authenticator — so a refresh can never recurse
|
||||||
|
|||||||
@@ -5,13 +5,15 @@ package com.runicgateway.app.di
|
|||||||
|
|
||||||
import com.runicgateway.app.core.auth.EncryptedTokenStore
|
import com.runicgateway.app.core.auth.EncryptedTokenStore
|
||||||
import com.runicgateway.app.core.auth.TokenStore
|
import com.runicgateway.app.core.auth.TokenStore
|
||||||
|
import com.runicgateway.app.core.auth.sso.EncryptedPendingSsoStore
|
||||||
|
import com.runicgateway.app.core.auth.sso.PendingSsoStore
|
||||||
import dagger.Binds
|
import dagger.Binds
|
||||||
import dagger.Module
|
import dagger.Module
|
||||||
import dagger.hilt.InstallIn
|
import dagger.hilt.InstallIn
|
||||||
import dagger.hilt.components.SingletonComponent
|
import dagger.hilt.components.SingletonComponent
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
|
||||||
/** Binds the at-rest token store to its EncryptedSharedPreferences impl (§4.3). */
|
/** Binds the at-rest stores to their EncryptedSharedPreferences impls (§4.3). */
|
||||||
@Module
|
@Module
|
||||||
@InstallIn(SingletonComponent::class)
|
@InstallIn(SingletonComponent::class)
|
||||||
abstract class StorageModule {
|
abstract class StorageModule {
|
||||||
@@ -19,4 +21,8 @@ abstract class StorageModule {
|
|||||||
@Binds
|
@Binds
|
||||||
@Singleton
|
@Singleton
|
||||||
abstract fun bindTokenStore(impl: EncryptedTokenStore): TokenStore
|
abstract fun bindTokenStore(impl: EncryptedTokenStore): TokenStore
|
||||||
|
|
||||||
|
@Binds
|
||||||
|
@Singleton
|
||||||
|
abstract fun bindPendingSsoStore(impl: EncryptedPendingSsoStore): PendingSsoStore
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,9 +3,12 @@
|
|||||||
*/
|
*/
|
||||||
package com.runicgateway.app.ui
|
package com.runicgateway.app.ui
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
import androidx.compose.material.icons.filled.Menu
|
import androidx.compose.material.icons.filled.Menu
|
||||||
@@ -56,6 +59,10 @@ import com.runicgateway.app.ui.navigation.Routes
|
|||||||
import com.runicgateway.app.ui.navigation.visibleEntries
|
import com.runicgateway.app.ui.navigation.visibleEntries
|
||||||
import com.runicgateway.app.ui.news.NewsScreen
|
import com.runicgateway.app.ui.news.NewsScreen
|
||||||
import com.runicgateway.app.ui.news.PostScreen
|
import com.runicgateway.app.ui.news.PostScreen
|
||||||
|
import com.runicgateway.app.ui.admin.AdminContentScreen
|
||||||
|
import com.runicgateway.app.ui.admin.AdminDashboardScreen
|
||||||
|
import com.runicgateway.app.ui.admin.AdminModerationScreen
|
||||||
|
import com.runicgateway.app.ui.admin.AdminSupportScreen
|
||||||
import com.runicgateway.app.ui.notifications.NotificationsScreen
|
import com.runicgateway.app.ui.notifications.NotificationsScreen
|
||||||
import com.runicgateway.app.ui.page.PageScreen
|
import com.runicgateway.app.ui.page.PageScreen
|
||||||
import com.runicgateway.app.ui.player.CharacterSheetScreen
|
import com.runicgateway.app.ui.player.CharacterSheetScreen
|
||||||
@@ -78,6 +85,7 @@ private val TOP_LEVEL_ROUTES = setOf(
|
|||||||
Routes.HOME, Routes.NEWS, Routes.WIKI, Routes.SHARD, Routes.CONTACT, Routes.PAGE, Routes.ACCOUNT,
|
Routes.HOME, Routes.NEWS, Routes.WIKI, Routes.SHARD, Routes.CONTACT, Routes.PAGE, Routes.ACCOUNT,
|
||||||
Routes.NOTIFICATIONS,
|
Routes.NOTIFICATIONS,
|
||||||
Routes.PLAYER_CHARACTERS, Routes.PLAYER_VENDORS, Routes.PLAYER_HOUSES,
|
Routes.PLAYER_CHARACTERS, Routes.PLAYER_VENDORS, Routes.PLAYER_HOUSES,
|
||||||
|
Routes.ADMIN_DASHBOARD, Routes.ADMIN_CONTENT, Routes.ADMIN_MODERATION, Routes.ADMIN_SUPPORT,
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -134,6 +142,11 @@ fun RunicApp(
|
|||||||
selectedTextColor = MaterialTheme.colorScheme.onSecondaryContainer,
|
selectedTextColor = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||||
unselectedTextColor = MaterialTheme.colorScheme.onSurface,
|
unselectedTextColor = MaterialTheme.colorScheme.onSurface,
|
||||||
)
|
)
|
||||||
|
// Scroll the drawer: a signed-in session adds Account, Notifications, and
|
||||||
|
// the player groups, and the full list overflows a phone's drawer height —
|
||||||
|
// without this the lower entries (Notifications included) are clipped and
|
||||||
|
// unreachable. See RunicGateway M10.
|
||||||
|
Column(Modifier.verticalScroll(rememberScrollState())) {
|
||||||
Spacer(Modifier.height(12.dp))
|
Spacer(Modifier.height(12.dp))
|
||||||
Text(
|
Text(
|
||||||
text = brand?.name?.takeIf { it.isNotBlank() } ?: stringResource(R.string.app_name),
|
text = brand?.name?.takeIf { it.isNotBlank() } ?: stringResource(R.string.app_name),
|
||||||
@@ -189,6 +202,7 @@ fun RunicApp(
|
|||||||
colors = drawerItemColors,
|
colors = drawerItemColors,
|
||||||
modifier = Modifier.padding(NavigationDrawerItemDefaults.ItemPadding),
|
modifier = Modifier.padding(NavigationDrawerItemDefaults.ItemPadding),
|
||||||
)
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
@@ -306,7 +320,15 @@ private fun RunicNavHost(
|
|||||||
ContactScreen()
|
ContactScreen()
|
||||||
}
|
}
|
||||||
composable(Routes.LOGIN) {
|
composable(Routes.LOGIN) {
|
||||||
LoginScreen(onSignedIn = { navController.popBackStack() })
|
// Leave the login screen as soon as the session is established — whether by
|
||||||
|
// password or the SSO bridge. Keying off the shared session (not just the
|
||||||
|
// login VM's local flag) makes this robust to the deep-link/recomposition
|
||||||
|
// timing of the Custom-Tab return, which the LoginScreen callback alone can miss.
|
||||||
|
if (session is Session.SignedIn) {
|
||||||
|
LaunchedEffect(Unit) { navController.popBackStack(Routes.LOGIN, inclusive = true) }
|
||||||
|
} else {
|
||||||
|
LoginScreen(onSignedIn = { navController.popBackStack() })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
composable(Routes.ACCOUNT) {
|
composable(Routes.ACCOUNT) {
|
||||||
// Only meaningful while signed in; a sign-out (here or from the drawer)
|
// Only meaningful while signed in; a sign-out (here or from the drawer)
|
||||||
@@ -352,6 +374,24 @@ private fun RunicNavHost(
|
|||||||
composable(Routes.PLAYER_HOUSES) {
|
composable(Routes.PLAYER_HOUSES) {
|
||||||
PlayerGate(session, navController) { MyHousesScreen() }
|
PlayerGate(session, navController) { MyHousesScreen() }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Staff operations (§1, §6.4, M10) — reached from the staff menu section.
|
||||||
|
// The backend re-checks role on every /admin/… call; these gates only mirror
|
||||||
|
// the menu's visibility so a signed-out/demoted user isn't left on a stale screen.
|
||||||
|
composable(Routes.ADMIN_DASHBOARD) {
|
||||||
|
StaffGate(session, navController) {
|
||||||
|
AdminDashboardScreen(isAdmin = (session as? Session.SignedIn)?.user?.isAdmin == true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
composable(Routes.ADMIN_CONTENT) {
|
||||||
|
StaffGate(session, navController) { AdminContentScreen() }
|
||||||
|
}
|
||||||
|
composable(Routes.ADMIN_MODERATION) {
|
||||||
|
StaffGate(session, navController, require = { it.isModerator }) { AdminModerationScreen() }
|
||||||
|
}
|
||||||
|
composable(Routes.ADMIN_SUPPORT) {
|
||||||
|
StaffGate(session, navController, require = { it.isModerator }) { AdminSupportScreen() }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -373,6 +413,23 @@ private fun PlayerGate(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The staff-operations analogue of [PlayerGate] (§1, M10): render [content] only for
|
||||||
|
* a signed-in staff account; a signed-out/demoted session (caught on resume, §4.3) is
|
||||||
|
* sent home rather than left on a stale admin screen. The backend is the authority —
|
||||||
|
* every `/admin/…` call re-checks role — so this only mirrors the menu's visibility.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun StaffGate(
|
||||||
|
session: Session,
|
||||||
|
navController: NavHostController,
|
||||||
|
require: (com.runicgateway.app.core.auth.SessionUser) -> Boolean = { it.isStaff },
|
||||||
|
content: @Composable () -> Unit,
|
||||||
|
) {
|
||||||
|
val ok = (session as? Session.SignedIn)?.user?.let(require) == true
|
||||||
|
if (ok) content() else LaunchedEffect(Unit) { navController.navigateTopLevel(Routes.HOME) }
|
||||||
|
}
|
||||||
|
|
||||||
/** Navigate to a top-level menu destination: single instance, reset to it. */
|
/** Navigate to a top-level menu destination: single instance, reset to it. */
|
||||||
private fun NavHostController.navigateTopLevel(route: String) {
|
private fun NavHostController.navigateTopLevel(route: String) {
|
||||||
navigate(route) {
|
navigate(route) {
|
||||||
|
|||||||
@@ -0,0 +1,292 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
package com.runicgateway.app.ui.admin
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.Card
|
||||||
|
import androidx.compose.material3.FilterChip
|
||||||
|
import androidx.compose.material3.HorizontalDivider
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedButton
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.Switch
|
||||||
|
import androidx.compose.material3.Tab
|
||||||
|
import androidx.compose.material3.TabRow
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import com.runicgateway.app.R
|
||||||
|
import com.runicgateway.app.data.api.dto.AdminPostDto
|
||||||
|
import com.runicgateway.app.data.api.dto.AdminWikiCategoryDto
|
||||||
|
import com.runicgateway.app.data.api.dto.AdminWikiTagDto
|
||||||
|
import com.runicgateway.app.ui.UiState
|
||||||
|
import com.runicgateway.app.ui.components.ErrorView
|
||||||
|
import com.runicgateway.app.ui.components.LoadingView
|
||||||
|
import com.runicgateway.app.ui.components.PillTone
|
||||||
|
import com.runicgateway.app.ui.components.StatusPill
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The staff content screen (PLAN.md §1, M10): news posts and wiki taxonomy, in two
|
||||||
|
* tabs. Create/publish/delete over the existing `/admin/posts` + `/admin/wiki/…`
|
||||||
|
* routes; the CMS block/hero editor stays out of scope. Any staff role; the server
|
||||||
|
* re-checks on every call.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun AdminContentScreen(
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
viewModel: AdminContentViewModel = hiltViewModel(),
|
||||||
|
) {
|
||||||
|
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||||
|
var tab by rememberSaveable { mutableIntStateOf(0) }
|
||||||
|
var showNewPost by rememberSaveable { mutableStateOf(false) }
|
||||||
|
var showNewCategory by rememberSaveable { mutableStateOf(false) }
|
||||||
|
|
||||||
|
Column(modifier.fillMaxSize()) {
|
||||||
|
TabRow(selectedTabIndex = tab) {
|
||||||
|
Tab(selected = tab == 0, onClick = { tab = 0 }, text = { Text(stringResource(R.string.admin_content_tab_posts)) })
|
||||||
|
Tab(selected = tab == 1, onClick = { tab = 1 }, text = { Text(stringResource(R.string.admin_content_tab_wiki)) })
|
||||||
|
}
|
||||||
|
|
||||||
|
state.feedback?.let {
|
||||||
|
Text(
|
||||||
|
text = stringResource(it.messageRes),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = if (it.ok) MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.error,
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 6.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
when (tab) {
|
||||||
|
0 -> PostsTab(
|
||||||
|
state = state.posts,
|
||||||
|
busy = state.busy,
|
||||||
|
onNew = { showNewPost = true },
|
||||||
|
onToggle = viewModel::togglePublish,
|
||||||
|
onDelete = viewModel::deletePost,
|
||||||
|
onRetry = viewModel::loadPosts,
|
||||||
|
)
|
||||||
|
else -> WikiTab(
|
||||||
|
state = state.categories,
|
||||||
|
tags = state.tags,
|
||||||
|
busy = state.busy,
|
||||||
|
onNew = { showNewCategory = true },
|
||||||
|
onDelete = viewModel::deleteCategory,
|
||||||
|
onRetry = viewModel::loadWiki,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showNewPost) {
|
||||||
|
NewPostDialog(
|
||||||
|
categories = viewModel.postCategories,
|
||||||
|
onDismiss = { showNewPost = false },
|
||||||
|
onCreate = { cat, title, excerpt, body, published ->
|
||||||
|
viewModel.createPost(cat, title, excerpt, body, published)
|
||||||
|
showNewPost = false
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (showNewCategory) {
|
||||||
|
NewCategoryDialog(
|
||||||
|
onDismiss = { showNewCategory = false },
|
||||||
|
onCreate = { slug, title, desc, sort ->
|
||||||
|
viewModel.createCategory(slug, title, desc, sort)
|
||||||
|
showNewCategory = false
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun PostsTab(
|
||||||
|
state: UiState<List<AdminPostDto>>,
|
||||||
|
busy: Boolean,
|
||||||
|
onNew: () -> Unit,
|
||||||
|
onToggle: (AdminPostDto) -> Unit,
|
||||||
|
onDelete: (Long) -> Unit,
|
||||||
|
onRetry: () -> Unit,
|
||||||
|
) {
|
||||||
|
when (state) {
|
||||||
|
is UiState.Loading -> LoadingView()
|
||||||
|
is UiState.Error -> ErrorView(state.kind, onRetry = onRetry)
|
||||||
|
is UiState.Success -> LazyColumn(Modifier.fillMaxSize().padding(16.dp)) {
|
||||||
|
item {
|
||||||
|
OutlinedButton(onClick = onNew, enabled = !busy, modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp)) {
|
||||||
|
Text(stringResource(R.string.admin_content_new_post))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
items(state.data, key = { it.id }) { post ->
|
||||||
|
Card(Modifier.fillMaxWidth().padding(vertical = 6.dp)) {
|
||||||
|
Column(Modifier.padding(12.dp)) {
|
||||||
|
Text(post.title, style = MaterialTheme.typography.bodyLarge)
|
||||||
|
Spacer(Modifier.height(4.dp))
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
StatusPill(
|
||||||
|
text = if (post.isPublished) stringResource(R.string.admin_content_published)
|
||||||
|
else stringResource(R.string.admin_content_draft),
|
||||||
|
tone = if (post.isPublished) PillTone.Success else PillTone.Neutral,
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
Text(post.category, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
}
|
||||||
|
Row(Modifier.fillMaxWidth().padding(top = 8.dp), horizontalArrangement = Arrangement.End) {
|
||||||
|
TextButton(onClick = { onToggle(post) }, enabled = !busy) {
|
||||||
|
Text(
|
||||||
|
stringResource(
|
||||||
|
if (post.isPublished) R.string.admin_content_unpublish else R.string.admin_content_publish,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
TextButton(onClick = { onDelete(post.id) }, enabled = !busy) {
|
||||||
|
Text(stringResource(R.string.admin_content_delete), color = MaterialTheme.colorScheme.error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun WikiTab(
|
||||||
|
state: UiState<List<AdminWikiCategoryDto>>,
|
||||||
|
tags: List<AdminWikiTagDto>,
|
||||||
|
busy: Boolean,
|
||||||
|
onNew: () -> Unit,
|
||||||
|
onDelete: (Long) -> Unit,
|
||||||
|
onRetry: () -> Unit,
|
||||||
|
) {
|
||||||
|
when (state) {
|
||||||
|
is UiState.Loading -> LoadingView()
|
||||||
|
is UiState.Error -> ErrorView(state.kind, onRetry = onRetry)
|
||||||
|
is UiState.Success -> LazyColumn(Modifier.fillMaxSize().padding(16.dp)) {
|
||||||
|
item {
|
||||||
|
OutlinedButton(onClick = onNew, enabled = !busy, modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp)) {
|
||||||
|
Text(stringResource(R.string.admin_content_new_category))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
items(state.data, key = { it.id }) { cat ->
|
||||||
|
Card(Modifier.fillMaxWidth().padding(vertical = 6.dp)) {
|
||||||
|
Column(Modifier.padding(12.dp)) {
|
||||||
|
Text(cat.title, style = MaterialTheme.typography.bodyLarge)
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.admin_content_cat_meta, cat.slug, cat.pageCount ?: 0),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
Row(Modifier.fillMaxWidth().padding(top = 8.dp), horizontalArrangement = Arrangement.End) {
|
||||||
|
TextButton(onClick = { onDelete(cat.id) }, enabled = !busy) {
|
||||||
|
Text(stringResource(R.string.admin_content_delete), color = MaterialTheme.colorScheme.error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (tags.isNotEmpty()) {
|
||||||
|
item {
|
||||||
|
HorizontalDivider(Modifier.padding(vertical = 12.dp))
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.admin_content_tags, tags.joinToString(", ") { it.label }),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun NewPostDialog(
|
||||||
|
categories: List<String>,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
onCreate: (category: String, title: String, excerpt: String, body: String, published: Boolean) -> Unit,
|
||||||
|
) {
|
||||||
|
var category by rememberSaveable { mutableStateOf(categories.first()) }
|
||||||
|
var title by rememberSaveable { mutableStateOf("") }
|
||||||
|
var excerpt by rememberSaveable { mutableStateOf("") }
|
||||||
|
var body by rememberSaveable { mutableStateOf("") }
|
||||||
|
var published by rememberSaveable { mutableStateOf(false) }
|
||||||
|
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = { onCreate(category, title, excerpt, body, published) }) {
|
||||||
|
Text(stringResource(R.string.admin_content_create))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.action_cancel)) } },
|
||||||
|
title = { Text(stringResource(R.string.admin_content_new_post)) },
|
||||||
|
text = {
|
||||||
|
Column {
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||||
|
categories.forEach { c ->
|
||||||
|
FilterChip(selected = category == c, onClick = { category = c }, label = { Text(c) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
OutlinedTextField(value = title, onValueChange = { title = it }, singleLine = true, label = { Text(stringResource(R.string.admin_content_field_title)) }, modifier = Modifier.fillMaxWidth().padding(top = 8.dp))
|
||||||
|
OutlinedTextField(value = excerpt, onValueChange = { excerpt = it }, label = { Text(stringResource(R.string.admin_content_field_excerpt)) }, modifier = Modifier.fillMaxWidth().padding(top = 8.dp))
|
||||||
|
OutlinedTextField(value = body, onValueChange = { body = it }, label = { Text(stringResource(R.string.admin_content_field_body)) }, modifier = Modifier.fillMaxWidth().padding(top = 8.dp))
|
||||||
|
Row(Modifier.fillMaxWidth().padding(top = 8.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Text(stringResource(R.string.admin_content_publish_now), modifier = Modifier.weight(1f))
|
||||||
|
Switch(checked = published, onCheckedChange = { published = it })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun NewCategoryDialog(
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
onCreate: (slug: String, title: String, description: String, sortOrder: Int?) -> Unit,
|
||||||
|
) {
|
||||||
|
var slug by rememberSaveable { mutableStateOf("") }
|
||||||
|
var title by rememberSaveable { mutableStateOf("") }
|
||||||
|
var description by rememberSaveable { mutableStateOf("") }
|
||||||
|
var sort by rememberSaveable { mutableStateOf("") }
|
||||||
|
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = { onCreate(slug, title, description, sort.toIntOrNull()) }) {
|
||||||
|
Text(stringResource(R.string.admin_content_create))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.action_cancel)) } },
|
||||||
|
title = { Text(stringResource(R.string.admin_content_new_category)) },
|
||||||
|
text = {
|
||||||
|
Column {
|
||||||
|
OutlinedTextField(value = slug, onValueChange = { slug = it }, singleLine = true, label = { Text(stringResource(R.string.admin_content_field_slug)) }, modifier = Modifier.fillMaxWidth())
|
||||||
|
OutlinedTextField(value = title, onValueChange = { title = it }, singleLine = true, label = { Text(stringResource(R.string.admin_content_field_title)) }, modifier = Modifier.fillMaxWidth().padding(top = 8.dp))
|
||||||
|
OutlinedTextField(value = description, onValueChange = { description = it }, label = { Text(stringResource(R.string.admin_content_field_description)) }, modifier = Modifier.fillMaxWidth().padding(top = 8.dp))
|
||||||
|
OutlinedTextField(value = sort, onValueChange = { sort = it.filter(Char::isDigit) }, singleLine = true, label = { Text(stringResource(R.string.admin_content_field_sort)) }, modifier = Modifier.fillMaxWidth().padding(top = 8.dp))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
package com.runicgateway.app.ui.admin
|
||||||
|
|
||||||
|
import androidx.annotation.StringRes
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.runicgateway.app.R
|
||||||
|
import com.runicgateway.app.core.result.ApiResult
|
||||||
|
import com.runicgateway.app.data.api.dto.AdminPostDto
|
||||||
|
import com.runicgateway.app.data.api.dto.PostCreateRequest
|
||||||
|
import com.runicgateway.app.data.api.dto.AdminWikiCategoryDto
|
||||||
|
import com.runicgateway.app.data.api.dto.WikiCategoryRequest
|
||||||
|
import com.runicgateway.app.data.api.dto.AdminWikiTagDto
|
||||||
|
import com.runicgateway.app.data.repository.AdminRepository
|
||||||
|
import com.runicgateway.app.ui.UiState
|
||||||
|
import com.runicgateway.app.ui.toUiState
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drives the staff content screen (PLAN.md §1, M10): news posts (list, create,
|
||||||
|
* publish/unpublish, delete) and wiki taxonomy (list categories/tags, create/delete
|
||||||
|
* category). Any staff role reaches these (`staffOnly`); the full CMS block/hero
|
||||||
|
* editor stays out of scope. Reads go through the typed [AdminRepository] (§7).
|
||||||
|
*/
|
||||||
|
@HiltViewModel
|
||||||
|
class AdminContentViewModel @Inject constructor(
|
||||||
|
private val admin: AdminRepository,
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
/** The valid URL categories the backend maps (posts.model CATEGORY_MAP keys). */
|
||||||
|
val postCategories = listOf("news", "five-on-friday", "newsletter", "screenshots")
|
||||||
|
|
||||||
|
data class Feedback(val ok: Boolean, @param:StringRes val messageRes: Int)
|
||||||
|
|
||||||
|
data class State(
|
||||||
|
val posts: UiState<List<AdminPostDto>> = UiState.Loading,
|
||||||
|
val categories: UiState<List<AdminWikiCategoryDto>> = UiState.Loading,
|
||||||
|
val tags: List<AdminWikiTagDto> = emptyList(),
|
||||||
|
val busy: Boolean = false,
|
||||||
|
val feedback: Feedback? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
private val _state = MutableStateFlow(State())
|
||||||
|
val state: StateFlow<State> = _state.asStateFlow()
|
||||||
|
|
||||||
|
init {
|
||||||
|
loadPosts()
|
||||||
|
loadWiki()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clearFeedback() = _state.update { it.copy(feedback = null) }
|
||||||
|
|
||||||
|
fun loadPosts() {
|
||||||
|
_state.update { it.copy(posts = UiState.Loading) }
|
||||||
|
viewModelScope.launch { _state.update { it.copy(posts = admin.posts().toUiState()) } }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun loadWiki() {
|
||||||
|
_state.update { it.copy(categories = UiState.Loading) }
|
||||||
|
viewModelScope.launch {
|
||||||
|
_state.update { it.copy(categories = admin.wikiCategories().toUiState()) }
|
||||||
|
when (val tags = admin.wikiTags()) {
|
||||||
|
is ApiResult.Ok -> _state.update { it.copy(tags = tags.data) }
|
||||||
|
else -> Unit // tags are secondary; leave the last list on a failure
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun togglePublish(post: AdminPostDto) = mutate(onSuccess = ::loadPosts) {
|
||||||
|
admin.setPostPublished(post.id, !post.isPublished).asFeedback(R.string.admin_content_post_updated)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun deletePost(id: Long) = mutate(onSuccess = ::loadPosts) {
|
||||||
|
admin.deletePost(id).asFeedback(R.string.admin_content_post_deleted)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun createPost(category: String, title: String, excerpt: String, body: String, published: Boolean) {
|
||||||
|
if (title.isBlank()) {
|
||||||
|
_state.update { it.copy(feedback = Feedback(false, R.string.admin_content_title_required)) }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
mutate(onSuccess = ::loadPosts) {
|
||||||
|
admin.createPost(
|
||||||
|
PostCreateRequest(
|
||||||
|
category = category,
|
||||||
|
title = title.trim(),
|
||||||
|
excerpt = excerpt.ifBlank { null },
|
||||||
|
body = body.ifBlank { null },
|
||||||
|
published = published,
|
||||||
|
),
|
||||||
|
).asFeedback(R.string.admin_content_post_created)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun createCategory(slug: String, title: String, description: String, sortOrder: Int?) {
|
||||||
|
if (slug.isBlank() || title.isBlank()) {
|
||||||
|
_state.update { it.copy(feedback = Feedback(false, R.string.admin_content_cat_fields_required)) }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
mutate(onSuccess = ::loadWiki) {
|
||||||
|
admin.createWikiCategory(
|
||||||
|
WikiCategoryRequest(slug.trim(), title.trim(), description.ifBlank { null }, sortOrder),
|
||||||
|
).asFeedback(R.string.admin_content_cat_created)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun deleteCategory(id: Long) = mutate(onSuccess = ::loadWiki) {
|
||||||
|
admin.deleteWikiCategory(id).asFeedback(R.string.admin_content_cat_deleted)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Shared mutation plumbing ──────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Run a write: set busy + clear feedback, then on completion set the feedback
|
||||||
|
* banner and, only if it succeeded, run [onSuccess] (a targeted reload). */
|
||||||
|
private fun mutate(onSuccess: () -> Unit = {}, block: suspend () -> Feedback) {
|
||||||
|
if (_state.value.busy) return
|
||||||
|
_state.update { it.copy(busy = true, feedback = null) }
|
||||||
|
viewModelScope.launch {
|
||||||
|
val feedback = block()
|
||||||
|
if (feedback.ok) onSuccess()
|
||||||
|
_state.update { it.copy(busy = false, feedback = feedback) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Map an [ApiResult] to a [Feedback], with role/permission-aware failure copy. */
|
||||||
|
private fun ApiResult<*>.asFeedback(@StringRes okRes: Int): Feedback = when (this) {
|
||||||
|
is ApiResult.Ok -> Feedback(true, okRes)
|
||||||
|
is ApiResult.HttpError ->
|
||||||
|
Feedback(false, if (status == 403) R.string.admin_forbidden else R.string.admin_action_failed)
|
||||||
|
is ApiResult.NetworkError -> Feedback(false, R.string.error_network)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
package com.runicgateway.app.ui.admin
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import com.runicgateway.app.R
|
||||||
|
import com.runicgateway.app.data.api.dto.AdminDashboardDto
|
||||||
|
import com.runicgateway.app.ui.UiState
|
||||||
|
import com.runicgateway.app.ui.components.ErrorView
|
||||||
|
import com.runicgateway.app.ui.components.LoadingView
|
||||||
|
import com.runicgateway.app.ui.components.PillTone
|
||||||
|
import com.runicgateway.app.ui.components.SectionLabel
|
||||||
|
import com.runicgateway.app.ui.components.StatusPill
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The staff dashboard (PLAN.md §1, M10): site mode + a site-mode toggle (admins
|
||||||
|
* only), summary counts, and recent admin activity. Read-only for moderators/editors;
|
||||||
|
* only [isAdmin] callers see the maintenance switch, and the server enforces it too.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun AdminDashboardScreen(
|
||||||
|
isAdmin: Boolean,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
viewModel: AdminDashboardViewModel = hiltViewModel(),
|
||||||
|
) {
|
||||||
|
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
|
when (val ds = state.dashboard) {
|
||||||
|
is UiState.Loading -> LoadingView(modifier)
|
||||||
|
is UiState.Error -> ErrorView(ds.kind, onRetry = viewModel::load, modifier = modifier)
|
||||||
|
is UiState.Success -> DashboardContent(
|
||||||
|
data = ds.data,
|
||||||
|
isAdmin = isAdmin,
|
||||||
|
switching = state.switching,
|
||||||
|
feedbackRes = state.feedback?.messageRes,
|
||||||
|
onSetMode = viewModel::setSiteMode,
|
||||||
|
modifier = modifier,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun DashboardContent(
|
||||||
|
data: AdminDashboardDto,
|
||||||
|
isAdmin: Boolean,
|
||||||
|
switching: Boolean,
|
||||||
|
feedbackRes: Int?,
|
||||||
|
onSetMode: (String) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val live = data.siteMode.equals("live", ignoreCase = true)
|
||||||
|
Column(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.verticalScroll(rememberScrollState())
|
||||||
|
.padding(20.dp),
|
||||||
|
) {
|
||||||
|
// ── Site status ──────────────────────────────────────────────
|
||||||
|
SectionLabel(stringResource(R.string.admin_dashboard_site))
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
StatusPill(
|
||||||
|
text = if (live) stringResource(R.string.admin_site_live) else stringResource(R.string.admin_site_maintenance),
|
||||||
|
tone = if (live) PillTone.Success else PillTone.Warning,
|
||||||
|
)
|
||||||
|
data.lastChange.by?.takeIf { it.isNotBlank() }?.let { by ->
|
||||||
|
Spacer(Modifier.width(12.dp))
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.admin_site_changed_by, by),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isAdmin) {
|
||||||
|
Spacer(Modifier.height(12.dp))
|
||||||
|
Button(
|
||||||
|
onClick = { onSetMode(if (live) "maintenance" else "live") },
|
||||||
|
enabled = !switching,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
if (switching) {
|
||||||
|
CircularProgressIndicator(strokeWidth = 2.dp, modifier = Modifier.height(20.dp))
|
||||||
|
} else {
|
||||||
|
Text(
|
||||||
|
stringResource(
|
||||||
|
if (live) R.string.admin_site_switch_maintenance else R.string.admin_site_switch_live,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
feedbackRes?.let {
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
Text(
|
||||||
|
text = stringResource(it),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Counts ───────────────────────────────────────────────────
|
||||||
|
Spacer(Modifier.height(24.dp))
|
||||||
|
SectionLabel(stringResource(R.string.admin_dashboard_counts))
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
StatRow(stringResource(R.string.admin_count_users), data.counts.users.toString())
|
||||||
|
val totalPosts = data.counts.posts.values.sum()
|
||||||
|
StatRow(stringResource(R.string.admin_count_posts), totalPosts.toString())
|
||||||
|
data.counts.posts.forEach { (category, count) ->
|
||||||
|
StatRow("· $category", count.toString())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Recent activity ──────────────────────────────────────────
|
||||||
|
if (data.recentActivity.isNotEmpty()) {
|
||||||
|
Spacer(Modifier.height(24.dp))
|
||||||
|
SectionLabel(stringResource(R.string.admin_dashboard_recent_activity))
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
data.recentActivity.forEach { row ->
|
||||||
|
Column(Modifier.fillMaxWidth().padding(vertical = 6.dp)) {
|
||||||
|
Text(row.action, style = MaterialTheme.typography.bodyMedium)
|
||||||
|
val meta = listOfNotNull(row.username, row.createdAt).joinToString(" · ")
|
||||||
|
if (meta.isNotBlank()) {
|
||||||
|
Text(
|
||||||
|
text = meta,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun StatRow(label: String, value: String) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
) {
|
||||||
|
Text(label, style = MaterialTheme.typography.bodyMedium)
|
||||||
|
Text(value, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
package com.runicgateway.app.ui.admin
|
||||||
|
|
||||||
|
import androidx.annotation.StringRes
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.runicgateway.app.R
|
||||||
|
import com.runicgateway.app.core.result.ApiResult
|
||||||
|
import com.runicgateway.app.data.api.dto.AdminDashboardDto
|
||||||
|
import com.runicgateway.app.data.repository.AdminRepository
|
||||||
|
import com.runicgateway.app.ui.UiState
|
||||||
|
import com.runicgateway.app.ui.toUiState
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drives the staff dashboard (PLAN.md §1, M10): summary counts + the site-mode
|
||||||
|
* toggle. The mode switch is admin-only server-side (`adminOnly`); the screen only
|
||||||
|
* offers it to admins, but a `403` is still handled cleanly if a moderator reaches
|
||||||
|
* it. Everything is read through the typed [AdminRepository] (§7).
|
||||||
|
*/
|
||||||
|
@HiltViewModel
|
||||||
|
class AdminDashboardViewModel @Inject constructor(
|
||||||
|
private val admin: AdminRepository,
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
data class Feedback(val ok: Boolean, @param:StringRes val messageRes: Int)
|
||||||
|
|
||||||
|
data class State(
|
||||||
|
val dashboard: UiState<AdminDashboardDto> = UiState.Loading,
|
||||||
|
/** True while a site-mode switch is in flight (disables the control). */
|
||||||
|
val switching: Boolean = false,
|
||||||
|
val feedback: Feedback? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
private val _state = MutableStateFlow(State())
|
||||||
|
val state: StateFlow<State> = _state.asStateFlow()
|
||||||
|
|
||||||
|
init {
|
||||||
|
load()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun load() {
|
||||||
|
_state.update { it.copy(dashboard = UiState.Loading) }
|
||||||
|
viewModelScope.launch {
|
||||||
|
_state.update { it.copy(dashboard = admin.dashboard().toUiState()) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clearFeedback() = _state.update { it.copy(feedback = null) }
|
||||||
|
|
||||||
|
/** Switch the site between "live" and "maintenance" (admin only). */
|
||||||
|
fun setSiteMode(mode: String) {
|
||||||
|
if (_state.value.switching) return
|
||||||
|
_state.update { it.copy(switching = true, feedback = null) }
|
||||||
|
viewModelScope.launch {
|
||||||
|
when (val result = admin.setSiteMode(mode)) {
|
||||||
|
is ApiResult.Ok -> {
|
||||||
|
// Reflect the new mode locally, then refresh the full summary.
|
||||||
|
val current = _state.value.dashboard
|
||||||
|
if (current is UiState.Success) {
|
||||||
|
_state.update {
|
||||||
|
it.copy(dashboard = UiState.Success(current.data.copy(siteMode = result.data.siteMode)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_state.update { it.copy(switching = false, feedback = Feedback(true, R.string.admin_site_mode_updated)) }
|
||||||
|
load()
|
||||||
|
}
|
||||||
|
is ApiResult.HttpError ->
|
||||||
|
_state.update {
|
||||||
|
it.copy(
|
||||||
|
switching = false,
|
||||||
|
feedback = Feedback(
|
||||||
|
false,
|
||||||
|
if (result.status == 403) R.string.admin_forbidden else R.string.admin_action_failed,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
is ApiResult.NetworkError ->
|
||||||
|
_state.update { it.copy(switching = false, feedback = Feedback(false, R.string.error_network)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
package com.runicgateway.app.ui.admin
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedButton
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import com.runicgateway.app.R
|
||||||
|
import com.runicgateway.app.ui.components.SectionLabel
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The moderation screen (PLAN.md §1, M10): kick / ban / unban an account and
|
||||||
|
* broadcast, over `/admin/shard/…` (admin/moderator). A live sidecar is required;
|
||||||
|
* offline, actions return a clean "shard offline" message. Fields are entered here;
|
||||||
|
* the [AdminModerationViewModel] performs the guarded action.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun AdminModerationScreen(
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
viewModel: AdminModerationViewModel = hiltViewModel(),
|
||||||
|
) {
|
||||||
|
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||||
|
var account by rememberSaveable { mutableStateOf("") }
|
||||||
|
var serial by rememberSaveable { mutableStateOf("") }
|
||||||
|
var reason by rememberSaveable { mutableStateOf("") }
|
||||||
|
var duration by rememberSaveable { mutableStateOf("") }
|
||||||
|
var broadcast by rememberSaveable { mutableStateOf("") }
|
||||||
|
val busy = state.busy
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(20.dp),
|
||||||
|
) {
|
||||||
|
state.feedback?.let {
|
||||||
|
Text(
|
||||||
|
text = stringResource(it.messageRes),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = if (it.ok) MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.error,
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Account actions ──────────────────────────────────────────────
|
||||||
|
SectionLabel(stringResource(R.string.admin_mod_account_action))
|
||||||
|
OutlinedTextField(value = account, onValueChange = { account = it }, singleLine = true, label = { Text(stringResource(R.string.admin_mod_account)) }, modifier = Modifier.fillMaxWidth().padding(top = 8.dp))
|
||||||
|
OutlinedTextField(value = serial, onValueChange = { serial = it }, singleLine = true, label = { Text(stringResource(R.string.admin_mod_serial)) }, modifier = Modifier.fillMaxWidth().padding(top = 8.dp))
|
||||||
|
OutlinedTextField(value = reason, onValueChange = { reason = it }, label = { Text(stringResource(R.string.admin_mod_reason)) }, modifier = Modifier.fillMaxWidth().padding(top = 8.dp))
|
||||||
|
OutlinedTextField(value = duration, onValueChange = { duration = it.filter(Char::isDigit) }, singleLine = true, label = { Text(stringResource(R.string.admin_mod_duration)) }, modifier = Modifier.fillMaxWidth().padding(top = 8.dp))
|
||||||
|
|
||||||
|
Row(Modifier.fillMaxWidth().padding(top = 12.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
OutlinedButton(onClick = { viewModel.kick(account, serial) }, enabled = !busy, modifier = Modifier.weight(1f)) {
|
||||||
|
Text(stringResource(R.string.admin_mod_kick))
|
||||||
|
}
|
||||||
|
Button(onClick = { viewModel.ban(account, serial, duration.toLongOrNull(), reason) }, enabled = !busy, modifier = Modifier.weight(1f)) {
|
||||||
|
Text(stringResource(R.string.admin_mod_ban))
|
||||||
|
}
|
||||||
|
OutlinedButton(onClick = { viewModel.unban(account) }, enabled = !busy, modifier = Modifier.weight(1f)) {
|
||||||
|
Text(stringResource(R.string.admin_mod_unban))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Broadcast ────────────────────────────────────────────────────
|
||||||
|
Spacer(Modifier.height(24.dp))
|
||||||
|
SectionLabel(stringResource(R.string.admin_mod_broadcast_section))
|
||||||
|
OutlinedTextField(value = broadcast, onValueChange = { broadcast = it }, label = { Text(stringResource(R.string.admin_mod_broadcast_text)) }, modifier = Modifier.fillMaxWidth().padding(top = 8.dp))
|
||||||
|
Button(onClick = { viewModel.broadcast(broadcast, null) }, enabled = !busy, modifier = Modifier.fillMaxWidth().padding(top = 12.dp)) {
|
||||||
|
Text(stringResource(R.string.admin_mod_broadcast))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
package com.runicgateway.app.ui.admin
|
||||||
|
|
||||||
|
import androidx.annotation.StringRes
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.runicgateway.app.R
|
||||||
|
import com.runicgateway.app.core.result.ApiResult
|
||||||
|
import com.runicgateway.app.data.repository.AdminRepository
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drives the moderation actions (PLAN.md §1, M10): kick / ban / unban an account
|
||||||
|
* and broadcast a system message, over the shard write plane (`/admin/shard/…`,
|
||||||
|
* admin/moderator). These need a live sidecar — when the shard is offline the call
|
||||||
|
* fails and the screen shows a clean error, never a crash (§7). The form fields live
|
||||||
|
* in the screen; this VM owns only the busy + feedback state and the actions.
|
||||||
|
*/
|
||||||
|
@HiltViewModel
|
||||||
|
class AdminModerationViewModel @Inject constructor(
|
||||||
|
private val admin: AdminRepository,
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
data class Feedback(val ok: Boolean, @param:StringRes val messageRes: Int)
|
||||||
|
|
||||||
|
data class State(val busy: Boolean = false, val feedback: Feedback? = null)
|
||||||
|
|
||||||
|
private val _state = MutableStateFlow(State())
|
||||||
|
val state: StateFlow<State> = _state.asStateFlow()
|
||||||
|
|
||||||
|
fun clearFeedback() = _state.update { it.copy(feedback = null) }
|
||||||
|
|
||||||
|
fun kick(account: String, serial: String) {
|
||||||
|
if (account.isBlank() && serial.isBlank()) return badTarget()
|
||||||
|
run(R.string.admin_mod_kicked) { admin.kick(account.ifBlank { null }, serial.ifBlank { null }) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun ban(account: String, serial: String, durationSec: Long?, reason: String) {
|
||||||
|
if (account.isBlank() && serial.isBlank()) return badTarget()
|
||||||
|
run(R.string.admin_mod_banned) {
|
||||||
|
admin.ban(account.ifBlank { null }, serial.ifBlank { null }, durationSec, reason.ifBlank { null })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun unban(account: String) {
|
||||||
|
if (account.isBlank()) return badTarget()
|
||||||
|
run(R.string.admin_mod_unbanned) { admin.unban(account.trim()) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun broadcast(text: String, hue: Int?) {
|
||||||
|
if (text.isBlank()) {
|
||||||
|
_state.update { it.copy(feedback = Feedback(false, R.string.admin_mod_text_required)) }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
run(R.string.admin_mod_broadcasted) { admin.broadcast(text.trim(), hue) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun badTarget() {
|
||||||
|
_state.update { it.copy(feedback = Feedback(false, R.string.admin_mod_target_required)) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun run(@StringRes okRes: Int, block: suspend () -> ApiResult<Unit>) {
|
||||||
|
if (_state.value.busy) return
|
||||||
|
_state.update { it.copy(busy = true, feedback = null) }
|
||||||
|
viewModelScope.launch {
|
||||||
|
val feedback = when (val r = block()) {
|
||||||
|
is ApiResult.Ok -> Feedback(true, okRes)
|
||||||
|
is ApiResult.HttpError -> Feedback(
|
||||||
|
false,
|
||||||
|
when (r.status) {
|
||||||
|
403 -> R.string.admin_forbidden
|
||||||
|
503 -> R.string.admin_mod_shard_offline
|
||||||
|
else -> R.string.admin_action_failed
|
||||||
|
},
|
||||||
|
)
|
||||||
|
is ApiResult.NetworkError -> Feedback(false, R.string.error_network)
|
||||||
|
}
|
||||||
|
_state.update { it.copy(busy = false, feedback = feedback) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
package com.runicgateway.app.ui.admin
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.Card
|
||||||
|
import androidx.compose.material3.Checkbox
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import com.runicgateway.app.R
|
||||||
|
import com.runicgateway.app.data.api.dto.SupportPageDto
|
||||||
|
import com.runicgateway.app.ui.UiState
|
||||||
|
import com.runicgateway.app.ui.components.EmptyView
|
||||||
|
import com.runicgateway.app.ui.components.ErrorView
|
||||||
|
import com.runicgateway.app.ui.components.LoadingView
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The support (help-page) queue (PLAN.md §1, M10): open tickets with reply/close,
|
||||||
|
* over `/admin/shard/pages…` (admin/moderator). Empty when there are no open pages
|
||||||
|
* (or the shard is offline); every read/write degrades cleanly (§7).
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun AdminSupportScreen(
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
viewModel: AdminSupportViewModel = hiltViewModel(),
|
||||||
|
) {
|
||||||
|
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||||
|
var replyTo by remember { mutableStateOf<SupportPageDto?>(null) }
|
||||||
|
|
||||||
|
Column(modifier.fillMaxSize()) {
|
||||||
|
state.feedback?.let {
|
||||||
|
Text(
|
||||||
|
text = stringResource(it.messageRes),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = if (it.ok) MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.error,
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 6.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
when (val s = state.pages) {
|
||||||
|
is UiState.Loading -> LoadingView()
|
||||||
|
is UiState.Error -> ErrorView(s.kind, onRetry = viewModel::load)
|
||||||
|
is UiState.Success ->
|
||||||
|
if (s.data.isEmpty()) {
|
||||||
|
EmptyView(stringResource(R.string.admin_support_empty))
|
||||||
|
} else {
|
||||||
|
LazyColumn(Modifier.fillMaxSize().padding(16.dp)) {
|
||||||
|
items(s.data, key = { it.pageId }) { page ->
|
||||||
|
SupportPageCard(
|
||||||
|
page = page,
|
||||||
|
busy = state.busy,
|
||||||
|
onReply = { replyTo = page },
|
||||||
|
onClose = { viewModel.close(page.pageId) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
replyTo?.let { page ->
|
||||||
|
RespondDialog(
|
||||||
|
page = page,
|
||||||
|
onDismiss = { replyTo = null },
|
||||||
|
onSend = { message, close ->
|
||||||
|
viewModel.respond(page.pageId, message, close)
|
||||||
|
replyTo = null
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SupportPageCard(
|
||||||
|
page: SupportPageDto,
|
||||||
|
busy: Boolean,
|
||||||
|
onReply: () -> Unit,
|
||||||
|
onClose: () -> Unit,
|
||||||
|
) {
|
||||||
|
Card(Modifier.fillMaxWidth().padding(vertical = 6.dp)) {
|
||||||
|
Column(Modifier.padding(12.dp)) {
|
||||||
|
val who = page.sender?.name ?: page.sender?.account ?: page.pageId
|
||||||
|
Text(
|
||||||
|
text = listOfNotNull(page.type, who).joinToString(" · "),
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
)
|
||||||
|
page.message?.takeIf { it.isNotBlank() }?.let {
|
||||||
|
Spacer(Modifier.height(4.dp))
|
||||||
|
Text(it, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
}
|
||||||
|
Row(Modifier.fillMaxWidth().padding(top = 8.dp), horizontalArrangement = Arrangement.End) {
|
||||||
|
TextButton(onClick = onReply, enabled = !busy) { Text(stringResource(R.string.admin_support_reply)) }
|
||||||
|
TextButton(onClick = onClose, enabled = !busy) { Text(stringResource(R.string.admin_support_close)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun RespondDialog(
|
||||||
|
page: SupportPageDto,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
onSend: (message: String, close: Boolean) -> Unit,
|
||||||
|
) {
|
||||||
|
var message by rememberSaveable { mutableStateOf("") }
|
||||||
|
var alsoClose by rememberSaveable { mutableStateOf(true) }
|
||||||
|
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
confirmButton = { TextButton(onClick = { onSend(message, alsoClose) }) { Text(stringResource(R.string.admin_support_send)) } },
|
||||||
|
dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.action_cancel)) } },
|
||||||
|
title = { Text(stringResource(R.string.admin_support_reply)) },
|
||||||
|
text = {
|
||||||
|
Column {
|
||||||
|
OutlinedTextField(value = message, onValueChange = { message = it }, label = { Text(stringResource(R.string.admin_support_message)) }, modifier = Modifier.fillMaxWidth())
|
||||||
|
Row(Modifier.fillMaxWidth().padding(top = 8.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Checkbox(checked = alsoClose, onCheckedChange = { alsoClose = it })
|
||||||
|
Text(stringResource(R.string.admin_support_close_after))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
package com.runicgateway.app.ui.admin
|
||||||
|
|
||||||
|
import androidx.annotation.StringRes
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.runicgateway.app.R
|
||||||
|
import com.runicgateway.app.core.result.ApiResult
|
||||||
|
import com.runicgateway.app.data.api.dto.SupportPageDto
|
||||||
|
import com.runicgateway.app.data.repository.AdminRepository
|
||||||
|
import com.runicgateway.app.ui.UiState
|
||||||
|
import com.runicgateway.app.ui.toUiState
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drives the support (help-page) queue (PLAN.md §1, M10): list open pages, reply
|
||||||
|
* (optionally closing), and close, over `/admin/shard/pages…` (admin/moderator).
|
||||||
|
* The list is served from shard state — empty when no tickets (or the shard is
|
||||||
|
* offline); writes need a live sidecar and fail cleanly otherwise (§7).
|
||||||
|
*/
|
||||||
|
@HiltViewModel
|
||||||
|
class AdminSupportViewModel @Inject constructor(
|
||||||
|
private val admin: AdminRepository,
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
data class Feedback(val ok: Boolean, @param:StringRes val messageRes: Int)
|
||||||
|
|
||||||
|
data class State(
|
||||||
|
val pages: UiState<List<SupportPageDto>> = UiState.Loading,
|
||||||
|
val busy: Boolean = false,
|
||||||
|
val feedback: Feedback? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
private val _state = MutableStateFlow(State())
|
||||||
|
val state: StateFlow<State> = _state.asStateFlow()
|
||||||
|
|
||||||
|
init {
|
||||||
|
load()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clearFeedback() = _state.update { it.copy(feedback = null) }
|
||||||
|
|
||||||
|
fun load() {
|
||||||
|
_state.update { it.copy(pages = UiState.Loading) }
|
||||||
|
viewModelScope.launch { _state.update { it.copy(pages = admin.supportPages().toUiState()) } }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun respond(id: String, message: String, close: Boolean) {
|
||||||
|
if (message.isBlank()) {
|
||||||
|
_state.update { it.copy(feedback = Feedback(false, R.string.admin_support_message_required)) }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
mutate(R.string.admin_support_responded) { admin.respondPage(id, message.trim(), close) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun close(id: String) = mutate(R.string.admin_support_closed) { admin.closePage(id) }
|
||||||
|
|
||||||
|
private fun mutate(@StringRes okRes: Int, block: suspend () -> ApiResult<Unit>) {
|
||||||
|
if (_state.value.busy) return
|
||||||
|
_state.update { it.copy(busy = true, feedback = null) }
|
||||||
|
viewModelScope.launch {
|
||||||
|
val feedback = when (val r = block()) {
|
||||||
|
is ApiResult.Ok -> Feedback(true, okRes)
|
||||||
|
is ApiResult.HttpError -> Feedback(
|
||||||
|
false,
|
||||||
|
when (r.status) {
|
||||||
|
403 -> R.string.admin_forbidden
|
||||||
|
404 -> R.string.admin_support_unknown_page
|
||||||
|
503 -> R.string.admin_mod_shard_offline
|
||||||
|
else -> R.string.admin_action_failed
|
||||||
|
},
|
||||||
|
)
|
||||||
|
is ApiResult.NetworkError -> Feedback(false, R.string.error_network)
|
||||||
|
}
|
||||||
|
if (feedback.ok) load()
|
||||||
|
_state.update { it.copy(busy = false, feedback = feedback) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,8 +5,10 @@ package com.runicgateway.app.ui.auth
|
|||||||
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
@@ -15,14 +17,20 @@ import androidx.compose.foundation.text.KeyboardOptions
|
|||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.ModalBottomSheet
|
||||||
import androidx.compose.material3.OutlinedButton
|
import androidx.compose.material3.OutlinedButton
|
||||||
import androidx.compose.material3.OutlinedTextField
|
import androidx.compose.material3.OutlinedTextField
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.material3.rememberModalBottomSheetState
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
@@ -163,27 +171,46 @@ fun LoginScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Native SSO (§4.2, M9): a button per enabled provider that opens the
|
// ── Native SSO (§4.2, M9): a single "Sign in with SSO" button that opens the
|
||||||
// Custom-Tab bridge and returns the user signed in. Falls back to the
|
// Custom-Tab bridge. With one provider it launches straight through; with
|
||||||
// website login hand-off when the shard exposes no providers.
|
// several it presents a native picker (below). No website-login fallback —
|
||||||
if (state.ssoProviders.isNotEmpty()) {
|
// that page can't deep-link the session back; a failed discovery offers a retry.
|
||||||
state.ssoProviders.forEach { provider ->
|
var showSsoPicker by remember { mutableStateOf(false) }
|
||||||
|
when {
|
||||||
|
state.ssoProviders.isNotEmpty() -> {
|
||||||
OutlinedButton(
|
OutlinedButton(
|
||||||
onClick = { viewModel.onSsoProviderClick(provider) },
|
onClick = {
|
||||||
|
val providers = state.ssoProviders
|
||||||
|
if (providers.size == 1) viewModel.onSsoProviderClick(providers.first())
|
||||||
|
else showSsoPicker = true
|
||||||
|
},
|
||||||
enabled = !state.submitting,
|
enabled = !state.submitting,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(top = 12.dp),
|
.padding(top = 12.dp),
|
||||||
) {
|
) {
|
||||||
Text(stringResource(R.string.login_sso_provider, provider.name))
|
Text(stringResource(R.string.login_sso_button))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
viewModel.ssoLoginUrl?.let { url ->
|
state.ssoDiscovering -> {
|
||||||
TextButton(onClick = { WebHandoff.open(context, url) }) {
|
Text(
|
||||||
Text(stringResource(R.string.login_sso))
|
text = stringResource(R.string.login_sso_loading),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(top = 12.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
state.ssoUnavailable -> {
|
||||||
|
TextButton(
|
||||||
|
onClick = { viewModel.discoverSsoProviders() },
|
||||||
|
modifier = Modifier.padding(top = 4.dp),
|
||||||
|
) {
|
||||||
|
Text(stringResource(R.string.login_sso_retry))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// else: discovery succeeded with no providers — this shard offers no SSO.
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Website hand-offs (§4.2): open the site's own pages in a Custom Tab ──
|
// ── Website hand-offs (§4.2): open the site's own pages in a Custom Tab ──
|
||||||
@@ -198,6 +225,53 @@ fun LoginScreen(
|
|||||||
Text(stringResource(R.string.login_forgot))
|
Text(stringResource(R.string.login_forgot))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (showSsoPicker) {
|
||||||
|
SsoProviderPicker(
|
||||||
|
providers = state.ssoProviders,
|
||||||
|
onDismiss = { showSsoPicker = false },
|
||||||
|
onPick = { provider ->
|
||||||
|
showSsoPicker = false
|
||||||
|
viewModel.onSsoProviderClick(provider)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The native provider picker (§4.2): a bottom sheet listing the shard's enabled SSO
|
||||||
|
* providers so a single "Sign in with SSO" button can serve several IdPs without a
|
||||||
|
* website chooser page. Each row opens the Custom-Tab bridge for that provider.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
private fun SsoProviderPicker(
|
||||||
|
providers: List<com.runicgateway.app.data.api.dto.SsoProviderDto>,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
onPick: (com.runicgateway.app.data.api.dto.SsoProviderDto) -> Unit,
|
||||||
|
) {
|
||||||
|
ModalBottomSheet(onDismissRequest = onDismiss, sheetState = rememberModalBottomSheetState()) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.login_sso_pick_title),
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
modifier = Modifier.padding(horizontal = 24.dp, vertical = 8.dp),
|
||||||
|
)
|
||||||
|
providers.forEach { provider ->
|
||||||
|
TextButton(
|
||||||
|
onClick = { onPick(provider) },
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 12.dp, vertical = 2.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.login_sso_provider, provider.name),
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
textAlign = TextAlign.Start,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(24.dp)) // clears the gesture inset at the sheet's bottom
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import com.runicgateway.app.core.web.WebsiteUrls
|
|||||||
import com.runicgateway.app.data.api.dto.SsoProviderDto
|
import com.runicgateway.app.data.api.dto.SsoProviderDto
|
||||||
import com.runicgateway.app.data.repository.AuthRepository
|
import com.runicgateway.app.data.repository.AuthRepository
|
||||||
import com.runicgateway.app.data.repository.AuthRepository.LoginResult
|
import com.runicgateway.app.data.repository.AuthRepository.LoginResult
|
||||||
|
import com.runicgateway.app.data.repository.AuthRepository.SsoDiscovery
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
@@ -43,8 +44,12 @@ class LoginViewModel @Inject constructor(
|
|||||||
val submitting: Boolean = false,
|
val submitting: Boolean = false,
|
||||||
val error: LoginError? = null,
|
val error: LoginError? = null,
|
||||||
val signedIn: Boolean = false,
|
val signedIn: Boolean = false,
|
||||||
/** The shard's enabled SSO providers (§4.2); empty → website hand-off fallback. */
|
/** The shard's enabled SSO providers (§4.2); empty until discovery resolves. */
|
||||||
val ssoProviders: List<SsoProviderDto> = emptyList(),
|
val ssoProviders: List<SsoProviderDto> = emptyList(),
|
||||||
|
/** True while discovery is in flight — the screen shows a spinner, not an empty gap. */
|
||||||
|
val ssoDiscovering: Boolean = true,
|
||||||
|
/** True when discovery failed (offline/server) — offer a retry rather than a dead end. */
|
||||||
|
val ssoUnavailable: Boolean = false,
|
||||||
/** A `/auth/mobile/sso/start` URL the screen should open in a Custom Tab, once. */
|
/** A `/auth/mobile/sso/start` URL the screen should open in a Custom Tab, once. */
|
||||||
val ssoLaunchUrl: String? = null,
|
val ssoLaunchUrl: String? = null,
|
||||||
)
|
)
|
||||||
@@ -53,11 +58,7 @@ class LoginViewModel @Inject constructor(
|
|||||||
val state: StateFlow<UiState> = _state.asStateFlow()
|
val state: StateFlow<UiState> = _state.asStateFlow()
|
||||||
|
|
||||||
init {
|
init {
|
||||||
// Discover the native SSO providers to render buttons for (§4.2).
|
discoverSsoProviders()
|
||||||
viewModelScope.launch {
|
|
||||||
val providers = authRepository.ssoProviders()
|
|
||||||
if (providers.isNotEmpty()) _state.update { it.copy(ssoProviders = providers) }
|
|
||||||
}
|
|
||||||
// Consume the SSO bridge outcome: a returned callback completes here even if
|
// Consume the SSO bridge outcome: a returned callback completes here even if
|
||||||
// this ViewModel was recreated while the Custom Tab was foreground (§4.2).
|
// this ViewModel was recreated while the Custom Tab was foreground (§4.2).
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
@@ -85,8 +86,30 @@ class LoginViewModel @Inject constructor(
|
|||||||
val registerUrl: String? get() = websiteUrls.register()
|
val registerUrl: String? get() = websiteUrls.register()
|
||||||
val forgotPasswordUrl: String? get() = websiteUrls.forgotPassword()
|
val forgotPasswordUrl: String? get() = websiteUrls.forgotPassword()
|
||||||
|
|
||||||
/** Website login hand-off — the fallback when native SSO discovery is empty (§4.2). */
|
/**
|
||||||
val ssoLoginUrl: String? get() = websiteUrls.login()
|
* Discover the shard's native SSO providers (§4.2). A failure surfaces a retry
|
||||||
|
* affordance instead of the old dead website-login hand-off, which was never
|
||||||
|
* mobile-formatted and could not deep-link the session back.
|
||||||
|
*/
|
||||||
|
fun discoverSsoProviders() {
|
||||||
|
_state.update { it.copy(ssoDiscovering = true, ssoUnavailable = false) }
|
||||||
|
viewModelScope.launch {
|
||||||
|
when (val result = authRepository.ssoProviders()) {
|
||||||
|
is SsoDiscovery.Available ->
|
||||||
|
_state.update {
|
||||||
|
it.copy(ssoProviders = result.providers, ssoDiscovering = false, ssoUnavailable = false)
|
||||||
|
}
|
||||||
|
SsoDiscovery.None ->
|
||||||
|
_state.update {
|
||||||
|
it.copy(ssoProviders = emptyList(), ssoDiscovering = false, ssoUnavailable = false)
|
||||||
|
}
|
||||||
|
SsoDiscovery.Unavailable ->
|
||||||
|
_state.update {
|
||||||
|
it.copy(ssoProviders = emptyList(), ssoDiscovering = false, ssoUnavailable = true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Begin a native SSO flow for [provider]: mint PKCE + state and surface the
|
* Begin a native SSO flow for [provider]: mint PKCE + state and surface the
|
||||||
|
|||||||
@@ -23,6 +23,12 @@ enum class MenuAccess {
|
|||||||
|
|
||||||
/** Visible only to a player — the linked game-data groups (§6.3). */
|
/** Visible only to a player — the linked game-data groups (§6.3). */
|
||||||
PLAYER,
|
PLAYER,
|
||||||
|
|
||||||
|
/** Visible to any staff role (admin/editor/moderator) — the M10 staff surface (§1). */
|
||||||
|
STAFF,
|
||||||
|
|
||||||
|
/** Visible to admin/moderator — moderation actions + the support queue (§1, M10). */
|
||||||
|
MODERATOR,
|
||||||
}
|
}
|
||||||
|
|
||||||
data class MenuEntry(
|
data class MenuEntry(
|
||||||
@@ -48,6 +54,11 @@ val APP_MENU: List<MenuEntry> = listOf(
|
|||||||
MenuEntry(Routes.PLAYER_CHARACTERS, R.string.menu_my_characters, MenuAccess.PLAYER),
|
MenuEntry(Routes.PLAYER_CHARACTERS, R.string.menu_my_characters, MenuAccess.PLAYER),
|
||||||
MenuEntry(Routes.PLAYER_VENDORS, R.string.menu_my_vendors, MenuAccess.PLAYER),
|
MenuEntry(Routes.PLAYER_VENDORS, R.string.menu_my_vendors, MenuAccess.PLAYER),
|
||||||
MenuEntry(Routes.PLAYER_HOUSES, R.string.menu_my_houses, MenuAccess.PLAYER),
|
MenuEntry(Routes.PLAYER_HOUSES, R.string.menu_my_houses, MenuAccess.PLAYER),
|
||||||
|
// Staff operations (§1, M10) — revealed for staff roles; the backend re-checks every call.
|
||||||
|
MenuEntry(Routes.ADMIN_DASHBOARD, R.string.menu_admin_dashboard, MenuAccess.STAFF),
|
||||||
|
MenuEntry(Routes.ADMIN_CONTENT, R.string.menu_admin_content, MenuAccess.STAFF),
|
||||||
|
MenuEntry(Routes.ADMIN_MODERATION, R.string.menu_admin_moderation, MenuAccess.MODERATOR),
|
||||||
|
MenuEntry(Routes.ADMIN_SUPPORT, R.string.menu_admin_support, MenuAccess.MODERATOR),
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -60,5 +71,7 @@ fun visibleEntries(entries: List<MenuEntry>, session: Session): List<MenuEntry>
|
|||||||
MenuAccess.PUBLIC -> true
|
MenuAccess.PUBLIC -> true
|
||||||
MenuAccess.SIGNED_IN -> session is Session.SignedIn
|
MenuAccess.SIGNED_IN -> session is Session.SignedIn
|
||||||
MenuAccess.PLAYER -> session is Session.SignedIn && session.user.isPlayer
|
MenuAccess.PLAYER -> session is Session.SignedIn && session.user.isPlayer
|
||||||
|
MenuAccess.STAFF -> session is Session.SignedIn && session.user.isStaff
|
||||||
|
MenuAccess.MODERATOR -> session is Session.SignedIn && session.user.isModerator
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,6 +38,13 @@ object Routes {
|
|||||||
/** A single character sheet by in-game (hex) serial. */
|
/** A single character sheet by in-game (hex) serial. */
|
||||||
const val PLAYER_CHAR = "player/char/{serial}"
|
const val PLAYER_CHAR = "player/char/{serial}"
|
||||||
|
|
||||||
|
/** Staff operations (§1, §6.4, M10). Gated to staff roles by the menu access level;
|
||||||
|
* the backend re-checks role on every `/admin/…` call. */
|
||||||
|
const val ADMIN_DASHBOARD = "admin/dashboard"
|
||||||
|
const val ADMIN_MODERATION = "admin/moderation"
|
||||||
|
const val ADMIN_SUPPORT = "admin/support"
|
||||||
|
const val ADMIN_CONTENT = "admin/content"
|
||||||
|
|
||||||
/** CMS page by slug (e.g. the conventional "about" page, mirrored from the site nav). */
|
/** CMS page by slug (e.g. the conventional "about" page, mirrored from the site nav). */
|
||||||
const val PAGE = "page/{slug}"
|
const val PAGE = "page/{slug}"
|
||||||
|
|
||||||
|
|||||||
@@ -46,10 +46,92 @@
|
|||||||
<string name="menu_my_characters">My characters</string>
|
<string name="menu_my_characters">My characters</string>
|
||||||
<string name="menu_my_vendors">My vendors</string>
|
<string name="menu_my_vendors">My vendors</string>
|
||||||
<string name="menu_my_houses">My houses</string>
|
<string name="menu_my_houses">My houses</string>
|
||||||
|
<string name="menu_admin_dashboard">Dashboard</string>
|
||||||
|
<string name="menu_admin_content">Content</string>
|
||||||
|
<string name="menu_admin_moderation">Moderation</string>
|
||||||
|
<string name="menu_admin_support">Support queue</string>
|
||||||
<string name="menu_sign_in">Sign in</string>
|
<string name="menu_sign_in">Sign in</string>
|
||||||
<string name="menu_sign_out">Sign out</string>
|
<string name="menu_sign_out">Sign out</string>
|
||||||
<string name="menu_change_server">Change server</string>
|
<string name="menu_change_server">Change server</string>
|
||||||
|
|
||||||
|
<!-- ── Staff operations (§1, M10) ──────────────────────────────────── -->
|
||||||
|
<string name="admin_dashboard_site">Site</string>
|
||||||
|
<string name="admin_dashboard_counts">Counts</string>
|
||||||
|
<string name="admin_dashboard_recent_activity">Recent activity</string>
|
||||||
|
<string name="admin_site_live">Live</string>
|
||||||
|
<string name="admin_site_maintenance">Maintenance</string>
|
||||||
|
<string name="admin_site_switch_maintenance">Switch to maintenance</string>
|
||||||
|
<string name="admin_site_switch_live">Switch to live</string>
|
||||||
|
<string name="admin_site_changed_by">by %1$s</string>
|
||||||
|
<string name="admin_site_mode_updated">Site mode updated.</string>
|
||||||
|
<string name="admin_count_users">Users</string>
|
||||||
|
<string name="admin_count_posts">Posts</string>
|
||||||
|
<string name="admin_forbidden">You don\'t have permission for that action.</string>
|
||||||
|
<string name="admin_action_failed">That action couldn\'t be completed. Please try again.</string>
|
||||||
|
<string name="action_cancel">Cancel</string>
|
||||||
|
|
||||||
|
<!-- Staff content (posts + wiki) -->
|
||||||
|
<string name="admin_content_tab_posts">Posts</string>
|
||||||
|
<string name="admin_content_tab_wiki">Wiki</string>
|
||||||
|
<string name="admin_content_new_post">New post</string>
|
||||||
|
<string name="admin_content_new_category">New category</string>
|
||||||
|
<string name="admin_content_create">Create</string>
|
||||||
|
<string name="admin_content_published">Published</string>
|
||||||
|
<string name="admin_content_draft">Draft</string>
|
||||||
|
<string name="admin_content_publish">Publish</string>
|
||||||
|
<string name="admin_content_unpublish">Unpublish</string>
|
||||||
|
<string name="admin_content_delete">Delete</string>
|
||||||
|
<string name="admin_content_publish_now">Publish now</string>
|
||||||
|
<string name="admin_content_field_title">Title</string>
|
||||||
|
<string name="admin_content_field_excerpt">Excerpt</string>
|
||||||
|
<string name="admin_content_field_body">Body</string>
|
||||||
|
<string name="admin_content_field_slug">Slug</string>
|
||||||
|
<string name="admin_content_field_description">Description</string>
|
||||||
|
<string name="admin_content_field_sort">Sort order</string>
|
||||||
|
<!-- %1$s slug, %2$d page count -->
|
||||||
|
<string name="admin_content_cat_meta">%1$s · %2$d pages</string>
|
||||||
|
<!-- %1$s comma-separated tag labels -->
|
||||||
|
<string name="admin_content_tags">Tags: %1$s</string>
|
||||||
|
<string name="admin_content_post_created">Post created.</string>
|
||||||
|
<string name="admin_content_post_updated">Post updated.</string>
|
||||||
|
<string name="admin_content_post_deleted">Post deleted.</string>
|
||||||
|
<string name="admin_content_cat_created">Category created.</string>
|
||||||
|
<string name="admin_content_cat_deleted">Category deleted.</string>
|
||||||
|
<string name="admin_content_title_required">A title is required.</string>
|
||||||
|
<string name="admin_content_cat_fields_required">Slug and title are required.</string>
|
||||||
|
|
||||||
|
<!-- Staff moderation (shard write plane) -->
|
||||||
|
<string name="admin_mod_account_action">Account action</string>
|
||||||
|
<string name="admin_mod_account">Account</string>
|
||||||
|
<string name="admin_mod_serial">Serial (0x…)</string>
|
||||||
|
<string name="admin_mod_reason">Reason (ban)</string>
|
||||||
|
<string name="admin_mod_duration">Ban duration (seconds; blank = indefinite)</string>
|
||||||
|
<string name="admin_mod_kick">Kick</string>
|
||||||
|
<string name="admin_mod_ban">Ban</string>
|
||||||
|
<string name="admin_mod_unban">Unban</string>
|
||||||
|
<string name="admin_mod_broadcast_section">Broadcast</string>
|
||||||
|
<string name="admin_mod_broadcast_text">Message to everyone online</string>
|
||||||
|
<string name="admin_mod_broadcast">Broadcast</string>
|
||||||
|
<string name="admin_mod_kicked">Account kicked.</string>
|
||||||
|
<string name="admin_mod_banned">Account banned.</string>
|
||||||
|
<string name="admin_mod_unbanned">Ban cleared.</string>
|
||||||
|
<string name="admin_mod_broadcasted">Message broadcast.</string>
|
||||||
|
<string name="admin_mod_target_required">Enter an account or serial.</string>
|
||||||
|
<string name="admin_mod_text_required">Enter a message to broadcast.</string>
|
||||||
|
<string name="admin_mod_shard_offline">The shard is offline — the action couldn\'t be delivered.</string>
|
||||||
|
|
||||||
|
<!-- Staff support queue -->
|
||||||
|
<string name="admin_support_empty">No open help pages.</string>
|
||||||
|
<string name="admin_support_reply">Reply</string>
|
||||||
|
<string name="admin_support_close">Close</string>
|
||||||
|
<string name="admin_support_send">Send</string>
|
||||||
|
<string name="admin_support_message">Reply message</string>
|
||||||
|
<string name="admin_support_close_after">Close the page after replying</string>
|
||||||
|
<string name="admin_support_responded">Reply sent.</string>
|
||||||
|
<string name="admin_support_closed">Page closed.</string>
|
||||||
|
<string name="admin_support_message_required">Enter a reply message.</string>
|
||||||
|
<string name="admin_support_unknown_page">That page is no longer in the queue.</string>
|
||||||
|
|
||||||
<!-- ── Auth: login (§4.1) ──────────────────────────────────────────── -->
|
<!-- ── Auth: login (§4.1) ──────────────────────────────────────────── -->
|
||||||
<string name="login_title">Sign in</string>
|
<string name="login_title">Sign in</string>
|
||||||
<string name="login_subtitle">Sign in with your shard account.</string>
|
<string name="login_subtitle">Sign in with your shard account.</string>
|
||||||
@@ -60,9 +142,13 @@
|
|||||||
<string name="login_button">Sign in</string>
|
<string name="login_button">Sign in</string>
|
||||||
<string name="login_register">Create an account</string>
|
<string name="login_register">Create an account</string>
|
||||||
<string name="login_forgot">Forgot your password?</string>
|
<string name="login_forgot">Forgot your password?</string>
|
||||||
<string name="login_sso">Sign in with Google or Discord (on the website)</string>
|
<!-- Single SSO entry point; the picker lists the shard's providers (native SSO, M9/M10). -->
|
||||||
<!-- %1$s is the provider name, e.g. "Google" or "Discord" (native SSO, M9). -->
|
<string name="login_sso_button">Sign in with SSO</string>
|
||||||
|
<string name="login_sso_pick_title">Choose a sign-in provider</string>
|
||||||
|
<!-- %1$s is the provider name, e.g. "Google" or "Discord". -->
|
||||||
<string name="login_sso_provider">Sign in with %1$s</string>
|
<string name="login_sso_provider">Sign in with %1$s</string>
|
||||||
|
<string name="login_sso_loading">Loading sign-in options…</string>
|
||||||
|
<string name="login_sso_retry">Couldn\'t load sign-in options. Tap to retry.</string>
|
||||||
<string name="login_error_credentials">Incorrect username or password.</string>
|
<string name="login_error_credentials">Incorrect username or password.</string>
|
||||||
<string name="login_error_code">That code didn\'t match. Try the current code.</string>
|
<string name="login_error_code">That code didn\'t match. Try the current code.</string>
|
||||||
<string name="login_error_rate_limited">Too many attempts. Please try again shortly.</string>
|
<string name="login_error_rate_limited">Too many attempts. Please try again shortly.</string>
|
||||||
|
|||||||
@@ -37,6 +37,14 @@ class SsoAuthManagerTest {
|
|||||||
override fun clear() { stored = null }
|
override fun clear() { stored = null }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** In-memory stand-in for the encrypted pending-SSO store (survives across
|
||||||
|
* manager instances the way the on-disk store survives process death). */
|
||||||
|
private class FakePendingSsoStore(var pending: PendingSso? = null) : PendingSsoStore {
|
||||||
|
override fun save(state: String, verifier: String) { pending = PendingSso(state, verifier) }
|
||||||
|
override fun load(): PendingSso? = pending
|
||||||
|
override fun clear() { pending = null }
|
||||||
|
}
|
||||||
|
|
||||||
/** Records the exchange it was called with and returns a scripted response. */
|
/** Records the exchange it was called with and returns a scripted response. */
|
||||||
private class FakeSsoApi(
|
private class FakeSsoApi(
|
||||||
private val exchangeResult: () -> Response<MobileTokenResponse>,
|
private val exchangeResult: () -> Response<MobileTokenResponse>,
|
||||||
@@ -67,10 +75,11 @@ class SsoAuthManagerTest {
|
|||||||
api: SsoApi,
|
api: SsoApi,
|
||||||
session: SessionManager,
|
session: SessionManager,
|
||||||
base: String? = "https://shard.example.com/",
|
base: String? = "https://shard.example.com/",
|
||||||
|
store: PendingSsoStore = FakePendingSsoStore(),
|
||||||
): SsoAuthManager {
|
): SsoAuthManager {
|
||||||
val holder = BaseUrlHolder()
|
val holder = BaseUrlHolder()
|
||||||
if (base != null) holder.set(base.toHttpUrl())
|
if (base != null) holder.set(base.toHttpUrl())
|
||||||
return SsoAuthManager(api, session, holder)
|
return SsoAuthManager(api, session, holder, store)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Build a start URL and pull the generated `state` back out of it. */
|
/** Build a start URL and pull the generated `state` back out of it. */
|
||||||
@@ -135,6 +144,25 @@ class SsoAuthManagerTest {
|
|||||||
assertEquals(SsoAuthManager.Outcome.Failed(SsoAuthManager.Failure.STATE_MISMATCH), mgr.outcome.value)
|
assertEquals(SsoAuthManager.Outcome.Failed(SsoAuthManager.Failure.STATE_MISMATCH), mgr.outcome.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test fun `pending survives process death — a fresh manager on the same store completes`() = runTest {
|
||||||
|
// Persist the pending on one instance, then throw that instance away.
|
||||||
|
val store = FakePendingSsoStore()
|
||||||
|
val session = SessionManager(FakeTokenStore())
|
||||||
|
val started = managerWith(FakeSsoApi { Response.success(tokenPair()) }, session, store = store)
|
||||||
|
val state = startAndState(started)
|
||||||
|
|
||||||
|
// A brand-new manager (simulating the app relaunched after eviction) reads the
|
||||||
|
// persisted pending and completes the exchange — the old in-memory holder would
|
||||||
|
// have lost it and failed STATE_MISMATCH.
|
||||||
|
val api = FakeSsoApi { Response.success(tokenPair()) }
|
||||||
|
val revived = managerWith(api, session, store = store)
|
||||||
|
revived.complete(state = state, code = "auth-code-1", error = null)
|
||||||
|
|
||||||
|
assertEquals(1, api.exchangeCalls)
|
||||||
|
assertTrue(session.state.value is Session.SignedIn)
|
||||||
|
assertEquals(SsoAuthManager.Outcome.Success, revived.outcome.value)
|
||||||
|
}
|
||||||
|
|
||||||
@Test fun `error callback maps to a declined sign-in and does not exchange`() = runTest {
|
@Test fun `error callback maps to a declined sign-in and does not exchange`() = runTest {
|
||||||
val api = FakeSsoApi { Response.success(tokenPair()) }
|
val api = FakeSsoApi { Response.success(tokenPair()) }
|
||||||
val mgr = managerWith(api, SessionManager(FakeTokenStore()))
|
val mgr = managerWith(api, SessionManager(FakeTokenStore()))
|
||||||
|
|||||||
@@ -64,4 +64,24 @@ class MenuAccessTest {
|
|||||||
assertTrue(visibleEntries(entries, signedIn(Role.ADMIN)).isEmpty())
|
assertTrue(visibleEntries(entries, signedIn(Role.ADMIN)).isEmpty())
|
||||||
assertTrue(visibleEntries(entries, Session.SignedOut).isEmpty())
|
assertTrue(visibleEntries(entries, Session.SignedOut).isEmpty())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test fun staffSeeTheAdminDashboardButPlayersDoNot() {
|
||||||
|
// STAFF entries (M10) show for every staff role, never for a player or anon.
|
||||||
|
for (role in listOf(Role.ADMIN, Role.EDITOR, Role.MODERATOR)) {
|
||||||
|
assertTrue("$role should see the dashboard", routes(signedIn(role)).contains(Routes.ADMIN_DASHBOARD))
|
||||||
|
}
|
||||||
|
assertFalse(routes(signedIn(Role.PLAYER)).contains(Routes.ADMIN_DASHBOARD))
|
||||||
|
assertFalse(routes(Session.SignedOut).contains(Routes.ADMIN_DASHBOARD))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun moderatorAccessIsAdminAndModeratorOnly() {
|
||||||
|
// A synthetic MODERATOR-gated entry (moderation / support) is visible to
|
||||||
|
// admin + moderator, but NOT editor, player, or anon.
|
||||||
|
val entries = listOf(MenuEntry("mod", 0, MenuAccess.MODERATOR))
|
||||||
|
assertTrue(visibleEntries(entries, signedIn(Role.ADMIN)).isNotEmpty())
|
||||||
|
assertTrue(visibleEntries(entries, signedIn(Role.MODERATOR)).isNotEmpty())
|
||||||
|
assertTrue(visibleEntries(entries, signedIn(Role.EDITOR)).isEmpty())
|
||||||
|
assertTrue(visibleEntries(entries, signedIn(Role.PLAYER)).isEmpty())
|
||||||
|
assertTrue(visibleEntries(entries, Session.SignedOut).isEmpty())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user