diff --git a/app/src/main/java/com/runicgateway/app/core/auth/Session.kt b/app/src/main/java/com/runicgateway/app/core/auth/Session.kt index a2f5f58..da93185 100644 --- a/app/src/main/java/com/runicgateway/app/core/auth/Session.kt +++ b/app/src/main/java/com/runicgateway/app/core/auth/Session.kt @@ -16,6 +16,15 @@ data class SessionUser( val role: Role, ) { 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 } /** diff --git a/app/src/main/java/com/runicgateway/app/core/auth/sso/EncryptedPendingSsoStore.kt b/app/src/main/java/com/runicgateway/app/core/auth/sso/EncryptedPendingSsoStore.kt new file mode 100644 index 0000000..3548ff2 --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/core/auth/sso/EncryptedPendingSsoStore.kt @@ -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" + } +} diff --git a/app/src/main/java/com/runicgateway/app/core/auth/sso/PendingSsoStore.kt b/app/src/main/java/com/runicgateway/app/core/auth/sso/PendingSsoStore.kt new file mode 100644 index 0000000..822db83 --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/core/auth/sso/PendingSsoStore.kt @@ -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) diff --git a/app/src/main/java/com/runicgateway/app/core/auth/sso/SsoAuthManager.kt b/app/src/main/java/com/runicgateway/app/core/auth/sso/SsoAuthManager.kt index b3ae095..a3dcc50 100644 --- a/app/src/main/java/com/runicgateway/app/core/auth/sso/SsoAuthManager.kt +++ b/app/src/main/java/com/runicgateway/app/core/auth/sso/SsoAuthManager.kt @@ -13,7 +13,6 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import java.io.IOException -import java.util.concurrent.atomic.AtomicReference import javax.inject.Inject 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, * 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 - * while the Custom Tab is foreground it is lost and the exchange **fails closed** - * (the user simply retries) — never a security downgrade. + * The pending `{state, verifier}` is persisted via [PendingSsoStore] (encrypted at + * rest), so the exchange survives the process being evicted while the Custom Tab is + * 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 - * activity's coroutine scope after a deep link. The pending holder is an - * [AtomicReference] and [outcome] a [StateFlow], so a ViewModel/activity recreation - * while the Custom Tab is open cannot drop a result. + * activity's coroutine scope after a deep link. [outcome] is a [StateFlow], so a + * ViewModel/activity recreation while the Custom Tab is open cannot drop a result. */ @Singleton class SsoAuthManager @Inject constructor( private val ssoApi: SsoApi, private val sessionManager: SessionManager, private val baseUrlHolder: BaseUrlHolder, + private val pendingStore: PendingSsoStore, ) { /** 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 } - private data class Pending(val state: String, val verifier: String) - - private val pending = AtomicReference(null) - /** * 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). @@ -97,16 +94,16 @@ class SsoAuthManager @Inject constructor( /** * 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 - * (the caller then keeps the website hand-off fallback). Also resets [outcome] - * to [Outcome.Idle] so a stale prior result can't fire against the new attempt. + * PKCE verifier + CSRF state (persisted so it survives process death). Returns + * null when no shard site is configured yet. Also resets [outcome] to + * [Outcome.Idle] so a stale prior result can't fire against the new attempt. */ fun buildStartUrl(providerId: String): String? { val base = baseUrlHolder.current ?: return null val verifier = Pkce.newVerifier() val challenge = Pkce.challengeOf(verifier) val state = Pkce.newState() - pending.set(Pending(state = state, verifier = verifier)) + pendingStore.save(state = state, verifier = verifier) _outcome.value = Outcome.Idle return base.newBuilder() .addPathSegments("api/v1/auth/mobile/sso/start") @@ -158,7 +155,8 @@ class SsoAuthManager @Inject constructor( * single-uses the code). */ 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. if (stashed == null || state.isNullOrEmpty() || state != stashed.state) { diff --git a/app/src/main/java/com/runicgateway/app/core/web/WebsiteUrls.kt b/app/src/main/java/com/runicgateway/app/core/web/WebsiteUrls.kt index a4d3185..55c66f7 100644 --- a/app/src/main/java/com/runicgateway/app/core/web/WebsiteUrls.kt +++ b/app/src/main/java/com/runicgateway/app/core/web/WebsiteUrls.kt @@ -26,12 +26,8 @@ class WebsiteUrls @Inject constructor( /** Forgot / reset password (the flow built on the backend before app work, §8). */ fun forgotPassword(): String? = resolve(FORGOT) - /** The website login page — carries the SSO provider buttons (§4.2). */ - fun login(): String? = resolve(LOGIN) - private companion object { const val REGISTER = "account/register" const val FORGOT = "account/forgot" - const val LOGIN = "account/login" } } diff --git a/app/src/main/java/com/runicgateway/app/data/api/AdminApi.kt b/app/src/main/java/com/runicgateway/app/data/api/AdminApi.kt new file mode 100644 index 0000000..775af9b --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/data/api/AdminApi.kt @@ -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 + + @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 + + // ── Content: wiki taxonomy (any staff role) ─────────────────────────── + @GET("api/v1/admin/wiki/categories") + suspend fun wikiCategories(): List + + @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 + + @GET("api/v1/admin/wiki/tags") + suspend fun wikiTags(): List + + // ── Moderation: shard write plane (admin/moderator) ─────────────────── + @POST("api/v1/admin/shard/kick") + suspend fun kick(@Body body: KickRequest): Response + + @POST("api/v1/admin/shard/ban") + suspend fun ban(@Body body: BanRequest): Response + + @POST("api/v1/admin/shard/unban") + suspend fun unban(@Body body: UnbanRequest): Response + + @POST("api/v1/admin/shard/broadcast") + suspend fun broadcast(@Body body: BroadcastRequest): Response + + // ── Support queue: help pages (admin/moderator) ─────────────────────── + @GET("api/v1/admin/shard/pages") + suspend fun supportPages(): List + + @POST("api/v1/admin/shard/pages/{id}/respond") + suspend fun respondPage(@Path("id") id: String, @Body body: PageRespondRequest): Response + + @POST("api/v1/admin/shard/pages/{id}/close") + suspend fun closePage(@Path("id") id: String): Response +} diff --git a/app/src/main/java/com/runicgateway/app/data/api/dto/AdminDto.kt b/app/src/main/java/com/runicgateway/app/data/api/dto/AdminDto.kt new file mode 100644 index 0000000..7646246 --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/data/api/dto/AdminDto.kt @@ -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 = 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 = 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) diff --git a/app/src/main/java/com/runicgateway/app/data/repository/AdminRepository.kt b/app/src/main/java/com/runicgateway/app/data/repository/AdminRepository.kt new file mode 100644 index 0000000..255cf77 --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/data/repository/AdminRepository.kt @@ -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 = safeApiCall { api.dashboard() } + + suspend fun setSiteMode(mode: String): ApiResult = + safeApiCall { api.setSiteMode(SiteModeRequest(mode)) } + + // ── Content: news posts ─────────────────────────────────────────────── + suspend fun posts(): ApiResult> = safeApiCall { api.posts() } + + suspend fun createPost(body: PostCreateRequest): ApiResult = + safeApiCall { api.createPost(body) } + + suspend fun setPostPublished(id: Long, published: Boolean): ApiResult = + safeApiCall { api.publishPost(id, PublishRequest(published)) } + + suspend fun deletePost(id: Long): ApiResult = safeApiCall { api.deletePost(id).requireOk() } + + // ── Content: wiki taxonomy ──────────────────────────────────────────── + suspend fun wikiCategories(): ApiResult> = safeApiCall { api.wikiCategories() } + + suspend fun createWikiCategory(body: WikiCategoryRequest): ApiResult = + safeApiCall { api.createWikiCategory(body) } + + suspend fun deleteWikiCategory(id: Long): ApiResult = + safeApiCall { api.deleteWikiCategory(id).requireOk() } + + suspend fun wikiTags(): ApiResult> = safeApiCall { api.wikiTags() } + + // ── Moderation: shard write plane ───────────────────────────────────── + suspend fun kick(account: String?, serial: String?): ApiResult = + safeApiCall { api.kick(KickRequest(account, serial)).requireOk() } + + suspend fun ban(account: String?, serial: String?, durationSec: Long?, reason: String?): ApiResult = + safeApiCall { api.ban(BanRequest(account, serial, durationSec, reason)).requireOk() } + + suspend fun unban(account: String): ApiResult = + safeApiCall { api.unban(UnbanRequest(account)).requireOk() } + + suspend fun broadcast(text: String, hue: Int?): ApiResult = + safeApiCall { api.broadcast(BroadcastRequest(text, hue)).requireOk() } + + // ── Support queue: help pages ───────────────────────────────────────── + suspend fun supportPages(): ApiResult> = safeApiCall { api.supportPages() } + + suspend fun respondPage(id: String, message: String, close: Boolean): ApiResult = + safeApiCall { api.respondPage(id, PageRespondRequest(message, close)).requireOk() } + + suspend fun closePage(id: String): ApiResult = + 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.requireOk() { + if (!isSuccessful) throw HttpException(this) + } +} diff --git a/app/src/main/java/com/runicgateway/app/data/repository/AuthRepository.kt b/app/src/main/java/com/runicgateway/app/data/repository/AuthRepository.kt index a0c859e..79a3207 100644 --- a/app/src/main/java/com/runicgateway/app/data/repository/AuthRepository.kt +++ b/app/src/main/java/com/runicgateway/app/data/repository/AuthRepository.kt @@ -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.TotpRequiredError import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.delay import kotlinx.serialization.json.Json import retrofit2.Response import java.io.IOException @@ -34,17 +35,40 @@ class AuthRepository @Inject constructor( 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) : 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 - * discovery, never secrets. Returns an empty list on any failure — the login - * screen then keeps the website hand-off fallback rather than showing nothing. + * Discover the shard's enabled SSO providers for the native login buttons (§4.2). + * Public discovery, never secrets. Retries once before reporting [Unavailable], + * so a single transient blip doesn't strand the user. */ - suspend fun ssoProviders(): List = try { - ssoApi.providers() - } catch (e: CancellationException) { - throw e - } catch (_: Exception) { - emptyList() + suspend fun ssoProviders(): SsoDiscovery { + var lastFailed = false + repeat(2) { attempt -> + try { + val providers = ssoApi.providers() + 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). */ @@ -140,4 +164,8 @@ class AuthRepository @Inject constructor( } catch (_: Exception) { false } + + private companion object { + const val DISCOVERY_RETRY_DELAY_MS = 400L + } } diff --git a/app/src/main/java/com/runicgateway/app/di/NetworkModule.kt b/app/src/main/java/com/runicgateway/app/di/NetworkModule.kt index 015f5ca..8a0a8a2 100644 --- a/app/src/main/java/com/runicgateway/app/di/NetworkModule.kt +++ b/app/src/main/java/com/runicgateway/app/di/NetworkModule.kt @@ -14,6 +14,7 @@ import com.runicgateway.app.core.net.UserAgentInterceptor import com.runicgateway.app.data.api.AuthApi import com.runicgateway.app.data.api.AuthRefreshApi import com.runicgateway.app.data.api.MeApi +import com.runicgateway.app.data.api.AdminApi import com.runicgateway.app.data.api.NotificationsApi import com.runicgateway.app.data.api.PlayerShardApi import com.runicgateway.app.data.api.PublicApi @@ -119,6 +120,11 @@ object NetworkModule { fun provideNotificationsApi(retrofit: Retrofit): NotificationsApi = 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, * no auth interceptor and no authenticator — so a refresh can never recurse diff --git a/app/src/main/java/com/runicgateway/app/di/StorageModule.kt b/app/src/main/java/com/runicgateway/app/di/StorageModule.kt index 05cc4ff..96c9c91 100644 --- a/app/src/main/java/com/runicgateway/app/di/StorageModule.kt +++ b/app/src/main/java/com/runicgateway/app/di/StorageModule.kt @@ -5,13 +5,15 @@ package com.runicgateway.app.di import com.runicgateway.app.core.auth.EncryptedTokenStore 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.Module import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent import javax.inject.Singleton -/** Binds the at-rest token store to its EncryptedSharedPreferences impl (§4.3). */ +/** Binds the at-rest stores to their EncryptedSharedPreferences impls (§4.3). */ @Module @InstallIn(SingletonComponent::class) abstract class StorageModule { @@ -19,4 +21,8 @@ abstract class StorageModule { @Binds @Singleton abstract fun bindTokenStore(impl: EncryptedTokenStore): TokenStore + + @Binds + @Singleton + abstract fun bindPendingSsoStore(impl: EncryptedPendingSsoStore): PendingSsoStore } diff --git a/app/src/main/java/com/runicgateway/app/ui/RunicApp.kt b/app/src/main/java/com/runicgateway/app/ui/RunicApp.kt index 63769ad..1f17854 100644 --- a/app/src/main/java/com/runicgateway/app/ui/RunicApp.kt +++ b/app/src/main/java/com/runicgateway/app/ui/RunicApp.kt @@ -3,9 +3,12 @@ */ package com.runicgateway.app.ui +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer 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.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack 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.news.NewsScreen 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.page.PageScreen 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.NOTIFICATIONS, 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, 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)) Text( text = brand?.name?.takeIf { it.isNotBlank() } ?: stringResource(R.string.app_name), @@ -189,6 +202,7 @@ fun RunicApp( colors = drawerItemColors, modifier = Modifier.padding(NavigationDrawerItemDefaults.ItemPadding), ) + } } }, ) { @@ -306,7 +320,15 @@ private fun RunicNavHost( ContactScreen() } 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) { // Only meaningful while signed in; a sign-out (here or from the drawer) @@ -352,6 +374,24 @@ private fun RunicNavHost( composable(Routes.PLAYER_HOUSES) { 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. */ private fun NavHostController.navigateTopLevel(route: String) { navigate(route) { diff --git a/app/src/main/java/com/runicgateway/app/ui/admin/AdminContentScreen.kt b/app/src/main/java/com/runicgateway/app/ui/admin/AdminContentScreen.kt new file mode 100644 index 0000000..e230e1d --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/ui/admin/AdminContentScreen.kt @@ -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>, + 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>, + tags: List, + 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, + 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)) + } + }, + ) +} diff --git a/app/src/main/java/com/runicgateway/app/ui/admin/AdminContentViewModel.kt b/app/src/main/java/com/runicgateway/app/ui/admin/AdminContentViewModel.kt new file mode 100644 index 0000000..dbc967c --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/ui/admin/AdminContentViewModel.kt @@ -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> = UiState.Loading, + val categories: UiState> = UiState.Loading, + val tags: List = emptyList(), + val busy: Boolean = false, + val feedback: Feedback? = null, + ) + + private val _state = MutableStateFlow(State()) + val state: StateFlow = _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) + } +} diff --git a/app/src/main/java/com/runicgateway/app/ui/admin/AdminDashboardScreen.kt b/app/src/main/java/com/runicgateway/app/ui/admin/AdminDashboardScreen.kt new file mode 100644 index 0000000..da6f15d --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/ui/admin/AdminDashboardScreen.kt @@ -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) + } +} diff --git a/app/src/main/java/com/runicgateway/app/ui/admin/AdminDashboardViewModel.kt b/app/src/main/java/com/runicgateway/app/ui/admin/AdminDashboardViewModel.kt new file mode 100644 index 0000000..2f19f53 --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/ui/admin/AdminDashboardViewModel.kt @@ -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 = 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.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)) } + } + } + } +} diff --git a/app/src/main/java/com/runicgateway/app/ui/admin/AdminModerationScreen.kt b/app/src/main/java/com/runicgateway/app/ui/admin/AdminModerationScreen.kt new file mode 100644 index 0000000..9261d6f --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/ui/admin/AdminModerationScreen.kt @@ -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)) + } + } +} diff --git a/app/src/main/java/com/runicgateway/app/ui/admin/AdminModerationViewModel.kt b/app/src/main/java/com/runicgateway/app/ui/admin/AdminModerationViewModel.kt new file mode 100644 index 0000000..4ff7180 --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/ui/admin/AdminModerationViewModel.kt @@ -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.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) { + 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) } + } + } +} diff --git a/app/src/main/java/com/runicgateway/app/ui/admin/AdminSupportScreen.kt b/app/src/main/java/com/runicgateway/app/ui/admin/AdminSupportScreen.kt new file mode 100644 index 0000000..9f2d369 --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/ui/admin/AdminSupportScreen.kt @@ -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(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)) + } + } + }, + ) +} diff --git a/app/src/main/java/com/runicgateway/app/ui/admin/AdminSupportViewModel.kt b/app/src/main/java/com/runicgateway/app/ui/admin/AdminSupportViewModel.kt new file mode 100644 index 0000000..64648ef --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/ui/admin/AdminSupportViewModel.kt @@ -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> = UiState.Loading, + val busy: Boolean = false, + val feedback: Feedback? = null, + ) + + private val _state = MutableStateFlow(State()) + val state: StateFlow = _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) { + 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) } + } + } +} diff --git a/app/src/main/java/com/runicgateway/app/ui/auth/LoginScreen.kt b/app/src/main/java/com/runicgateway/app/ui/auth/LoginScreen.kt index a6e01dc..286d95b 100644 --- a/app/src/main/java/com/runicgateway/app/ui/auth/LoginScreen.kt +++ b/app/src/main/java/com/runicgateway/app/ui/auth/LoginScreen.kt @@ -5,8 +5,10 @@ package com.runicgateway.app.ui.auth import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +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.size import androidx.compose.foundation.rememberScrollState @@ -15,14 +17,20 @@ import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Button import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text import androidx.compose.material3.TextButton +import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect 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.Modifier 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 - // Custom-Tab bridge and returns the user signed in. Falls back to the - // website login hand-off when the shard exposes no providers. - if (state.ssoProviders.isNotEmpty()) { - state.ssoProviders.forEach { provider -> + // ── Native SSO (§4.2, M9): a single "Sign in with SSO" button that opens the + // Custom-Tab bridge. With one provider it launches straight through; with + // several it presents a native picker (below). No website-login fallback — + // that page can't deep-link the session back; a failed discovery offers a retry. + var showSsoPicker by remember { mutableStateOf(false) } + when { + state.ssoProviders.isNotEmpty() -> { OutlinedButton( - onClick = { viewModel.onSsoProviderClick(provider) }, + onClick = { + val providers = state.ssoProviders + if (providers.size == 1) viewModel.onSsoProviderClick(providers.first()) + else showSsoPicker = true + }, enabled = !state.submitting, modifier = Modifier .fillMaxWidth() .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 -> - TextButton(onClick = { WebHandoff.open(context, url) }) { - Text(stringResource(R.string.login_sso)) + + state.ssoDiscovering -> { + Text( + 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 ── @@ -198,6 +225,53 @@ fun LoginScreen( 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, + 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 } } diff --git a/app/src/main/java/com/runicgateway/app/ui/auth/LoginViewModel.kt b/app/src/main/java/com/runicgateway/app/ui/auth/LoginViewModel.kt index 0839e0d..3080407 100644 --- a/app/src/main/java/com/runicgateway/app/ui/auth/LoginViewModel.kt +++ b/app/src/main/java/com/runicgateway/app/ui/auth/LoginViewModel.kt @@ -10,6 +10,7 @@ import com.runicgateway.app.core.web.WebsiteUrls import com.runicgateway.app.data.api.dto.SsoProviderDto import com.runicgateway.app.data.repository.AuthRepository import com.runicgateway.app.data.repository.AuthRepository.LoginResult +import com.runicgateway.app.data.repository.AuthRepository.SsoDiscovery import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -43,8 +44,12 @@ class LoginViewModel @Inject constructor( val submitting: Boolean = false, val error: LoginError? = null, 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 = 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. */ val ssoLaunchUrl: String? = null, ) @@ -53,11 +58,7 @@ class LoginViewModel @Inject constructor( val state: StateFlow = _state.asStateFlow() init { - // Discover the native SSO providers to render buttons for (§4.2). - viewModelScope.launch { - val providers = authRepository.ssoProviders() - if (providers.isNotEmpty()) _state.update { it.copy(ssoProviders = providers) } - } + discoverSsoProviders() // Consume the SSO bridge outcome: a returned callback completes here even if // this ViewModel was recreated while the Custom Tab was foreground (§4.2). viewModelScope.launch { @@ -85,8 +86,30 @@ class LoginViewModel @Inject constructor( val registerUrl: String? get() = websiteUrls.register() 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 diff --git a/app/src/main/java/com/runicgateway/app/ui/navigation/Menu.kt b/app/src/main/java/com/runicgateway/app/ui/navigation/Menu.kt index 88d1b6a..283a6dd 100644 --- a/app/src/main/java/com/runicgateway/app/ui/navigation/Menu.kt +++ b/app/src/main/java/com/runicgateway/app/ui/navigation/Menu.kt @@ -23,6 +23,12 @@ enum class MenuAccess { /** Visible only to a player — the linked game-data groups (§6.3). */ 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( @@ -48,6 +54,11 @@ val APP_MENU: List = listOf( 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_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, session: Session): List MenuAccess.PUBLIC -> true MenuAccess.SIGNED_IN -> session is Session.SignedIn 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 } } diff --git a/app/src/main/java/com/runicgateway/app/ui/navigation/Routes.kt b/app/src/main/java/com/runicgateway/app/ui/navigation/Routes.kt index b508b12..f088cf6 100644 --- a/app/src/main/java/com/runicgateway/app/ui/navigation/Routes.kt +++ b/app/src/main/java/com/runicgateway/app/ui/navigation/Routes.kt @@ -38,6 +38,13 @@ object Routes { /** A single character sheet by in-game (hex) 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). */ const val PAGE = "page/{slug}" diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index cab1716..77e38ef 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -46,10 +46,92 @@ My characters My vendors My houses + Dashboard + Content + Moderation + Support queue Sign in Sign out Change server + + Site + Counts + Recent activity + Live + Maintenance + Switch to maintenance + Switch to live + by %1$s + Site mode updated. + Users + Posts + You don\'t have permission for that action. + That action couldn\'t be completed. Please try again. + Cancel + + + Posts + Wiki + New post + New category + Create + Published + Draft + Publish + Unpublish + Delete + Publish now + Title + Excerpt + Body + Slug + Description + Sort order + + %1$s · %2$d pages + + Tags: %1$s + Post created. + Post updated. + Post deleted. + Category created. + Category deleted. + A title is required. + Slug and title are required. + + + Account action + Account + Serial (0x…) + Reason (ban) + Ban duration (seconds; blank = indefinite) + Kick + Ban + Unban + Broadcast + Message to everyone online + Broadcast + Account kicked. + Account banned. + Ban cleared. + Message broadcast. + Enter an account or serial. + Enter a message to broadcast. + The shard is offline — the action couldn\'t be delivered. + + + No open help pages. + Reply + Close + Send + Reply message + Close the page after replying + Reply sent. + Page closed. + Enter a reply message. + That page is no longer in the queue. + Sign in Sign in with your shard account. @@ -60,9 +142,13 @@ Sign in Create an account Forgot your password? - Sign in with Google or Discord (on the website) - + + Sign in with SSO + Choose a sign-in provider + Sign in with %1$s + Loading sign-in options… + Couldn\'t load sign-in options. Tap to retry. Incorrect username or password. That code didn\'t match. Try the current code. Too many attempts. Please try again shortly. diff --git a/app/src/test/java/com/runicgateway/app/core/auth/sso/SsoAuthManagerTest.kt b/app/src/test/java/com/runicgateway/app/core/auth/sso/SsoAuthManagerTest.kt index 0f01ebe..c7af12b 100644 --- a/app/src/test/java/com/runicgateway/app/core/auth/sso/SsoAuthManagerTest.kt +++ b/app/src/test/java/com/runicgateway/app/core/auth/sso/SsoAuthManagerTest.kt @@ -37,6 +37,14 @@ class SsoAuthManagerTest { 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. */ private class FakeSsoApi( private val exchangeResult: () -> Response, @@ -67,10 +75,11 @@ class SsoAuthManagerTest { api: SsoApi, session: SessionManager, base: String? = "https://shard.example.com/", + store: PendingSsoStore = FakePendingSsoStore(), ): SsoAuthManager { val holder = BaseUrlHolder() 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. */ @@ -135,6 +144,25 @@ class SsoAuthManagerTest { 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 { val api = FakeSsoApi { Response.success(tokenPair()) } val mgr = managerWith(api, SessionManager(FakeTokenStore())) diff --git a/app/src/test/java/com/runicgateway/app/ui/navigation/MenuAccessTest.kt b/app/src/test/java/com/runicgateway/app/ui/navigation/MenuAccessTest.kt index 61ab317..40ec162 100644 --- a/app/src/test/java/com/runicgateway/app/ui/navigation/MenuAccessTest.kt +++ b/app/src/test/java/com/runicgateway/app/ui/navigation/MenuAccessTest.kt @@ -64,4 +64,24 @@ class MenuAccessTest { assertTrue(visibleEntries(entries, signedIn(Role.ADMIN)).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()) + } }