Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 422892f1ed | |||
| 7f876377f0 | |||
| c5596845c1 | |||
| ac99a012b0 | |||
| 44d039d2a0 | |||
| 0f93f3dcd3 | |||
| c69d704881 | |||
| 26b8eecde6 | |||
| f729b772fc | |||
| 402d750138 |
54
.gitea/workflows/sonarqube.yml
Normal file
54
.gitea/workflows/sonarqube.yml
Normal file
@@ -0,0 +1,54 @@
|
||||
# Run SonarQube static analysis against the code that just landed on `main` and
|
||||
# report the results to the self-hosted SonarQube server for review. This is
|
||||
# intentionally NON-BLOCKING: it triggers on push to main (i.e. AFTER merge),
|
||||
# not on pull_request, so it never gates a PR. It complements pr-checks.yml
|
||||
# (which gates PRs) and release.yml (which ships the APK) — this one only feeds
|
||||
# the dashboard.
|
||||
#
|
||||
# Prerequisites (one-time, in the Gitea UI — Repo → Settings → Actions):
|
||||
# • Secret SONAR_TOKEN — a SonarQube "Analysis" token generated at
|
||||
# My Account → Security in SonarQube for the
|
||||
# Runic-Gateway-Android-app project (or a global one).
|
||||
# • Variable SONAR_HOST_URL — the SonarQube base URL on your LAN, e.g.
|
||||
# http://192.168.0.56:9000
|
||||
# (kept as a variable, not committed, so the internal address stays out of git.)
|
||||
#
|
||||
# The runner (self-hosted `ubuntu-latest`, same as the other workflows) must be
|
||||
# able to reach SONAR_HOST_URL on your network. Nothing here waits on the
|
||||
# SonarQube Quality Gate, so a failing gate does not fail this job — check the
|
||||
# dashboard when you want to.
|
||||
#
|
||||
# Scope: this analyses the Kotlin source directly (the Sonar scanner reads
|
||||
# sonar-project.properties). It does NOT run a Gradle build, so no Android SDK /
|
||||
# JDK install is needed — the Kotlin analyzer is source-based. See the "Optional
|
||||
# enrichment" note in sonar-project.properties for wiring in Android Lint /
|
||||
# coverage reports later.
|
||||
|
||||
name: SonarQube
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
# Allow re-running the analysis on demand from the Actions tab.
|
||||
workflow_dispatch: {}
|
||||
|
||||
concurrency:
|
||||
group: sonarqube-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
analysis:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out (full history for accurate new-code + blame)
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
# SonarQube uses git history to attribute issues to authors and to
|
||||
# compute "new code". A shallow clone degrades both.
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Run SonarQube scan
|
||||
uses: sonarsource/sonarqube-scan-action@v4
|
||||
env:
|
||||
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
|
||||
SONAR_HOST_URL: ${{ vars.SONAR_HOST_URL }}
|
||||
20
app/src/debug/res/xml/network_security_config.xml
Normal file
20
app/src/debug/res/xml/network_security_config.xml
Normal file
@@ -0,0 +1,20 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- SPDX-License-Identifier: GPL-3.0-or-later -->
|
||||
<!--
|
||||
Debug-only override of the main network_security_config.xml. Keeps the secure
|
||||
base posture (no cleartext) but re-permits cleartext to loopback so debug builds
|
||||
can reach a local website backend at http://127.0.0.1:3000 / http://localhost:3000
|
||||
(ServerUrl allows plain HTTP only when allowInsecureHttp = BuildConfig.DEBUG).
|
||||
Because the platform default already blocks cleartext at targetSdk 28+, this
|
||||
domain-config is what actually makes the debug local-dev path work at runtime.
|
||||
|
||||
This file is compiled only into debug builds; release builds use the main
|
||||
source set's config and permit no cleartext at all.
|
||||
-->
|
||||
<network-security-config>
|
||||
<base-config cleartextTrafficPermitted="false" />
|
||||
<domain-config cleartextTrafficPermitted="true">
|
||||
<domain includeSubdomains="false">127.0.0.1</domain>
|
||||
<domain includeSubdomains="false">localhost</domain>
|
||||
</domain-config>
|
||||
</network-security-config>
|
||||
@@ -20,6 +20,7 @@
|
||||
android:fullBackupContent="@xml/backup_rules"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:networkSecurityConfig="@xml/network_security_config"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.RunicGateway">
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.core.auth.sso
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import androidx.security.crypto.EncryptedSharedPreferences
|
||||
import androidx.security.crypto.MasterKey
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* [PendingSsoStore] backed by Jetpack Security's [EncryptedSharedPreferences]
|
||||
* (Tink/AES-256-GCM), so the PKCE verifier is encrypted at rest for the brief
|
||||
* window a flow is in progress. Separate prefs file from the session token store —
|
||||
* this holds only the transient SSO handshake, cleared as soon as the callback is
|
||||
* consumed. Lazy, so a device that never signs in via SSO pays no keystore cost.
|
||||
*/
|
||||
@Singleton
|
||||
class EncryptedPendingSsoStore @Inject constructor(
|
||||
@param:ApplicationContext private val context: Context,
|
||||
) : PendingSsoStore {
|
||||
|
||||
private val prefs: SharedPreferences by lazy {
|
||||
val masterKey = MasterKey.Builder(context)
|
||||
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
|
||||
.build()
|
||||
EncryptedSharedPreferences.create(
|
||||
context,
|
||||
PREFS_NAME,
|
||||
masterKey,
|
||||
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
|
||||
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
|
||||
)
|
||||
}
|
||||
|
||||
override fun save(state: String, verifier: String) {
|
||||
prefs.edit()
|
||||
.putString(KEY_STATE, state)
|
||||
.putString(KEY_VERIFIER, verifier)
|
||||
.apply()
|
||||
}
|
||||
|
||||
override fun load(): PendingSso? {
|
||||
val state = prefs.getString(KEY_STATE, null) ?: return null
|
||||
val verifier = prefs.getString(KEY_VERIFIER, null) ?: return null
|
||||
return PendingSso(state = state, verifier = verifier)
|
||||
}
|
||||
|
||||
override fun clear() {
|
||||
prefs.edit().clear().apply()
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val PREFS_NAME = "runic_sso_pending"
|
||||
const val KEY_STATE = "state"
|
||||
const val KEY_VERIFIER = "verifier"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.core.auth.sso
|
||||
|
||||
/**
|
||||
* Persists the in-flight SSO `{state, verifier}` (PKCE Layer B + CSRF state) across
|
||||
* the Custom-Tab round trip so the exchange survives process death — a low-memory
|
||||
* device can evict the app while the Custom Tab is foreground, and the callback then
|
||||
* returns to a fresh process (PLAN.md §4.2). Kept behind an interface so
|
||||
* [SsoAuthManager] stays framework-free and unit-tests on the JVM with a fake.
|
||||
*
|
||||
* Exactly one flow is pending at a time; [save] overwrites any prior. The verifier
|
||||
* is a bearer-equivalent secret for the one-time code, so the production impl
|
||||
* ([EncryptedPendingSsoStore]) encrypts it at rest, mirroring the token store.
|
||||
*/
|
||||
interface PendingSsoStore {
|
||||
fun save(state: String, verifier: String)
|
||||
fun load(): PendingSso?
|
||||
fun clear()
|
||||
}
|
||||
|
||||
/** The stashed CSRF state + PKCE verifier for the current SSO attempt. */
|
||||
data class PendingSso(val state: String, val verifier: String)
|
||||
@@ -13,7 +13,6 @@ import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.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<Pending?>(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) {
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
97
app/src/main/java/com/runicgateway/app/data/api/AdminApi.kt
Normal file
97
app/src/main/java/com/runicgateway/app/data/api/AdminApi.kt
Normal file
@@ -0,0 +1,97 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.data.api
|
||||
|
||||
import com.runicgateway.app.data.api.dto.AdminDashboardDto
|
||||
import com.runicgateway.app.data.api.dto.AdminPostDto
|
||||
import com.runicgateway.app.data.api.dto.BanRequest
|
||||
import com.runicgateway.app.data.api.dto.BroadcastRequest
|
||||
import com.runicgateway.app.data.api.dto.KickRequest
|
||||
import com.runicgateway.app.data.api.dto.PageRespondRequest
|
||||
import com.runicgateway.app.data.api.dto.PostCreateRequest
|
||||
import com.runicgateway.app.data.api.dto.PublishRequest
|
||||
import com.runicgateway.app.data.api.dto.SiteModeRequest
|
||||
import com.runicgateway.app.data.api.dto.SiteModeStateDto
|
||||
import com.runicgateway.app.data.api.dto.SupportPageDto
|
||||
import com.runicgateway.app.data.api.dto.UnbanRequest
|
||||
import com.runicgateway.app.data.api.dto.AdminWikiCategoryDto
|
||||
import com.runicgateway.app.data.api.dto.WikiCategoryRequest
|
||||
import com.runicgateway.app.data.api.dto.AdminWikiTagDto
|
||||
import retrofit2.Response
|
||||
import retrofit2.http.Body
|
||||
import retrofit2.http.DELETE
|
||||
import retrofit2.http.GET
|
||||
import retrofit2.http.PATCH
|
||||
import retrofit2.http.PUT
|
||||
import retrofit2.http.POST
|
||||
import retrofit2.http.Path
|
||||
|
||||
/**
|
||||
* The M10 staff-operations surface over `/api/v1/admin/…` (PLAN.md §1, §6.4). On
|
||||
* the authed client — every call carries the bearer, and the backend re-checks the
|
||||
* caller's role on every request (`staffOnly` / `modAccess` / `adminOnly`), so a
|
||||
* demoted user is refused server-side even if a stale menu still showed the entry.
|
||||
*
|
||||
* Grows one group at a time (dashboard first); moderation, support, and content
|
||||
* endpoints are added with their screens.
|
||||
*/
|
||||
interface AdminApi {
|
||||
|
||||
/** `GET /admin/dashboard` — summary counts + site mode (any staff role). */
|
||||
@GET("api/v1/admin/dashboard")
|
||||
suspend fun dashboard(): AdminDashboardDto
|
||||
|
||||
/** `PUT /admin/site-mode` — switch live/maintenance (admin only; 403 otherwise). */
|
||||
@PUT("api/v1/admin/site-mode")
|
||||
suspend fun setSiteMode(@Body body: SiteModeRequest): SiteModeStateDto
|
||||
|
||||
// ── Content: news posts (any staff role) ──────────────────────────────
|
||||
@GET("api/v1/admin/posts")
|
||||
suspend fun posts(): List<AdminPostDto>
|
||||
|
||||
@POST("api/v1/admin/posts")
|
||||
suspend fun createPost(@Body body: PostCreateRequest): AdminPostDto
|
||||
|
||||
@PATCH("api/v1/admin/posts/{id}/publish")
|
||||
suspend fun publishPost(@Path("id") id: Long, @Body body: PublishRequest): AdminPostDto
|
||||
|
||||
@DELETE("api/v1/admin/posts/{id}")
|
||||
suspend fun deletePost(@Path("id") id: Long): Response<Unit>
|
||||
|
||||
// ── Content: wiki taxonomy (any staff role) ───────────────────────────
|
||||
@GET("api/v1/admin/wiki/categories")
|
||||
suspend fun wikiCategories(): List<AdminWikiCategoryDto>
|
||||
|
||||
@POST("api/v1/admin/wiki/categories")
|
||||
suspend fun createWikiCategory(@Body body: WikiCategoryRequest): AdminWikiCategoryDto
|
||||
|
||||
@DELETE("api/v1/admin/wiki/categories/{id}")
|
||||
suspend fun deleteWikiCategory(@Path("id") id: Long): Response<Unit>
|
||||
|
||||
@GET("api/v1/admin/wiki/tags")
|
||||
suspend fun wikiTags(): List<AdminWikiTagDto>
|
||||
|
||||
// ── Moderation: shard write plane (admin/moderator) ───────────────────
|
||||
@POST("api/v1/admin/shard/kick")
|
||||
suspend fun kick(@Body body: KickRequest): Response<Unit>
|
||||
|
||||
@POST("api/v1/admin/shard/ban")
|
||||
suspend fun ban(@Body body: BanRequest): Response<Unit>
|
||||
|
||||
@POST("api/v1/admin/shard/unban")
|
||||
suspend fun unban(@Body body: UnbanRequest): Response<Unit>
|
||||
|
||||
@POST("api/v1/admin/shard/broadcast")
|
||||
suspend fun broadcast(@Body body: BroadcastRequest): Response<Unit>
|
||||
|
||||
// ── Support queue: help pages (admin/moderator) ───────────────────────
|
||||
@GET("api/v1/admin/shard/pages")
|
||||
suspend fun supportPages(): List<SupportPageDto>
|
||||
|
||||
@POST("api/v1/admin/shard/pages/{id}/respond")
|
||||
suspend fun respondPage(@Path("id") id: String, @Body body: PageRespondRequest): Response<Unit>
|
||||
|
||||
@POST("api/v1/admin/shard/pages/{id}/close")
|
||||
suspend fun closePage(@Path("id") id: String): Response<Unit>
|
||||
}
|
||||
179
app/src/main/java/com/runicgateway/app/data/api/dto/AdminDto.kt
Normal file
179
app/src/main/java/com/runicgateway/app/data/api/dto/AdminDto.kt
Normal file
@@ -0,0 +1,179 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.data.api.dto
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
|
||||
/**
|
||||
* Wire shapes for the M10 staff-operations surface over `/api/v1/admin/…` (PLAN.md
|
||||
* §1, §6.4). These are consumed only by the staff screens (dashboard, moderation,
|
||||
* support, content); every DTO ignores unknown keys (NetworkModule's lenient Json)
|
||||
* so additive backend fields stay safe. Nothing here is auto-provisioned or secret.
|
||||
*/
|
||||
|
||||
/** `GET /admin/dashboard` — the staff landing summary. */
|
||||
@Serializable
|
||||
data class AdminDashboardDto(
|
||||
@SerialName("site_mode") val siteMode: String = "live",
|
||||
@SerialName("last_change") val lastChange: SiteModeChangeDto = SiteModeChangeDto(),
|
||||
val counts: AdminCountsDto = AdminCountsDto(),
|
||||
@SerialName("recent_activity") val recentActivity: List<AdminActivityDto> = emptyList(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class SiteModeChangeDto(
|
||||
val at: String? = null,
|
||||
val by: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class AdminCountsDto(
|
||||
/** Post counts keyed by DB category (`news`, `five_on_friday`, …). */
|
||||
val posts: Map<String, Int> = emptyMap(),
|
||||
val users: Int = 0,
|
||||
)
|
||||
|
||||
/** One row of the recent admin-activity log. `detail` is provider-shaped JSON. */
|
||||
@Serializable
|
||||
data class AdminActivityDto(
|
||||
val id: Long = 0,
|
||||
val username: String? = null,
|
||||
val action: String = "",
|
||||
val detail: JsonElement? = null,
|
||||
@SerialName("created_at") val createdAt: String? = null,
|
||||
)
|
||||
|
||||
/** `PUT /admin/site-mode` request + response. */
|
||||
@Serializable
|
||||
data class SiteModeRequest(val mode: String)
|
||||
|
||||
@Serializable
|
||||
data class SiteModeStateDto(
|
||||
@SerialName("site_mode") val siteMode: String = "live",
|
||||
@SerialName("changed_at") val changedAt: String? = null,
|
||||
@SerialName("changed_by") val changedBy: String? = null,
|
||||
)
|
||||
|
||||
// ── Content: news posts ───────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* A post row from `GET /admin/posts` (all posts, incl. unpublished — unlike the
|
||||
* public feed). `published` is a 0/1 flag (MariaDB tinyint), exposed as [isPublished].
|
||||
*/
|
||||
@Serializable
|
||||
data class AdminPostDto(
|
||||
val id: Long,
|
||||
val category: String = "",
|
||||
val title: String = "",
|
||||
val slug: String? = null,
|
||||
val excerpt: String? = null,
|
||||
val body: String? = null,
|
||||
@SerialName("image_url") val imageUrl: String? = null,
|
||||
val published: Int = 0,
|
||||
@SerialName("published_at") val publishedAt: String? = null,
|
||||
@SerialName("created_at") val createdAt: String? = null,
|
||||
) {
|
||||
val isPublished: Boolean get() = published != 0
|
||||
}
|
||||
|
||||
/** `POST/PUT /admin/posts` body. `category` is a URL category the backend maps
|
||||
* (news | five-on-friday | newsletter | screenshots). */
|
||||
@Serializable
|
||||
data class PostCreateRequest(
|
||||
val category: String,
|
||||
val title: String,
|
||||
val excerpt: String? = null,
|
||||
val body: String? = null,
|
||||
@SerialName("image_url") val imageUrl: String? = null,
|
||||
val published: Boolean = false,
|
||||
)
|
||||
|
||||
/** `PATCH /admin/posts/:id/publish` body. */
|
||||
@Serializable
|
||||
data class PublishRequest(val published: Boolean)
|
||||
|
||||
// ── Content: wiki taxonomy ────────────────────────────────────────────────
|
||||
|
||||
/** A wiki category from `GET /admin/wiki/categories` (with page counts). */
|
||||
@Serializable
|
||||
data class AdminWikiCategoryDto(
|
||||
val id: Long,
|
||||
val slug: String = "",
|
||||
val title: String = "",
|
||||
val description: String? = null,
|
||||
@SerialName("sort_order") val sortOrder: Int? = null,
|
||||
@SerialName("page_count") val pageCount: Int? = null,
|
||||
@SerialName("published_count") val publishedCount: Int? = null,
|
||||
)
|
||||
|
||||
/** `POST /admin/wiki/categories` body. */
|
||||
@Serializable
|
||||
data class WikiCategoryRequest(
|
||||
val slug: String,
|
||||
val title: String,
|
||||
val description: String? = null,
|
||||
@SerialName("sort_order") val sortOrder: Int? = null,
|
||||
)
|
||||
|
||||
/** A wiki tag from `GET /admin/wiki/tags` (tags derive from pages; read-only here). */
|
||||
@Serializable
|
||||
data class AdminWikiTagDto(
|
||||
val id: Long,
|
||||
val slug: String = "",
|
||||
val label: String = "",
|
||||
@SerialName("published_count") val publishedCount: Int? = null,
|
||||
)
|
||||
|
||||
// ── Moderation (admin/moderator; shard write plane) ───────────────────────
|
||||
|
||||
/** `POST /admin/shard/kick` — at least one of account/serial. */
|
||||
@Serializable
|
||||
data class KickRequest(val account: String? = null, val serial: String? = null)
|
||||
|
||||
/** `POST /admin/shard/ban` — account/serial + optional duration (0/absent = indefinite). */
|
||||
@Serializable
|
||||
data class BanRequest(
|
||||
val account: String? = null,
|
||||
val serial: String? = null,
|
||||
@SerialName("durationSec") val durationSec: Long? = null,
|
||||
val reason: String? = null,
|
||||
)
|
||||
|
||||
/** `POST /admin/shard/unban`. */
|
||||
@Serializable
|
||||
data class UnbanRequest(val account: String)
|
||||
|
||||
/** `POST /admin/shard/broadcast` — a system message to everyone online. */
|
||||
@Serializable
|
||||
data class BroadcastRequest(val text: String, val hue: Int? = null)
|
||||
|
||||
// ── Support queue (admin/moderator; help pages) ───────────────────────────
|
||||
|
||||
/**
|
||||
* One open help page from `GET /admin/shard/pages` (INTEGRATION.md §4). `pageId`
|
||||
* is the sender's in-game serial (the `:id` for respond/close). Permissive — the
|
||||
* shard-state fields beyond these (coords, timing) are ignored.
|
||||
*/
|
||||
@Serializable
|
||||
data class SupportPageDto(
|
||||
@SerialName("pageId") val pageId: String = "",
|
||||
val type: String? = null,
|
||||
val message: String? = null,
|
||||
val handled: Boolean? = null,
|
||||
val handler: String? = null,
|
||||
val sender: SupportActorDto? = null,
|
||||
)
|
||||
|
||||
/** The page's sender (actor object); [account] present when the character is linked. */
|
||||
@Serializable
|
||||
data class SupportActorDto(
|
||||
val name: String? = null,
|
||||
val account: String? = null,
|
||||
)
|
||||
|
||||
/** `POST /admin/shard/pages/:id/respond` — reply, optionally closing the page. */
|
||||
@Serializable
|
||||
data class PageRespondRequest(val message: String, val close: Boolean = false)
|
||||
@@ -0,0 +1,94 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.data.repository
|
||||
|
||||
import com.runicgateway.app.core.result.ApiResult
|
||||
import com.runicgateway.app.core.result.safeApiCall
|
||||
import com.runicgateway.app.data.api.AdminApi
|
||||
import com.runicgateway.app.data.api.dto.AdminDashboardDto
|
||||
import com.runicgateway.app.data.api.dto.AdminPostDto
|
||||
import com.runicgateway.app.data.api.dto.BanRequest
|
||||
import com.runicgateway.app.data.api.dto.BroadcastRequest
|
||||
import com.runicgateway.app.data.api.dto.KickRequest
|
||||
import com.runicgateway.app.data.api.dto.PageRespondRequest
|
||||
import com.runicgateway.app.data.api.dto.PostCreateRequest
|
||||
import com.runicgateway.app.data.api.dto.PublishRequest
|
||||
import com.runicgateway.app.data.api.dto.SiteModeRequest
|
||||
import com.runicgateway.app.data.api.dto.SiteModeStateDto
|
||||
import com.runicgateway.app.data.api.dto.SupportPageDto
|
||||
import com.runicgateway.app.data.api.dto.UnbanRequest
|
||||
import com.runicgateway.app.data.api.dto.AdminWikiCategoryDto
|
||||
import com.runicgateway.app.data.api.dto.WikiCategoryRequest
|
||||
import com.runicgateway.app.data.api.dto.AdminWikiTagDto
|
||||
import retrofit2.HttpException
|
||||
import retrofit2.Response
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* The M10 staff-operations data source over `/api/v1/admin/…` (PLAN.md §1, §6.4).
|
||||
* Every call returns a typed [ApiResult] so a screen renders a clean error/retry
|
||||
* rather than crashing — a `403` (role lost since the menu rendered) and a `503`
|
||||
* (shard/sidecar offline for the shard-write actions) are both expected outcomes
|
||||
* the UI handles, never thrown. Role is authoritative on the server.
|
||||
*/
|
||||
@Singleton
|
||||
class AdminRepository @Inject constructor(
|
||||
private val api: AdminApi,
|
||||
) {
|
||||
suspend fun dashboard(): ApiResult<AdminDashboardDto> = safeApiCall { api.dashboard() }
|
||||
|
||||
suspend fun setSiteMode(mode: String): ApiResult<SiteModeStateDto> =
|
||||
safeApiCall { api.setSiteMode(SiteModeRequest(mode)) }
|
||||
|
||||
// ── Content: news posts ───────────────────────────────────────────────
|
||||
suspend fun posts(): ApiResult<List<AdminPostDto>> = safeApiCall { api.posts() }
|
||||
|
||||
suspend fun createPost(body: PostCreateRequest): ApiResult<AdminPostDto> =
|
||||
safeApiCall { api.createPost(body) }
|
||||
|
||||
suspend fun setPostPublished(id: Long, published: Boolean): ApiResult<AdminPostDto> =
|
||||
safeApiCall { api.publishPost(id, PublishRequest(published)) }
|
||||
|
||||
suspend fun deletePost(id: Long): ApiResult<Unit> = safeApiCall { api.deletePost(id).requireOk() }
|
||||
|
||||
// ── Content: wiki taxonomy ────────────────────────────────────────────
|
||||
suspend fun wikiCategories(): ApiResult<List<AdminWikiCategoryDto>> = safeApiCall { api.wikiCategories() }
|
||||
|
||||
suspend fun createWikiCategory(body: WikiCategoryRequest): ApiResult<AdminWikiCategoryDto> =
|
||||
safeApiCall { api.createWikiCategory(body) }
|
||||
|
||||
suspend fun deleteWikiCategory(id: Long): ApiResult<Unit> =
|
||||
safeApiCall { api.deleteWikiCategory(id).requireOk() }
|
||||
|
||||
suspend fun wikiTags(): ApiResult<List<AdminWikiTagDto>> = safeApiCall { api.wikiTags() }
|
||||
|
||||
// ── Moderation: shard write plane ─────────────────────────────────────
|
||||
suspend fun kick(account: String?, serial: String?): ApiResult<Unit> =
|
||||
safeApiCall { api.kick(KickRequest(account, serial)).requireOk() }
|
||||
|
||||
suspend fun ban(account: String?, serial: String?, durationSec: Long?, reason: String?): ApiResult<Unit> =
|
||||
safeApiCall { api.ban(BanRequest(account, serial, durationSec, reason)).requireOk() }
|
||||
|
||||
suspend fun unban(account: String): ApiResult<Unit> =
|
||||
safeApiCall { api.unban(UnbanRequest(account)).requireOk() }
|
||||
|
||||
suspend fun broadcast(text: String, hue: Int?): ApiResult<Unit> =
|
||||
safeApiCall { api.broadcast(BroadcastRequest(text, hue)).requireOk() }
|
||||
|
||||
// ── Support queue: help pages ─────────────────────────────────────────
|
||||
suspend fun supportPages(): ApiResult<List<SupportPageDto>> = safeApiCall { api.supportPages() }
|
||||
|
||||
suspend fun respondPage(id: String, message: String, close: Boolean): ApiResult<Unit> =
|
||||
safeApiCall { api.respondPage(id, PageRespondRequest(message, close)).requireOk() }
|
||||
|
||||
suspend fun closePage(id: String): ApiResult<Unit> =
|
||||
safeApiCall { api.closePage(id).requireOk() }
|
||||
|
||||
/** Turn a bodyless [Response] into a thrown [HttpException] on a non-2xx, so
|
||||
* [safeApiCall] can fold it into an [ApiResult.HttpError] like every other call. */
|
||||
private fun Response<Unit>.requireOk() {
|
||||
if (!isSuccessful) throw HttpException(this)
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import com.runicgateway.app.data.api.dto.MobileTokenResponse
|
||||
import com.runicgateway.app.data.api.dto.SsoProviderDto
|
||||
import com.runicgateway.app.data.api.dto.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<SsoProviderDto>) : SsoDiscovery
|
||||
|
||||
/** Discovery succeeded but the shard has no SSO providers configured. */
|
||||
data object None : SsoDiscovery
|
||||
|
||||
/** The discovery call failed (offline / server error) — surface a retry. */
|
||||
data object Unavailable : SsoDiscovery
|
||||
}
|
||||
|
||||
/**
|
||||
* The shard's enabled SSO providers for the native login buttons (§4.2). Public
|
||||
* 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<SsoProviderDto> = try {
|
||||
ssoApi.providers()
|
||||
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) {
|
||||
emptyList()
|
||||
lastFailed = true
|
||||
if (attempt == 0) delay(DISCOVERY_RETRY_DELAY_MS)
|
||||
}
|
||||
}
|
||||
return if (lastFailed) SsoDiscovery.Unavailable else SsoDiscovery.None
|
||||
}
|
||||
|
||||
/** Outcome of a login attempt (§4.1). */
|
||||
@@ -140,4 +164,8 @@ class AuthRepository @Inject constructor(
|
||||
} catch (_: Exception) {
|
||||
false
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val DISCOVERY_RETRY_DELAY_MS = 400L
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import com.runicgateway.app.core.net.UserAgentInterceptor
|
||||
import com.runicgateway.app.data.api.AuthApi
|
||||
import com.runicgateway.app.data.api.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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
@@ -190,6 +203,7 @@ fun RunicApp(
|
||||
modifier = Modifier.padding(NavigationDrawerItemDefaults.ItemPadding),
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
) {
|
||||
Scaffold(
|
||||
@@ -306,8 +320,16 @@ private fun RunicNavHost(
|
||||
ContactScreen()
|
||||
}
|
||||
composable(Routes.LOGIN) {
|
||||
// Leave the login screen as soon as the session is established — whether by
|
||||
// password or the SSO bridge. Keying off the shared session (not just the
|
||||
// login VM's local flag) makes this robust to the deep-link/recomposition
|
||||
// timing of the Custom-Tab return, which the LoginScreen callback alone can miss.
|
||||
if (session is Session.SignedIn) {
|
||||
LaunchedEffect(Unit) { navController.popBackStack(Routes.LOGIN, inclusive = true) }
|
||||
} else {
|
||||
LoginScreen(onSignedIn = { navController.popBackStack() })
|
||||
}
|
||||
}
|
||||
composable(Routes.ACCOUNT) {
|
||||
// Only meaningful while signed in; a sign-out (here or from the drawer)
|
||||
// sends the user home rather than leaving a stale identity on screen.
|
||||
@@ -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) {
|
||||
|
||||
@@ -0,0 +1,292 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.ui.admin
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Tab
|
||||
import androidx.compose.material3.TabRow
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.runicgateway.app.R
|
||||
import com.runicgateway.app.data.api.dto.AdminPostDto
|
||||
import com.runicgateway.app.data.api.dto.AdminWikiCategoryDto
|
||||
import com.runicgateway.app.data.api.dto.AdminWikiTagDto
|
||||
import com.runicgateway.app.ui.UiState
|
||||
import com.runicgateway.app.ui.components.ErrorView
|
||||
import com.runicgateway.app.ui.components.LoadingView
|
||||
import com.runicgateway.app.ui.components.PillTone
|
||||
import com.runicgateway.app.ui.components.StatusPill
|
||||
|
||||
/**
|
||||
* The staff content screen (PLAN.md §1, M10): news posts and wiki taxonomy, in two
|
||||
* tabs. Create/publish/delete over the existing `/admin/posts` + `/admin/wiki/…`
|
||||
* routes; the CMS block/hero editor stays out of scope. Any staff role; the server
|
||||
* re-checks on every call.
|
||||
*/
|
||||
@Composable
|
||||
fun AdminContentScreen(
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: AdminContentViewModel = hiltViewModel(),
|
||||
) {
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
var tab by rememberSaveable { mutableIntStateOf(0) }
|
||||
var showNewPost by rememberSaveable { mutableStateOf(false) }
|
||||
var showNewCategory by rememberSaveable { mutableStateOf(false) }
|
||||
|
||||
Column(modifier.fillMaxSize()) {
|
||||
TabRow(selectedTabIndex = tab) {
|
||||
Tab(selected = tab == 0, onClick = { tab = 0 }, text = { Text(stringResource(R.string.admin_content_tab_posts)) })
|
||||
Tab(selected = tab == 1, onClick = { tab = 1 }, text = { Text(stringResource(R.string.admin_content_tab_wiki)) })
|
||||
}
|
||||
|
||||
state.feedback?.let {
|
||||
Text(
|
||||
text = stringResource(it.messageRes),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = if (it.ok) MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.error,
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 6.dp),
|
||||
)
|
||||
}
|
||||
|
||||
when (tab) {
|
||||
0 -> PostsTab(
|
||||
state = state.posts,
|
||||
busy = state.busy,
|
||||
onNew = { showNewPost = true },
|
||||
onToggle = viewModel::togglePublish,
|
||||
onDelete = viewModel::deletePost,
|
||||
onRetry = viewModel::loadPosts,
|
||||
)
|
||||
else -> WikiTab(
|
||||
state = state.categories,
|
||||
tags = state.tags,
|
||||
busy = state.busy,
|
||||
onNew = { showNewCategory = true },
|
||||
onDelete = viewModel::deleteCategory,
|
||||
onRetry = viewModel::loadWiki,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (showNewPost) {
|
||||
NewPostDialog(
|
||||
categories = viewModel.postCategories,
|
||||
onDismiss = { showNewPost = false },
|
||||
onCreate = { cat, title, excerpt, body, published ->
|
||||
viewModel.createPost(cat, title, excerpt, body, published)
|
||||
showNewPost = false
|
||||
},
|
||||
)
|
||||
}
|
||||
if (showNewCategory) {
|
||||
NewCategoryDialog(
|
||||
onDismiss = { showNewCategory = false },
|
||||
onCreate = { slug, title, desc, sort ->
|
||||
viewModel.createCategory(slug, title, desc, sort)
|
||||
showNewCategory = false
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PostsTab(
|
||||
state: UiState<List<AdminPostDto>>,
|
||||
busy: Boolean,
|
||||
onNew: () -> Unit,
|
||||
onToggle: (AdminPostDto) -> Unit,
|
||||
onDelete: (Long) -> Unit,
|
||||
onRetry: () -> Unit,
|
||||
) {
|
||||
when (state) {
|
||||
is UiState.Loading -> LoadingView()
|
||||
is UiState.Error -> ErrorView(state.kind, onRetry = onRetry)
|
||||
is UiState.Success -> LazyColumn(Modifier.fillMaxSize().padding(16.dp)) {
|
||||
item {
|
||||
OutlinedButton(onClick = onNew, enabled = !busy, modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp)) {
|
||||
Text(stringResource(R.string.admin_content_new_post))
|
||||
}
|
||||
}
|
||||
items(state.data, key = { it.id }) { post ->
|
||||
Card(Modifier.fillMaxWidth().padding(vertical = 6.dp)) {
|
||||
Column(Modifier.padding(12.dp)) {
|
||||
Text(post.title, style = MaterialTheme.typography.bodyLarge)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
StatusPill(
|
||||
text = if (post.isPublished) stringResource(R.string.admin_content_published)
|
||||
else stringResource(R.string.admin_content_draft),
|
||||
tone = if (post.isPublished) PillTone.Success else PillTone.Neutral,
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(post.category, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
Row(Modifier.fillMaxWidth().padding(top = 8.dp), horizontalArrangement = Arrangement.End) {
|
||||
TextButton(onClick = { onToggle(post) }, enabled = !busy) {
|
||||
Text(
|
||||
stringResource(
|
||||
if (post.isPublished) R.string.admin_content_unpublish else R.string.admin_content_publish,
|
||||
),
|
||||
)
|
||||
}
|
||||
TextButton(onClick = { onDelete(post.id) }, enabled = !busy) {
|
||||
Text(stringResource(R.string.admin_content_delete), color = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun WikiTab(
|
||||
state: UiState<List<AdminWikiCategoryDto>>,
|
||||
tags: List<AdminWikiTagDto>,
|
||||
busy: Boolean,
|
||||
onNew: () -> Unit,
|
||||
onDelete: (Long) -> Unit,
|
||||
onRetry: () -> Unit,
|
||||
) {
|
||||
when (state) {
|
||||
is UiState.Loading -> LoadingView()
|
||||
is UiState.Error -> ErrorView(state.kind, onRetry = onRetry)
|
||||
is UiState.Success -> LazyColumn(Modifier.fillMaxSize().padding(16.dp)) {
|
||||
item {
|
||||
OutlinedButton(onClick = onNew, enabled = !busy, modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp)) {
|
||||
Text(stringResource(R.string.admin_content_new_category))
|
||||
}
|
||||
}
|
||||
items(state.data, key = { it.id }) { cat ->
|
||||
Card(Modifier.fillMaxWidth().padding(vertical = 6.dp)) {
|
||||
Column(Modifier.padding(12.dp)) {
|
||||
Text(cat.title, style = MaterialTheme.typography.bodyLarge)
|
||||
Text(
|
||||
text = stringResource(R.string.admin_content_cat_meta, cat.slug, cat.pageCount ?: 0),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Row(Modifier.fillMaxWidth().padding(top = 8.dp), horizontalArrangement = Arrangement.End) {
|
||||
TextButton(onClick = { onDelete(cat.id) }, enabled = !busy) {
|
||||
Text(stringResource(R.string.admin_content_delete), color = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (tags.isNotEmpty()) {
|
||||
item {
|
||||
HorizontalDivider(Modifier.padding(vertical = 12.dp))
|
||||
Text(
|
||||
stringResource(R.string.admin_content_tags, tags.joinToString(", ") { it.label }),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NewPostDialog(
|
||||
categories: List<String>,
|
||||
onDismiss: () -> Unit,
|
||||
onCreate: (category: String, title: String, excerpt: String, body: String, published: Boolean) -> Unit,
|
||||
) {
|
||||
var category by rememberSaveable { mutableStateOf(categories.first()) }
|
||||
var title by rememberSaveable { mutableStateOf("") }
|
||||
var excerpt by rememberSaveable { mutableStateOf("") }
|
||||
var body by rememberSaveable { mutableStateOf("") }
|
||||
var published by rememberSaveable { mutableStateOf(false) }
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
confirmButton = {
|
||||
TextButton(onClick = { onCreate(category, title, excerpt, body, published) }) {
|
||||
Text(stringResource(R.string.admin_content_create))
|
||||
}
|
||||
},
|
||||
dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.action_cancel)) } },
|
||||
title = { Text(stringResource(R.string.admin_content_new_post)) },
|
||||
text = {
|
||||
Column {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
categories.forEach { c ->
|
||||
FilterChip(selected = category == c, onClick = { category = c }, label = { Text(c) })
|
||||
}
|
||||
}
|
||||
OutlinedTextField(value = title, onValueChange = { title = it }, singleLine = true, label = { Text(stringResource(R.string.admin_content_field_title)) }, modifier = Modifier.fillMaxWidth().padding(top = 8.dp))
|
||||
OutlinedTextField(value = excerpt, onValueChange = { excerpt = it }, label = { Text(stringResource(R.string.admin_content_field_excerpt)) }, modifier = Modifier.fillMaxWidth().padding(top = 8.dp))
|
||||
OutlinedTextField(value = body, onValueChange = { body = it }, label = { Text(stringResource(R.string.admin_content_field_body)) }, modifier = Modifier.fillMaxWidth().padding(top = 8.dp))
|
||||
Row(Modifier.fillMaxWidth().padding(top = 8.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(stringResource(R.string.admin_content_publish_now), modifier = Modifier.weight(1f))
|
||||
Switch(checked = published, onCheckedChange = { published = it })
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NewCategoryDialog(
|
||||
onDismiss: () -> Unit,
|
||||
onCreate: (slug: String, title: String, description: String, sortOrder: Int?) -> Unit,
|
||||
) {
|
||||
var slug by rememberSaveable { mutableStateOf("") }
|
||||
var title by rememberSaveable { mutableStateOf("") }
|
||||
var description by rememberSaveable { mutableStateOf("") }
|
||||
var sort by rememberSaveable { mutableStateOf("") }
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
confirmButton = {
|
||||
TextButton(onClick = { onCreate(slug, title, description, sort.toIntOrNull()) }) {
|
||||
Text(stringResource(R.string.admin_content_create))
|
||||
}
|
||||
},
|
||||
dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.action_cancel)) } },
|
||||
title = { Text(stringResource(R.string.admin_content_new_category)) },
|
||||
text = {
|
||||
Column {
|
||||
OutlinedTextField(value = slug, onValueChange = { slug = it }, singleLine = true, label = { Text(stringResource(R.string.admin_content_field_slug)) }, modifier = Modifier.fillMaxWidth())
|
||||
OutlinedTextField(value = title, onValueChange = { title = it }, singleLine = true, label = { Text(stringResource(R.string.admin_content_field_title)) }, modifier = Modifier.fillMaxWidth().padding(top = 8.dp))
|
||||
OutlinedTextField(value = description, onValueChange = { description = it }, label = { Text(stringResource(R.string.admin_content_field_description)) }, modifier = Modifier.fillMaxWidth().padding(top = 8.dp))
|
||||
OutlinedTextField(value = sort, onValueChange = { sort = it.filter(Char::isDigit) }, singleLine = true, label = { Text(stringResource(R.string.admin_content_field_sort)) }, modifier = Modifier.fillMaxWidth().padding(top = 8.dp))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.ui.admin
|
||||
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.runicgateway.app.R
|
||||
import com.runicgateway.app.core.result.ApiResult
|
||||
import com.runicgateway.app.data.api.dto.AdminPostDto
|
||||
import com.runicgateway.app.data.api.dto.PostCreateRequest
|
||||
import com.runicgateway.app.data.api.dto.AdminWikiCategoryDto
|
||||
import com.runicgateway.app.data.api.dto.WikiCategoryRequest
|
||||
import com.runicgateway.app.data.api.dto.AdminWikiTagDto
|
||||
import com.runicgateway.app.data.repository.AdminRepository
|
||||
import com.runicgateway.app.ui.UiState
|
||||
import com.runicgateway.app.ui.toUiState
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* Drives the staff content screen (PLAN.md §1, M10): news posts (list, create,
|
||||
* publish/unpublish, delete) and wiki taxonomy (list categories/tags, create/delete
|
||||
* category). Any staff role reaches these (`staffOnly`); the full CMS block/hero
|
||||
* editor stays out of scope. Reads go through the typed [AdminRepository] (§7).
|
||||
*/
|
||||
@HiltViewModel
|
||||
class AdminContentViewModel @Inject constructor(
|
||||
private val admin: AdminRepository,
|
||||
) : ViewModel() {
|
||||
|
||||
/** The valid URL categories the backend maps (posts.model CATEGORY_MAP keys). */
|
||||
val postCategories = listOf("news", "five-on-friday", "newsletter", "screenshots")
|
||||
|
||||
data class Feedback(val ok: Boolean, @param:StringRes val messageRes: Int)
|
||||
|
||||
data class State(
|
||||
val posts: UiState<List<AdminPostDto>> = UiState.Loading,
|
||||
val categories: UiState<List<AdminWikiCategoryDto>> = UiState.Loading,
|
||||
val tags: List<AdminWikiTagDto> = emptyList(),
|
||||
val busy: Boolean = false,
|
||||
val feedback: Feedback? = null,
|
||||
)
|
||||
|
||||
private val _state = MutableStateFlow(State())
|
||||
val state: StateFlow<State> = _state.asStateFlow()
|
||||
|
||||
init {
|
||||
loadPosts()
|
||||
loadWiki()
|
||||
}
|
||||
|
||||
fun clearFeedback() = _state.update { it.copy(feedback = null) }
|
||||
|
||||
fun loadPosts() {
|
||||
_state.update { it.copy(posts = UiState.Loading) }
|
||||
viewModelScope.launch { _state.update { it.copy(posts = admin.posts().toUiState()) } }
|
||||
}
|
||||
|
||||
fun loadWiki() {
|
||||
_state.update { it.copy(categories = UiState.Loading) }
|
||||
viewModelScope.launch {
|
||||
_state.update { it.copy(categories = admin.wikiCategories().toUiState()) }
|
||||
when (val tags = admin.wikiTags()) {
|
||||
is ApiResult.Ok -> _state.update { it.copy(tags = tags.data) }
|
||||
else -> Unit // tags are secondary; leave the last list on a failure
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun togglePublish(post: AdminPostDto) = mutate(onSuccess = ::loadPosts) {
|
||||
admin.setPostPublished(post.id, !post.isPublished).asFeedback(R.string.admin_content_post_updated)
|
||||
}
|
||||
|
||||
fun deletePost(id: Long) = mutate(onSuccess = ::loadPosts) {
|
||||
admin.deletePost(id).asFeedback(R.string.admin_content_post_deleted)
|
||||
}
|
||||
|
||||
fun createPost(category: String, title: String, excerpt: String, body: String, published: Boolean) {
|
||||
if (title.isBlank()) {
|
||||
_state.update { it.copy(feedback = Feedback(false, R.string.admin_content_title_required)) }
|
||||
return
|
||||
}
|
||||
mutate(onSuccess = ::loadPosts) {
|
||||
admin.createPost(
|
||||
PostCreateRequest(
|
||||
category = category,
|
||||
title = title.trim(),
|
||||
excerpt = excerpt.ifBlank { null },
|
||||
body = body.ifBlank { null },
|
||||
published = published,
|
||||
),
|
||||
).asFeedback(R.string.admin_content_post_created)
|
||||
}
|
||||
}
|
||||
|
||||
fun createCategory(slug: String, title: String, description: String, sortOrder: Int?) {
|
||||
if (slug.isBlank() || title.isBlank()) {
|
||||
_state.update { it.copy(feedback = Feedback(false, R.string.admin_content_cat_fields_required)) }
|
||||
return
|
||||
}
|
||||
mutate(onSuccess = ::loadWiki) {
|
||||
admin.createWikiCategory(
|
||||
WikiCategoryRequest(slug.trim(), title.trim(), description.ifBlank { null }, sortOrder),
|
||||
).asFeedback(R.string.admin_content_cat_created)
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteCategory(id: Long) = mutate(onSuccess = ::loadWiki) {
|
||||
admin.deleteWikiCategory(id).asFeedback(R.string.admin_content_cat_deleted)
|
||||
}
|
||||
|
||||
// ── Shared mutation plumbing ──────────────────────────────────────────
|
||||
|
||||
/** Run a write: set busy + clear feedback, then on completion set the feedback
|
||||
* banner and, only if it succeeded, run [onSuccess] (a targeted reload). */
|
||||
private fun mutate(onSuccess: () -> Unit = {}, block: suspend () -> Feedback) {
|
||||
if (_state.value.busy) return
|
||||
_state.update { it.copy(busy = true, feedback = null) }
|
||||
viewModelScope.launch {
|
||||
val feedback = block()
|
||||
if (feedback.ok) onSuccess()
|
||||
_state.update { it.copy(busy = false, feedback = feedback) }
|
||||
}
|
||||
}
|
||||
|
||||
/** Map an [ApiResult] to a [Feedback], with role/permission-aware failure copy. */
|
||||
private fun ApiResult<*>.asFeedback(@StringRes okRes: Int): Feedback = when (this) {
|
||||
is ApiResult.Ok -> Feedback(true, okRes)
|
||||
is ApiResult.HttpError ->
|
||||
Feedback(false, if (status == 403) R.string.admin_forbidden else R.string.admin_action_failed)
|
||||
is ApiResult.NetworkError -> Feedback(false, R.string.error_network)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.ui.admin
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.runicgateway.app.R
|
||||
import com.runicgateway.app.data.api.dto.AdminDashboardDto
|
||||
import com.runicgateway.app.ui.UiState
|
||||
import com.runicgateway.app.ui.components.ErrorView
|
||||
import com.runicgateway.app.ui.components.LoadingView
|
||||
import com.runicgateway.app.ui.components.PillTone
|
||||
import com.runicgateway.app.ui.components.SectionLabel
|
||||
import com.runicgateway.app.ui.components.StatusPill
|
||||
|
||||
/**
|
||||
* The staff dashboard (PLAN.md §1, M10): site mode + a site-mode toggle (admins
|
||||
* only), summary counts, and recent admin activity. Read-only for moderators/editors;
|
||||
* only [isAdmin] callers see the maintenance switch, and the server enforces it too.
|
||||
*/
|
||||
@Composable
|
||||
fun AdminDashboardScreen(
|
||||
isAdmin: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: AdminDashboardViewModel = hiltViewModel(),
|
||||
) {
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
|
||||
when (val ds = state.dashboard) {
|
||||
is UiState.Loading -> LoadingView(modifier)
|
||||
is UiState.Error -> ErrorView(ds.kind, onRetry = viewModel::load, modifier = modifier)
|
||||
is UiState.Success -> DashboardContent(
|
||||
data = ds.data,
|
||||
isAdmin = isAdmin,
|
||||
switching = state.switching,
|
||||
feedbackRes = state.feedback?.messageRes,
|
||||
onSetMode = viewModel::setSiteMode,
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DashboardContent(
|
||||
data: AdminDashboardDto,
|
||||
isAdmin: Boolean,
|
||||
switching: Boolean,
|
||||
feedbackRes: Int?,
|
||||
onSetMode: (String) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val live = data.siteMode.equals("live", ignoreCase = true)
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(20.dp),
|
||||
) {
|
||||
// ── Site status ──────────────────────────────────────────────
|
||||
SectionLabel(stringResource(R.string.admin_dashboard_site))
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
StatusPill(
|
||||
text = if (live) stringResource(R.string.admin_site_live) else stringResource(R.string.admin_site_maintenance),
|
||||
tone = if (live) PillTone.Success else PillTone.Warning,
|
||||
)
|
||||
data.lastChange.by?.takeIf { it.isNotBlank() }?.let { by ->
|
||||
Spacer(Modifier.width(12.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.admin_site_changed_by, by),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (isAdmin) {
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Button(
|
||||
onClick = { onSetMode(if (live) "maintenance" else "live") },
|
||||
enabled = !switching,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
if (switching) {
|
||||
CircularProgressIndicator(strokeWidth = 2.dp, modifier = Modifier.height(20.dp))
|
||||
} else {
|
||||
Text(
|
||||
stringResource(
|
||||
if (live) R.string.admin_site_switch_maintenance else R.string.admin_site_switch_live,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
feedbackRes?.let {
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(
|
||||
text = stringResource(it),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
|
||||
// ── Counts ───────────────────────────────────────────────────
|
||||
Spacer(Modifier.height(24.dp))
|
||||
SectionLabel(stringResource(R.string.admin_dashboard_counts))
|
||||
Spacer(Modifier.height(8.dp))
|
||||
StatRow(stringResource(R.string.admin_count_users), data.counts.users.toString())
|
||||
val totalPosts = data.counts.posts.values.sum()
|
||||
StatRow(stringResource(R.string.admin_count_posts), totalPosts.toString())
|
||||
data.counts.posts.forEach { (category, count) ->
|
||||
StatRow("· $category", count.toString())
|
||||
}
|
||||
|
||||
// ── Recent activity ──────────────────────────────────────────
|
||||
if (data.recentActivity.isNotEmpty()) {
|
||||
Spacer(Modifier.height(24.dp))
|
||||
SectionLabel(stringResource(R.string.admin_dashboard_recent_activity))
|
||||
Spacer(Modifier.height(8.dp))
|
||||
data.recentActivity.forEach { row ->
|
||||
Column(Modifier.fillMaxWidth().padding(vertical = 6.dp)) {
|
||||
Text(row.action, style = MaterialTheme.typography.bodyMedium)
|
||||
val meta = listOfNotNull(row.username, row.createdAt).joinToString(" · ")
|
||||
if (meta.isNotBlank()) {
|
||||
Text(
|
||||
text = meta,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StatRow(label: String, value: String) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Text(label, style = MaterialTheme.typography.bodyMedium)
|
||||
Text(value, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.ui.admin
|
||||
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.runicgateway.app.R
|
||||
import com.runicgateway.app.core.result.ApiResult
|
||||
import com.runicgateway.app.data.api.dto.AdminDashboardDto
|
||||
import com.runicgateway.app.data.repository.AdminRepository
|
||||
import com.runicgateway.app.ui.UiState
|
||||
import com.runicgateway.app.ui.toUiState
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* Drives the staff dashboard (PLAN.md §1, M10): summary counts + the site-mode
|
||||
* toggle. The mode switch is admin-only server-side (`adminOnly`); the screen only
|
||||
* offers it to admins, but a `403` is still handled cleanly if a moderator reaches
|
||||
* it. Everything is read through the typed [AdminRepository] (§7).
|
||||
*/
|
||||
@HiltViewModel
|
||||
class AdminDashboardViewModel @Inject constructor(
|
||||
private val admin: AdminRepository,
|
||||
) : ViewModel() {
|
||||
|
||||
data class Feedback(val ok: Boolean, @param:StringRes val messageRes: Int)
|
||||
|
||||
data class State(
|
||||
val dashboard: UiState<AdminDashboardDto> = UiState.Loading,
|
||||
/** True while a site-mode switch is in flight (disables the control). */
|
||||
val switching: Boolean = false,
|
||||
val feedback: Feedback? = null,
|
||||
)
|
||||
|
||||
private val _state = MutableStateFlow(State())
|
||||
val state: StateFlow<State> = _state.asStateFlow()
|
||||
|
||||
init {
|
||||
load()
|
||||
}
|
||||
|
||||
fun load() {
|
||||
_state.update { it.copy(dashboard = UiState.Loading) }
|
||||
viewModelScope.launch {
|
||||
_state.update { it.copy(dashboard = admin.dashboard().toUiState()) }
|
||||
}
|
||||
}
|
||||
|
||||
fun clearFeedback() = _state.update { it.copy(feedback = null) }
|
||||
|
||||
/** Switch the site between "live" and "maintenance" (admin only). */
|
||||
fun setSiteMode(mode: String) {
|
||||
if (_state.value.switching) return
|
||||
_state.update { it.copy(switching = true, feedback = null) }
|
||||
viewModelScope.launch {
|
||||
when (val result = admin.setSiteMode(mode)) {
|
||||
is ApiResult.Ok -> {
|
||||
// Reflect the new mode locally, then refresh the full summary.
|
||||
val current = _state.value.dashboard
|
||||
if (current is UiState.Success) {
|
||||
_state.update {
|
||||
it.copy(dashboard = UiState.Success(current.data.copy(siteMode = result.data.siteMode)))
|
||||
}
|
||||
}
|
||||
_state.update { it.copy(switching = false, feedback = Feedback(true, R.string.admin_site_mode_updated)) }
|
||||
load()
|
||||
}
|
||||
is ApiResult.HttpError ->
|
||||
_state.update {
|
||||
it.copy(
|
||||
switching = false,
|
||||
feedback = Feedback(
|
||||
false,
|
||||
if (result.status == 403) R.string.admin_forbidden else R.string.admin_action_failed,
|
||||
),
|
||||
)
|
||||
}
|
||||
is ApiResult.NetworkError ->
|
||||
_state.update { it.copy(switching = false, feedback = Feedback(false, R.string.error_network)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.ui.admin
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.runicgateway.app.R
|
||||
import com.runicgateway.app.ui.components.SectionLabel
|
||||
|
||||
/**
|
||||
* The moderation screen (PLAN.md §1, M10): kick / ban / unban an account and
|
||||
* broadcast, over `/admin/shard/…` (admin/moderator). A live sidecar is required;
|
||||
* offline, actions return a clean "shard offline" message. Fields are entered here;
|
||||
* the [AdminModerationViewModel] performs the guarded action.
|
||||
*/
|
||||
@Composable
|
||||
fun AdminModerationScreen(
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: AdminModerationViewModel = hiltViewModel(),
|
||||
) {
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
var account by rememberSaveable { mutableStateOf("") }
|
||||
var serial by rememberSaveable { mutableStateOf("") }
|
||||
var reason by rememberSaveable { mutableStateOf("") }
|
||||
var duration by rememberSaveable { mutableStateOf("") }
|
||||
var broadcast by rememberSaveable { mutableStateOf("") }
|
||||
val busy = state.busy
|
||||
|
||||
Column(
|
||||
modifier = modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(20.dp),
|
||||
) {
|
||||
state.feedback?.let {
|
||||
Text(
|
||||
text = stringResource(it.messageRes),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = if (it.ok) MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.error,
|
||||
modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp),
|
||||
)
|
||||
}
|
||||
|
||||
// ── Account actions ──────────────────────────────────────────────
|
||||
SectionLabel(stringResource(R.string.admin_mod_account_action))
|
||||
OutlinedTextField(value = account, onValueChange = { account = it }, singleLine = true, label = { Text(stringResource(R.string.admin_mod_account)) }, modifier = Modifier.fillMaxWidth().padding(top = 8.dp))
|
||||
OutlinedTextField(value = serial, onValueChange = { serial = it }, singleLine = true, label = { Text(stringResource(R.string.admin_mod_serial)) }, modifier = Modifier.fillMaxWidth().padding(top = 8.dp))
|
||||
OutlinedTextField(value = reason, onValueChange = { reason = it }, label = { Text(stringResource(R.string.admin_mod_reason)) }, modifier = Modifier.fillMaxWidth().padding(top = 8.dp))
|
||||
OutlinedTextField(value = duration, onValueChange = { duration = it.filter(Char::isDigit) }, singleLine = true, label = { Text(stringResource(R.string.admin_mod_duration)) }, modifier = Modifier.fillMaxWidth().padding(top = 8.dp))
|
||||
|
||||
Row(Modifier.fillMaxWidth().padding(top = 12.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
OutlinedButton(onClick = { viewModel.kick(account, serial) }, enabled = !busy, modifier = Modifier.weight(1f)) {
|
||||
Text(stringResource(R.string.admin_mod_kick))
|
||||
}
|
||||
Button(onClick = { viewModel.ban(account, serial, duration.toLongOrNull(), reason) }, enabled = !busy, modifier = Modifier.weight(1f)) {
|
||||
Text(stringResource(R.string.admin_mod_ban))
|
||||
}
|
||||
OutlinedButton(onClick = { viewModel.unban(account) }, enabled = !busy, modifier = Modifier.weight(1f)) {
|
||||
Text(stringResource(R.string.admin_mod_unban))
|
||||
}
|
||||
}
|
||||
|
||||
// ── Broadcast ────────────────────────────────────────────────────
|
||||
Spacer(Modifier.height(24.dp))
|
||||
SectionLabel(stringResource(R.string.admin_mod_broadcast_section))
|
||||
OutlinedTextField(value = broadcast, onValueChange = { broadcast = it }, label = { Text(stringResource(R.string.admin_mod_broadcast_text)) }, modifier = Modifier.fillMaxWidth().padding(top = 8.dp))
|
||||
Button(onClick = { viewModel.broadcast(broadcast, null) }, enabled = !busy, modifier = Modifier.fillMaxWidth().padding(top = 12.dp)) {
|
||||
Text(stringResource(R.string.admin_mod_broadcast))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.ui.admin
|
||||
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.runicgateway.app.R
|
||||
import com.runicgateway.app.core.result.ApiResult
|
||||
import com.runicgateway.app.data.repository.AdminRepository
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* Drives the moderation actions (PLAN.md §1, M10): kick / ban / unban an account
|
||||
* and broadcast a system message, over the shard write plane (`/admin/shard/…`,
|
||||
* admin/moderator). These need a live sidecar — when the shard is offline the call
|
||||
* fails and the screen shows a clean error, never a crash (§7). The form fields live
|
||||
* in the screen; this VM owns only the busy + feedback state and the actions.
|
||||
*/
|
||||
@HiltViewModel
|
||||
class AdminModerationViewModel @Inject constructor(
|
||||
private val admin: AdminRepository,
|
||||
) : ViewModel() {
|
||||
|
||||
data class Feedback(val ok: Boolean, @param:StringRes val messageRes: Int)
|
||||
|
||||
data class State(val busy: Boolean = false, val feedback: Feedback? = null)
|
||||
|
||||
private val _state = MutableStateFlow(State())
|
||||
val state: StateFlow<State> = _state.asStateFlow()
|
||||
|
||||
fun clearFeedback() = _state.update { it.copy(feedback = null) }
|
||||
|
||||
fun kick(account: String, serial: String) {
|
||||
if (account.isBlank() && serial.isBlank()) return badTarget()
|
||||
run(R.string.admin_mod_kicked) { admin.kick(account.ifBlank { null }, serial.ifBlank { null }) }
|
||||
}
|
||||
|
||||
fun ban(account: String, serial: String, durationSec: Long?, reason: String) {
|
||||
if (account.isBlank() && serial.isBlank()) return badTarget()
|
||||
run(R.string.admin_mod_banned) {
|
||||
admin.ban(account.ifBlank { null }, serial.ifBlank { null }, durationSec, reason.ifBlank { null })
|
||||
}
|
||||
}
|
||||
|
||||
fun unban(account: String) {
|
||||
if (account.isBlank()) return badTarget()
|
||||
run(R.string.admin_mod_unbanned) { admin.unban(account.trim()) }
|
||||
}
|
||||
|
||||
fun broadcast(text: String, hue: Int?) {
|
||||
if (text.isBlank()) {
|
||||
_state.update { it.copy(feedback = Feedback(false, R.string.admin_mod_text_required)) }
|
||||
return
|
||||
}
|
||||
run(R.string.admin_mod_broadcasted) { admin.broadcast(text.trim(), hue) }
|
||||
}
|
||||
|
||||
private fun badTarget() {
|
||||
_state.update { it.copy(feedback = Feedback(false, R.string.admin_mod_target_required)) }
|
||||
}
|
||||
|
||||
private fun run(@StringRes okRes: Int, block: suspend () -> ApiResult<Unit>) {
|
||||
if (_state.value.busy) return
|
||||
_state.update { it.copy(busy = true, feedback = null) }
|
||||
viewModelScope.launch {
|
||||
val feedback = when (val r = block()) {
|
||||
is ApiResult.Ok -> Feedback(true, okRes)
|
||||
is ApiResult.HttpError -> Feedback(
|
||||
false,
|
||||
when (r.status) {
|
||||
403 -> R.string.admin_forbidden
|
||||
503 -> R.string.admin_mod_shard_offline
|
||||
else -> R.string.admin_action_failed
|
||||
},
|
||||
)
|
||||
is ApiResult.NetworkError -> Feedback(false, R.string.error_network)
|
||||
}
|
||||
_state.update { it.copy(busy = false, feedback = feedback) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.ui.admin
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.runicgateway.app.R
|
||||
import com.runicgateway.app.data.api.dto.SupportPageDto
|
||||
import com.runicgateway.app.ui.UiState
|
||||
import com.runicgateway.app.ui.components.EmptyView
|
||||
import com.runicgateway.app.ui.components.ErrorView
|
||||
import com.runicgateway.app.ui.components.LoadingView
|
||||
|
||||
/**
|
||||
* The support (help-page) queue (PLAN.md §1, M10): open tickets with reply/close,
|
||||
* over `/admin/shard/pages…` (admin/moderator). Empty when there are no open pages
|
||||
* (or the shard is offline); every read/write degrades cleanly (§7).
|
||||
*/
|
||||
@Composable
|
||||
fun AdminSupportScreen(
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: AdminSupportViewModel = hiltViewModel(),
|
||||
) {
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
var replyTo by remember { mutableStateOf<SupportPageDto?>(null) }
|
||||
|
||||
Column(modifier.fillMaxSize()) {
|
||||
state.feedback?.let {
|
||||
Text(
|
||||
text = stringResource(it.messageRes),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = if (it.ok) MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.error,
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 6.dp),
|
||||
)
|
||||
}
|
||||
when (val s = state.pages) {
|
||||
is UiState.Loading -> LoadingView()
|
||||
is UiState.Error -> ErrorView(s.kind, onRetry = viewModel::load)
|
||||
is UiState.Success ->
|
||||
if (s.data.isEmpty()) {
|
||||
EmptyView(stringResource(R.string.admin_support_empty))
|
||||
} else {
|
||||
LazyColumn(Modifier.fillMaxSize().padding(16.dp)) {
|
||||
items(s.data, key = { it.pageId }) { page ->
|
||||
SupportPageCard(
|
||||
page = page,
|
||||
busy = state.busy,
|
||||
onReply = { replyTo = page },
|
||||
onClose = { viewModel.close(page.pageId) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
replyTo?.let { page ->
|
||||
RespondDialog(
|
||||
page = page,
|
||||
onDismiss = { replyTo = null },
|
||||
onSend = { message, close ->
|
||||
viewModel.respond(page.pageId, message, close)
|
||||
replyTo = null
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SupportPageCard(
|
||||
page: SupportPageDto,
|
||||
busy: Boolean,
|
||||
onReply: () -> Unit,
|
||||
onClose: () -> Unit,
|
||||
) {
|
||||
Card(Modifier.fillMaxWidth().padding(vertical = 6.dp)) {
|
||||
Column(Modifier.padding(12.dp)) {
|
||||
val who = page.sender?.name ?: page.sender?.account ?: page.pageId
|
||||
Text(
|
||||
text = listOfNotNull(page.type, who).joinToString(" · "),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
)
|
||||
page.message?.takeIf { it.isNotBlank() }?.let {
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text(it, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
Row(Modifier.fillMaxWidth().padding(top = 8.dp), horizontalArrangement = Arrangement.End) {
|
||||
TextButton(onClick = onReply, enabled = !busy) { Text(stringResource(R.string.admin_support_reply)) }
|
||||
TextButton(onClick = onClose, enabled = !busy) { Text(stringResource(R.string.admin_support_close)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RespondDialog(
|
||||
page: SupportPageDto,
|
||||
onDismiss: () -> Unit,
|
||||
onSend: (message: String, close: Boolean) -> Unit,
|
||||
) {
|
||||
var message by rememberSaveable { mutableStateOf("") }
|
||||
var alsoClose by rememberSaveable { mutableStateOf(true) }
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
confirmButton = { TextButton(onClick = { onSend(message, alsoClose) }) { Text(stringResource(R.string.admin_support_send)) } },
|
||||
dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.action_cancel)) } },
|
||||
title = { Text(stringResource(R.string.admin_support_reply)) },
|
||||
text = {
|
||||
Column {
|
||||
OutlinedTextField(value = message, onValueChange = { message = it }, label = { Text(stringResource(R.string.admin_support_message)) }, modifier = Modifier.fillMaxWidth())
|
||||
Row(Modifier.fillMaxWidth().padding(top = 8.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
Checkbox(checked = alsoClose, onCheckedChange = { alsoClose = it })
|
||||
Text(stringResource(R.string.admin_support_close_after))
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.ui.admin
|
||||
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.runicgateway.app.R
|
||||
import com.runicgateway.app.core.result.ApiResult
|
||||
import com.runicgateway.app.data.api.dto.SupportPageDto
|
||||
import com.runicgateway.app.data.repository.AdminRepository
|
||||
import com.runicgateway.app.ui.UiState
|
||||
import com.runicgateway.app.ui.toUiState
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* Drives the support (help-page) queue (PLAN.md §1, M10): list open pages, reply
|
||||
* (optionally closing), and close, over `/admin/shard/pages…` (admin/moderator).
|
||||
* The list is served from shard state — empty when no tickets (or the shard is
|
||||
* offline); writes need a live sidecar and fail cleanly otherwise (§7).
|
||||
*/
|
||||
@HiltViewModel
|
||||
class AdminSupportViewModel @Inject constructor(
|
||||
private val admin: AdminRepository,
|
||||
) : ViewModel() {
|
||||
|
||||
data class Feedback(val ok: Boolean, @param:StringRes val messageRes: Int)
|
||||
|
||||
data class State(
|
||||
val pages: UiState<List<SupportPageDto>> = UiState.Loading,
|
||||
val busy: Boolean = false,
|
||||
val feedback: Feedback? = null,
|
||||
)
|
||||
|
||||
private val _state = MutableStateFlow(State())
|
||||
val state: StateFlow<State> = _state.asStateFlow()
|
||||
|
||||
init {
|
||||
load()
|
||||
}
|
||||
|
||||
fun clearFeedback() = _state.update { it.copy(feedback = null) }
|
||||
|
||||
fun load() {
|
||||
_state.update { it.copy(pages = UiState.Loading) }
|
||||
viewModelScope.launch { _state.update { it.copy(pages = admin.supportPages().toUiState()) } }
|
||||
}
|
||||
|
||||
fun respond(id: String, message: String, close: Boolean) {
|
||||
if (message.isBlank()) {
|
||||
_state.update { it.copy(feedback = Feedback(false, R.string.admin_support_message_required)) }
|
||||
return
|
||||
}
|
||||
mutate(R.string.admin_support_responded) { admin.respondPage(id, message.trim(), close) }
|
||||
}
|
||||
|
||||
fun close(id: String) = mutate(R.string.admin_support_closed) { admin.closePage(id) }
|
||||
|
||||
private fun mutate(@StringRes okRes: Int, block: suspend () -> ApiResult<Unit>) {
|
||||
if (_state.value.busy) return
|
||||
_state.update { it.copy(busy = true, feedback = null) }
|
||||
viewModelScope.launch {
|
||||
val feedback = when (val r = block()) {
|
||||
is ApiResult.Ok -> Feedback(true, okRes)
|
||||
is ApiResult.HttpError -> Feedback(
|
||||
false,
|
||||
when (r.status) {
|
||||
403 -> R.string.admin_forbidden
|
||||
404 -> R.string.admin_support_unknown_page
|
||||
503 -> R.string.admin_mod_shard_offline
|
||||
else -> R.string.admin_action_failed
|
||||
},
|
||||
)
|
||||
is ApiResult.NetworkError -> Feedback(false, R.string.error_network)
|
||||
}
|
||||
if (feedback.ok) load()
|
||||
_state.update { it.copy(busy = false, feedback = feedback) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,8 +5,10 @@ package com.runicgateway.app.ui.auth
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.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<com.runicgateway.app.data.api.dto.SsoProviderDto>,
|
||||
onDismiss: () -> Unit,
|
||||
onPick: (com.runicgateway.app.data.api.dto.SsoProviderDto) -> Unit,
|
||||
) {
|
||||
ModalBottomSheet(onDismissRequest = onDismiss, sheetState = rememberModalBottomSheetState()) {
|
||||
Text(
|
||||
text = stringResource(R.string.login_sso_pick_title),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
modifier = Modifier.padding(horizontal = 24.dp, vertical = 8.dp),
|
||||
)
|
||||
providers.forEach { provider ->
|
||||
TextButton(
|
||||
onClick = { onPick(provider) },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp, vertical = 2.dp),
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.login_sso_provider, provider.name),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
textAlign = TextAlign.Start,
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(24.dp)) // clears the gesture inset at the sheet's bottom
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import com.runicgateway.app.core.web.WebsiteUrls
|
||||
import com.runicgateway.app.data.api.dto.SsoProviderDto
|
||||
import com.runicgateway.app.data.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<SsoProviderDto> = emptyList(),
|
||||
/** True while discovery is in flight — the screen shows a spinner, not an empty gap. */
|
||||
val ssoDiscovering: Boolean = true,
|
||||
/** True when discovery failed (offline/server) — offer a retry rather than a dead end. */
|
||||
val ssoUnavailable: Boolean = false,
|
||||
/** A `/auth/mobile/sso/start` URL the screen should open in a Custom Tab, once. */
|
||||
val ssoLaunchUrl: String? = null,
|
||||
)
|
||||
@@ -53,11 +58,7 @@ class LoginViewModel @Inject constructor(
|
||||
val state: StateFlow<UiState> = _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
|
||||
|
||||
@@ -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<MenuEntry> = 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<MenuEntry>, session: Session): List<MenuEntry>
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}"
|
||||
|
||||
|
||||
@@ -46,10 +46,92 @@
|
||||
<string name="menu_my_characters">My characters</string>
|
||||
<string name="menu_my_vendors">My vendors</string>
|
||||
<string name="menu_my_houses">My houses</string>
|
||||
<string name="menu_admin_dashboard">Dashboard</string>
|
||||
<string name="menu_admin_content">Content</string>
|
||||
<string name="menu_admin_moderation">Moderation</string>
|
||||
<string name="menu_admin_support">Support queue</string>
|
||||
<string name="menu_sign_in">Sign in</string>
|
||||
<string name="menu_sign_out">Sign out</string>
|
||||
<string name="menu_change_server">Change server</string>
|
||||
|
||||
<!-- ── Staff operations (§1, M10) ──────────────────────────────────── -->
|
||||
<string name="admin_dashboard_site">Site</string>
|
||||
<string name="admin_dashboard_counts">Counts</string>
|
||||
<string name="admin_dashboard_recent_activity">Recent activity</string>
|
||||
<string name="admin_site_live">Live</string>
|
||||
<string name="admin_site_maintenance">Maintenance</string>
|
||||
<string name="admin_site_switch_maintenance">Switch to maintenance</string>
|
||||
<string name="admin_site_switch_live">Switch to live</string>
|
||||
<string name="admin_site_changed_by">by %1$s</string>
|
||||
<string name="admin_site_mode_updated">Site mode updated.</string>
|
||||
<string name="admin_count_users">Users</string>
|
||||
<string name="admin_count_posts">Posts</string>
|
||||
<string name="admin_forbidden">You don\'t have permission for that action.</string>
|
||||
<string name="admin_action_failed">That action couldn\'t be completed. Please try again.</string>
|
||||
<string name="action_cancel">Cancel</string>
|
||||
|
||||
<!-- Staff content (posts + wiki) -->
|
||||
<string name="admin_content_tab_posts">Posts</string>
|
||||
<string name="admin_content_tab_wiki">Wiki</string>
|
||||
<string name="admin_content_new_post">New post</string>
|
||||
<string name="admin_content_new_category">New category</string>
|
||||
<string name="admin_content_create">Create</string>
|
||||
<string name="admin_content_published">Published</string>
|
||||
<string name="admin_content_draft">Draft</string>
|
||||
<string name="admin_content_publish">Publish</string>
|
||||
<string name="admin_content_unpublish">Unpublish</string>
|
||||
<string name="admin_content_delete">Delete</string>
|
||||
<string name="admin_content_publish_now">Publish now</string>
|
||||
<string name="admin_content_field_title">Title</string>
|
||||
<string name="admin_content_field_excerpt">Excerpt</string>
|
||||
<string name="admin_content_field_body">Body</string>
|
||||
<string name="admin_content_field_slug">Slug</string>
|
||||
<string name="admin_content_field_description">Description</string>
|
||||
<string name="admin_content_field_sort">Sort order</string>
|
||||
<!-- %1$s slug, %2$d page count -->
|
||||
<string name="admin_content_cat_meta">%1$s · %2$d pages</string>
|
||||
<!-- %1$s comma-separated tag labels -->
|
||||
<string name="admin_content_tags">Tags: %1$s</string>
|
||||
<string name="admin_content_post_created">Post created.</string>
|
||||
<string name="admin_content_post_updated">Post updated.</string>
|
||||
<string name="admin_content_post_deleted">Post deleted.</string>
|
||||
<string name="admin_content_cat_created">Category created.</string>
|
||||
<string name="admin_content_cat_deleted">Category deleted.</string>
|
||||
<string name="admin_content_title_required">A title is required.</string>
|
||||
<string name="admin_content_cat_fields_required">Slug and title are required.</string>
|
||||
|
||||
<!-- Staff moderation (shard write plane) -->
|
||||
<string name="admin_mod_account_action">Account action</string>
|
||||
<string name="admin_mod_account">Account</string>
|
||||
<string name="admin_mod_serial">Serial (0x…)</string>
|
||||
<string name="admin_mod_reason">Reason (ban)</string>
|
||||
<string name="admin_mod_duration">Ban duration (seconds; blank = indefinite)</string>
|
||||
<string name="admin_mod_kick">Kick</string>
|
||||
<string name="admin_mod_ban">Ban</string>
|
||||
<string name="admin_mod_unban">Unban</string>
|
||||
<string name="admin_mod_broadcast_section">Broadcast</string>
|
||||
<string name="admin_mod_broadcast_text">Message to everyone online</string>
|
||||
<string name="admin_mod_broadcast">Broadcast</string>
|
||||
<string name="admin_mod_kicked">Account kicked.</string>
|
||||
<string name="admin_mod_banned">Account banned.</string>
|
||||
<string name="admin_mod_unbanned">Ban cleared.</string>
|
||||
<string name="admin_mod_broadcasted">Message broadcast.</string>
|
||||
<string name="admin_mod_target_required">Enter an account or serial.</string>
|
||||
<string name="admin_mod_text_required">Enter a message to broadcast.</string>
|
||||
<string name="admin_mod_shard_offline">The shard is offline — the action couldn\'t be delivered.</string>
|
||||
|
||||
<!-- Staff support queue -->
|
||||
<string name="admin_support_empty">No open help pages.</string>
|
||||
<string name="admin_support_reply">Reply</string>
|
||||
<string name="admin_support_close">Close</string>
|
||||
<string name="admin_support_send">Send</string>
|
||||
<string name="admin_support_message">Reply message</string>
|
||||
<string name="admin_support_close_after">Close the page after replying</string>
|
||||
<string name="admin_support_responded">Reply sent.</string>
|
||||
<string name="admin_support_closed">Page closed.</string>
|
||||
<string name="admin_support_message_required">Enter a reply message.</string>
|
||||
<string name="admin_support_unknown_page">That page is no longer in the queue.</string>
|
||||
|
||||
<!-- ── Auth: login (§4.1) ──────────────────────────────────────────── -->
|
||||
<string name="login_title">Sign in</string>
|
||||
<string name="login_subtitle">Sign in with your shard account.</string>
|
||||
@@ -60,9 +142,13 @@
|
||||
<string name="login_button">Sign in</string>
|
||||
<string name="login_register">Create an account</string>
|
||||
<string name="login_forgot">Forgot your password?</string>
|
||||
<string name="login_sso">Sign in with Google or Discord (on the website)</string>
|
||||
<!-- %1$s is the provider name, e.g. "Google" or "Discord" (native SSO, M9). -->
|
||||
<!-- Single SSO entry point; the picker lists the shard's providers (native SSO, M9/M10). -->
|
||||
<string name="login_sso_button">Sign in with SSO</string>
|
||||
<string name="login_sso_pick_title">Choose a sign-in provider</string>
|
||||
<!-- %1$s is the provider name, e.g. "Google" or "Discord". -->
|
||||
<string name="login_sso_provider">Sign in with %1$s</string>
|
||||
<string name="login_sso_loading">Loading sign-in options…</string>
|
||||
<string name="login_sso_retry">Couldn\'t load sign-in options. Tap to retry.</string>
|
||||
<string name="login_error_credentials">Incorrect username or password.</string>
|
||||
<string name="login_error_code">That code didn\'t match. Try the current code.</string>
|
||||
<string name="login_error_rate_limited">Too many attempts. Please try again shortly.</string>
|
||||
|
||||
16
app/src/main/res/xml/network_security_config.xml
Normal file
16
app/src/main/res/xml/network_security_config.xml
Normal file
@@ -0,0 +1,16 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- SPDX-License-Identifier: GPL-3.0-or-later -->
|
||||
<!--
|
||||
The app is purely an HTTPS API client of a shard's website backend, so the base
|
||||
posture forbids all cleartext (HTTP) traffic. This makes explicit what minSdk 29 /
|
||||
targetSdk 35 already default to, satisfies the "usesCleartextTraffic implicitly
|
||||
enabled" scanner finding, and stops any merged library manifest from re-enabling
|
||||
cleartext. It also mirrors ServerUrl's release-build rule (HTTPS required) at the
|
||||
platform socket layer — defense in depth.
|
||||
|
||||
The debug variant overrides this file (app/src/debug/res/xml/) to re-permit
|
||||
cleartext to loopback only, for local dev against http://127.0.0.1:3000.
|
||||
-->
|
||||
<network-security-config>
|
||||
<base-config cleartextTrafficPermitted="false" />
|
||||
</network-security-config>
|
||||
@@ -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<MobileTokenResponse>,
|
||||
@@ -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()))
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
32
sonar-project.properties
Normal file
32
sonar-project.properties
Normal file
@@ -0,0 +1,32 @@
|
||||
# SonarQube analysis config for the Android-app repo.
|
||||
# Consumed by the scanner in .gitea/workflows/sonarqube.yml on push to main.
|
||||
# The project key must match the one created in SonarQube (dashboard URL
|
||||
# ?id=Runic-Gateway-Android-app).
|
||||
|
||||
sonar.projectKey=Runic-Gateway-Android-app
|
||||
sonar.projectName=runic gateway android app
|
||||
|
||||
# Analysed application code. The single :app module's Kotlin sources.
|
||||
# SonarQube's Kotlin analyzer works on source directly, so no compiled classes
|
||||
# or Gradle build are required for the scan.
|
||||
sonar.sources=app/src/main
|
||||
|
||||
# Local unit tests (app/src/test). Instrumented tests (app/src/androidTest) can
|
||||
# be added here once that source set exists.
|
||||
sonar.tests=app/src/test
|
||||
|
||||
# Never analyse build output, Gradle internals, or generated code.
|
||||
sonar.exclusions=**/build/**,**/.gradle/**,**/generated/**
|
||||
|
||||
sonar.sourceEncoding=UTF-8
|
||||
|
||||
# ── Optional enrichment (enable once the reports are produced in CI) ──
|
||||
# For richer Kotlin/Android results, run the reporters in sonarqube.yml and point
|
||||
# SonarQube at their output:
|
||||
# • Android Lint: ./gradlew lintDebug → app/build/reports/lint-results-debug.xml
|
||||
# sonar.androidLint.reportPaths=app/build/reports/lint-results-debug.xml
|
||||
# • JaCoCo coverage (needs a coverage-enabled test run):
|
||||
# sonar.coverage.jacoco.xmlReportPaths=app/build/reports/jacoco/.../*.xml
|
||||
# The alternative to the CLI scanner used here is the SonarQube Gradle plugin
|
||||
# (org.sonarqube), which auto-discovers these reports; the CLI + properties file
|
||||
# is used instead to keep this repo's setup identical to website/ and link/.
|
||||
Reference in New Issue
Block a user