Compare commits
27 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4fe7a7e2a3 | |||
| b10dd444b3 | |||
| f3da6ea618 | |||
| ae170670d9 | |||
| 7fc497a1a4 | |||
| 4e3bb914ff | |||
| efe14d3828 | |||
| 43215b49a0 | |||
| a6446b04d8 | |||
| 9c52a3dafa | |||
| f0a3b6c03e | |||
| 3aeb295342 | |||
| 03d4ef6fad | |||
| a1fa4901ef | |||
| befbc01670 | |||
| 1a14d47d5c | |||
| d6d966882b | |||
| 422892f1ed | |||
| 7f876377f0 | |||
| c5596845c1 | |||
| ac99a012b0 | |||
| 44d039d2a0 | |||
| 0f93f3dcd3 | |||
| c69d704881 | |||
| 26b8eecde6 | |||
| f729b772fc | |||
| 402d750138 |
54
.gitea/scripts/gen_tree.py
Normal file
54
.gitea/scripts/gen_tree.py
Normal file
@@ -0,0 +1,54 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Render an ASCII tree of tracked files, read from stdin (one path per line).
|
||||
|
||||
Used by the `sync-project-tree` workflow to regenerate this repo's PROJECT_TREE.md
|
||||
snapshot in the RunicGateway/docs repo. Feed it `git ls-files`:
|
||||
|
||||
git ls-files | python3 .gitea/scripts/gen_tree.py <root-label>
|
||||
|
||||
Deterministic ordering: directories before files, each group sorted
|
||||
case-insensitively with the raw name as a tiebreak. Output uses the classic
|
||||
`tree(1)` box-drawing style so the result is stable across runs and platforms.
|
||||
"""
|
||||
import sys
|
||||
|
||||
|
||||
def build(paths):
|
||||
root = {}
|
||||
for p in paths:
|
||||
p = p.strip().replace("\\", "/")
|
||||
if not p:
|
||||
continue
|
||||
node = root
|
||||
for part in p.split("/"):
|
||||
node = node.setdefault(part, {})
|
||||
return root
|
||||
|
||||
|
||||
def render(node, prefix, lines):
|
||||
entries = list(node.items())
|
||||
# directories (non-empty children dict) before files, then case-insensitive name
|
||||
entries.sort(key=lambda kv: (0 if kv[1] else 1, kv[0].lower(), kv[0]))
|
||||
for i, (name, child) in enumerate(entries):
|
||||
last = i == len(entries) - 1
|
||||
branch = "└── " if last else "├── "
|
||||
suffix = "/" if child else ""
|
||||
lines.append(f"{prefix}{branch}{name}{suffix}")
|
||||
if child:
|
||||
render(child, prefix + (" " if last else "│ "), lines)
|
||||
|
||||
|
||||
def main():
|
||||
try:
|
||||
sys.stdout.reconfigure(encoding="utf-8", newline="\n")
|
||||
except AttributeError:
|
||||
pass
|
||||
root_label = sys.argv[1] if len(sys.argv) > 1 else "."
|
||||
tree = build(sys.stdin.read().splitlines())
|
||||
lines = [f"{root_label}/"]
|
||||
render(tree, "", lines)
|
||||
sys.stdout.write("\n".join(lines) + "\n")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
90
.gitea/workflows/sonarqube.yml
Normal file
90
.gitea/workflows/sonarqube.yml
Normal file
@@ -0,0 +1,90 @@
|
||||
# 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: the Sonar scanner reads sonar-project.properties and analyses the Kotlin
|
||||
# source directly. Before the scan we run the JVM unit tests + JaCoCo so SonarQube
|
||||
# receives real coverage (sonar.coverage.jacoco.xmlReportPaths) — otherwise it
|
||||
# reports 0% and the coverage gate fails despite the test suite existing. That
|
||||
# Gradle step needs JDK 17 + the Android SDK (same toolchain as pr-checks.yml);
|
||||
# the runner container is bare, so base tools are apt-installed first.
|
||||
|
||||
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:
|
||||
# The bare runner container lacks git/curl/unzip (checkout + sdkmanager need
|
||||
# them) and we install JDK 17 from the Ubuntu archive rather than
|
||||
# actions/setup-java (this runner can't reach api.adoptium.net). Mirrors
|
||||
# pr-checks.yml — see its header note.
|
||||
- name: Install base tools + JDK 17
|
||||
run: |
|
||||
apt-get update
|
||||
apt-get install -y git curl unzip openjdk-17-jdk-headless
|
||||
echo "JAVA_HOME=/usr/lib/jvm/java-17-openjdk-amd64" >> "$GITHUB_ENV"
|
||||
|
||||
- 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: Set up Android SDK
|
||||
uses: android-actions/setup-android@v3
|
||||
|
||||
- name: Install Android SDK packages
|
||||
run: |
|
||||
set +o pipefail
|
||||
yes | sdkmanager "platform-tools" "platforms;android-35" "build-tools;35.0.0"
|
||||
|
||||
- name: Cache Gradle
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper
|
||||
key: gradle-${{ runner.os }}-${{ hashFiles('**/*.gradle.kts', 'gradle/libs.versions.toml', 'gradle/wrapper/gradle-wrapper.properties') }}
|
||||
restore-keys: |
|
||||
gradle-${{ runner.os }}-
|
||||
|
||||
# Produce the JaCoCo XML the scan reports as coverage. Scoped to the debug
|
||||
# variant (matches enableUnitTestCoverage) to keep peak memory down.
|
||||
- name: Unit tests + JaCoCo coverage
|
||||
run: |
|
||||
chmod +x ./gradlew
|
||||
./gradlew --no-daemon testDebugUnitTest jacocoTestReport
|
||||
|
||||
- name: Run SonarQube scan
|
||||
uses: sonarsource/sonarqube-scan-action@v4
|
||||
env:
|
||||
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
|
||||
SONAR_HOST_URL: ${{ vars.SONAR_HOST_URL }}
|
||||
111
.gitea/workflows/sync-project-tree.yml
Normal file
111
.gitea/workflows/sync-project-tree.yml
Normal file
@@ -0,0 +1,111 @@
|
||||
name: sync-project-tree
|
||||
|
||||
# Keeps this repo's file-layout snapshot (docs/android/PROJECT_TREE.md in the
|
||||
# RunicGateway/docs repo) current. On every push to `main` it regenerates the
|
||||
# tree from tracked files and, if it changed, opens (or force-updates) a pull
|
||||
# request against the docs repo. It never writes to the docs repo's `main`
|
||||
# directly. Auth reuses the same REGISTRY_USER / REGISTRY_TOKEN secrets the
|
||||
# other workflows use (the token needs repo read/write on RunicGateway/docs).
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
workflow_dispatch: {}
|
||||
|
||||
concurrency:
|
||||
group: sync-project-tree
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
GITEA_HOST: gitea.whitlocktech.com
|
||||
DOCS_REPO: RunicGateway/docs
|
||||
SELF_REPO: RunicGateway/Android-app
|
||||
DOCS_PATH: android/PROJECT_TREE.md
|
||||
TREE_TITLE: Android App
|
||||
ROOT_LABEL: android-app
|
||||
PR_BRANCH: chore/sync-android-tree
|
||||
|
||||
jobs:
|
||||
sync:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out this repo
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Ensure python3 is available
|
||||
run: |
|
||||
set -euo pipefail
|
||||
command -v python3 >/dev/null 2>&1 || { sudo apt-get update -qq && sudo apt-get install -y -qq python3; }
|
||||
|
||||
- name: Render PROJECT_TREE.md from tracked files
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p _sync
|
||||
{
|
||||
printf '# %s — Project Tree\n\n' "${TREE_TITLE}"
|
||||
printf '> **Auto-generated.** This file is maintained by the `sync-project-tree` CI workflow in\n'
|
||||
printf '> the [`%s`](https://%s/%s) repository, which\n' "${SELF_REPO}" "${GITEA_HOST}" "${SELF_REPO}"
|
||||
printf '> opens a pull request here whenever the tracked file layout on `main` changes. Do not edit\n'
|
||||
printf '> by hand — changes will be overwritten by the next sync.\n\n'
|
||||
printf 'A snapshot of the tracked files in the repository (build output, dependencies, and other\n'
|
||||
printf 'git-ignored paths are excluded).\n\n'
|
||||
printf '```text\n'
|
||||
git ls-files | python3 .gitea/scripts/gen_tree.py "${ROOT_LABEL}"
|
||||
printf '```\n'
|
||||
} > _sync/PROJECT_TREE.md
|
||||
echo "----- generated ${DOCS_PATH} -----"
|
||||
cat _sync/PROJECT_TREE.md
|
||||
|
||||
- name: Open or update the docs PR if the tree changed
|
||||
env:
|
||||
REGISTRY_USER: ${{ secrets.REGISTRY_USER }}
|
||||
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Secrets can carry a trailing CR/LF depending on how they were pasted;
|
||||
# strip line breaks before they land in a URL or Authorization header.
|
||||
CI_USER="$(printf '%s' "${REGISTRY_USER}" | tr -d '\r\n')"
|
||||
CI_TOKEN="$(printf '%s' "${REGISTRY_TOKEN}" | tr -d '\r\n')"
|
||||
API="https://${GITEA_HOST}/api/v1/repos/${DOCS_REPO}"
|
||||
REMOTE="https://${CI_USER}:${CI_TOKEN}@${GITEA_HOST}/${DOCS_REPO}.git"
|
||||
|
||||
git clone --depth 1 "${REMOTE}" docs_repo
|
||||
cd docs_repo
|
||||
git config user.name "runic-docs-bot"
|
||||
git config user.email "ci@whitlocktech.com"
|
||||
|
||||
mkdir -p "$(dirname "${DOCS_PATH}")"
|
||||
cp ../_sync/PROJECT_TREE.md "${DOCS_PATH}"
|
||||
git add "${DOCS_PATH}"
|
||||
if git diff --cached --quiet; then
|
||||
echo "PROJECT_TREE.md already up to date — nothing to sync."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
SHORT_SHA="$(echo "${GITHUB_SHA:-local}" | cut -c1-7)"
|
||||
git checkout -B "${PR_BRANCH}"
|
||||
git commit -m "docs(tree): sync ${DOCS_PATH} from ${SELF_REPO}@${SHORT_SHA} [skip ci]"
|
||||
git push --force "${REMOTE}" "HEAD:${PR_BRANCH}"
|
||||
|
||||
# Open a PR only if one isn't already open for this branch (a force-push
|
||||
# to an existing open PR's head updates it in place).
|
||||
OPEN="$(curl -sSf -H "Authorization: token ${CI_TOKEN}" \
|
||||
"${API}/pulls?state=open&limit=50" \
|
||||
| jq --arg b "${PR_BRANCH}" '[.[] | select(.head.ref == $b)] | length')"
|
||||
if [ "${OPEN}" = "0" ]; then
|
||||
curl -sSf -X POST "${API}/pulls" \
|
||||
-H "Authorization: token ${CI_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$(jq -n \
|
||||
--arg head "${PR_BRANCH}" \
|
||||
--arg base "main" \
|
||||
--arg title "docs(tree): sync ${DOCS_PATH}" \
|
||||
--arg body "Automated project-tree sync from [\`${SELF_REPO}\`](https://${GITEA_HOST}/${SELF_REPO}), regenerated from tracked files on \`main\`. Merge once the layout looks right; the workflow will keep this branch current until then." \
|
||||
'{head: $head, base: $base, title: $title, body: $body}')" \
|
||||
>/dev/null
|
||||
echo "Opened a new docs PR for ${PR_BRANCH}."
|
||||
else
|
||||
echo "Existing open docs PR for ${PR_BRANCH} was updated via force-push."
|
||||
fi
|
||||
@@ -10,6 +10,11 @@ plugins {
|
||||
alias(libs.plugins.kotlin.serialization)
|
||||
alias(libs.plugins.ksp)
|
||||
alias(libs.plugins.hilt)
|
||||
jacoco
|
||||
}
|
||||
|
||||
jacoco {
|
||||
toolVersion = "0.8.12"
|
||||
}
|
||||
|
||||
// Release signing material (PLAN.md §12) is never committed. It is read from, in
|
||||
@@ -78,6 +83,11 @@ android {
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
debug {
|
||||
// Produce a JaCoCo .exec from JVM unit tests so SonarQube receives real
|
||||
// coverage (§12.1). Debug-only: the scan analyses the debug variant.
|
||||
enableUnitTestCoverage = true
|
||||
}
|
||||
release {
|
||||
// R8 full-mode minify + resource shrink (§7: no offline cache, so a lean
|
||||
// release APK). Keep rules live in proguard-rules.pro.
|
||||
@@ -170,3 +180,35 @@ dependencies {
|
||||
androidTestImplementation(platform(libs.androidx.compose.bom))
|
||||
androidTestImplementation(libs.androidx.compose.ui.test.junit4)
|
||||
}
|
||||
|
||||
// JaCoCo XML coverage from the JVM unit tests, consumed by SonarQube (§12.1). Generated,
|
||||
// DI (Hilt), and Compose-scaffold classes are excluded so they don't dilute the number;
|
||||
// pure-@Composable UI is excluded on the Sonar side (sonar.coverage.exclusions) because
|
||||
// JVM unit tests can't execute composable bodies without Robolectric.
|
||||
tasks.register<JacocoReport>("jacocoTestReport") {
|
||||
dependsOn("testDebugUnitTest")
|
||||
group = "verification"
|
||||
description = "Generates JaCoCo XML/HTML coverage for the debug unit tests."
|
||||
|
||||
reports {
|
||||
xml.required.set(true)
|
||||
html.required.set(true)
|
||||
}
|
||||
|
||||
val coverageExcludes = listOf(
|
||||
"**/R.class", "**/R$*.class", "**/BuildConfig.*", "**/Manifest*.*",
|
||||
"**/*_Hilt*.*", "**/Hilt_*.*", "**/*_Factory*.*", "**/*_MembersInjector*.*",
|
||||
"**/*_Impl*.*", "**/di/**", "**/*Module.*", "**/*Module$*.*",
|
||||
"**/*ComposableSingletons*.*", "**/ComposableSingletons$*.*",
|
||||
)
|
||||
val buildDirFile = layout.buildDirectory.get().asFile
|
||||
classDirectories.setFrom(
|
||||
fileTree("$buildDirFile/tmp/kotlin-classes/debug") { exclude(coverageExcludes) },
|
||||
)
|
||||
sourceDirectories.setFrom(files("src/main/java", "src/main/kotlin"))
|
||||
executionData.setFrom(
|
||||
fileTree(buildDirFile) {
|
||||
include("outputs/unit_test_code_coverage/debugUnitTest/testDebugUnitTest.exec")
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
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">
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.core.auth
|
||||
|
||||
import android.os.Build
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Supplies a friendly label for this device, sent as `device_name` at login so a
|
||||
* trusted-device / active-session row is recognizable in the account lists
|
||||
* (TRUSTED_DEVICES_MFA.md). Behind an interface so the auth repository stays free of
|
||||
* `android.os.Build` and unit-testable on the JVM.
|
||||
*/
|
||||
fun interface DeviceNameProvider {
|
||||
/** A human label like "Google Pixel 8", or null if nothing meaningful is available. */
|
||||
fun deviceName(): String?
|
||||
}
|
||||
|
||||
/** Production impl: manufacturer + model from [Build] (e.g. "Samsung SM-S918B"). */
|
||||
@Singleton
|
||||
class BuildDeviceNameProvider @Inject constructor() : DeviceNameProvider {
|
||||
override fun deviceName(): String? {
|
||||
val manufacturer = Build.MANUFACTURER?.trim().orEmpty()
|
||||
val model = Build.MODEL?.trim().orEmpty()
|
||||
val label = when {
|
||||
model.isEmpty() -> manufacturer
|
||||
manufacturer.isEmpty() || model.startsWith(manufacturer, ignoreCase = true) -> model
|
||||
else -> "$manufacturer $model"
|
||||
}.replaceFirstChar { if (it.isLowerCase()) it.titlecase() else it.toString() }
|
||||
return label.take(100).ifBlank { null }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.core.auth
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import androidx.security.crypto.EncryptedSharedPreferences
|
||||
import androidx.security.crypto.MasterKey
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* [TrustTokenStore] backed by its **own** EncryptedSharedPreferences file
|
||||
* (Tink/AES-256-GCM), distinct from the session store so it is never wiped by
|
||||
* [SessionManager.onSignedOut] — the trust token must outlive a logout to do its
|
||||
* job (TRUSTED_DEVICES_MFA.md). The token is stored alongside the username it was
|
||||
* minted for so [tokenFor] only returns it for a matching login.
|
||||
*
|
||||
* The prefs handle is lazy so a device that never trusts pays the keystore cost
|
||||
* only if a token is actually stored or read.
|
||||
*/
|
||||
@Singleton
|
||||
class EncryptedTrustTokenStore @Inject constructor(
|
||||
@param:ApplicationContext private val context: Context,
|
||||
) : TrustTokenStore {
|
||||
|
||||
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 tokenFor(username: String): String? {
|
||||
val token = prefs.getString(KEY_TOKEN, null) ?: return null
|
||||
val owner = prefs.getString(KEY_USERNAME, null) ?: return null
|
||||
// Case-insensitive: usernames are matched case-insensitively server-side.
|
||||
return if (owner.equals(username, ignoreCase = true)) token else null
|
||||
}
|
||||
|
||||
override fun save(username: String, token: String) {
|
||||
prefs.edit()
|
||||
.putString(KEY_TOKEN, token)
|
||||
.putString(KEY_USERNAME, username)
|
||||
.apply()
|
||||
}
|
||||
|
||||
override fun clear() {
|
||||
prefs.edit().clear().apply()
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val PREFS_NAME = "runic_trust"
|
||||
const val KEY_TOKEN = "trust_token"
|
||||
const val KEY_USERNAME = "trust_username"
|
||||
}
|
||||
}
|
||||
@@ -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,31 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.core.auth
|
||||
|
||||
/**
|
||||
* At-rest home for the opaque trusted-device token (TRUSTED_DEVICES_MFA.md). It is
|
||||
* the native analogue of the web `rg_trust` cookie: a device that holds a valid
|
||||
* token skips the TOTP step on its next login (never the password).
|
||||
*
|
||||
* Deliberately **separate** from [TokenStore] and untouched by session teardown —
|
||||
* the token must **survive logout and a dead-refresh sign-out**, because it is only
|
||||
* ever consulted at a *fresh* login (exactly the moment after the session is gone).
|
||||
* Clearing it there would make the feature a no-op. It is scoped to the username it
|
||||
* was minted for so it is never replayed for a different account on a shared device,
|
||||
* and is cleared only by an explicit untrust, a Settings → Server switch, or a
|
||||
* server-side revocation (password change/reset, TOTP disable) that renders it dead.
|
||||
*
|
||||
* Tokens are sensitive, so the production impl uses EncryptedSharedPreferences —
|
||||
* never plain prefs or logs. Kept behind an interface for an in-memory test fake.
|
||||
*/
|
||||
interface TrustTokenStore {
|
||||
/** The stored trust token for [username], or null if this device isn't trusted for them. */
|
||||
fun tokenFor(username: String): String?
|
||||
|
||||
/** Persist [token] as the trust token for [username] (overwrites any prior one). */
|
||||
fun save(username: String, token: String)
|
||||
|
||||
/** Drop the trust token — untrust-all and the Settings → Server hard reset. */
|
||||
fun clear()
|
||||
}
|
||||
@@ -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)
|
||||
@@ -5,6 +5,7 @@ package com.runicgateway.app.core.auth.sso
|
||||
|
||||
import com.runicgateway.app.BuildConfig
|
||||
import com.runicgateway.app.core.auth.SessionManager
|
||||
import com.runicgateway.app.core.auth.TrustTokenStore
|
||||
import com.runicgateway.app.core.net.BaseUrlHolder
|
||||
import com.runicgateway.app.data.api.SsoApi
|
||||
import com.runicgateway.app.data.api.dto.MobileSsoExchangeRequest
|
||||
@@ -13,7 +14,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 +34,23 @@ 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,
|
||||
private val trustTokenStore: TrustTokenStore,
|
||||
) {
|
||||
|
||||
/** Why an SSO attempt ended, for a friendly inline message on the login screen. */
|
||||
@@ -75,10 +78,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 +96,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 +157,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) {
|
||||
@@ -195,6 +195,14 @@ class SsoAuthManager @Inject constructor(
|
||||
_outcome.value = Outcome.Failed(Failure.SERVER)
|
||||
return
|
||||
}
|
||||
// The user ticked "trust this device" on the TOTP form inside the Custom
|
||||
// Tab. That tab's cookie already covers future SSO sign-ins; persisting
|
||||
// the token the exchange handed back is what lets a native PASSWORD login
|
||||
// on this device skip the code too (TRUSTED_DEVICES_MFA.md). Scoped to the
|
||||
// username exactly like the password path, so it is never replayed for a
|
||||
// different account on a shared device. Saved BEFORE onSignedIn so a
|
||||
// process death mid-callback can't lose it.
|
||||
body.trustToken?.let { trustTokenStore.save(body.user.username, it) }
|
||||
sessionManager.onSignedIn(body.accessToken, body.refreshToken, body.user)
|
||||
_outcome.value = Outcome.Success
|
||||
return
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.core.net
|
||||
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
/**
|
||||
* The live shard SSE feed as a cold flow of lifecycle + frame events (PLAN.md §6.2,
|
||||
* §7). Extracted as an interface so consumers (e.g. [com.runicgateway.app.data.repository.ShardRepository])
|
||||
* depend on the capability, not the OkHttp-backed [ShardStreamClient] — the boards
|
||||
* can then be unit-tested against a fake stream instead of a real network connection.
|
||||
*/
|
||||
interface ShardStream {
|
||||
fun events(): Flow<ShardStreamEvent>
|
||||
}
|
||||
@@ -40,7 +40,7 @@ class ShardStreamClient @Inject constructor(
|
||||
baseClient: OkHttpClient,
|
||||
private val baseUrlHolder: BaseUrlHolder,
|
||||
private val json: Json,
|
||||
) {
|
||||
) : ShardStream {
|
||||
// SSE is a long-lived, mostly-idle connection (keepalive comments every ~25s),
|
||||
// so the read timeout must be disabled or the idle stream would be killed.
|
||||
private val sseClient: OkHttpClient = baseClient.newBuilder()
|
||||
@@ -56,7 +56,7 @@ class ShardStreamClient @Inject constructor(
|
||||
* drive a live/offline indicator; [ShardStreamEvent.Frame] carries a decoded
|
||||
* `{ kind, … }` payload the boards merge in place.
|
||||
*/
|
||||
fun events(): Flow<ShardStreamEvent> = channelFlow {
|
||||
override fun events(): Flow<ShardStreamEvent> = channelFlow {
|
||||
var backoffMs = INITIAL_BACKOFF_MS
|
||||
while (isActive) {
|
||||
val url = baseUrlHolder.current?.resolve(STREAM_PATH)
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
package com.runicgateway.app.core.result
|
||||
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.serialization.SerializationException
|
||||
import retrofit2.HttpException
|
||||
import java.io.IOException
|
||||
|
||||
@@ -37,6 +38,16 @@ inline fun <T, R> ApiResult<T>.map(transform: (T) -> R): ApiResult<R> = when (th
|
||||
* Run a suspending Retrofit call and normalize every outcome into an [ApiResult].
|
||||
* Coroutine cancellation is rethrown so structured concurrency still works — it
|
||||
* is control flow, not a network failure.
|
||||
*
|
||||
* A body the app can't decode (a field whose type/shape doesn't match its DTO, e.g.
|
||||
* a live-shaped `guild.update` snapshot carrying an unexpected value) throws a
|
||||
* [SerializationException] out of the Retrofit converter. That is a broken contract
|
||||
* with the backend, not a bug to crash on: the request completed but the response is
|
||||
* unusable — an invalid upstream response — so it is surfaced as a server-side error
|
||||
* (`502` → [ErrorKind.SERVER]) the screen renders as "something went wrong, retry",
|
||||
* exactly the graceful-degradation the layer promises (never throw for an expected
|
||||
* failure). Without this catch the exception escapes the collecting coroutine and
|
||||
* takes down the whole app.
|
||||
*/
|
||||
suspend fun <T> safeApiCall(block: suspend () -> T): ApiResult<T> = try {
|
||||
ApiResult.Ok(block())
|
||||
@@ -46,4 +57,9 @@ suspend fun <T> safeApiCall(block: suspend () -> T): ApiResult<T> = try {
|
||||
ApiResult.HttpError(e.code(), e.message())
|
||||
} catch (e: IOException) {
|
||||
ApiResult.NetworkError(e)
|
||||
} catch (e: SerializationException) {
|
||||
ApiResult.HttpError(MALFORMED_RESPONSE_STATUS, e.message)
|
||||
}
|
||||
|
||||
/** Synthetic status for a 2xx body the app couldn't decode — an invalid upstream response. */
|
||||
private const val MALFORMED_RESPONSE_STATUS = 502
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import com.runicgateway.app.data.api.dto.MobileTokenResponse
|
||||
import retrofit2.Response
|
||||
import retrofit2.http.Body
|
||||
import retrofit2.http.GET
|
||||
import retrofit2.http.Header
|
||||
import retrofit2.http.Headers
|
||||
import retrofit2.http.POST
|
||||
|
||||
@@ -27,9 +28,15 @@ import retrofit2.http.POST
|
||||
interface AuthApi {
|
||||
|
||||
// Literal header value required by Retrofit @Headers; matches Http.NO_SESSION_HEADER.
|
||||
// [trustToken] rides the `X-Trust-Token` header (TRUSTED_DEVICES_MFA.md): a valid
|
||||
// token bound to this user lets the server skip the TOTP step. Retrofit omits the
|
||||
// header entirely when it is null, so an untrusted device sends nothing.
|
||||
@Headers("X-Runic-No-Session: 1")
|
||||
@POST("api/v1/auth/mobile/login")
|
||||
suspend fun login(@Body body: MobileLoginRequest): Response<MobileTokenResponse>
|
||||
suspend fun login(
|
||||
@Body body: MobileLoginRequest,
|
||||
@Header("X-Trust-Token") trustToken: String? = null,
|
||||
): Response<MobileTokenResponse>
|
||||
|
||||
@POST("api/v1/auth/mobile/logout")
|
||||
suspend fun logout(@Body body: MobileLogoutRequest): Response<Unit>
|
||||
|
||||
@@ -7,11 +7,21 @@ import com.runicgateway.app.data.api.dto.ChangePasswordRequest
|
||||
import com.runicgateway.app.data.api.dto.ChangeUsernameRequest
|
||||
import com.runicgateway.app.data.api.dto.LinkedIdentityDto
|
||||
import com.runicgateway.app.data.api.dto.PlayerAccountDto
|
||||
import com.runicgateway.app.data.api.dto.RecoveryCodesDto
|
||||
import com.runicgateway.app.data.api.dto.RecoveryGenerateRequest
|
||||
import com.runicgateway.app.data.api.dto.RecoveryStatusDto
|
||||
import com.runicgateway.app.data.api.dto.RevokedCountDto
|
||||
import com.runicgateway.app.data.api.dto.RevokedFlagDto
|
||||
import com.runicgateway.app.data.api.dto.TotpCodeRequest
|
||||
import com.runicgateway.app.data.api.dto.TotpSetupDto
|
||||
import com.runicgateway.app.data.api.dto.TotpStateDto
|
||||
import com.runicgateway.app.data.api.dto.TrustDeviceRequest
|
||||
import com.runicgateway.app.data.api.dto.TrustDeviceResultDto
|
||||
import com.runicgateway.app.data.api.dto.TrustedDeviceDto
|
||||
import com.runicgateway.app.data.api.dto.UsernameResponse
|
||||
import retrofit2.Response
|
||||
import retrofit2.http.Body
|
||||
import retrofit2.http.DELETE
|
||||
import retrofit2.http.GET
|
||||
import retrofit2.http.HTTP
|
||||
import retrofit2.http.PATCH
|
||||
@@ -52,4 +62,28 @@ interface MeApi {
|
||||
// path template explicit alongside the provider argument.
|
||||
@HTTP(method = "DELETE", path = "api/v1/auth/me/account/identities/{provider}")
|
||||
suspend fun unlinkIdentity(@Path("provider") provider: String): Unit
|
||||
|
||||
// ── Trusted devices (TRUSTED_DEVICES_MFA.md) — devices allowed to skip TOTP ──
|
||||
|
||||
@GET("api/v1/auth/me/trusted-devices")
|
||||
suspend fun trustedDevices(): List<TrustedDeviceDto>
|
||||
|
||||
// Raw [Response] so the caller can read the `409 { error, devices }` cap body,
|
||||
// which a thrown HttpException would discard.
|
||||
@POST("api/v1/auth/me/trusted-devices")
|
||||
suspend fun trustThisDevice(@Body body: TrustDeviceRequest): Response<TrustDeviceResultDto>
|
||||
|
||||
@DELETE("api/v1/auth/me/trusted-devices/{id}")
|
||||
suspend fun revokeTrustedDevice(@Path("id") id: Long): RevokedFlagDto
|
||||
|
||||
@DELETE("api/v1/auth/me/trusted-devices")
|
||||
suspend fun revokeAllTrustedDevices(): RevokedCountDto
|
||||
|
||||
// ── Recovery (backup) codes ──────────────────────────────────────────────
|
||||
|
||||
@GET("api/v1/auth/me/account/recovery-codes/status")
|
||||
suspend fun recoveryCodesStatus(): RecoveryStatusDto
|
||||
|
||||
@POST("api/v1/auth/me/account/recovery-codes/generate")
|
||||
suspend fun generateRecoveryCodes(@Body body: RecoveryGenerateRequest): RecoveryCodesDto
|
||||
}
|
||||
|
||||
@@ -55,9 +55,78 @@ data class TotpSetupDto(
|
||||
@Serializable
|
||||
data class TotpCodeRequest(val code: String)
|
||||
|
||||
/** Result of enabling/disabling 2FA. */
|
||||
/**
|
||||
* Result of enabling/disabling 2FA. Enabling also returns the freshly generated
|
||||
* single-use [recoveryCodes] **once** (null on disable and for older backends) — the
|
||||
* app shows them for the user to save and never persists them.
|
||||
*/
|
||||
@Serializable
|
||||
data class TotpStateDto(val totp_enabled: Boolean = false)
|
||||
data class TotpStateDto(
|
||||
val totp_enabled: Boolean = false,
|
||||
val recoveryCodes: List<String>? = null,
|
||||
)
|
||||
|
||||
// ── Trusted devices & recovery codes (TRUSTED_DEVICES_MFA.md) ───────────────
|
||||
|
||||
/**
|
||||
* An active trusted device (`GET /auth/me/trusted-devices`): a browser/app allowed
|
||||
* to skip the TOTP step at login. Never carries the token. Timestamps are ISO-8601
|
||||
* strings shown as-is (advisory display).
|
||||
*/
|
||||
@Serializable
|
||||
data class TrustedDeviceDto(
|
||||
val id: Long = 0,
|
||||
val platform: String? = null,
|
||||
val deviceName: String? = null,
|
||||
val userAgent: String? = null,
|
||||
val createdAt: String? = null,
|
||||
val lastUsedAt: String? = null,
|
||||
val expiresAt: String? = null,
|
||||
)
|
||||
|
||||
/** `POST /auth/me/trusted-devices` body — an optional friendly label. */
|
||||
@Serializable
|
||||
data class TrustDeviceRequest(val deviceName: String? = null)
|
||||
|
||||
/**
|
||||
* `POST /auth/me/trusted-devices` success (native): the opaque [trustToken] to store
|
||||
* and replay via `X-Trust-Token`. Web receives the token as a cookie and no body token.
|
||||
*/
|
||||
@Serializable
|
||||
data class TrustDeviceResultDto(
|
||||
val trusted: Boolean = false,
|
||||
val trustToken: String? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* `409 { error: "trusted_device_limit", devices }` from a trust attempt at the cap —
|
||||
* the app lists [devices] and asks the user to revoke one, then retry.
|
||||
*/
|
||||
@Serializable
|
||||
data class TrustedDeviceLimitDto(
|
||||
val error: String? = null,
|
||||
val devices: List<TrustedDeviceDto> = emptyList(),
|
||||
)
|
||||
|
||||
/** `DELETE /auth/me/trusted-devices/:id` — idempotent single-revoke result. */
|
||||
@Serializable
|
||||
data class RevokedFlagDto(val revoked: Boolean = false)
|
||||
|
||||
/** `DELETE /auth/me/trusted-devices` — count of devices untrusted ("untrust all"). */
|
||||
@Serializable
|
||||
data class RevokedCountDto(val revoked: Int = 0)
|
||||
|
||||
/** `GET /auth/me/account/recovery-codes/status` — remaining unused count only. */
|
||||
@Serializable
|
||||
data class RecoveryStatusDto(val remaining: Int = 0)
|
||||
|
||||
/** `POST /auth/me/account/recovery-codes/generate` body — password step-up. */
|
||||
@Serializable
|
||||
data class RecoveryGenerateRequest(val currentPassword: String? = null)
|
||||
|
||||
/** A fresh single-use recovery-code batch, returned **once** (generate + totp enable). */
|
||||
@Serializable
|
||||
data class RecoveryCodesDto(val recoveryCodes: List<String> = emptyList())
|
||||
|
||||
/** A linked external identity (`GET /auth/me/account/identities`). */
|
||||
@Serializable
|
||||
|
||||
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)
|
||||
@@ -12,12 +12,23 @@ import kotlinx.serialization.Serializable
|
||||
* safe (§8, recorded for M1).
|
||||
*/
|
||||
|
||||
/** `POST /auth/mobile/login` body. [code] is only sent on the 2FA retry. */
|
||||
/**
|
||||
* `POST /auth/mobile/login` body (trusted-devices contract, TRUSTED_DEVICES_MFA.md).
|
||||
* [code] is only sent on the 2FA retry; [recoveryCode] is its single-use fallback
|
||||
* (sent instead of [code]). [trustDevice] asks the server to remember this device so
|
||||
* future logins skip the second factor — on success the response carries a
|
||||
* [MobileTokenResponse.trustToken] the app stores and replays via `X-Trust-Token`.
|
||||
* [device_name] labels the resulting trusted-device / session row (snake_case to
|
||||
* match the backend field exactly).
|
||||
*/
|
||||
@Serializable
|
||||
data class MobileLoginRequest(
|
||||
val username: String,
|
||||
val password: String,
|
||||
val code: String? = null,
|
||||
val recoveryCode: String? = null,
|
||||
val trustDevice: Boolean? = null,
|
||||
val device_name: String? = null,
|
||||
)
|
||||
|
||||
/** `POST /auth/mobile/refresh` body. */
|
||||
@@ -34,6 +45,11 @@ data class MobileLogoutRequest(
|
||||
/**
|
||||
* Success payload from login and refresh: the token pair, the access lifetime
|
||||
* (a zeit/ms duration string, e.g. "15m"), and the safe (secret-stripped) user.
|
||||
*
|
||||
* Login additionally carries the trusted-device outcome when `trustDevice` was set:
|
||||
* [trustToken] is the opaque token to persist + replay (present only when the trust
|
||||
* was accepted), or [trustLimitReached] + [devices] when the per-user cap blocked it
|
||||
* (the login itself still succeeded). Refresh never sets these.
|
||||
*/
|
||||
@Serializable
|
||||
data class MobileTokenResponse(
|
||||
@@ -41,6 +57,9 @@ data class MobileTokenResponse(
|
||||
val refreshToken: String,
|
||||
val expiresIn: String? = null,
|
||||
val user: SafeUserDto,
|
||||
val trustToken: String? = null,
|
||||
val trustLimitReached: Boolean = false,
|
||||
val devices: List<TrustedDeviceDto> = emptyList(),
|
||||
)
|
||||
|
||||
/** The minimal, non-sensitive user the app needs to render + gate the menu (§5). */
|
||||
|
||||
@@ -60,8 +60,15 @@ data class NotificationStreamsDto(
|
||||
* `GET/PUT /auth/me/notifications/subscriptions` — the user's opted-in stream ids.
|
||||
* PUT replaces the full set; unknown ids are dropped server-side and the stored set
|
||||
* echoed back.
|
||||
*
|
||||
* [streams] intentionally has NO default: this DTO doubles as the PUT body, and the
|
||||
* backend validator requires the `streams` field (`body('streams').isArray()`).
|
||||
* kotlinx omits a property equal to its default (encodeDefaults=false), so a default
|
||||
* of `emptyList()` would drop the field when the user clears their LAST subscription,
|
||||
* sending `{}` → 400 "Validation failed" (the "can't turn off the last one" bug). With
|
||||
* no default the empty list always serializes as `{"streams":[]}`. Do not re-add a default.
|
||||
*/
|
||||
@Serializable
|
||||
data class NotificationSubscriptionsDto(
|
||||
val streams: List<String> = emptyList(),
|
||||
val streams: List<String>,
|
||||
)
|
||||
|
||||
@@ -14,8 +14,8 @@ import kotlinx.serialization.json.JsonObject
|
||||
* `CharacterSheet.jsx` / `GameAccounts.jsx` and `docs/link/INTEGRATION.md` §5).
|
||||
* Presentation is text-only for v1 (no item icons / paperdoll).
|
||||
*
|
||||
* In-game serials are hex strings (e.g. "0x24C"), unlike the numeric serials on
|
||||
* the public boards — these are separate endpoints with separate shapes.
|
||||
* In-game serials are hex strings (e.g. "0x24C"), the same opaque-key form used on
|
||||
* the public boards (`ShardDto.ActorDto`/`ChampDto`/`HouseDto`) — never numbers.
|
||||
*/
|
||||
|
||||
// ── Game-account linking ─────────────────────────────────────────────────────
|
||||
|
||||
@@ -15,13 +15,18 @@ import kotlinx.serialization.json.JsonObject
|
||||
* `*.update` frames on `/public/shard/stream` decode into these same DTOs.
|
||||
*/
|
||||
|
||||
/** A game actor (player/leader/governor) as embedded in board payloads. */
|
||||
/**
|
||||
* A game actor (player/leader/governor) as embedded in board payloads. Per the wire
|
||||
* spec (`docs/link/INTEGRATION.md` §1), in-game [serial]s are opaque hex-string keys
|
||||
* (e.g. `"0x1A2B"`), never numbers, and [webId] is the linked site-user id as a
|
||||
* string (e.g. `"9931"`) — both are decoded as strings, not parsed.
|
||||
*/
|
||||
@Serializable
|
||||
data class ActorDto(
|
||||
val serial: Long? = null,
|
||||
val serial: String? = null,
|
||||
val name: String? = null,
|
||||
val acct: String? = null,
|
||||
val webId: Long? = null,
|
||||
val webId: String? = null,
|
||||
) {
|
||||
/** Best display label for this actor. */
|
||||
val label: String get() = name ?: acct ?: "Someone"
|
||||
@@ -73,7 +78,7 @@ data class FeedEventDto(
|
||||
*/
|
||||
@Serializable
|
||||
data class OnlineStaffDto(
|
||||
val serial: Long? = null,
|
||||
val serial: String? = null,
|
||||
val name: String? = null,
|
||||
val map: String? = null,
|
||||
val x: Int? = null,
|
||||
@@ -87,7 +92,7 @@ data class OnlineStaffDto(
|
||||
*/
|
||||
@Serializable
|
||||
data class HouseDto(
|
||||
val serial: Long = 0,
|
||||
val serial: String = "",
|
||||
val name: String? = null,
|
||||
val region: String? = null,
|
||||
val map: String? = null,
|
||||
@@ -104,7 +109,7 @@ data class HouseDto(
|
||||
*/
|
||||
@Serializable
|
||||
data class ChampDto(
|
||||
val serial: Long = 0,
|
||||
val serial: String = "",
|
||||
val category: String? = null,
|
||||
val type: String? = null,
|
||||
val name: String? = null,
|
||||
|
||||
@@ -10,10 +10,19 @@ import com.runicgateway.app.data.api.dto.ChangePasswordRequest
|
||||
import com.runicgateway.app.data.api.dto.ChangeUsernameRequest
|
||||
import com.runicgateway.app.data.api.dto.LinkedIdentityDto
|
||||
import com.runicgateway.app.data.api.dto.PlayerAccountDto
|
||||
import com.runicgateway.app.data.api.dto.RecoveryCodesDto
|
||||
import com.runicgateway.app.data.api.dto.RecoveryGenerateRequest
|
||||
import com.runicgateway.app.data.api.dto.RecoveryStatusDto
|
||||
import com.runicgateway.app.data.api.dto.TotpCodeRequest
|
||||
import com.runicgateway.app.data.api.dto.TotpSetupDto
|
||||
import com.runicgateway.app.data.api.dto.TotpStateDto
|
||||
import com.runicgateway.app.data.api.dto.TrustDeviceRequest
|
||||
import com.runicgateway.app.data.api.dto.TrustedDeviceDto
|
||||
import com.runicgateway.app.data.api.dto.TrustedDeviceLimitDto
|
||||
import com.runicgateway.app.data.api.dto.UsernameResponse
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.serialization.json.Json
|
||||
import java.io.IOException
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@@ -26,6 +35,7 @@ import javax.inject.Singleton
|
||||
@Singleton
|
||||
class AccountRepository @Inject constructor(
|
||||
private val api: MeApi,
|
||||
private val json: Json,
|
||||
) {
|
||||
suspend fun getAccount(): ApiResult<PlayerAccountDto> = safeApiCall { api.getAccount() }
|
||||
|
||||
@@ -48,4 +58,63 @@ class AccountRepository @Inject constructor(
|
||||
|
||||
suspend fun unlinkIdentity(provider: String): ApiResult<Unit> =
|
||||
safeApiCall { api.unlinkIdentity(provider) }
|
||||
|
||||
// ── Trusted devices (TRUSTED_DEVICES_MFA.md) ───────────────────────────
|
||||
|
||||
suspend fun trustedDevices(): ApiResult<List<TrustedDeviceDto>> =
|
||||
safeApiCall { api.trustedDevices() }
|
||||
|
||||
/** The distinct outcomes of trusting the current device — the cap is a first-class case. */
|
||||
sealed interface TrustOutcome {
|
||||
/** Trusted; [trustToken] is the opaque token to persist (native). */
|
||||
data class Trusted(val trustToken: String?) : TrustOutcome
|
||||
|
||||
/** At the per-user cap — [devices] must be pruned before retrying. */
|
||||
data class LimitReached(val devices: List<TrustedDeviceDto>) : TrustOutcome
|
||||
data object NetworkError : TrustOutcome
|
||||
data object ServerError : TrustOutcome
|
||||
}
|
||||
|
||||
/**
|
||||
* Trust the current device. Reads the raw response so the `409 { error, devices }`
|
||||
* cap body survives (a thrown [retrofit2.HttpException] would discard it).
|
||||
*/
|
||||
suspend fun trustThisDevice(deviceName: String? = null): TrustOutcome {
|
||||
val response = try {
|
||||
api.trustThisDevice(TrustDeviceRequest(deviceName))
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (_: IOException) {
|
||||
return TrustOutcome.NetworkError
|
||||
} catch (_: Exception) {
|
||||
return TrustOutcome.ServerError
|
||||
}
|
||||
if (response.isSuccessful) {
|
||||
return TrustOutcome.Trusted(response.body()?.trustToken)
|
||||
}
|
||||
if (response.code() == 409) {
|
||||
val devices = runCatching {
|
||||
val raw = response.errorBody()?.string()
|
||||
if (raw.isNullOrBlank()) emptyList()
|
||||
else json.decodeFromString<TrustedDeviceLimitDto>(raw).devices
|
||||
}.getOrDefault(emptyList())
|
||||
return TrustOutcome.LimitReached(devices)
|
||||
}
|
||||
return TrustOutcome.ServerError
|
||||
}
|
||||
|
||||
suspend fun revokeTrustedDevice(id: Long): ApiResult<Boolean> =
|
||||
safeApiCall { api.revokeTrustedDevice(id).revoked }
|
||||
|
||||
suspend fun revokeAllTrustedDevices(): ApiResult<Int> =
|
||||
safeApiCall { api.revokeAllTrustedDevices().revoked }
|
||||
|
||||
// ── Recovery (backup) codes ────────────────────────────────────────────
|
||||
|
||||
suspend fun recoveryCodesStatus(): ApiResult<RecoveryStatusDto> =
|
||||
safeApiCall { api.recoveryCodesStatus() }
|
||||
|
||||
/** Regenerate the single-use codes (password step-up). Returned once — never stored. */
|
||||
suspend fun generateRecoveryCodes(currentPassword: String?): ApiResult<RecoveryCodesDto> =
|
||||
safeApiCall { api.generateRecoveryCodes(RecoveryGenerateRequest(currentPassword)) }
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,9 @@
|
||||
*/
|
||||
package com.runicgateway.app.data.repository
|
||||
|
||||
import com.runicgateway.app.core.auth.DeviceNameProvider
|
||||
import com.runicgateway.app.core.auth.SessionManager
|
||||
import com.runicgateway.app.core.auth.TrustTokenStore
|
||||
import com.runicgateway.app.core.push.PushManager
|
||||
import com.runicgateway.app.data.api.AuthApi
|
||||
import com.runicgateway.app.data.api.SsoApi
|
||||
@@ -12,7 +14,9 @@ import com.runicgateway.app.data.api.dto.MobileLogoutRequest
|
||||
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 com.runicgateway.app.data.api.dto.TrustedDeviceDto
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.serialization.json.Json
|
||||
import retrofit2.Response
|
||||
import java.io.IOException
|
||||
@@ -31,25 +35,58 @@ class AuthRepository @Inject constructor(
|
||||
private val ssoApi: SsoApi,
|
||||
private val sessionManager: SessionManager,
|
||||
private val pushManager: PushManager,
|
||||
private val trustTokenStore: TrustTokenStore,
|
||||
private val deviceNameProvider: DeviceNameProvider,
|
||||
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). */
|
||||
sealed interface LoginResult {
|
||||
data object Success : LoginResult
|
||||
/**
|
||||
* Signed in. [trustLimitReached] is true when "trust this device" was asked
|
||||
* for but the per-user cap blocked it (the login still succeeded, but no trust
|
||||
* token was issued); [devices] then lists the trusted devices to manage.
|
||||
*/
|
||||
data class Success(
|
||||
val trustLimitReached: Boolean = false,
|
||||
val devices: List<TrustedDeviceDto> = emptyList(),
|
||||
) : LoginResult
|
||||
|
||||
/** The account has 2FA on — reveal the code field and resubmit with a code. */
|
||||
data object TotpRequired : LoginResult
|
||||
@@ -65,9 +102,32 @@ class AuthRepository @Inject constructor(
|
||||
data object NetworkError : LoginResult
|
||||
}
|
||||
|
||||
suspend fun login(username: String, password: String, code: String? = null): LoginResult {
|
||||
/**
|
||||
* Native login (TRUSTED_DEVICES_MFA.md). A stored trust token bound to [username]
|
||||
* rides the `X-Trust-Token` header so a trusted device skips the TOTP step. A
|
||||
* second factor is either a [code] (TOTP) or a single-use [recoveryCode]. With
|
||||
* [trustDevice], the server may return a fresh trust token to persist for next time.
|
||||
*/
|
||||
suspend fun login(
|
||||
username: String,
|
||||
password: String,
|
||||
code: String? = null,
|
||||
recoveryCode: String? = null,
|
||||
trustDevice: Boolean = false,
|
||||
): LoginResult {
|
||||
val storedTrustToken = trustTokenStore.tokenFor(username)
|
||||
val response: Response<MobileTokenResponse> = try {
|
||||
authApi.login(MobileLoginRequest(username = username, password = password, code = code))
|
||||
authApi.login(
|
||||
MobileLoginRequest(
|
||||
username = username,
|
||||
password = password,
|
||||
code = code,
|
||||
recoveryCode = recoveryCode,
|
||||
trustDevice = trustDevice.takeIf { it },
|
||||
device_name = if (trustDevice) deviceNameProvider.deviceName() else null,
|
||||
),
|
||||
trustToken = storedTrustToken,
|
||||
)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (_: IOException) {
|
||||
@@ -76,8 +136,14 @@ class AuthRepository @Inject constructor(
|
||||
|
||||
if (response.isSuccessful) {
|
||||
val body = response.body() ?: return LoginResult.ServerError
|
||||
// Persist a freshly minted trust token (scoped to this account) so the next
|
||||
// login skips the second factor — it deliberately outlives logout.
|
||||
body.trustToken?.let { trustTokenStore.save(username, it) }
|
||||
sessionManager.onSignedIn(body.accessToken, body.refreshToken, body.user)
|
||||
return LoginResult.Success
|
||||
return LoginResult.Success(
|
||||
trustLimitReached = body.trustLimitReached,
|
||||
devices = body.devices,
|
||||
)
|
||||
}
|
||||
|
||||
return when (response.code()) {
|
||||
@@ -87,6 +153,20 @@ class AuthRepository @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist a trust token minted by the self-service "trust this device" action
|
||||
* (Account → Trusted Devices), scoped to [username] exactly like the login path.
|
||||
*/
|
||||
fun saveTrustToken(username: String, token: String) = trustTokenStore.save(username, token)
|
||||
|
||||
/**
|
||||
* Drop the locally stored trust token so this device stops skipping the TOTP step
|
||||
* (used after "untrust all" and on a Settings → Server switch). Server-side
|
||||
* revocation makes any surviving token inert anyway — the next login just prompts
|
||||
* for the code — so this is a client-side cleanliness step, never load-bearing.
|
||||
*/
|
||||
fun clearTrustToken() = trustTokenStore.clear()
|
||||
|
||||
/**
|
||||
* Revoke this session (or, with [allDevices], every session) and clear local
|
||||
* tokens (§4.3). Best-effort: the local session is torn down even if the
|
||||
@@ -140,4 +220,8 @@ class AuthRepository @Inject constructor(
|
||||
} catch (_: Exception) {
|
||||
false
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val DISCOVERY_RETRY_DELAY_MS = 400L
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
package com.runicgateway.app.data.repository
|
||||
|
||||
import com.runicgateway.app.core.auth.SessionManager
|
||||
import com.runicgateway.app.core.auth.TrustTokenStore
|
||||
import com.runicgateway.app.core.net.BaseUrlHolder
|
||||
import com.runicgateway.app.core.net.ServerUrl
|
||||
import com.runicgateway.app.core.prefs.ServerPreferences
|
||||
@@ -26,6 +27,7 @@ class ConnectionRepository @Inject constructor(
|
||||
private val prefs: ServerPreferences,
|
||||
private val baseUrlHolder: BaseUrlHolder,
|
||||
private val sessionManager: SessionManager,
|
||||
private val trustTokenStore: TrustTokenStore,
|
||||
private val pushManager: com.runicgateway.app.core.push.PushManager,
|
||||
private val config: com.runicgateway.app.core.AppConfig,
|
||||
) {
|
||||
@@ -106,6 +108,9 @@ class ConnectionRepository @Inject constructor(
|
||||
}
|
||||
pushManager.setNtfyUrl(null)
|
||||
sessionManager.onSignedOut()
|
||||
// The trust token is bound to the old host — drop it so we don't replay it
|
||||
// against a different shard (it survives a plain logout, but not a host switch).
|
||||
trustTokenStore.clear()
|
||||
prefs.clear()
|
||||
baseUrlHolder.set(null)
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
*/
|
||||
package com.runicgateway.app.data.repository
|
||||
|
||||
import com.runicgateway.app.core.net.ShardStreamClient
|
||||
import com.runicgateway.app.core.net.ShardStream
|
||||
import com.runicgateway.app.core.net.ShardStreamEvent
|
||||
import com.runicgateway.app.core.result.ApiResult
|
||||
import com.runicgateway.app.core.result.safeApiCall
|
||||
@@ -35,7 +35,7 @@ import javax.inject.Singleton
|
||||
@Singleton
|
||||
class ShardRepository @Inject constructor(
|
||||
private val api: PublicApi,
|
||||
private val stream: ShardStreamClient,
|
||||
private val stream: ShardStream,
|
||||
private val json: Json,
|
||||
) {
|
||||
// ── Snapshots ────────────────────────────────────────────────────────
|
||||
|
||||
@@ -9,11 +9,14 @@ import com.runicgateway.app.BuildConfig
|
||||
import com.runicgateway.app.core.net.AuthInterceptor
|
||||
import com.runicgateway.app.core.net.BaseUrlHolder
|
||||
import com.runicgateway.app.core.net.HostSelectionInterceptor
|
||||
import com.runicgateway.app.core.net.ShardStream
|
||||
import com.runicgateway.app.core.net.ShardStreamClient
|
||||
import com.runicgateway.app.core.net.TokenAuthenticator
|
||||
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
|
||||
@@ -93,6 +96,12 @@ object NetworkModule {
|
||||
@Singleton
|
||||
fun providePublicApi(retrofit: Retrofit): PublicApi = retrofit.create(PublicApi::class.java)
|
||||
|
||||
/** Expose the live SSE feed as the [ShardStream] capability so repositories depend
|
||||
* on the interface (unit-testable against a fake), not the OkHttp-backed client. */
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideShardStream(client: ShardStreamClient): ShardStream = client
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideAuthApi(retrofit: Retrofit): AuthApi = retrofit.create(AuthApi::class.java)
|
||||
@@ -119,6 +128,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
|
||||
|
||||
@@ -3,15 +3,21 @@
|
||||
*/
|
||||
package com.runicgateway.app.di
|
||||
|
||||
import com.runicgateway.app.core.auth.BuildDeviceNameProvider
|
||||
import com.runicgateway.app.core.auth.DeviceNameProvider
|
||||
import com.runicgateway.app.core.auth.EncryptedTokenStore
|
||||
import com.runicgateway.app.core.auth.EncryptedTrustTokenStore
|
||||
import com.runicgateway.app.core.auth.TokenStore
|
||||
import com.runicgateway.app.core.auth.TrustTokenStore
|
||||
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 +25,17 @@ abstract class StorageModule {
|
||||
@Binds
|
||||
@Singleton
|
||||
abstract fun bindTokenStore(impl: EncryptedTokenStore): TokenStore
|
||||
|
||||
@Binds
|
||||
@Singleton
|
||||
abstract fun bindPendingSsoStore(impl: EncryptedPendingSsoStore): PendingSsoStore
|
||||
|
||||
/** The trusted-device token store — its own encrypted file, outlives session teardown. */
|
||||
@Binds
|
||||
@Singleton
|
||||
abstract fun bindTrustTokenStore(impl: EncryptedTrustTokenStore): TrustTokenStore
|
||||
|
||||
@Binds
|
||||
@Singleton
|
||||
abstract fun bindDeviceNameProvider(impl: BuildDeviceNameProvider): DeviceNameProvider
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -48,6 +51,8 @@ import com.runicgateway.app.core.auth.Session
|
||||
import com.runicgateway.app.data.api.dto.BrandDto
|
||||
import com.runicgateway.app.ui.auth.AccountScreen
|
||||
import com.runicgateway.app.ui.auth.LoginScreen
|
||||
import com.runicgateway.app.ui.auth.RecoveryCodesScreen
|
||||
import com.runicgateway.app.ui.auth.TrustedDevicesScreen
|
||||
import com.runicgateway.app.ui.auth.roleLabelRes
|
||||
import com.runicgateway.app.ui.contact.ContactScreen
|
||||
import com.runicgateway.app.ui.home.HomeScreen
|
||||
@@ -56,6 +61,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 +87,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 +144,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 +205,7 @@ fun RunicApp(
|
||||
modifier = Modifier.padding(NavigationDrawerItemDefaults.ItemPadding),
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
) {
|
||||
Scaffold(
|
||||
@@ -306,8 +322,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.
|
||||
@@ -317,12 +341,27 @@ private fun RunicNavHost(
|
||||
roleLabel = stringResource(roleLabelRes(s.user.role)),
|
||||
onSignOut = onSignOut,
|
||||
onSignOutEverywhere = onSignOutEverywhere,
|
||||
onOpenTrustedDevices = { navController.navigate(Routes.ACCOUNT_TRUSTED_DEVICES) },
|
||||
onOpenRecoveryCodes = { navController.navigate(Routes.ACCOUNT_RECOVERY_CODES) },
|
||||
)
|
||||
Session.SignedOut -> LaunchedEffect(Unit) {
|
||||
navController.navigateTopLevel(Routes.HOME)
|
||||
}
|
||||
}
|
||||
}
|
||||
composable(Routes.ACCOUNT_TRUSTED_DEVICES) {
|
||||
// Signed-in only; a drop (sign-out/demotion) sends the user home (§4.3).
|
||||
when (session) {
|
||||
is Session.SignedIn -> TrustedDevicesScreen()
|
||||
Session.SignedOut -> LaunchedEffect(Unit) { navController.navigateTopLevel(Routes.HOME) }
|
||||
}
|
||||
}
|
||||
composable(Routes.ACCOUNT_RECOVERY_CODES) {
|
||||
when (session) {
|
||||
is Session.SignedIn -> RecoveryCodesScreen()
|
||||
Session.SignedOut -> LaunchedEffect(Unit) { navController.navigateTopLevel(Routes.HOME) }
|
||||
}
|
||||
}
|
||||
composable(Routes.NOTIFICATIONS) {
|
||||
// Signed-in only; a sign-out (or demotion) sends the user home rather than
|
||||
// leaving stale settings up. The backend gates every call regardless (§5).
|
||||
@@ -352,6 +391,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 +430,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,291 @@
|
||||
/*
|
||||
* 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.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,145 @@
|
||||
/*
|
||||
* 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(
|
||||
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(
|
||||
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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -65,6 +65,8 @@ fun AccountScreen(
|
||||
roleLabel: String,
|
||||
onSignOut: () -> Unit,
|
||||
onSignOutEverywhere: () -> Unit,
|
||||
onOpenTrustedDevices: () -> Unit,
|
||||
onOpenRecoveryCodes: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: AccountViewModel = hiltViewModel(),
|
||||
) {
|
||||
@@ -78,10 +80,15 @@ fun AccountScreen(
|
||||
) {
|
||||
IdentityCard(username = username, roleLabel = roleLabel)
|
||||
|
||||
// One-time recovery codes surfaced right after enabling 2FA — save them now.
|
||||
state.recoveryCodesOnce?.let { codes ->
|
||||
RecoveryCodesShowOnceCard(codes, onDismiss = viewModel::dismissRecoveryCodes)
|
||||
}
|
||||
|
||||
when (val account = state.account) {
|
||||
is UiState.Loading -> LoadingView(Modifier.padding(top = 32.dp))
|
||||
is UiState.Error -> ErrorView(account.kind, onRetry = viewModel::load, modifier = Modifier.padding(top = 32.dp))
|
||||
is UiState.Success -> AccountSections(account.data, state, viewModel)
|
||||
is UiState.Success -> AccountSections(account.data, state, viewModel, onOpenTrustedDevices, onOpenRecoveryCodes)
|
||||
}
|
||||
|
||||
HorizontalDivider(Modifier.padding(vertical = 20.dp))
|
||||
@@ -117,13 +124,34 @@ private fun AccountSections(
|
||||
account: PlayerAccountDto,
|
||||
state: AccountViewModel.State,
|
||||
viewModel: AccountViewModel,
|
||||
onOpenTrustedDevices: () -> Unit,
|
||||
onOpenRecoveryCodes: () -> Unit,
|
||||
) {
|
||||
UsernameSection(account, state, viewModel)
|
||||
PasswordSection(account, state, viewModel)
|
||||
TwoFactorSection(account, state, viewModel)
|
||||
SecuritySection(onOpenTrustedDevices, onOpenRecoveryCodes)
|
||||
IdentitiesSection(state, viewModel)
|
||||
}
|
||||
|
||||
/**
|
||||
* Links to the dedicated trusted-device and recovery-code screens
|
||||
* (TRUSTED_DEVICES_MFA.md). Kept simple — the management UX lives on those screens.
|
||||
*/
|
||||
@Composable
|
||||
private fun SecuritySection(onOpenTrustedDevices: () -> Unit, onOpenRecoveryCodes: () -> Unit) {
|
||||
SectionCard(R.string.account_security_title) {
|
||||
OutlinedButton(
|
||||
onClick = onOpenTrustedDevices,
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 12.dp),
|
||||
) { Text(stringResource(R.string.account_security_trusted_devices)) }
|
||||
OutlinedButton(
|
||||
onClick = onOpenRecoveryCodes,
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
|
||||
) { Text(stringResource(R.string.account_security_recovery_codes)) }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SectionCard(@StringRes titleRes: Int, content: @Composable () -> Unit) {
|
||||
Card(Modifier.fillMaxWidth().padding(top = 12.dp)) {
|
||||
|
||||
@@ -49,6 +49,8 @@ class AccountViewModel @Inject constructor(
|
||||
val busy: Boolean = false,
|
||||
/** The pending TOTP enrollment (QR shown) between setup and enable. */
|
||||
val totpSetup: TotpSetupDto? = null,
|
||||
/** The single-use recovery codes returned once when 2FA was just enabled. */
|
||||
val recoveryCodesOnce: List<String>? = null,
|
||||
val feedback: Feedback? = null,
|
||||
)
|
||||
|
||||
@@ -122,9 +124,12 @@ class AccountViewModel @Inject constructor(
|
||||
if (_state.value.busy) return
|
||||
_state.update { it.copy(busy = true, feedback = null) }
|
||||
viewModelScope.launch {
|
||||
when (accountRepository.totpEnable(code.trim())) {
|
||||
when (val result = accountRepository.totpEnable(code.trim())) {
|
||||
is ApiResult.Ok -> {
|
||||
_state.update { it.copy(totpSetup = null) }
|
||||
// 2FA enable returns the fresh recovery-code batch once — surface it.
|
||||
_state.update {
|
||||
it.copy(totpSetup = null, recoveryCodesOnce = result.data.recoveryCodes?.takeIf(List<String>::isNotEmpty))
|
||||
}
|
||||
finish(Section.TOTP, true, R.string.account_totp_enabled)
|
||||
reloadAccount()
|
||||
}
|
||||
@@ -134,6 +139,9 @@ class AccountViewModel @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
/** Dismiss the one-time recovery-code batch shown after enabling 2FA. */
|
||||
fun dismissRecoveryCodes() = _state.update { it.copy(recoveryCodesOnce = null) }
|
||||
|
||||
fun disableTotp(code: String) {
|
||||
if (_state.value.busy) return
|
||||
_state.update { it.copy(busy = true, feedback = null) }
|
||||
|
||||
@@ -5,8 +5,11 @@ package com.runicgateway.app.ui.auth
|
||||
|
||||
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.size
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
@@ -14,15 +17,22 @@ import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Checkbox
|
||||
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
|
||||
@@ -115,6 +125,24 @@ fun LoginScreen(
|
||||
)
|
||||
|
||||
if (state.totpRequired) {
|
||||
if (state.useRecoveryCode) {
|
||||
OutlinedTextField(
|
||||
value = state.recoveryCode,
|
||||
onValueChange = viewModel::onRecoveryCodeChange,
|
||||
singleLine = true,
|
||||
enabled = !state.submitting,
|
||||
label = { Text(stringResource(R.string.login_recovery_code)) },
|
||||
supportingText = { Text(stringResource(R.string.login_recovery_hint)) },
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = KeyboardType.Password,
|
||||
imeAction = ImeAction.Go,
|
||||
),
|
||||
keyboardActions = KeyboardActions(onGo = { viewModel.submit() }),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 12.dp),
|
||||
)
|
||||
} else {
|
||||
OutlinedTextField(
|
||||
value = state.code,
|
||||
onValueChange = viewModel::onCodeChange,
|
||||
@@ -133,6 +161,39 @@ fun LoginScreen(
|
||||
)
|
||||
}
|
||||
|
||||
// Toggle between authenticator code and a single-use recovery code.
|
||||
TextButton(
|
||||
onClick = { viewModel.onUseRecoveryCodeChange(!state.useRecoveryCode) },
|
||||
enabled = !state.submitting,
|
||||
modifier = Modifier.align(Alignment.Start),
|
||||
) {
|
||||
Text(
|
||||
stringResource(
|
||||
if (state.useRecoveryCode) R.string.login_use_totp_instead
|
||||
else R.string.login_use_recovery_instead,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// "Trust this device" → skip the 2FA step on future logins here.
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 4.dp),
|
||||
) {
|
||||
Checkbox(
|
||||
checked = state.trustDevice,
|
||||
onCheckedChange = viewModel::onTrustDeviceChange,
|
||||
enabled = !state.submitting,
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.login_trust_device),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
state.error?.let { err ->
|
||||
Text(
|
||||
text = stringResource(loginErrorRes(err)),
|
||||
@@ -163,27 +224,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 +278,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
|
||||
@@ -38,13 +39,23 @@ class LoginViewModel @Inject constructor(
|
||||
val username: String = "",
|
||||
val password: String = "",
|
||||
val code: String = "",
|
||||
/** A single-use recovery code, entered instead of [code] when [useRecoveryCode]. */
|
||||
val recoveryCode: String = "",
|
||||
/** True once the account is known to have 2FA on — reveal the code field. */
|
||||
val totpRequired: Boolean = false,
|
||||
/** "Enter a recovery code instead" — swap the TOTP field for the recovery field. */
|
||||
val useRecoveryCode: Boolean = false,
|
||||
/** "Trust this device" — skip the 2FA step on future logins (TRUSTED_DEVICES_MFA.md). */
|
||||
val trustDevice: Boolean = false,
|
||||
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 +64,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 {
|
||||
@@ -82,11 +89,43 @@ class LoginViewModel @Inject constructor(
|
||||
fun onCodeChange(value: String) =
|
||||
_state.update { it.copy(code = value.filter(Char::isDigit).take(8), error = null) }
|
||||
|
||||
/** Recovery codes are alphanumeric; keep it permissive, just trim length + noise. */
|
||||
fun onRecoveryCodeChange(value: String) =
|
||||
_state.update { it.copy(recoveryCode = value.filterNot(Char::isWhitespace).take(32), error = null) }
|
||||
|
||||
fun onTrustDeviceChange(value: Boolean) = _state.update { it.copy(trustDevice = value) }
|
||||
|
||||
/** Toggle between the TOTP field and the recovery-code field on the 2FA step. */
|
||||
fun onUseRecoveryCodeChange(value: Boolean) =
|
||||
_state.update { it.copy(useRecoveryCode = value, error = null) }
|
||||
|
||||
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
|
||||
@@ -114,30 +153,55 @@ class LoginViewModel @Inject constructor(
|
||||
fun submit() {
|
||||
val s = _state.value
|
||||
if (s.submitting) return
|
||||
if (s.username.isBlank() || s.password.isBlank()) {
|
||||
_state.update { it.copy(error = LoginError.INVALID_CREDENTIALS) }
|
||||
return
|
||||
}
|
||||
// If 2FA is being requested, a code must accompany the resubmit.
|
||||
if (s.totpRequired && s.code.isBlank()) {
|
||||
_state.update { it.copy(error = LoginError.BAD_CODE) }
|
||||
val validationError = validateForSubmit(s)
|
||||
if (validationError != null) {
|
||||
_state.update { it.copy(error = validationError) }
|
||||
return
|
||||
}
|
||||
|
||||
_state.update { it.copy(submitting = true, error = null) }
|
||||
viewModelScope.launch {
|
||||
val code = s.code.trim().takeIf { it.isNotBlank() }
|
||||
when (authRepository.login(s.username.trim(), s.password, code)) {
|
||||
LoginResult.Success ->
|
||||
// Only one second factor is sent; the recovery toggle picks which.
|
||||
val code = s.code.trim().takeIf { it.isNotBlank() && !s.useRecoveryCode }
|
||||
val recoveryCode = s.recoveryCode.trim().takeIf { it.isNotBlank() && s.useRecoveryCode }
|
||||
val result = authRepository.login(
|
||||
username = s.username.trim(),
|
||||
password = s.password,
|
||||
code = code,
|
||||
recoveryCode = recoveryCode,
|
||||
trustDevice = s.trustDevice,
|
||||
)
|
||||
applyLoginResult(result)
|
||||
}
|
||||
}
|
||||
|
||||
/** Pre-flight form checks for [submit]; returns the error to surface, or null if ready to send. */
|
||||
private fun validateForSubmit(s: UiState): LoginError? {
|
||||
if (s.username.isBlank() || s.password.isBlank()) return LoginError.INVALID_CREDENTIALS
|
||||
// If 2FA is being requested, the chosen second factor must accompany the resubmit.
|
||||
if (s.totpRequired) {
|
||||
val factor = if (s.useRecoveryCode) s.recoveryCode else s.code
|
||||
if (factor.isBlank()) return LoginError.BAD_CODE
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** Folds a [LoginResult] back into the UI state (clears [UiState.submitting] on every path). */
|
||||
private fun applyLoginResult(result: LoginResult) = when (result) {
|
||||
is LoginResult.Success ->
|
||||
// The trusted-device cap (result.trustLimitReached) is an edge case:
|
||||
// login succeeded but the device wasn't remembered. It's surfaced +
|
||||
// managed on the Trusted Devices screen rather than blocking sign-in.
|
||||
_state.update { it.copy(submitting = false, signedIn = true) }
|
||||
|
||||
LoginResult.TotpRequired ->
|
||||
// Reveal the code field; a wrong code re-lands here as BAD_CODE.
|
||||
// Reveal the 2FA fields; a wrong code/recovery code re-lands here as BAD_CODE.
|
||||
_state.update {
|
||||
val hadFactor = if (it.useRecoveryCode) it.recoveryCode.isNotBlank() else it.code.isNotBlank()
|
||||
it.copy(
|
||||
submitting = false,
|
||||
totpRequired = true,
|
||||
error = if (it.code.isNotBlank()) LoginError.BAD_CODE else null,
|
||||
error = if (hadFactor) LoginError.BAD_CODE else null,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -154,5 +218,3 @@ class LoginViewModel @Inject constructor(
|
||||
_state.update { it.copy(submitting = false, error = LoginError.NETWORK) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.ui.auth
|
||||
|
||||
import android.content.Intent
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
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.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalClipboardManager
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
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.UiState
|
||||
|
||||
/**
|
||||
* Account → Recovery Codes (TRUSTED_DEVICES_MFA.md): shows the remaining count and a
|
||||
* password-stepped regenerate that reveals a fresh single-use batch **once**. The
|
||||
* codes are shown only in memory — copy or share them before leaving; they are never
|
||||
* stored on the device.
|
||||
*/
|
||||
@Composable
|
||||
fun RecoveryCodesScreen(
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: RecoveryCodesViewModel = hiltViewModel(),
|
||||
) {
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
var currentPassword by rememberSaveable { mutableStateOf("") }
|
||||
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(16.dp),
|
||||
) {
|
||||
Text(stringResource(R.string.recovery_codes_title), style = MaterialTheme.typography.titleLarge)
|
||||
Text(
|
||||
stringResource(R.string.recovery_codes_subtitle),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 4.dp),
|
||||
)
|
||||
|
||||
val remainingText = when (val r = state.remaining) {
|
||||
is UiState.Success -> stringResource(R.string.recovery_codes_remaining, r.data)
|
||||
is UiState.Error -> stringResource(R.string.recovery_codes_remaining_unknown)
|
||||
UiState.Loading -> stringResource(R.string.recovery_codes_remaining_loading)
|
||||
}
|
||||
Text(remainingText, style = MaterialTheme.typography.bodyLarge, modifier = Modifier.padding(top = 16.dp))
|
||||
|
||||
state.freshCodes?.let { codes ->
|
||||
RecoveryCodesShowOnceCard(codes, onDismiss = { viewModel.dismissFreshCodes(); currentPassword = "" })
|
||||
}
|
||||
|
||||
OutlinedTextField(
|
||||
value = currentPassword,
|
||||
onValueChange = { currentPassword = it },
|
||||
singleLine = true,
|
||||
enabled = !state.busy,
|
||||
label = { Text(stringResource(R.string.account_password_current)) },
|
||||
supportingText = { Text(stringResource(R.string.recovery_codes_password_hint)) },
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 20.dp),
|
||||
)
|
||||
|
||||
state.error?.let { err ->
|
||||
Text(
|
||||
text = stringResource(err),
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier.padding(top = 12.dp),
|
||||
)
|
||||
}
|
||||
|
||||
Button(
|
||||
onClick = { viewModel.regenerate(currentPassword) },
|
||||
enabled = !state.busy,
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 16.dp),
|
||||
) { Text(stringResource(R.string.recovery_codes_regenerate)) }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A show-once display of a freshly generated recovery-code batch, with copy/share and
|
||||
* a dismiss. Shared by this screen and the "2FA just enabled" surface on AccountScreen.
|
||||
*/
|
||||
@Composable
|
||||
fun RecoveryCodesShowOnceCard(codes: List<String>, onDismiss: () -> Unit) {
|
||||
val context = LocalContext.current
|
||||
val clipboard = LocalClipboardManager.current
|
||||
val joined = remember(codes) { codes.joinToString("\n") }
|
||||
|
||||
Card(Modifier.fillMaxWidth().padding(top = 16.dp)) {
|
||||
Column(Modifier.padding(16.dp)) {
|
||||
Text(stringResource(R.string.recovery_codes_new_title), style = MaterialTheme.typography.titleMedium)
|
||||
Text(
|
||||
stringResource(R.string.recovery_codes_new_hint),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 4.dp),
|
||||
)
|
||||
codes.forEach { code ->
|
||||
Text(
|
||||
code,
|
||||
style = MaterialTheme.typography.bodyLarge.copy(fontFamily = FontFamily.Monospace),
|
||||
modifier = Modifier.padding(top = 8.dp),
|
||||
)
|
||||
}
|
||||
Row(Modifier.fillMaxWidth().padding(top = 16.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
OutlinedButton(
|
||||
onClick = { clipboard.setText(AnnotatedString(joined)) },
|
||||
) { Text(stringResource(R.string.recovery_codes_copy)) }
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
val send = Intent(Intent.ACTION_SEND).apply {
|
||||
type = "text/plain"
|
||||
putExtra(Intent.EXTRA_TEXT, joined)
|
||||
}
|
||||
context.startActivity(Intent.createChooser(send, null))
|
||||
},
|
||||
) { Text(stringResource(R.string.recovery_codes_share)) }
|
||||
Button(onClick = onDismiss) { Text(stringResource(R.string.recovery_codes_done)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.ui.auth
|
||||
|
||||
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.AccountRepository
|
||||
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 Account → Recovery Codes (TRUSTED_DEVICES_MFA.md): the remaining-count
|
||||
* status and a password-stepped regenerate that surfaces a fresh single-use batch
|
||||
* **once** (never persisted). The freshly generated codes live only in memory until
|
||||
* the user leaves the screen or dismisses them.
|
||||
*/
|
||||
@HiltViewModel
|
||||
class RecoveryCodesViewModel @Inject constructor(
|
||||
private val accountRepository: AccountRepository,
|
||||
) : ViewModel() {
|
||||
|
||||
data class State(
|
||||
/** Remaining unused codes (the status endpoint). */
|
||||
val remaining: UiState<Int> = UiState.Loading,
|
||||
/** A just-generated batch to show once, or null. Cleared on dismiss/leave. */
|
||||
val freshCodes: List<String>? = null,
|
||||
val busy: Boolean = false,
|
||||
@param:StringRes val error: Int? = null,
|
||||
)
|
||||
|
||||
private val _state = MutableStateFlow(State())
|
||||
val state: StateFlow<State> = _state.asStateFlow()
|
||||
|
||||
init {
|
||||
load()
|
||||
}
|
||||
|
||||
fun load() {
|
||||
_state.update { it.copy(remaining = UiState.Loading) }
|
||||
viewModelScope.launch {
|
||||
_state.update { it.copy(remaining = accountRepository.recoveryCodesStatus().toUiState().map { s -> s.remaining }) }
|
||||
}
|
||||
}
|
||||
|
||||
/** Regenerate the codes; [currentPassword] is required for accounts that have one. */
|
||||
fun regenerate(currentPassword: String?) {
|
||||
if (_state.value.busy) return
|
||||
_state.update { it.copy(busy = true, error = null, freshCodes = null) }
|
||||
viewModelScope.launch {
|
||||
when (val result = accountRepository.generateRecoveryCodes(currentPassword?.takeIf { it.isNotBlank() })) {
|
||||
is ApiResult.Ok -> {
|
||||
_state.update { it.copy(busy = false, freshCodes = result.data.recoveryCodes) }
|
||||
// Refresh the remaining count to reflect the new batch.
|
||||
_state.update { it.copy(remaining = accountRepository.recoveryCodesStatus().toUiState().map { s -> s.remaining }) }
|
||||
}
|
||||
is ApiResult.HttpError ->
|
||||
_state.update { it.copy(busy = false, error = R.string.recovery_codes_error) }
|
||||
is ApiResult.NetworkError ->
|
||||
_state.update { it.copy(busy = false, error = R.string.error_network) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Drop the shown-once batch from memory (user saved them / navigated away). */
|
||||
fun dismissFreshCodes() = _state.update { it.copy(freshCodes = null) }
|
||||
}
|
||||
|
||||
/** Map an [UiState] success value (local helper mirroring ApiResult.map). */
|
||||
private inline fun <T, R> UiState<T>.map(transform: (T) -> R): UiState<R> = when (this) {
|
||||
is UiState.Success -> UiState.Success(transform(data))
|
||||
is UiState.Loading -> UiState.Loading
|
||||
is UiState.Error -> this
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.ui.auth
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
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.Card
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.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.TrustedDeviceDto
|
||||
import com.runicgateway.app.ui.UiState
|
||||
import com.runicgateway.app.ui.components.ErrorView
|
||||
import com.runicgateway.app.ui.components.LoadingView
|
||||
|
||||
/**
|
||||
* Account → Trusted Devices (TRUSTED_DEVICES_MFA.md): the devices allowed to skip
|
||||
* the TOTP step at login. Trust the current device, revoke one, or untrust all. The
|
||||
* server re-checks ownership on every call; this screen just renders the outcomes.
|
||||
*/
|
||||
@Composable
|
||||
fun TrustedDevicesScreen(
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: TrustedDevicesViewModel = hiltViewModel(),
|
||||
) {
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(16.dp),
|
||||
) {
|
||||
Text(
|
||||
stringResource(R.string.trusted_devices_title),
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
)
|
||||
Text(
|
||||
stringResource(R.string.trusted_devices_subtitle),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 4.dp),
|
||||
)
|
||||
|
||||
state.feedback?.let { fb ->
|
||||
Text(
|
||||
text = stringResource(fb.messageRes),
|
||||
color = if (fb.ok) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier.padding(top = 12.dp),
|
||||
)
|
||||
}
|
||||
|
||||
when (val devices = state.devices) {
|
||||
is UiState.Loading -> LoadingView(Modifier.padding(top = 32.dp))
|
||||
is UiState.Error -> ErrorView(devices.kind, onRetry = viewModel::load, modifier = Modifier.padding(top = 32.dp))
|
||||
is UiState.Success -> {
|
||||
if (devices.data.isEmpty()) {
|
||||
Text(
|
||||
stringResource(R.string.trusted_devices_empty),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 24.dp),
|
||||
)
|
||||
} else {
|
||||
devices.data.forEach { device ->
|
||||
TrustedDeviceRow(device, state.busy, onRevoke = { viewModel.revoke(device.id) })
|
||||
}
|
||||
}
|
||||
|
||||
HorizontalDivider(Modifier.padding(vertical = 20.dp))
|
||||
|
||||
Button(
|
||||
onClick = viewModel::trustThisDevice,
|
||||
enabled = !state.busy,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) { Text(stringResource(R.string.trusted_devices_trust_this)) }
|
||||
|
||||
if (devices.data.isNotEmpty()) {
|
||||
OutlinedButton(
|
||||
onClick = viewModel::revokeAll,
|
||||
enabled = !state.busy,
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
|
||||
) { Text(stringResource(R.string.trusted_devices_untrust_all)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TrustedDeviceRow(device: TrustedDeviceDto, busy: Boolean, onRevoke: () -> Unit) {
|
||||
Card(Modifier.fillMaxWidth().padding(top = 12.dp)) {
|
||||
Row(
|
||||
Modifier.fillMaxWidth().padding(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = device.deviceName?.takeIf { it.isNotBlank() }
|
||||
?: device.platform?.replaceFirstChar { it.uppercase() }
|
||||
?: stringResource(R.string.trusted_devices_unknown),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
)
|
||||
device.lastUsedAt?.let {
|
||||
Text(
|
||||
stringResource(R.string.trusted_devices_last_used, it),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
TextButton(onClick = onRevoke, enabled = !busy) {
|
||||
Text(stringResource(R.string.trusted_devices_revoke))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.ui.auth
|
||||
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.runicgateway.app.R
|
||||
import com.runicgateway.app.core.auth.DeviceNameProvider
|
||||
import com.runicgateway.app.core.result.ApiResult
|
||||
import com.runicgateway.app.data.api.dto.TrustedDeviceDto
|
||||
import com.runicgateway.app.data.repository.AccountRepository
|
||||
import com.runicgateway.app.data.repository.AccountRepository.TrustOutcome
|
||||
import com.runicgateway.app.data.repository.AuthRepository
|
||||
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 Trusted Devices screen (TRUSTED_DEVICES_MFA.md): list the devices
|
||||
* allowed to skip the TOTP step, trust the current one (persisting the returned
|
||||
* token via [AuthRepository]), revoke one, and untrust all. The trust action folds
|
||||
* the `409` cap into a first-class [Feedback] telling the user to revoke one first.
|
||||
*/
|
||||
@HiltViewModel
|
||||
class TrustedDevicesViewModel @Inject constructor(
|
||||
private val accountRepository: AccountRepository,
|
||||
private val authRepository: AuthRepository,
|
||||
private val sessionManager: com.runicgateway.app.core.auth.SessionManager,
|
||||
private val deviceNameProvider: DeviceNameProvider,
|
||||
) : ViewModel() {
|
||||
|
||||
/** A one-shot result banner shown above the list. */
|
||||
data class Feedback(val ok: Boolean, @param:StringRes val messageRes: Int)
|
||||
|
||||
data class State(
|
||||
val devices: UiState<List<TrustedDeviceDto>> = UiState.Loading,
|
||||
val busy: 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(devices = UiState.Loading) }
|
||||
viewModelScope.launch {
|
||||
_state.update { it.copy(devices = accountRepository.trustedDevices().toUiState()) }
|
||||
}
|
||||
}
|
||||
|
||||
/** Trust the current device; persist the returned token so future logins skip 2FA. */
|
||||
fun trustThisDevice() {
|
||||
if (_state.value.busy) return
|
||||
_state.update { it.copy(busy = true, feedback = null) }
|
||||
viewModelScope.launch {
|
||||
when (val outcome = accountRepository.trustThisDevice(deviceNameProvider.deviceName())) {
|
||||
is TrustOutcome.Trusted -> {
|
||||
// Bind the fresh token to the signed-in username (mirrors the login path).
|
||||
val username = sessionManager.state.value.let {
|
||||
(it as? com.runicgateway.app.core.auth.Session.SignedIn)?.user?.username
|
||||
}
|
||||
if (outcome.trustToken != null && username != null) {
|
||||
authRepository.saveTrustToken(username, outcome.trustToken)
|
||||
}
|
||||
finish(true, R.string.trusted_devices_trusted)
|
||||
reload()
|
||||
}
|
||||
is TrustOutcome.LimitReached -> finish(false, R.string.trusted_devices_limit)
|
||||
TrustOutcome.NetworkError -> finish(false, R.string.error_network)
|
||||
TrustOutcome.ServerError -> finish(false, R.string.trusted_devices_error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun revoke(id: Long) {
|
||||
if (_state.value.busy) return
|
||||
_state.update { it.copy(busy = true, feedback = null) }
|
||||
viewModelScope.launch {
|
||||
when (accountRepository.revokeTrustedDevice(id)) {
|
||||
is ApiResult.Ok -> {
|
||||
finish(true, R.string.trusted_devices_revoked)
|
||||
reload()
|
||||
}
|
||||
else -> finish(false, R.string.trusted_devices_error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun revokeAll() {
|
||||
if (_state.value.busy) return
|
||||
_state.update { it.copy(busy = true, feedback = null) }
|
||||
viewModelScope.launch {
|
||||
when (accountRepository.revokeAllTrustedDevices()) {
|
||||
is ApiResult.Ok -> {
|
||||
// Every device is untrusted now, including this one — drop the local token.
|
||||
authRepository.clearTrustToken()
|
||||
finish(true, R.string.trusted_devices_revoked_all)
|
||||
reload()
|
||||
}
|
||||
else -> finish(false, R.string.trusted_devices_error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun clearFeedback() = _state.update { it.copy(feedback = null) }
|
||||
|
||||
private suspend fun reload() {
|
||||
_state.update { it.copy(devices = accountRepository.trustedDevices().toUiState()) }
|
||||
}
|
||||
|
||||
private fun finish(ok: Boolean, @StringRes messageRes: Int) =
|
||||
_state.update { it.copy(busy = false, feedback = Feedback(ok, messageRes)) }
|
||||
}
|
||||
@@ -21,8 +21,19 @@ enum class MenuAccess {
|
||||
/** Visible to any signed-in account (§5, "My Account"). */
|
||||
SIGNED_IN,
|
||||
|
||||
/** Visible only to a player — the linked game-data groups (§6.3). */
|
||||
/**
|
||||
* The linked game-data groups (§6.3). Visible to any player **or** staff:
|
||||
* staff are a superset of players (all player abilities plus their staff
|
||||
* tools), and the backend's player self-service surface is role-agnostic, so
|
||||
* a signed-in admin/editor/moderator sees + uses their own characters too.
|
||||
*/
|
||||
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 +59,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),
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -59,6 +75,8 @@ fun visibleEntries(entries: List<MenuEntry>, session: Session): List<MenuEntry>
|
||||
when (entry.access) {
|
||||
MenuAccess.PUBLIC -> true
|
||||
MenuAccess.SIGNED_IN -> session is Session.SignedIn
|
||||
MenuAccess.PLAYER -> session is Session.SignedIn && session.user.isPlayer
|
||||
MenuAccess.PLAYER -> session is Session.SignedIn && (session.user.isPlayer || session.user.isStaff)
|
||||
MenuAccess.STAFF -> session is Session.SignedIn && session.user.isStaff
|
||||
MenuAccess.MODERATOR -> session is Session.SignedIn && session.user.isModerator
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,10 @@ object Routes {
|
||||
const val LOGIN = "login"
|
||||
const val ACCOUNT = "account"
|
||||
|
||||
/** MFA management, reached from Account (TRUSTED_DEVICES_MFA.md). Signed-in only. */
|
||||
const val ACCOUNT_TRUSTED_DEVICES = "account/trusted-devices"
|
||||
const val ACCOUNT_RECOVERY_CODES = "account/recovery-codes"
|
||||
|
||||
/** Opt-in push notification settings (§11, signed-in). */
|
||||
const val NOTIFICATIONS = "notifications"
|
||||
|
||||
@@ -38,6 +42,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}"
|
||||
|
||||
|
||||
@@ -69,7 +69,9 @@ class ChampsViewModel @Inject constructor(
|
||||
private fun applyFrame(frame: ShardStreamEvent.Frame) {
|
||||
when (frame.kind) {
|
||||
"champ.update" -> repository.champFrame(frame.data)?.let { board.upsert(it) }
|
||||
"champ.remove" -> FrameFields.longField(frame.data, "serial")?.let { board.remove(it.toString()) }
|
||||
// Serial is an opaque hex-string key ("0x…"), not a number — read as a
|
||||
// string (reading it as a Long silently dropped every champ.remove).
|
||||
"champ.remove" -> FrameFields.stringField(frame.data, "serial")?.let { board.remove(it) }
|
||||
else -> return
|
||||
}
|
||||
// Only republish when the board actually changed (Success state only).
|
||||
|
||||
@@ -69,7 +69,9 @@ class HousesViewModel @Inject constructor(
|
||||
|
||||
private fun applyFrame(frame: ShardStreamEvent.Frame) {
|
||||
if (frame.kind != "house.decay") return
|
||||
val serial = FrameFields.longField(frame.data, "serial") ?: return
|
||||
// Serials are opaque hex-string keys ("0x…"), not numbers — read as a string
|
||||
// (reading it as a Long silently dropped every live IDOC update).
|
||||
val serial = FrameFields.stringField(frame.data, "serial") ?: return
|
||||
// `to` is the new decay stage; only IDOC belongs on the public board.
|
||||
val stage = FrameFields.stringField(frame.data, "to")
|
||||
?: FrameFields.stringField(frame.data, "stage")
|
||||
|
||||
@@ -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>
|
||||
@@ -115,6 +201,49 @@
|
||||
<string name="account_identity_unlinked">Account unlinked.</string>
|
||||
<string name="account_identity_error">Couldn\'t unlink that account.</string>
|
||||
|
||||
<!-- ── Trusted devices & recovery codes (TRUSTED_DEVICES_MFA.md) ─────── -->
|
||||
<!-- Login 2FA step -->
|
||||
<string name="login_recovery_code">Recovery code</string>
|
||||
<string name="login_recovery_hint">Enter one of your single-use backup codes.</string>
|
||||
<string name="login_use_recovery_instead">Use a recovery code instead</string>
|
||||
<string name="login_use_totp_instead">Use your authenticator code instead</string>
|
||||
<string name="login_trust_device">Trust this device (skip codes for 30 days)</string>
|
||||
|
||||
<!-- Account: security section -->
|
||||
<string name="account_security_title">Security</string>
|
||||
<string name="account_security_trusted_devices">Trusted devices</string>
|
||||
<string name="account_security_recovery_codes">Recovery codes</string>
|
||||
|
||||
<!-- Trusted devices screen -->
|
||||
<string name="trusted_devices_title">Trusted devices</string>
|
||||
<string name="trusted_devices_subtitle">These devices can skip the authentication code at sign-in for 30 days.</string>
|
||||
<string name="trusted_devices_empty">No trusted devices yet.</string>
|
||||
<string name="trusted_devices_unknown">Unknown device</string>
|
||||
<string name="trusted_devices_last_used">Last used %1$s</string>
|
||||
<string name="trusted_devices_revoke">Revoke</string>
|
||||
<string name="trusted_devices_trust_this">Trust this device</string>
|
||||
<string name="trusted_devices_untrust_all">Untrust all devices</string>
|
||||
<string name="trusted_devices_trusted">This device is now trusted.</string>
|
||||
<string name="trusted_devices_revoked">Device revoked.</string>
|
||||
<string name="trusted_devices_revoked_all">All devices untrusted.</string>
|
||||
<string name="trusted_devices_limit">You\'ve reached the trusted-device limit. Revoke one, then try again.</string>
|
||||
<string name="trusted_devices_error">Something went wrong. Please try again.</string>
|
||||
|
||||
<!-- Recovery codes screen -->
|
||||
<string name="recovery_codes_title">Recovery codes</string>
|
||||
<string name="recovery_codes_subtitle">Single-use backup codes let you sign in if you lose your authenticator.</string>
|
||||
<string name="recovery_codes_remaining">%1$d codes remaining</string>
|
||||
<string name="recovery_codes_remaining_loading">Checking remaining codes…</string>
|
||||
<string name="recovery_codes_remaining_unknown">Couldn\'t load the remaining count.</string>
|
||||
<string name="recovery_codes_password_hint">Enter your current password to generate a new set.</string>
|
||||
<string name="recovery_codes_regenerate">Generate new codes</string>
|
||||
<string name="recovery_codes_error">Couldn\'t generate codes. Check your password and that two-factor is on.</string>
|
||||
<string name="recovery_codes_new_title">Your new recovery codes</string>
|
||||
<string name="recovery_codes_new_hint">Save these now — they\'re shown only once and each works a single time.</string>
|
||||
<string name="recovery_codes_copy">Copy</string>
|
||||
<string name="recovery_codes_share">Share</string>
|
||||
<string name="recovery_codes_done">Done</string>
|
||||
|
||||
<!-- ── Player: game-account linking (§6.3) ─────────────────────────── -->
|
||||
<string name="player_link_title">Link your game account</string>
|
||||
<string name="player_link_hint">In game, type [link to get a one-time code, then enter it here to see your characters, vendors and houses.</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>
|
||||
@@ -7,6 +7,7 @@ import com.runicgateway.app.core.auth.Session
|
||||
import com.runicgateway.app.core.auth.SessionManager
|
||||
import com.runicgateway.app.core.auth.StoredSession
|
||||
import com.runicgateway.app.core.auth.TokenStore
|
||||
import com.runicgateway.app.core.auth.TrustTokenStore
|
||||
import com.runicgateway.app.core.net.BaseUrlHolder
|
||||
import com.runicgateway.app.data.api.SsoApi
|
||||
import com.runicgateway.app.data.api.dto.MobileSsoExchangeRequest
|
||||
@@ -37,6 +38,25 @@ 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 }
|
||||
}
|
||||
|
||||
/** In-memory stand-in for the encrypted trust-token store, scoped by username
|
||||
* the same way the production impl is. */
|
||||
private class FakeTrustTokenStore : TrustTokenStore {
|
||||
var owner: String? = null
|
||||
var token: String? = null
|
||||
override fun tokenFor(username: String): String? =
|
||||
if (owner.equals(username, ignoreCase = true)) token else null
|
||||
override fun save(username: String, token: String) { owner = username; this.token = token }
|
||||
override fun clear() { owner = null; token = null }
|
||||
}
|
||||
|
||||
/** Records the exchange it was called with and returns a scripted response. */
|
||||
private class FakeSsoApi(
|
||||
private val exchangeResult: () -> Response<MobileTokenResponse>,
|
||||
@@ -67,10 +87,12 @@ class SsoAuthManagerTest {
|
||||
api: SsoApi,
|
||||
session: SessionManager,
|
||||
base: String? = "https://shard.example.com/",
|
||||
store: PendingSsoStore = FakePendingSsoStore(),
|
||||
trust: TrustTokenStore = FakeTrustTokenStore(),
|
||||
): SsoAuthManager {
|
||||
val holder = BaseUrlHolder()
|
||||
if (base != null) holder.set(base.toHttpUrl())
|
||||
return SsoAuthManager(api, session, holder)
|
||||
return SsoAuthManager(api, session, holder, store, trust)
|
||||
}
|
||||
|
||||
/** Build a start URL and pull the generated `state` back out of it. */
|
||||
@@ -111,6 +133,38 @@ class SsoAuthManagerTest {
|
||||
assertEquals(SsoAuthManager.Outcome.Success, mgr.outcome.value)
|
||||
}
|
||||
|
||||
// Trusted devices over SSO (TRUSTED_DEVICES_MFA.md). Ticking "trust this device"
|
||||
// on the TOTP form inside the Custom Tab trusts that browser via cookie; the
|
||||
// exchange additionally hands the APP its own token so a native password login
|
||||
// on this device skips the code too. Before this, SSO ignored trust entirely.
|
||||
@Test fun `a trustToken on the exchange response is persisted for the signed-in user`() = runTest {
|
||||
val api = FakeSsoApi { Response.success(tokenPair().copy(trustToken = "opaque-trust")) }
|
||||
val session = SessionManager(FakeTokenStore())
|
||||
val trust = FakeTrustTokenStore()
|
||||
val mgr = managerWith(api, session, trust = trust)
|
||||
|
||||
val state = startAndState(mgr)
|
||||
mgr.complete(state = state, code = "auth-code-1", error = null)
|
||||
|
||||
assertEquals(SsoAuthManager.Outcome.Success, mgr.outcome.value)
|
||||
assertEquals("opaque-trust", trust.tokenFor("alice"))
|
||||
// Scoped to the account that minted it — never replayed for someone else.
|
||||
assertNull(trust.tokenFor("mallory"))
|
||||
}
|
||||
|
||||
@Test fun `no trustToken on the response leaves the store untouched`() = runTest {
|
||||
val api = FakeSsoApi { Response.success(tokenPair()) }
|
||||
val session = SessionManager(FakeTokenStore())
|
||||
val trust = FakeTrustTokenStore()
|
||||
val mgr = managerWith(api, session, trust = trust)
|
||||
|
||||
val state = startAndState(mgr)
|
||||
mgr.complete(state = state, code = "auth-code-1", error = null)
|
||||
|
||||
assertEquals(SsoAuthManager.Outcome.Success, mgr.outcome.value)
|
||||
assertNull(trust.tokenFor("alice"))
|
||||
}
|
||||
|
||||
@Test fun `state mismatch fails without exchanging`() = runTest {
|
||||
val api = FakeSsoApi { Response.success(tokenPair()) }
|
||||
val session = SessionManager(FakeTokenStore())
|
||||
@@ -135,6 +189,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()))
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.core.result
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertSame
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* The [ApiResult] helpers: [map] transforms an [ApiResult.Ok] and passes the two
|
||||
* failure variants through unchanged; [isShardUnavailable] is the 503 "shard down"
|
||||
* signal the player screens render as offline.
|
||||
*/
|
||||
class ApiResultExtrasTest {
|
||||
|
||||
@Test fun mapTransformsOkBody() {
|
||||
val mapped = ApiResult.Ok(listOf(1, 2, 3)).map { it.size }
|
||||
assertEquals(ApiResult.Ok(3), mapped)
|
||||
}
|
||||
|
||||
@Test fun mapPassesFailuresThroughUnchanged() {
|
||||
val http: ApiResult<Int> = ApiResult.HttpError(500, "boom")
|
||||
assertSame(http, http.map { it + 1 })
|
||||
|
||||
val cause = RuntimeException("offline")
|
||||
val network: ApiResult<Int> = ApiResult.NetworkError(cause)
|
||||
val out = network.map { it + 1 }
|
||||
assertTrue(out is ApiResult.NetworkError)
|
||||
assertSame(cause, (out as ApiResult.NetworkError).cause)
|
||||
}
|
||||
|
||||
@Test fun isShardUnavailableOnlyForHttp503() {
|
||||
assertTrue(ApiResult.HttpError(503).isShardUnavailable())
|
||||
assertFalse(ApiResult.HttpError(500).isShardUnavailable())
|
||||
assertFalse(ApiResult.Ok(Unit).isShardUnavailable())
|
||||
assertFalse(ApiResult.NetworkError(RuntimeException()).isShardUnavailable())
|
||||
}
|
||||
}
|
||||
@@ -35,6 +35,20 @@ class ApiResultTest {
|
||||
assertTrue(result is ApiResult.NetworkError)
|
||||
}
|
||||
|
||||
/**
|
||||
* A body the app can't decode (a field whose type doesn't match its DTO) throws a
|
||||
* [SerializationException] out of the Retrofit converter. It must degrade to a
|
||||
* server-side error the UI renders, not escape and crash the app — the guild-board
|
||||
* crash this fixes. `502` folds to [ui.ErrorKind.SERVER] via `toUiState`.
|
||||
*/
|
||||
@Test fun serializationExceptionBecomesServerError() = runTest {
|
||||
val result = safeApiCall {
|
||||
throw kotlinx.serialization.SerializationException("Unexpected symbol 'm' at path: \$[0].members")
|
||||
}
|
||||
assertTrue(result is ApiResult.HttpError)
|
||||
assertEquals(502, (result as ApiResult.HttpError).status)
|
||||
}
|
||||
|
||||
@Test fun cancellationIsRethrown() = runTest {
|
||||
assertThrows(CancellationException::class.java) {
|
||||
kotlinx.coroutines.runBlocking {
|
||||
|
||||
@@ -54,6 +54,66 @@ class AccountDtoTest {
|
||||
assertTrue(json.decodeFromString<TotpStateDto>("""{"totp_enabled":true}""").totp_enabled)
|
||||
}
|
||||
|
||||
@Test fun totpEnableCarriesOneTimeRecoveryCodes() {
|
||||
// Enabling 2FA now returns the fresh single-use batch once (TRUSTED_DEVICES_MFA.md).
|
||||
val dto = json.decodeFromString<TotpStateDto>(
|
||||
"""{"totp_enabled":true,"recoveryCodes":["aaaa-1111","bbbb-2222"]}""",
|
||||
)
|
||||
assertTrue(dto.totp_enabled)
|
||||
assertEquals(listOf("aaaa-1111", "bbbb-2222"), dto.recoveryCodes)
|
||||
}
|
||||
|
||||
@Test fun totpStateDisableHasNoRecoveryCodes() {
|
||||
// Disable (and older backends) omit the field — must decode to null, not crash.
|
||||
val dto = json.decodeFromString<TotpStateDto>("""{"totp_enabled":false}""")
|
||||
assertFalse(dto.totp_enabled)
|
||||
assertEquals(null, dto.recoveryCodes)
|
||||
}
|
||||
|
||||
@Test fun trustedDeviceDecodes() {
|
||||
val dto = json.decodeFromString<TrustedDeviceDto>(
|
||||
"""{"id":5,"platform":"mobile","deviceName":"Pixel 8","userAgent":"RunicGatewayApp/1.0",
|
||||
"createdAt":"2026-07-20T10:00:00Z","lastUsedAt":"2026-07-22T09:00:00Z",
|
||||
"expiresAt":"2026-08-19T10:00:00Z"}""",
|
||||
)
|
||||
assertEquals(5L, dto.id)
|
||||
assertEquals("mobile", dto.platform)
|
||||
assertEquals("Pixel 8", dto.deviceName)
|
||||
assertEquals("2026-07-22T09:00:00Z", dto.lastUsedAt)
|
||||
}
|
||||
|
||||
@Test fun trustDeviceResultCarriesNativeToken() {
|
||||
val dto = json.decodeFromString<TrustDeviceResultDto>(
|
||||
"""{"trusted":true,"trustToken":"opaque-token-abc"}""",
|
||||
)
|
||||
assertTrue(dto.trusted)
|
||||
assertEquals("opaque-token-abc", dto.trustToken)
|
||||
}
|
||||
|
||||
@Test fun trustedDeviceLimitDecodesDevices() {
|
||||
val dto = json.decodeFromString<TrustedDeviceLimitDto>(
|
||||
"""{"error":"trusted_device_limit","devices":[
|
||||
{"id":1,"platform":"web","deviceName":"Firefox"},
|
||||
{"id":2,"platform":"mobile","deviceName":"Pixel"}]}""",
|
||||
)
|
||||
assertEquals("trusted_device_limit", dto.error)
|
||||
assertEquals(2, dto.devices.size)
|
||||
assertEquals(2L, dto.devices[1].id)
|
||||
}
|
||||
|
||||
@Test fun recoveryStatusAndCodesDecode() {
|
||||
assertEquals(7, json.decodeFromString<RecoveryStatusDto>("""{"remaining":7}""").remaining)
|
||||
val codes = json.decodeFromString<RecoveryCodesDto>(
|
||||
"""{"recoveryCodes":["c1","c2","c3"]}""",
|
||||
)
|
||||
assertEquals(3, codes.recoveryCodes.size)
|
||||
}
|
||||
|
||||
@Test fun revokedResultsDecode() {
|
||||
assertTrue(json.decodeFromString<RevokedFlagDto>("""{"revoked":true}""").revoked)
|
||||
assertEquals(4, json.decodeFromString<RevokedCountDto>("""{"revoked":4}""").revoked)
|
||||
}
|
||||
|
||||
@Test fun linkedIdentityDecodes() {
|
||||
val dto = json.decodeFromString<LinkedIdentityDto>(
|
||||
"""{"provider":"discord","email":"u@example.com","linked_at":"2026-07-19T22:00:00Z"}""",
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.data.api.dto
|
||||
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.int
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Decode/encode tests for the staff-operations DTOs (`/admin/…`, PLAN.md §6.4).
|
||||
* Covers the snake_case `@SerialName` mappings, the `AdminPostDto.isPublished`
|
||||
* tinyint bridge, nested dashboard shapes, and the request bodies the app encodes.
|
||||
*/
|
||||
class AdminDtoTest {
|
||||
|
||||
private val json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
explicitNulls = false
|
||||
coerceInputValues = true
|
||||
}
|
||||
|
||||
@Test fun dashboardDecodesNestedCountsAndActivity() {
|
||||
val dto = json.decodeFromString<AdminDashboardDto>(
|
||||
"""{
|
||||
"site_mode":"maintenance",
|
||||
"last_change":{"at":"2026-07-20T10:00:00Z","by":"admin"},
|
||||
"counts":{"posts":{"news":4,"newsletter":1},"users":37},
|
||||
"recent_activity":[
|
||||
{"id":9,"username":"mod","action":"post.publish",
|
||||
"detail":{"postId":12},"created_at":"2026-07-22T09:00:00Z"}
|
||||
]
|
||||
}""",
|
||||
)
|
||||
assertEquals("maintenance", dto.siteMode)
|
||||
assertEquals("admin", dto.lastChange.by)
|
||||
assertEquals(4, dto.counts.posts["news"])
|
||||
assertEquals(37, dto.counts.users)
|
||||
assertEquals(1, dto.recentActivity.size)
|
||||
assertEquals("post.publish", dto.recentActivity[0].action)
|
||||
// `detail` is provider-shaped JSON kept as a raw element.
|
||||
assertEquals(12, dto.recentActivity[0].detail!!.jsonObject["postId"]!!.jsonPrimitive.int)
|
||||
}
|
||||
|
||||
@Test fun dashboardDefaultsWhenKeysAbsent() {
|
||||
val dto = json.decodeFromString<AdminDashboardDto>("{}")
|
||||
assertEquals("live", dto.siteMode)
|
||||
assertTrue(dto.counts.posts.isEmpty())
|
||||
assertTrue(dto.recentActivity.isEmpty())
|
||||
}
|
||||
|
||||
@Test fun adminPostBridgesPublishedTinyintToBoolean() {
|
||||
val published = json.decodeFromString<AdminPostDto>(
|
||||
"""{"id":1,"category":"news","title":"Hi","published":1,"published_at":"2026-07-21T00:00:00Z"}""",
|
||||
)
|
||||
assertTrue(published.isPublished)
|
||||
assertEquals("2026-07-21T00:00:00Z", published.publishedAt)
|
||||
|
||||
val draft = json.decodeFromString<AdminPostDto>("""{"id":2,"title":"Draft","published":0}""")
|
||||
assertFalse(draft.isPublished)
|
||||
}
|
||||
|
||||
@Test fun adminWikiCategoryAndTagDecodeCounts() {
|
||||
val cat = json.decodeFromString<AdminWikiCategoryDto>(
|
||||
"""{"id":3,"slug":"lore","title":"Lore","sort_order":2,"page_count":5,"published_count":4}""",
|
||||
)
|
||||
assertEquals(2, cat.sortOrder)
|
||||
assertEquals(5, cat.pageCount)
|
||||
assertEquals(4, cat.publishedCount)
|
||||
|
||||
val tag = json.decodeFromString<AdminWikiTagDto>("""{"id":8,"slug":"pvp","label":"PvP","published_count":11}""")
|
||||
assertEquals("PvP", tag.label)
|
||||
assertEquals(11, tag.publishedCount)
|
||||
}
|
||||
|
||||
@Test fun supportPageDecodesSenderActor() {
|
||||
val dto = json.decodeFromString<SupportPageDto>(
|
||||
"""{"pageId":"0x1A2B","type":"other","message":"stuck",
|
||||
"handled":false,"sender":{"name":"Gwen","account":"gwen01"}}""",
|
||||
)
|
||||
assertEquals("0x1A2B", dto.pageId)
|
||||
assertEquals("Gwen", dto.sender?.name)
|
||||
assertEquals("gwen01", dto.sender?.account)
|
||||
assertEquals(false, dto.handled)
|
||||
}
|
||||
|
||||
@Test fun supportPageToleratesMissingSender() {
|
||||
val dto = json.decodeFromString<SupportPageDto>("""{"pageId":"0x01"}""")
|
||||
assertNull(dto.sender)
|
||||
assertNull(dto.type)
|
||||
}
|
||||
|
||||
@Test fun siteModeStateDecodesAudit() {
|
||||
val dto = json.decodeFromString<SiteModeStateDto>(
|
||||
"""{"site_mode":"maintenance","changed_at":"2026-07-22T08:00:00Z","changed_by":"admin"}""",
|
||||
)
|
||||
assertEquals("maintenance", dto.siteMode)
|
||||
assertEquals("admin", dto.changedBy)
|
||||
}
|
||||
|
||||
@Test fun requestBodiesEncodeWithSnakeCaseKeys() {
|
||||
assertTrue(json.encodeToString(SiteModeRequest("maintenance")).contains("\"mode\":\"maintenance\""))
|
||||
assertTrue(json.encodeToString(PublishRequest(true)).contains("\"published\":true"))
|
||||
assertTrue(json.encodeToString(UnbanRequest("gwen01")).contains("\"account\":\"gwen01\""))
|
||||
assertTrue(json.encodeToString(BroadcastRequest("hello", hue = 33)).contains("\"hue\":33"))
|
||||
assertTrue(json.encodeToString(PageRespondRequest("done", close = true)).contains("\"close\":true"))
|
||||
assertTrue(json.encodeToString(WikiCategoryRequest(slug = "lore", title = "Lore", sortOrder = 1))
|
||||
.contains("\"sort_order\":1"))
|
||||
|
||||
val post = json.encodeToString(PostCreateRequest(category = "news", title = "T", imageUrl = "/img.png"))
|
||||
assertTrue(post.contains("\"image_url\":\"/img.png\""))
|
||||
assertTrue(post.contains("\"category\":\"news\""))
|
||||
|
||||
val ban = json.encodeToString(BanRequest(account = "x", durationSec = 3600, reason = "afk"))
|
||||
assertTrue(ban.contains("\"durationSec\":3600"))
|
||||
|
||||
val kick = json.encodeToString(KickRequest(serial = "0xFF"))
|
||||
assertTrue(kick.contains("\"serial\":\"0xFF\""))
|
||||
}
|
||||
}
|
||||
@@ -68,4 +68,35 @@ class AuthDtoTest {
|
||||
assertNull(dto.expiresIn)
|
||||
assertEquals("admin", dto.user.role)
|
||||
}
|
||||
|
||||
@Test fun loginCarriesTrustTokenWhenDeviceTrusted() {
|
||||
// trustDevice accepted → an opaque token to persist + replay (TRUSTED_DEVICES_MFA.md).
|
||||
val dto = json.decodeFromString<MobileTokenResponse>(
|
||||
"""{"accessToken":"a","refreshToken":"r","user":{"id":3,"username":"c","role":"player"},
|
||||
"trustToken":"opaque-abc"}""",
|
||||
)
|
||||
assertEquals("opaque-abc", dto.trustToken)
|
||||
assertFalse(dto.trustLimitReached)
|
||||
}
|
||||
|
||||
@Test fun loginSignalsTrustLimitWithDevices() {
|
||||
// At the cap: login still succeeds, but no token; the device list is returned.
|
||||
val dto = json.decodeFromString<MobileTokenResponse>(
|
||||
"""{"accessToken":"a","refreshToken":"r","user":{"id":3,"username":"c","role":"player"},
|
||||
"trustLimitReached":true,"devices":[{"id":1,"platform":"mobile","deviceName":"Old"}]}""",
|
||||
)
|
||||
assertNull(dto.trustToken)
|
||||
assertTrue(dto.trustLimitReached)
|
||||
assertEquals(1, dto.devices.size)
|
||||
}
|
||||
|
||||
@Test fun loginWithoutTrustFieldsDefaultsCleanly() {
|
||||
// A normal (no-trust) login omits every trust field — must not crash or mis-flag.
|
||||
val dto = json.decodeFromString<MobileTokenResponse>(
|
||||
"""{"accessToken":"a","refreshToken":"r","user":{"id":4,"username":"d","role":"player"}}""",
|
||||
)
|
||||
assertNull(dto.trustToken)
|
||||
assertFalse(dto.trustLimitReached)
|
||||
assertTrue(dto.devices.isEmpty())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.data.api.dto
|
||||
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Encode/decode tests for the mobile bearer-auth request bodies (`/auth/mobile/…`)
|
||||
* and the token pair — the snake_case `device_name`, the omit-nulls behaviour, and
|
||||
* the trusted-device outcome fields on the login response.
|
||||
*/
|
||||
class AuthRequestDtoTest {
|
||||
|
||||
private val json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
explicitNulls = false
|
||||
coerceInputValues = true
|
||||
}
|
||||
|
||||
@Test fun loginRequestEncodesSnakeCaseDeviceNameAndOmitsNulls() {
|
||||
val body = json.encodeToString(
|
||||
MobileLoginRequest(username = "gwen", password = "pw", trustDevice = true, device_name = "Pixel 8"),
|
||||
)
|
||||
assertTrue(body.contains("\"username\":\"gwen\""))
|
||||
assertTrue(body.contains("\"device_name\":\"Pixel 8\""))
|
||||
assertTrue(body.contains("\"trustDevice\":true"))
|
||||
assertFalse(body.contains("\"code\"")) // null omitted (explicitNulls = false)
|
||||
}
|
||||
|
||||
@Test fun loginRequestCarriesSecondFactorOnRetry() {
|
||||
val withCode = json.encodeToString(MobileLoginRequest("u", "p", code = "123456"))
|
||||
assertTrue(withCode.contains("\"code\":\"123456\""))
|
||||
val withRecovery = json.encodeToString(MobileLoginRequest("u", "p", recoveryCode = "aaaa-1111"))
|
||||
assertTrue(withRecovery.contains("\"recoveryCode\":\"aaaa-1111\""))
|
||||
}
|
||||
|
||||
@Test fun refreshAndLogoutBodiesEncode() {
|
||||
assertTrue(json.encodeToString(MobileRefreshRequest("rt")).contains("\"refreshToken\":\"rt\""))
|
||||
assertTrue(json.encodeToString(MobileLogoutRequest(all = true)).contains("\"all\":true"))
|
||||
}
|
||||
|
||||
@Test fun tokenResponseDecodesTrustOutcome() {
|
||||
val dto = json.decodeFromString<MobileTokenResponse>(
|
||||
"""{"accessToken":"a","refreshToken":"r","expiresIn":"15m",
|
||||
"user":{"id":1,"username":"gwen","role":"player"},
|
||||
"trustToken":"opaque"}""",
|
||||
)
|
||||
assertEquals("a", dto.accessToken)
|
||||
assertEquals("opaque", dto.trustToken)
|
||||
assertFalse(dto.trustLimitReached)
|
||||
assertEquals("gwen", dto.user.username)
|
||||
}
|
||||
|
||||
@Test fun tokenResponseDecodesTrustLimitReached() {
|
||||
val dto = json.decodeFromString<MobileTokenResponse>(
|
||||
"""{"accessToken":"a","refreshToken":"r","user":{"id":1,"username":"g","role":"player"},
|
||||
"trustLimitReached":true,"devices":[{"id":1,"platform":"web","deviceName":"FF"}]}""",
|
||||
)
|
||||
assertTrue(dto.trustLimitReached)
|
||||
assertEquals(1, dto.devices.size)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.data.api.dto
|
||||
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Decode tests for the news post + CMS page + contact DTOs (`/public/posts…`,
|
||||
* `/public/pages/:slug`, `/public/contact`). One [PostDto] shape serves both the
|
||||
* list (no body) and detail (with body); a [PageDto] keeps block props as raw JSON.
|
||||
*/
|
||||
class ContentDtoTest {
|
||||
|
||||
private val json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
explicitNulls = false
|
||||
coerceInputValues = true
|
||||
}
|
||||
|
||||
@Test fun postDetailDecodesBodyAndImage() {
|
||||
val dto = json.decodeFromString<PostDto>(
|
||||
"""{"id":10,"category":"news","title":"Update","slug":"update",
|
||||
"excerpt":"e","body":"<p>full</p>","image_url":"/i.png",
|
||||
"published_at":"2026-07-21T00:00:00Z","created_at":"2026-07-20T00:00:00Z"}""",
|
||||
)
|
||||
assertEquals(10L, dto.id)
|
||||
assertEquals("<p>full</p>", dto.body)
|
||||
assertEquals("/i.png", dto.imageUrl)
|
||||
assertEquals("2026-07-21T00:00:00Z", dto.publishedAt)
|
||||
}
|
||||
|
||||
@Test fun postListRowToleratesMissingBody() {
|
||||
val dto = json.decodeFromString<PostDto>("""{"id":11,"category":"newsletter","title":"N"}""")
|
||||
assertNull(dto.body)
|
||||
assertNull(dto.imageUrl)
|
||||
}
|
||||
|
||||
@Test fun pageDecodesBlocksWithRawProps() {
|
||||
val dto = json.decodeFromString<PageDto>(
|
||||
"""{
|
||||
"id":3,"slug":"about","title":"About","status":"published",
|
||||
"blocks":[
|
||||
{"type":"heading","props":{"text":"Welcome","level":1},"visible":true},
|
||||
{"type":"divider","props":{}}
|
||||
],
|
||||
"publishedAt":"2026-07-01T00:00:00Z"
|
||||
}""",
|
||||
)
|
||||
assertEquals("about", dto.slug)
|
||||
assertEquals(2, dto.blocks.size)
|
||||
assertEquals("heading", dto.blocks[0].type)
|
||||
// props stay a raw JSON object the renderer reads by key.
|
||||
assertEquals("Welcome", dto.blocks[0].props["text"]!!.jsonPrimitive.content)
|
||||
assertTrue(dto.blocks[1].visible) // default true when absent
|
||||
}
|
||||
|
||||
@Test fun contactResponseSentAndFallbackVariants() {
|
||||
val sent = json.decodeFromString<ContactResponse>("""{"sent":true}""")
|
||||
assertTrue(sent.sent)
|
||||
assertNull(sent.fallback)
|
||||
|
||||
val fallback = json.decodeFromString<ContactResponse>(
|
||||
"""{"sent":false,"fallback":"mailto","email":"a@b.c"}""",
|
||||
)
|
||||
assertEquals("mailto", fallback.fallback)
|
||||
assertEquals("a@b.c", fallback.email)
|
||||
}
|
||||
|
||||
@Test fun contactRequestEncodesAllFields() {
|
||||
val body = json.encodeToString(ContactRequest(name = "Gwen", email = "g@x.c", message = "hi"))
|
||||
assertTrue(body.contains("\"name\":\"Gwen\""))
|
||||
assertTrue(body.contains("\"email\":\"g@x.c\""))
|
||||
assertTrue(body.contains("\"message\":\"hi\""))
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
*/
|
||||
package com.runicgateway.app.data.api.dto
|
||||
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
@@ -58,6 +59,15 @@ class NotificationsDtoTest {
|
||||
assertEquals(listOf("news.post", "champ.start"), dto.streams)
|
||||
}
|
||||
|
||||
@Test fun emptySubscriptionsStillSerializeStreamsField() {
|
||||
// Regression: clearing the LAST subscription sends an empty set. The backend
|
||||
// validator requires `streams`, so it must be present as `[]`, not omitted.
|
||||
// Uses the production Json config (no encodeDefaults) to prove the field is
|
||||
// always emitted because the DTO field has no default.
|
||||
val body = json.encodeToString(NotificationSubscriptionsDto(emptyList()))
|
||||
assertEquals("""{"streams":[]}""", body)
|
||||
}
|
||||
|
||||
@Test fun settingsPushBlockDecodes() {
|
||||
val dto = json.decodeFromString<SettingsDto>(
|
||||
"""{"site_title":"Shard","brand":{"name":"Shard"},"push":{"ntfyUrl":"https://ntfy.shard.tld"}}""",
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.data.api.dto
|
||||
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Decode/encode tests for the player self-service game-data DTOs
|
||||
* (`/player/shard/…`): account linking, roster, the full character sheet, vendors,
|
||||
* sales, and own-houses. Only the fields the text-only v1 renders are asserted.
|
||||
*/
|
||||
class PlayerGameDataDtoTest {
|
||||
|
||||
private val json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
explicitNulls = false
|
||||
coerceInputValues = true
|
||||
}
|
||||
|
||||
@Test fun linkRequestAndResultRoundTrip() {
|
||||
assertTrue(json.encodeToString(ShardLinkRequest("ABC123")).contains("\"code\":\"ABC123\""))
|
||||
val result = json.decodeFromString<ShardLinkResultDto>("""{"linked":true,"account":"acct1"}""")
|
||||
assertTrue(result.linked)
|
||||
assertEquals("acct1", result.account)
|
||||
assertTrue(json.encodeToString(CreateGameAccountRequest("acct1", "pw")).contains("\"account\":\"acct1\""))
|
||||
}
|
||||
|
||||
@Test fun linkedAccountDecodes() {
|
||||
val dto = json.decodeFromString<ShardLinkDto>(
|
||||
"""{"account":"acct1","userId":42,"charName":"Gwen","linkedAt":"2026-07-20T00:00:00Z"}""",
|
||||
)
|
||||
assertEquals("acct1", dto.account)
|
||||
assertEquals(42L, dto.userId)
|
||||
}
|
||||
|
||||
@Test fun rosterDecodesCharacters() {
|
||||
val dto = json.decodeFromString<RosterDto>(
|
||||
"""{"acct":"acct1","chars":[
|
||||
{"slot":0,"serial":"0x24C","name":"Gwen","body":401,"online":true},
|
||||
{"slot":1,"serial":"0x24D","name":"Alt","online":false}]}""",
|
||||
)
|
||||
assertEquals(2, dto.chars.size)
|
||||
assertTrue(dto.chars[0].online)
|
||||
assertEquals("0x24C", dto.chars[0].serial)
|
||||
}
|
||||
|
||||
@Test fun charSheetDecodesStatsSkillsEquipmentTitlesGuild() {
|
||||
val dto = json.decodeFromString<CharProfileDto>(
|
||||
"""{
|
||||
"serial":"0x24C","name":"Gwen","title":"the Brave","online":true,"acct":"acct1",
|
||||
"stats":{"str":100,"dex":90,"int":80,"hits":95,"hitsMax":100,
|
||||
"resist":{"phys":70,"fire":50,"cold":45,"pois":40,"energy":35}},
|
||||
"skills":[{"n":"Swords","base":100.0,"value":110.0,"cap":120.0}],
|
||||
"equipment":[{"serial":"0x9","layer":"OneHanded","itemId":5044,"hue":0}],
|
||||
"titles":{"selected":0,"reward":["1049643"],"fameKarma":"Glorious"},
|
||||
"guild":{"name":"Knights","abbr":"KoT"},
|
||||
"governorOf":["Britain"]
|
||||
}""",
|
||||
)
|
||||
assertEquals("Gwen", dto.name)
|
||||
assertEquals(100, dto.stats!!.str)
|
||||
assertEquals(70, dto.stats!!.resist!!.phys)
|
||||
assertEquals(110.0, dto.skills.first().value!!, 0.0)
|
||||
assertEquals("OneHanded", dto.equipment.first().layer)
|
||||
assertEquals("Glorious", dto.titles!!.fameKarma)
|
||||
assertEquals("Knights", dto.guild!!.name)
|
||||
assertEquals(listOf("Britain"), dto.governorOf)
|
||||
}
|
||||
|
||||
@Test fun charSheetToleratesMinimalPayload() {
|
||||
val dto = json.decodeFromString<CharProfileDto>("""{"serial":"0x1","name":"Bare"}""")
|
||||
assertEquals(null, dto.stats)
|
||||
assertTrue(dto.skills.isEmpty())
|
||||
assertTrue(dto.equipment.isEmpty())
|
||||
assertFalse(dto.online)
|
||||
}
|
||||
|
||||
@Test fun vendorSnapshotAndListingsDecode() {
|
||||
val dto = json.decodeFromString<VendorSnapshotDto>(
|
||||
"""{"acct":"acct1","vendors":[
|
||||
{"serial":"0x9","shopName":"Wares","holdGold":5000,"map":"Felucca","x":1,"y":2,
|
||||
"listings":[{"serial":"0xA","itemId":3862,"amount":5,"price":250,"forSale":true}]}]}""",
|
||||
)
|
||||
val vendor = dto.vendors.first()
|
||||
assertEquals("Wares", vendor.shopName)
|
||||
assertEquals(5000L, vendor.holdGold)
|
||||
val listing = vendor.listings.first()
|
||||
assertEquals(250L, listing.price)
|
||||
assertTrue(listing.forSale)
|
||||
}
|
||||
|
||||
@Test fun vendorSaleDecodes() {
|
||||
val dto = json.decodeFromString<VendorSaleDto>(
|
||||
"""{"t":1700000000000,"itemType":"katana","amount":1,"price":1000,"commission":50,"ownerAcct":"acct1"}""",
|
||||
)
|
||||
assertEquals(1000L, dto.price)
|
||||
assertEquals(50, dto.commission)
|
||||
}
|
||||
|
||||
@Test fun playerHouseDecodesDecayFields() {
|
||||
val dto = json.decodeFromString<PlayerHouseDto>(
|
||||
"""{"serial":"0x40001","stage":"LikeNew","region":"Britain","name":"Keep",
|
||||
"isIdoc":false,"builtOn":"2026-01-01T00:00:00Z","lastRefreshed":"2026-07-22T00:00:00Z"}""",
|
||||
)
|
||||
assertEquals("LikeNew", dto.stage)
|
||||
assertEquals("Keep", dto.name)
|
||||
assertFalse(dto.isIdoc)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.data.api.dto
|
||||
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Decode tests for the public site/identity DTOs (`/public/status`,
|
||||
* `/public/settings`). Covers the `StatusDto.isMaintenance` derivation, nested
|
||||
* branding/registration/push blocks, and the additive-field tolerance.
|
||||
*/
|
||||
class PublicDtoTest {
|
||||
|
||||
private val json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
explicitNulls = false
|
||||
coerceInputValues = true
|
||||
}
|
||||
|
||||
@Test fun statusDecodesVersionAndMaintenanceFlag() {
|
||||
val dto = json.decodeFromString<StatusDto>(
|
||||
"""{"mode":"MAINTENANCE","status_message":"back soon",
|
||||
"version":{"service":"web","api":"v1","server":"1.2.3"}}""",
|
||||
)
|
||||
assertTrue(dto.isMaintenance) // case-insensitive
|
||||
assertEquals("back soon", dto.statusMessage)
|
||||
assertEquals("1.2.3", dto.version.server)
|
||||
}
|
||||
|
||||
@Test fun liveStatusIsNotMaintenance() {
|
||||
assertFalse(json.decodeFromString<StatusDto>("""{"mode":"live"}""").isMaintenance)
|
||||
}
|
||||
|
||||
@Test fun statusDefaultsWhenEmpty() {
|
||||
val dto = json.decodeFromString<StatusDto>("{}")
|
||||
assertEquals("live", dto.mode)
|
||||
assertFalse(dto.isMaintenance)
|
||||
assertEquals("", dto.version.api)
|
||||
}
|
||||
|
||||
@Test fun settingsDecodesBrandRegistrationAndPush() {
|
||||
val dto = json.decodeFromString<SettingsDto>(
|
||||
"""{
|
||||
"site_title":"UOMysticmoon","status_message":"welcome",
|
||||
"registration":{"password":true,"sso":false},
|
||||
"gameAccountSignup":true,
|
||||
"brand":{"name":"UOMysticmoon","shortName":"UOM","accent":"#7f99bd",
|
||||
"logo":"/logo.png","hero":"/hero.png","contactEmail":"a@b.c","url":"https://x"},
|
||||
"push":{"ntfyUrl":"https://ntfy.example.com"}
|
||||
}""",
|
||||
)
|
||||
assertEquals("UOMysticmoon", dto.siteTitle)
|
||||
assertTrue(dto.registration.password)
|
||||
assertFalse(dto.registration.sso)
|
||||
assertTrue(dto.gameAccountSignup)
|
||||
assertEquals("#7f99bd", dto.brand.accent)
|
||||
assertEquals("/logo.png", dto.brand.logo)
|
||||
assertEquals("https://ntfy.example.com", dto.push.ntfyUrl)
|
||||
}
|
||||
|
||||
@Test fun settingsDefaultsOnOlderBackend() {
|
||||
// A backend that predates push/branding: nested blocks fall back to defaults.
|
||||
val dto = json.decodeFromString<SettingsDto>("""{"site_title":"Bare"}""")
|
||||
assertFalse(dto.registration.password)
|
||||
assertEquals("", dto.brand.name)
|
||||
assertEquals(null, dto.push.ntfyUrl)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.data.api.dto
|
||||
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Decode tests for the public shard board DTOs (`/public/shard/…`), covering the
|
||||
* computed helpers ([ActorDto.label], [ShardStatusDto.isOnline]) and the
|
||||
* permissive board payloads (champ/guild/governor/house/presence).
|
||||
*/
|
||||
class ShardBoardDtoTest {
|
||||
|
||||
private val json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
explicitNulls = false
|
||||
coerceInputValues = true
|
||||
}
|
||||
|
||||
@Test fun actorLabelPrefersNameThenAcctThenFallback() {
|
||||
assertEquals("Gwen", json.decodeFromString<ActorDto>("""{"name":"Gwen","acct":"g01"}""").label)
|
||||
assertEquals("g01", json.decodeFromString<ActorDto>("""{"acct":"g01"}""").label)
|
||||
assertEquals("Someone", json.decodeFromString<ActorDto>("{}").label)
|
||||
}
|
||||
|
||||
@Test fun shardStatusIsOnlineOnlyWhenEnabledAndPluginConnected() {
|
||||
val online = json.decodeFromString<ShardStatusDto>(
|
||||
"""{"enabled":true,"pluginConnected":true,"onlineCount":42,
|
||||
"economy":{"accounts":10,"gold":123456.0,"t":1000}}""",
|
||||
)
|
||||
assertTrue(online.isOnline)
|
||||
assertEquals(42, online.onlineCount)
|
||||
assertEquals(123456.0, online.economy!!.gold!!, 0.0)
|
||||
|
||||
assertFalse(json.decodeFromString<ShardStatusDto>("""{"enabled":true,"pluginConnected":false}""").isOnline)
|
||||
assertFalse(json.decodeFromString<ShardStatusDto>("{}").isOnline)
|
||||
}
|
||||
|
||||
@Test fun champDecodesBossAndProgressFields() {
|
||||
val dto = json.decodeFromString<ChampDto>(
|
||||
"""{"serial":"0x1","category":"champion","name":"Rikktor","active":true,
|
||||
"bossUp":true,"boss":"Rikktor","level":3,"maxLevel":16,"kills":10,"maxKills":100,
|
||||
"hits":5000,"hitsMax":9000,"map":"Felucca","x":1,"y":2,"z":0}""",
|
||||
)
|
||||
assertTrue(dto.active)
|
||||
assertTrue(dto.bossUp)
|
||||
assertEquals(16, dto.maxLevel)
|
||||
assertEquals(5000L, dto.hits)
|
||||
}
|
||||
|
||||
@Test fun guildDecodesLeaderActor() {
|
||||
val dto = json.decodeFromString<GuildDto>(
|
||||
"""{"id":7,"name":"Knights","abbr":"KoT","members":12,"online":3,
|
||||
"leader":{"name":"Arthur","webId":"9931"}}""",
|
||||
)
|
||||
assertEquals("Knights", dto.name)
|
||||
assertEquals("Arthur", dto.leader!!.label)
|
||||
assertEquals("9931", dto.leader!!.webId)
|
||||
}
|
||||
|
||||
@Test fun governorAndTermDecode() {
|
||||
val gov = json.decodeFromString<GovernorDto>(
|
||||
"""{"city":"Britain","governor":{"name":"Dawn"},"electionPhase":"campaign"}""",
|
||||
)
|
||||
assertEquals("Britain", gov.city)
|
||||
assertEquals("Dawn", gov.governor!!.label)
|
||||
|
||||
val term = json.decodeFromString<GovernorTermDto>(
|
||||
"""{"city":"Britain","governor":{"name":"Dawn"},"startedAt":1000,"endedAt":2000,"votes":50}""",
|
||||
)
|
||||
assertEquals(50, term.votes)
|
||||
assertEquals(2000L, term.endedAt)
|
||||
}
|
||||
|
||||
@Test fun houseAndPresenceAndStaffDecode() {
|
||||
val house = json.decodeFromString<HouseDto>(
|
||||
"""{"serial":"0x40","name":"Tower","region":"Britain","isIdoc":true,"x":5,"y":6}""",
|
||||
)
|
||||
assertTrue(house.isIdoc)
|
||||
assertEquals("Tower", house.name)
|
||||
|
||||
val presence = json.decodeFromString<PresenceDto>(
|
||||
"""{"count":30,"byFacet":{"Felucca":10,"Trammel":20},"byRegion":{"Britain":5}}""",
|
||||
)
|
||||
assertEquals(30, presence.count)
|
||||
assertEquals(10, presence.byFacet["Felucca"])
|
||||
|
||||
val staff = json.decodeFromString<OnlineStaffDto>("""{"serial":"0x2","name":"GM Bob","map":"Felucca","x":1,"y":2,"z":0}""")
|
||||
assertEquals("GM Bob", staff.name)
|
||||
}
|
||||
|
||||
@Test fun feedEventDecodesPayloadObject() {
|
||||
val ev = json.decodeFromString<FeedEventDto>(
|
||||
"""{"id":9,"kind":"champ.spawn","t":1234,"payload":{"name":"Rikktor"},"createdAt":"2026-07-22T00:00:00Z"}""",
|
||||
)
|
||||
assertEquals("champ.spawn", ev.kind)
|
||||
assertTrue(ev.payload!!.containsKey("name"))
|
||||
}
|
||||
}
|
||||
@@ -43,25 +43,31 @@ class ShardDtoTest {
|
||||
}
|
||||
|
||||
@Test fun champUpdateFrameDecodesWithKindAndExtras() {
|
||||
// A live champ.update frame: has `kind`, `serial`, and category extras. The
|
||||
// `kind` field is ignored (not on the DTO) and the extras decode.
|
||||
// A live champ.update frame: has `kind`, a hex-string `serial` (INTEGRATION.md
|
||||
// §1 — serials are opaque hex keys, never numbers), and category extras. The
|
||||
// `kind`/`rank`/`autoRestart` fields are ignored (not on the DTO); extras decode.
|
||||
val dto = json.decodeFromString<ChampDto>(
|
||||
"""{"kind":"champ.update","serial":12345,"category":"champion","name":"Barracoon",
|
||||
"status":"active","active":true,"level":10,"maxKills":250,"kills":120,
|
||||
"bossUp":false,"map":"Felucca","x":5571,"y":1379,"z":0,"t":1721426400000}""",
|
||||
"""{"kind":"champ.update","serial":"0x40012345","category":"champion","name":"Barracoon",
|
||||
"status":"active","active":true,"level":10,"rank":3,"maxKills":250,"kills":120,
|
||||
"autoRestart":true,"bossUp":false,"map":"Felucca","x":5571,"y":1379,"z":0,"t":1721426400000}""",
|
||||
)
|
||||
assertEquals(12345L, dto.serial)
|
||||
assertEquals("0x40012345", dto.serial)
|
||||
assertEquals("champion", dto.category)
|
||||
assertEquals(120, dto.kills)
|
||||
assertTrue(dto.active)
|
||||
}
|
||||
|
||||
@Test fun guildFrameDecodesLeaderActor() {
|
||||
// The leader actor carries a hex-string serial and a string webId (the linked
|
||||
// site-user id) — the exact wire shape from INTEGRATION.md §7.
|
||||
val dto = json.decodeFromString<GuildDto>(
|
||||
"""{"kind":"guild.update","id":7,"name":"Knights","abbr":"KNT","members":12,
|
||||
"online":3,"alliance":"Light","leader":{"serial":1,"name":"Arthur","acct":"art"}}""",
|
||||
"online":3,"alliance":"Light",
|
||||
"leader":{"serial":"0x1A2B","name":"Arthur","acct":"art","webId":"9931","player":true}}""",
|
||||
)
|
||||
assertEquals(7L, dto.id)
|
||||
assertEquals("0x1A2B", dto.leader?.serial)
|
||||
assertEquals("9931", dto.leader?.webId)
|
||||
assertEquals("Arthur", dto.leader?.label)
|
||||
assertEquals(12, dto.members)
|
||||
}
|
||||
@@ -86,13 +92,21 @@ class ShardDtoTest {
|
||||
|
||||
@Test fun houseDecodesPublicIdocShape() {
|
||||
val dto = json.decodeFromString<HouseDto>(
|
||||
"""{"serial":999,"name":"Tower","region":"Britain","map":"Felucca",
|
||||
"""{"serial":"0x40001234","name":"Tower","region":"Britain","map":"Felucca",
|
||||
"x":1,"y":2,"z":3,"isIdoc":true}""",
|
||||
)
|
||||
assertEquals(999L, dto.serial)
|
||||
assertEquals("0x40001234", dto.serial)
|
||||
assertTrue(dto.isIdoc)
|
||||
}
|
||||
|
||||
@Test fun onlineStaffDecodesHexSerial() {
|
||||
val dto = json.decodeFromString<OnlineStaffDto>(
|
||||
"""{"serial":"0x24C","name":"Darrow"}""",
|
||||
)
|
||||
assertEquals("0x24C", dto.serial)
|
||||
assertEquals("Darrow", dto.name)
|
||||
}
|
||||
|
||||
@Test fun actorLabelFallsBackToAcctThenSomeone() {
|
||||
assertEquals("bob", ActorDto(acct = "bob").label)
|
||||
assertEquals("Someone", ActorDto().label)
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.data.api.dto
|
||||
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Decode/encode tests for the Mobile SSO bridge DTOs (PLAN.md §4.2). Provider
|
||||
* discovery is public (never secrets); the exchange body uses snake_case
|
||||
* `code_verifier` to match the backend.
|
||||
*/
|
||||
class SsoDtoTest {
|
||||
|
||||
private val json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
explicitNulls = false
|
||||
coerceInputValues = true
|
||||
}
|
||||
|
||||
@Test fun providerDecodesWithOptionalFields() {
|
||||
val dto = json.decodeFromString<SsoProviderDto>(
|
||||
"""{"id":"discord","name":"Discord","icon":"discord","loginUrl":"/auth/discord","priority":2}""",
|
||||
)
|
||||
assertEquals("discord", dto.id)
|
||||
assertEquals("Discord", dto.name)
|
||||
assertEquals(2, dto.priority)
|
||||
}
|
||||
|
||||
@Test fun providerToleratesMissingOptionals() {
|
||||
val dto = json.decodeFromString<SsoProviderDto>("""{"id":"oidc","name":"Corp SSO"}""")
|
||||
assertNull(dto.icon)
|
||||
assertNull(dto.priority)
|
||||
}
|
||||
|
||||
@Test fun exchangeRequestEncodesSnakeCaseVerifier() {
|
||||
val body = json.encodeToString(MobileSsoExchangeRequest(code = "abc123", codeVerifier = "v-e-r-i-f-i-e-r"))
|
||||
assertTrue(body.contains("\"code\":\"abc123\""))
|
||||
assertTrue(body.contains("\"code_verifier\":\"v-e-r-i-f-i-e-r\""))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.data.api.dto
|
||||
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Decode tests for the wiki DTOs (`/public/wiki*`). Summary rows omit the body;
|
||||
* the detail page carries tags, backlinks, and unresolved ("red") link targets.
|
||||
*/
|
||||
class WikiDtoTest {
|
||||
|
||||
private val json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
explicitNulls = false
|
||||
coerceInputValues = true
|
||||
}
|
||||
|
||||
@Test fun summaryRowDecodesWithCategory() {
|
||||
val dto = json.decodeFromString<WikiSummaryDto>(
|
||||
"""{"id":5,"slug":"pvp","title":"PvP","excerpt":"combat",
|
||||
"category_slug":"systems","category_title":"Systems","updated_at":"2026-07-20T00:00:00Z"}""",
|
||||
)
|
||||
assertEquals(5L, dto.id)
|
||||
assertEquals("systems", dto.categorySlug)
|
||||
assertEquals("Systems", dto.categoryTitle)
|
||||
assertEquals("combat", dto.excerpt)
|
||||
}
|
||||
|
||||
@Test fun pageDecodesTagsBacklinksAndMissingLinks() {
|
||||
val dto = json.decodeFromString<WikiPageDto>(
|
||||
"""{
|
||||
"id":9,"slug":"housing","title":"Housing","body":"<p>text</p>",
|
||||
"category_slug":"systems","category_title":"Systems",
|
||||
"tags":[{"slug":"idoc","label":"IDOC"}],
|
||||
"backlinks":[{"slug":"pvp","title":"PvP"}],
|
||||
"missing_links":["nonexistent-page"]
|
||||
}""",
|
||||
)
|
||||
assertEquals("<p>text</p>", dto.body)
|
||||
assertEquals(1, dto.tags.size)
|
||||
assertEquals("IDOC", dto.tags[0].label)
|
||||
assertEquals("pvp", dto.backlinks[0].slug)
|
||||
assertEquals(listOf("nonexistent-page"), dto.missingLinks)
|
||||
}
|
||||
|
||||
@Test fun pageDefaultsCollectionsWhenAbsent() {
|
||||
val dto = json.decodeFromString<WikiPageDto>("""{"id":1,"slug":"x","title":"X"}""")
|
||||
assertTrue(dto.tags.isEmpty())
|
||||
assertTrue(dto.backlinks.isEmpty())
|
||||
assertTrue(dto.missingLinks.isEmpty())
|
||||
}
|
||||
|
||||
@Test fun categoryAndTagDecodePublishedCounts() {
|
||||
val cat = json.decodeFromString<WikiCategoryDto>(
|
||||
"""{"id":2,"slug":"systems","title":"Systems","description":"d","published_count":12}""",
|
||||
)
|
||||
assertEquals(12L, cat.publishedCount)
|
||||
val tag = json.decodeFromString<WikiTagDto>("""{"id":4,"slug":"idoc","label":"IDOC","published_count":3}""")
|
||||
assertEquals(3L, tag.publishedCount)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.data.api.fake
|
||||
|
||||
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.AdminWikiCategoryDto
|
||||
import com.runicgateway.app.data.api.dto.AdminWikiTagDto
|
||||
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.WikiCategoryRequest
|
||||
import com.runicgateway.app.util.okUnit
|
||||
import retrofit2.Response
|
||||
|
||||
/**
|
||||
* A configurable fake of [AdminApi] for the staff-ops repository/ViewModel tests.
|
||||
* Read endpoints return their `var`; write endpoints returning `Response<Unit>`
|
||||
* return [unitResponse] (default 200) so a test can drive the 200 / 403 / 503 copy
|
||||
* branches. Set [error] to throw from every call (network / decode failure paths).
|
||||
*/
|
||||
class FakeAdminApi : AdminApi {
|
||||
|
||||
var error: Throwable? = null
|
||||
|
||||
var dashboard: AdminDashboardDto = AdminDashboardDto()
|
||||
var siteMode: SiteModeStateDto = SiteModeStateDto()
|
||||
var posts: List<AdminPostDto> = emptyList()
|
||||
var createdPost: AdminPostDto = AdminPostDto(id = 0)
|
||||
var publishedPost: AdminPostDto = AdminPostDto(id = 0)
|
||||
var wikiCategories: List<AdminWikiCategoryDto> = emptyList()
|
||||
var createdCategory: AdminWikiCategoryDto = AdminWikiCategoryDto(id = 0)
|
||||
var wikiTags: List<AdminWikiTagDto> = emptyList()
|
||||
var supportPages: List<SupportPageDto> = emptyList()
|
||||
|
||||
/** Response returned by the bodyless write endpoints (kick/ban/delete/respond/…). */
|
||||
var unitResponse: Response<Unit> = okUnit()
|
||||
|
||||
/** Bodies seen by write calls, so a test can assert what was sent. */
|
||||
var lastPostCreate: PostCreateRequest? = null
|
||||
var lastBan: BanRequest? = null
|
||||
var lastRespond: Pair<String, PageRespondRequest>? = null
|
||||
|
||||
private fun <T> reply(value: T): T {
|
||||
error?.let { throw it }
|
||||
return value
|
||||
}
|
||||
|
||||
override suspend fun dashboard(): AdminDashboardDto = reply(dashboard)
|
||||
override suspend fun setSiteMode(body: SiteModeRequest): SiteModeStateDto = reply(siteMode)
|
||||
|
||||
override suspend fun posts(): List<AdminPostDto> = reply(posts)
|
||||
override suspend fun createPost(body: PostCreateRequest): AdminPostDto {
|
||||
lastPostCreate = body
|
||||
return reply(createdPost)
|
||||
}
|
||||
override suspend fun publishPost(id: Long, body: PublishRequest): AdminPostDto = reply(publishedPost)
|
||||
override suspend fun deletePost(id: Long): Response<Unit> = reply(unitResponse)
|
||||
|
||||
override suspend fun wikiCategories(): List<AdminWikiCategoryDto> = reply(wikiCategories)
|
||||
override suspend fun createWikiCategory(body: WikiCategoryRequest): AdminWikiCategoryDto = reply(createdCategory)
|
||||
override suspend fun deleteWikiCategory(id: Long): Response<Unit> = reply(unitResponse)
|
||||
override suspend fun wikiTags(): List<AdminWikiTagDto> = reply(wikiTags)
|
||||
|
||||
override suspend fun kick(body: KickRequest): Response<Unit> = reply(unitResponse)
|
||||
override suspend fun ban(body: BanRequest): Response<Unit> {
|
||||
lastBan = body
|
||||
return reply(unitResponse)
|
||||
}
|
||||
override suspend fun unban(body: UnbanRequest): Response<Unit> = reply(unitResponse)
|
||||
override suspend fun broadcast(body: BroadcastRequest): Response<Unit> = reply(unitResponse)
|
||||
|
||||
override suspend fun supportPages(): List<SupportPageDto> = reply(supportPages)
|
||||
override suspend fun respondPage(id: String, body: PageRespondRequest): Response<Unit> {
|
||||
lastRespond = id to body
|
||||
return reply(unitResponse)
|
||||
}
|
||||
override suspend fun closePage(id: String): Response<Unit> = reply(unitResponse)
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.data.api.fake
|
||||
|
||||
import com.runicgateway.app.data.api.PlayerShardApi
|
||||
import com.runicgateway.app.data.api.dto.CharProfileDto
|
||||
import com.runicgateway.app.data.api.dto.CreateGameAccountRequest
|
||||
import com.runicgateway.app.data.api.dto.PlayerHouseDto
|
||||
import com.runicgateway.app.data.api.dto.RosterDto
|
||||
import com.runicgateway.app.data.api.dto.ShardLinkDto
|
||||
import com.runicgateway.app.data.api.dto.ShardLinkRequest
|
||||
import com.runicgateway.app.data.api.dto.ShardLinkResultDto
|
||||
import com.runicgateway.app.data.api.dto.VendorSaleDto
|
||||
import com.runicgateway.app.data.api.dto.VendorSnapshotDto
|
||||
|
||||
/**
|
||||
* A configurable fake of [PlayerShardApi] for the player self-service repository /
|
||||
* ViewModel tests. Set the relevant `var`; set [error] to throw from every call
|
||||
* (drives the `503 shard offline` / `403 not-linked` / network paths).
|
||||
*/
|
||||
class FakePlayerShardApi : PlayerShardApi {
|
||||
|
||||
var error: Throwable? = null
|
||||
|
||||
var linkResult: ShardLinkResultDto = ShardLinkResultDto()
|
||||
var accounts: List<ShardLinkDto> = emptyList()
|
||||
var roster: RosterDto = RosterDto()
|
||||
var char: CharProfileDto = CharProfileDto()
|
||||
var vendors: VendorSnapshotDto = VendorSnapshotDto()
|
||||
var sales: List<VendorSaleDto> = emptyList()
|
||||
var houses: List<PlayerHouseDto> = emptyList()
|
||||
|
||||
private fun <T> reply(value: T): T {
|
||||
error?.let { throw it }
|
||||
return value
|
||||
}
|
||||
|
||||
override suspend fun link(body: ShardLinkRequest): ShardLinkResultDto = reply(linkResult)
|
||||
override suspend fun createAccount(body: CreateGameAccountRequest): ShardLinkResultDto = reply(linkResult)
|
||||
override suspend fun accounts(): List<ShardLinkDto> = reply(accounts)
|
||||
override suspend fun roster(account: String): RosterDto = reply(roster)
|
||||
override suspend fun char(serial: String): CharProfileDto = reply(char)
|
||||
override suspend fun vendors(account: String): VendorSnapshotDto = reply(vendors)
|
||||
override suspend fun sales(): List<VendorSaleDto> = reply(sales)
|
||||
override suspend fun houses(): List<PlayerHouseDto> = reply(houses)
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.data.api.fake
|
||||
|
||||
import com.runicgateway.app.data.api.PublicApi
|
||||
import com.runicgateway.app.data.api.dto.ChampDto
|
||||
import com.runicgateway.app.data.api.dto.ContactRequest
|
||||
import com.runicgateway.app.data.api.dto.ContactResponse
|
||||
import com.runicgateway.app.data.api.dto.EconomySampleDto
|
||||
import com.runicgateway.app.data.api.dto.FeedEventDto
|
||||
import com.runicgateway.app.data.api.dto.GovernorDto
|
||||
import com.runicgateway.app.data.api.dto.GovernorTermDto
|
||||
import com.runicgateway.app.data.api.dto.GuildDto
|
||||
import com.runicgateway.app.data.api.dto.HouseDto
|
||||
import com.runicgateway.app.data.api.dto.OnlineStaffDto
|
||||
import com.runicgateway.app.data.api.dto.PageDto
|
||||
import com.runicgateway.app.data.api.dto.PostDto
|
||||
import com.runicgateway.app.data.api.dto.PresenceDto
|
||||
import com.runicgateway.app.data.api.dto.SettingsDto
|
||||
import com.runicgateway.app.data.api.dto.ShardStatusDto
|
||||
import com.runicgateway.app.data.api.dto.StatusDto
|
||||
import com.runicgateway.app.data.api.dto.WikiCategoryDto
|
||||
import com.runicgateway.app.data.api.dto.WikiPageDto
|
||||
import com.runicgateway.app.data.api.dto.WikiSummaryDto
|
||||
import com.runicgateway.app.data.api.dto.WikiTagDto
|
||||
|
||||
/**
|
||||
* A configurable fake of [PublicApi] for repository/ViewModel tests. Set the
|
||||
* relevant `var` to the body a call should return; set [error] to make every call
|
||||
* throw (drives the `ApiResult.HttpError` / `NetworkError` paths). Defaults are
|
||||
* empty/neutral so a call an assertion doesn't care about never crashes.
|
||||
*/
|
||||
class FakePublicApi : PublicApi {
|
||||
|
||||
/** When non-null, every call throws this (use `httpError(code)` or an IOException). */
|
||||
var error: Throwable? = null
|
||||
|
||||
var status: StatusDto = StatusDto()
|
||||
var settings: SettingsDto = SettingsDto()
|
||||
var posts: List<PostDto> = emptyList()
|
||||
var post: PostDto = PostDto(id = 0)
|
||||
var page: PageDto = PageDto(id = 0)
|
||||
var wikiPages: List<WikiSummaryDto> = emptyList()
|
||||
var wikiCategories: List<WikiCategoryDto> = emptyList()
|
||||
var wikiTags: List<WikiTagDto> = emptyList()
|
||||
var wikiPage: WikiPageDto = WikiPageDto(id = 0)
|
||||
var contactResponse: ContactResponse = ContactResponse(sent = true)
|
||||
var shardStatus: ShardStatusDto = ShardStatusDto()
|
||||
var shardFeed: List<FeedEventDto> = emptyList()
|
||||
var shardEconomy: List<EconomySampleDto> = emptyList()
|
||||
var shardOnline: List<OnlineStaffDto> = emptyList()
|
||||
var shardPresence: PresenceDto = PresenceDto()
|
||||
var champs: List<ChampDto> = emptyList()
|
||||
var guilds: List<GuildDto> = emptyList()
|
||||
var governors: List<GovernorDto> = emptyList()
|
||||
var governorHistory: List<GovernorTermDto> = emptyList()
|
||||
var houses: List<HouseDto> = emptyList()
|
||||
|
||||
/** Last contact request body seen (so a test can assert it was trimmed/forwarded). */
|
||||
var lastContact: ContactRequest? = null
|
||||
|
||||
private fun <T> reply(value: T): T {
|
||||
error?.let { throw it }
|
||||
return value
|
||||
}
|
||||
|
||||
override suspend fun probeStatus(absoluteStatusUrl: String): StatusDto = reply(status)
|
||||
override suspend fun getStatus(): StatusDto = reply(status)
|
||||
override suspend fun getSettings(): SettingsDto = reply(settings)
|
||||
|
||||
override suspend fun getPosts(category: String): List<PostDto> = reply(posts)
|
||||
override suspend fun getPost(category: String, idOrSlug: String): PostDto = reply(post)
|
||||
override suspend fun getPage(slug: String): PageDto = reply(page)
|
||||
|
||||
override suspend fun getWikiPages(query: String?, category: String?, tag: String?): List<WikiSummaryDto> =
|
||||
reply(wikiPages)
|
||||
override suspend fun getWikiCategories(): List<WikiCategoryDto> = reply(wikiCategories)
|
||||
override suspend fun getWikiTags(): List<WikiTagDto> = reply(wikiTags)
|
||||
override suspend fun getWikiPage(slug: String): WikiPageDto = reply(wikiPage)
|
||||
|
||||
override suspend fun postContact(body: ContactRequest): ContactResponse {
|
||||
lastContact = body
|
||||
return reply(contactResponse)
|
||||
}
|
||||
|
||||
override suspend fun getShardStatus(): ShardStatusDto = reply(shardStatus)
|
||||
override suspend fun getShardFeed(kind: String?, limit: Int?): List<FeedEventDto> = reply(shardFeed)
|
||||
override suspend fun getShardEconomy(limit: Int?): List<EconomySampleDto> = reply(shardEconomy)
|
||||
override suspend fun getShardOnline(): List<OnlineStaffDto> = reply(shardOnline)
|
||||
override suspend fun getShardPresence(): PresenceDto = reply(shardPresence)
|
||||
override suspend fun getShardChamps(): List<ChampDto> = reply(champs)
|
||||
override suspend fun getShardGuilds(): List<GuildDto> = reply(guilds)
|
||||
override suspend fun getShardGovernors(): List<GovernorDto> = reply(governors)
|
||||
override suspend fun getShardGovernorHistory(city: String, limit: Int?): List<GovernorTermDto> =
|
||||
reply(governorHistory)
|
||||
override suspend fun getShardHouses(): List<HouseDto> = reply(houses)
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.data.api.fake
|
||||
|
||||
import com.runicgateway.app.core.net.ShardStream
|
||||
import com.runicgateway.app.core.net.ShardStreamEvent
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
|
||||
/**
|
||||
* A finite fake of the live [ShardStream] for board-ViewModel tests: it emits the
|
||||
* given [events] once and completes, so the ViewModel's `collectLive()` finishes
|
||||
* immediately (no perpetual reconnect loop) and any live-frame handling it triggers
|
||||
* is exercised deterministically.
|
||||
*/
|
||||
class FakeShardStream(private val events: List<ShardStreamEvent> = emptyList()) : ShardStream {
|
||||
override fun events(): Flow<ShardStreamEvent> = flowOf(*events.toTypedArray())
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
/*
|
||||
* 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.data.api.MeApi
|
||||
import com.runicgateway.app.data.api.dto.ChangePasswordRequest
|
||||
import com.runicgateway.app.data.api.dto.ChangeUsernameRequest
|
||||
import com.runicgateway.app.data.api.dto.LinkedIdentityDto
|
||||
import com.runicgateway.app.data.api.dto.PlayerAccountDto
|
||||
import com.runicgateway.app.data.api.dto.RecoveryCodesDto
|
||||
import com.runicgateway.app.data.api.dto.RecoveryGenerateRequest
|
||||
import com.runicgateway.app.data.api.dto.RecoveryStatusDto
|
||||
import com.runicgateway.app.data.api.dto.RevokedCountDto
|
||||
import com.runicgateway.app.data.api.dto.RevokedFlagDto
|
||||
import com.runicgateway.app.data.api.dto.TotpCodeRequest
|
||||
import com.runicgateway.app.data.api.dto.TotpSetupDto
|
||||
import com.runicgateway.app.data.api.dto.TotpStateDto
|
||||
import com.runicgateway.app.data.api.dto.TrustDeviceRequest
|
||||
import com.runicgateway.app.data.api.dto.TrustDeviceResultDto
|
||||
import com.runicgateway.app.data.api.dto.TrustedDeviceDto
|
||||
import com.runicgateway.app.data.api.dto.UsernameResponse
|
||||
import com.runicgateway.app.data.repository.AccountRepository.TrustOutcome
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlinx.serialization.json.Json
|
||||
import okhttp3.MediaType.Companion.toMediaTypeOrNull
|
||||
import okhttp3.ResponseBody.Companion.toResponseBody
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import retrofit2.Response
|
||||
|
||||
/**
|
||||
* [AccountRepository] trusted-device + recovery logic (TRUSTED_DEVICES_MFA.md) over a
|
||||
* fake [MeApi]. The interesting case is the `409` cap: the device list must survive
|
||||
* into a typed [TrustOutcome.LimitReached] rather than being lost as a bare error.
|
||||
*/
|
||||
class AccountTrustedDevicesTest {
|
||||
|
||||
private val json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
explicitNulls = false
|
||||
coerceInputValues = true
|
||||
}
|
||||
|
||||
/** A fake MeApi; only the trusted-device/recovery methods under test are wired. */
|
||||
private open class FakeMeApi(
|
||||
var trustResponse: Response<TrustDeviceResultDto>? = null,
|
||||
var devices: List<TrustedDeviceDto> = emptyList(),
|
||||
var revokeFlag: Boolean = true,
|
||||
var revokeCount: Int = 0,
|
||||
var remaining: Int = 0,
|
||||
var generated: List<String> = emptyList(),
|
||||
) : MeApi {
|
||||
override suspend fun trustedDevices(): List<TrustedDeviceDto> = devices
|
||||
override suspend fun trustThisDevice(body: TrustDeviceRequest): Response<TrustDeviceResultDto> =
|
||||
trustResponse ?: Response.success(TrustDeviceResultDto(trusted = true, trustToken = "t"))
|
||||
override suspend fun revokeTrustedDevice(id: Long): RevokedFlagDto = RevokedFlagDto(revokeFlag)
|
||||
override suspend fun revokeAllTrustedDevices(): RevokedCountDto = RevokedCountDto(revokeCount)
|
||||
override suspend fun recoveryCodesStatus(): RecoveryStatusDto = RecoveryStatusDto(remaining)
|
||||
override suspend fun generateRecoveryCodes(body: RecoveryGenerateRequest): RecoveryCodesDto =
|
||||
RecoveryCodesDto(generated)
|
||||
|
||||
// Unused by these tests.
|
||||
override suspend fun getAccount(): PlayerAccountDto = PlayerAccountDto()
|
||||
override suspend fun changeUsername(body: ChangeUsernameRequest): UsernameResponse = UsernameResponse()
|
||||
override suspend fun changePassword(body: ChangePasswordRequest) = Unit
|
||||
override suspend fun totpSetup(): TotpSetupDto = TotpSetupDto()
|
||||
override suspend fun totpEnable(body: TotpCodeRequest): TotpStateDto = TotpStateDto()
|
||||
override suspend fun totpDisable(body: TotpCodeRequest): TotpStateDto = TotpStateDto()
|
||||
override suspend fun identities(): List<LinkedIdentityDto> = emptyList()
|
||||
override suspend fun unlinkIdentity(provider: String) = Unit
|
||||
}
|
||||
|
||||
private fun repo(api: MeApi) = AccountRepository(api, json)
|
||||
|
||||
@Test fun trustThisDeviceReturnsToken() = runTest {
|
||||
val api = FakeMeApi(trustResponse = Response.success(TrustDeviceResultDto(true, "opaque-xyz")))
|
||||
val outcome = repo(api).trustThisDevice("Pixel")
|
||||
assertTrue(outcome is TrustOutcome.Trusted)
|
||||
assertEquals("opaque-xyz", (outcome as TrustOutcome.Trusted).trustToken)
|
||||
}
|
||||
|
||||
@Test fun trustThisDeviceParsesCapDevicesFrom409() = runTest {
|
||||
val body = """{"error":"trusted_device_limit","devices":[
|
||||
{"id":1,"platform":"web","deviceName":"Firefox"},
|
||||
{"id":2,"platform":"mobile","deviceName":"Pixel"}]}"""
|
||||
.toResponseBody("application/json".toMediaTypeOrNull())
|
||||
val api = FakeMeApi(trustResponse = Response.error(409, body))
|
||||
val outcome = repo(api).trustThisDevice(null)
|
||||
assertTrue(outcome is TrustOutcome.LimitReached)
|
||||
val devices = (outcome as TrustOutcome.LimitReached).devices
|
||||
assertEquals(2, devices.size)
|
||||
assertEquals("Pixel", devices[1].deviceName)
|
||||
}
|
||||
|
||||
@Test fun trustThisDeviceOtherErrorIsServerError() = runTest {
|
||||
val body = """{"message":"boom"}""".toResponseBody("application/json".toMediaTypeOrNull())
|
||||
val api = FakeMeApi(trustResponse = Response.error(500, body))
|
||||
assertTrue(repo(api).trustThisDevice(null) is TrustOutcome.ServerError)
|
||||
}
|
||||
|
||||
@Test fun revokeMapsFlagAndCount() = runTest {
|
||||
val revoked = repo(FakeMeApi(revokeFlag = true)).revokeTrustedDevice(9)
|
||||
assertTrue(revoked is ApiResult.Ok && revoked.data)
|
||||
|
||||
val all = repo(FakeMeApi(revokeCount = 3)).revokeAllTrustedDevices()
|
||||
assertTrue(all is ApiResult.Ok && all.data == 3)
|
||||
}
|
||||
|
||||
@Test fun recoveryStatusAndGenerateMap() = runTest {
|
||||
val status = repo(FakeMeApi(remaining = 6)).recoveryCodesStatus()
|
||||
assertTrue(status is ApiResult.Ok && status.data.remaining == 6)
|
||||
|
||||
val gen = repo(FakeMeApi(generated = listOf("a", "b"))).generateRecoveryCodes("pw")
|
||||
assertTrue(gen is ApiResult.Ok)
|
||||
assertEquals(listOf("a", "b"), (gen as ApiResult.Ok).data.recoveryCodes)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.ui
|
||||
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import com.runicgateway.app.data.api.dto.PageDto
|
||||
import com.runicgateway.app.data.api.dto.PostDto
|
||||
import com.runicgateway.app.data.api.dto.StatusDto
|
||||
import com.runicgateway.app.data.api.dto.WikiPageDto
|
||||
import com.runicgateway.app.data.api.dto.WikiSummaryDto
|
||||
import com.runicgateway.app.data.api.fake.FakePublicApi
|
||||
import com.runicgateway.app.data.repository.ContentRepository
|
||||
import com.runicgateway.app.data.repository.SettingsRepository
|
||||
import com.runicgateway.app.data.repository.WikiRepository
|
||||
import com.runicgateway.app.ui.home.HomeViewModel
|
||||
import com.runicgateway.app.ui.navigation.Routes
|
||||
import com.runicgateway.app.ui.news.NewsViewModel
|
||||
import com.runicgateway.app.ui.news.PostViewModel
|
||||
import com.runicgateway.app.ui.page.PageViewModel
|
||||
import com.runicgateway.app.ui.wiki.WikiPageViewModel
|
||||
import com.runicgateway.app.ui.wiki.WikiViewModel
|
||||
import com.runicgateway.app.util.MainDispatcherRule
|
||||
import com.runicgateway.app.util.httpError
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
|
||||
/** ViewModels over the public content APIs (news, CMS pages, wiki, home status). */
|
||||
class ContentViewModelTest {
|
||||
|
||||
@get:Rule val mainDispatcher = MainDispatcherRule()
|
||||
|
||||
private val api = FakePublicApi()
|
||||
private val content = ContentRepository(api)
|
||||
private val wiki = WikiRepository(api)
|
||||
private val settings = SettingsRepository(api)
|
||||
|
||||
// ── News hub ──────────────────────────────────────────────────────────
|
||||
@Test fun newsLoadsSelectedCategory() {
|
||||
api.posts = listOf(PostDto(id = 1, category = "news", title = "Hi"))
|
||||
val vm = NewsViewModel(content)
|
||||
assertTrue(vm.state.value is UiState.Success)
|
||||
assertEquals(1, (vm.state.value as UiState.Success).data.size)
|
||||
}
|
||||
|
||||
@Test fun newsSelectCategoryReloads() {
|
||||
val vm = NewsViewModel(content)
|
||||
api.posts = listOf(PostDto(id = 2, category = "newsletter", title = "N"))
|
||||
vm.selectCategory(ContentRepository.PostCategory.NEWSLETTER)
|
||||
assertEquals(ContentRepository.PostCategory.NEWSLETTER, vm.category.value)
|
||||
assertEquals(1, (vm.state.value as UiState.Success).data.size)
|
||||
}
|
||||
|
||||
@Test fun newsServerErrorIsUiError() {
|
||||
api.error = httpError(500)
|
||||
assertTrue(NewsViewModel(content).state.value is UiState.Error)
|
||||
}
|
||||
|
||||
// ── Post detail (SavedStateHandle args) ─────────────────────────────────
|
||||
@Test fun postDetailLoadsForKnownCategory() {
|
||||
api.post = PostDto(id = 7, category = "news", title = "Update", body = "<p>x</p>")
|
||||
val handle = SavedStateHandle(
|
||||
mapOf(Routes.Args.CATEGORY to "news", Routes.Args.ID_OR_SLUG to "update"),
|
||||
)
|
||||
val vm = PostViewModel(content, handle)
|
||||
assertEquals("Update", (vm.state.value as UiState.Success).data.title)
|
||||
}
|
||||
|
||||
@Test fun postDetailUnknownCategoryIsNotFoundWithoutApiCall() {
|
||||
val handle = SavedStateHandle(
|
||||
mapOf(Routes.Args.CATEGORY to "bogus", Routes.Args.ID_OR_SLUG to "x"),
|
||||
)
|
||||
val state = PostViewModel(content, handle).state.value
|
||||
assertTrue(state is UiState.Error)
|
||||
assertEquals(ErrorKind.NOT_FOUND, (state as UiState.Error).kind)
|
||||
}
|
||||
|
||||
// ── CMS page ────────────────────────────────────────────────────────────
|
||||
@Test fun pageLoadsBySlug() {
|
||||
api.page = PageDto(id = 3, slug = "about", title = "About")
|
||||
val vm = PageViewModel(content, SavedStateHandle(mapOf(Routes.Args.SLUG to "about")))
|
||||
assertEquals("About", (vm.state.value as UiState.Success).data.title)
|
||||
}
|
||||
|
||||
@Test fun pageNotFoundIsUiError() {
|
||||
api.error = httpError(404)
|
||||
val vm = PageViewModel(content, SavedStateHandle(mapOf(Routes.Args.SLUG to "missing")))
|
||||
assertEquals(ErrorKind.NOT_FOUND, (vm.state.value as UiState.Error).kind)
|
||||
}
|
||||
|
||||
// ── Wiki index + detail ─────────────────────────────────────────────────
|
||||
@Test fun wikiIndexLoadsAndTracksQuery() {
|
||||
api.wikiPages = listOf(WikiSummaryDto(id = 1, slug = "pvp", title = "PvP"))
|
||||
val vm = WikiViewModel(wiki)
|
||||
assertTrue(vm.state.value is UiState.Success)
|
||||
vm.onQueryChange("housing")
|
||||
assertEquals("housing", vm.query.value)
|
||||
}
|
||||
|
||||
@Test fun wikiPageLoadsBySlug() {
|
||||
api.wikiPage = WikiPageDto(id = 9, slug = "housing", title = "Housing", body = "b")
|
||||
val vm = WikiPageViewModel(wiki, SavedStateHandle(mapOf(Routes.Args.SLUG to "housing")))
|
||||
assertEquals("Housing", (vm.state.value as UiState.Success).data.title)
|
||||
}
|
||||
|
||||
// ── Home status ─────────────────────────────────────────────────────────
|
||||
@Test fun homeLoadsStatus() {
|
||||
api.status = StatusDto(mode = "maintenance")
|
||||
val vm = HomeViewModel(settings)
|
||||
assertTrue((vm.state.value as UiState.Success).data.isMaintenance)
|
||||
}
|
||||
|
||||
@Test fun homeNetworkErrorIsUiError() {
|
||||
api.error = java.io.IOException("offline")
|
||||
val state = HomeViewModel(settings).state.value
|
||||
assertEquals(ErrorKind.NETWORK, (state as UiState.Error).kind)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.ui.admin
|
||||
|
||||
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.data.api.fake.FakeAdminApi
|
||||
import com.runicgateway.app.data.repository.AdminRepository
|
||||
import com.runicgateway.app.ui.UiState
|
||||
import com.runicgateway.app.util.MainDispatcherRule
|
||||
import com.runicgateway.app.util.errorUnit
|
||||
import com.runicgateway.app.util.httpError
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
|
||||
class AdminContentViewModelTest {
|
||||
|
||||
@get:Rule val mainDispatcher = MainDispatcherRule()
|
||||
|
||||
private val api = FakeAdminApi()
|
||||
private fun viewModel() = AdminContentViewModel(AdminRepository(api))
|
||||
|
||||
@Test fun loadsPostsAndWikiOnInit() {
|
||||
api.posts = listOf(AdminPostDto(id = 1, title = "A", published = 1))
|
||||
api.wikiCategories = listOf(AdminWikiCategoryDto(id = 2, slug = "lore", title = "Lore"))
|
||||
api.wikiTags = listOf(AdminWikiTagDto(id = 3, slug = "pvp", label = "PvP"))
|
||||
val vm = viewModel()
|
||||
assertTrue(vm.state.value.posts is UiState.Success)
|
||||
assertEquals(1, (vm.state.value.posts as UiState.Success).data.size)
|
||||
assertEquals(1, vm.state.value.tags.size)
|
||||
}
|
||||
|
||||
@Test fun createPostRejectsBlankTitleWithoutCallingApi() {
|
||||
val vm = viewModel()
|
||||
vm.createPost(category = "news", title = " ", excerpt = "", body = "", published = false)
|
||||
assertFalse(vm.state.value.feedback!!.ok)
|
||||
assertEquals(R.string.admin_content_title_required, vm.state.value.feedback!!.messageRes)
|
||||
assertEquals(null, api.lastPostCreate) // never reached the API
|
||||
}
|
||||
|
||||
@Test fun createPostTrimsAndNullsBlanksThenReloads() {
|
||||
val vm = viewModel()
|
||||
vm.createPost(category = "news", title = " Hello ", excerpt = "", body = "b", published = true)
|
||||
val sent = api.lastPostCreate!!
|
||||
assertEquals("Hello", sent.title)
|
||||
assertEquals(null, sent.excerpt) // blank -> null
|
||||
assertEquals("b", sent.body)
|
||||
assertTrue(vm.state.value.feedback!!.ok)
|
||||
assertFalse(vm.state.value.busy)
|
||||
}
|
||||
|
||||
@Test fun togglePublishForbiddenSurfacesForbiddenCopy() {
|
||||
api.unitResponse = errorUnit(403)
|
||||
api.error = httpError(403)
|
||||
val vm = viewModel()
|
||||
vm.togglePublish(AdminPostDto(id = 5, title = "x", published = 1))
|
||||
assertEquals(R.string.admin_forbidden, vm.state.value.feedback!!.messageRes)
|
||||
}
|
||||
|
||||
@Test fun createCategoryRejectsBlankFields() {
|
||||
val vm = viewModel()
|
||||
vm.createCategory(slug = "", title = "", description = "", sortOrder = null)
|
||||
assertEquals(R.string.admin_content_cat_fields_required, vm.state.value.feedback!!.messageRes)
|
||||
}
|
||||
|
||||
@Test fun deletePostNetworkErrorShowsNetworkCopy() {
|
||||
api.error = java.io.IOException("offline")
|
||||
val vm = viewModel()
|
||||
vm.deletePost(9)
|
||||
assertEquals(R.string.error_network, vm.state.value.feedback!!.messageRes)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.ui.admin
|
||||
|
||||
import com.runicgateway.app.R
|
||||
import com.runicgateway.app.data.api.dto.AdminCountsDto
|
||||
import com.runicgateway.app.data.api.dto.AdminDashboardDto
|
||||
import com.runicgateway.app.data.api.dto.SiteModeStateDto
|
||||
import com.runicgateway.app.data.api.fake.FakeAdminApi
|
||||
import com.runicgateway.app.data.repository.AdminRepository
|
||||
import com.runicgateway.app.ui.UiState
|
||||
import com.runicgateway.app.util.MainDispatcherRule
|
||||
import com.runicgateway.app.util.httpError
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
|
||||
class AdminDashboardViewModelTest {
|
||||
|
||||
@get:Rule val mainDispatcher = MainDispatcherRule()
|
||||
|
||||
private val api = FakeAdminApi()
|
||||
private fun viewModel() = AdminDashboardViewModel(AdminRepository(api))
|
||||
|
||||
@Test fun loadsDashboardOnInit() {
|
||||
api.dashboard = AdminDashboardDto(siteMode = "live", counts = AdminCountsDto(users = 12))
|
||||
val vm = viewModel()
|
||||
val state = vm.state.value.dashboard
|
||||
assertTrue(state is UiState.Success)
|
||||
assertEquals(12, (state as UiState.Success).data.counts.users)
|
||||
}
|
||||
|
||||
@Test fun loadSurfacesServerErrorAsUiError() {
|
||||
api.error = httpError(500)
|
||||
val vm = viewModel()
|
||||
assertTrue(vm.state.value.dashboard is UiState.Error)
|
||||
}
|
||||
|
||||
@Test fun setSiteModeSuccessUpdatesModeAndClearsSwitching() {
|
||||
api.dashboard = AdminDashboardDto(siteMode = "live")
|
||||
api.siteMode = SiteModeStateDto(siteMode = "maintenance")
|
||||
val vm = viewModel()
|
||||
vm.setSiteMode("maintenance")
|
||||
val s = vm.state.value
|
||||
assertFalse(s.switching)
|
||||
assertTrue(s.feedback!!.ok)
|
||||
assertEquals(R.string.admin_site_mode_updated, s.feedback!!.messageRes)
|
||||
}
|
||||
|
||||
@Test fun setSiteModeForbiddenShowsForbiddenCopy() {
|
||||
api.dashboard = AdminDashboardDto()
|
||||
api.error = httpError(403)
|
||||
val vm = viewModel()
|
||||
vm.setSiteMode("maintenance")
|
||||
val fb = vm.state.value.feedback!!
|
||||
assertFalse(fb.ok)
|
||||
assertEquals(R.string.admin_forbidden, fb.messageRes)
|
||||
}
|
||||
|
||||
@Test fun clearFeedbackResetsBanner() {
|
||||
api.error = httpError(500)
|
||||
val vm = viewModel()
|
||||
vm.setSiteMode("live")
|
||||
vm.clearFeedback()
|
||||
assertEquals(null, vm.state.value.feedback)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.ui.admin
|
||||
|
||||
import com.runicgateway.app.R
|
||||
import com.runicgateway.app.data.api.fake.FakeAdminApi
|
||||
import com.runicgateway.app.data.repository.AdminRepository
|
||||
import com.runicgateway.app.util.MainDispatcherRule
|
||||
import com.runicgateway.app.util.errorUnit
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
|
||||
class AdminModerationViewModelTest {
|
||||
|
||||
@get:Rule val mainDispatcher = MainDispatcherRule()
|
||||
|
||||
private val api = FakeAdminApi()
|
||||
private fun viewModel() = AdminModerationViewModel(AdminRepository(api))
|
||||
|
||||
@Test fun kickWithNoTargetShowsTargetRequired() {
|
||||
val vm = viewModel()
|
||||
vm.kick(account = "", serial = "")
|
||||
assertEquals(R.string.admin_mod_target_required, vm.state.value.feedback!!.messageRes)
|
||||
}
|
||||
|
||||
@Test fun banForwardsFieldsAndSucceeds() {
|
||||
val vm = viewModel()
|
||||
vm.ban(account = "gwen", serial = "", durationSec = 3600, reason = "afk")
|
||||
assertEquals("gwen", api.lastBan!!.account)
|
||||
assertNull(api.lastBan!!.serial) // blank -> null
|
||||
assertEquals(3600L, api.lastBan!!.durationSec)
|
||||
assertEquals("afk", api.lastBan!!.reason)
|
||||
val fb = vm.state.value.feedback!!
|
||||
assertTrue(fb.ok)
|
||||
assertEquals(R.string.admin_mod_banned, fb.messageRes)
|
||||
assertFalse(vm.state.value.busy)
|
||||
}
|
||||
|
||||
@Test fun shardOfflineMapsTo503Copy() {
|
||||
api.unitResponse = errorUnit(503)
|
||||
val vm = viewModel()
|
||||
vm.kick(account = "x", serial = "")
|
||||
assertEquals(R.string.admin_mod_shard_offline, vm.state.value.feedback!!.messageRes)
|
||||
}
|
||||
|
||||
@Test fun broadcastRejectsBlankText() {
|
||||
val vm = viewModel()
|
||||
vm.broadcast(text = " ", hue = null)
|
||||
assertEquals(R.string.admin_mod_text_required, vm.state.value.feedback!!.messageRes)
|
||||
}
|
||||
|
||||
@Test fun unbanForbiddenMapsTo403Copy() {
|
||||
api.unitResponse = errorUnit(403)
|
||||
val vm = viewModel()
|
||||
vm.unban("gwen")
|
||||
assertEquals(R.string.admin_forbidden, vm.state.value.feedback!!.messageRes)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.ui.admin
|
||||
|
||||
import com.runicgateway.app.R
|
||||
import com.runicgateway.app.data.api.dto.SupportPageDto
|
||||
import com.runicgateway.app.data.api.fake.FakeAdminApi
|
||||
import com.runicgateway.app.data.repository.AdminRepository
|
||||
import com.runicgateway.app.ui.UiState
|
||||
import com.runicgateway.app.util.MainDispatcherRule
|
||||
import com.runicgateway.app.util.errorUnit
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
|
||||
class AdminSupportViewModelTest {
|
||||
|
||||
@get:Rule val mainDispatcher = MainDispatcherRule()
|
||||
|
||||
private val api = FakeAdminApi()
|
||||
private fun viewModel() = AdminSupportViewModel(AdminRepository(api))
|
||||
|
||||
@Test fun loadsOpenPagesOnInit() {
|
||||
api.supportPages = listOf(SupportPageDto(pageId = "0x1", message = "help"))
|
||||
val vm = viewModel()
|
||||
val pages = vm.state.value.pages
|
||||
assertTrue(pages is UiState.Success)
|
||||
assertEquals("0x1", (pages as UiState.Success).data.first().pageId)
|
||||
}
|
||||
|
||||
@Test fun respondRejectsBlankMessage() {
|
||||
val vm = viewModel()
|
||||
vm.respond(id = "0x1", message = " ", close = true)
|
||||
assertEquals(R.string.admin_support_message_required, vm.state.value.feedback!!.messageRes)
|
||||
assertEquals(null, api.lastRespond)
|
||||
}
|
||||
|
||||
@Test fun respondTrimsMessageAndReloadsOnSuccess() {
|
||||
api.supportPages = listOf(SupportPageDto(pageId = "0x1"))
|
||||
val vm = viewModel()
|
||||
vm.respond(id = "0x1", message = " on it ", close = false)
|
||||
assertEquals("on it", api.lastRespond!!.second.message)
|
||||
assertTrue(vm.state.value.feedback!!.ok)
|
||||
}
|
||||
|
||||
@Test fun respondUnknownPageMapsTo404Copy() {
|
||||
api.unitResponse = errorUnit(404)
|
||||
val vm = viewModel()
|
||||
vm.respond(id = "0xZ", message = "hi", close = false)
|
||||
assertEquals(R.string.admin_support_unknown_page, vm.state.value.feedback!!.messageRes)
|
||||
}
|
||||
|
||||
@Test fun closeShardOfflineMapsTo503Copy() {
|
||||
api.unitResponse = errorUnit(503)
|
||||
val vm = viewModel()
|
||||
vm.close("0x1")
|
||||
assertEquals(R.string.admin_mod_shard_offline, vm.state.value.feedback!!.messageRes)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.ui.contact
|
||||
|
||||
import com.runicgateway.app.data.api.dto.ContactResponse
|
||||
import com.runicgateway.app.data.api.fake.FakePublicApi
|
||||
import com.runicgateway.app.data.repository.ContactRepository
|
||||
import com.runicgateway.app.ui.ErrorKind
|
||||
import com.runicgateway.app.util.MainDispatcherRule
|
||||
import com.runicgateway.app.util.httpError
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
|
||||
class ContactViewModelTest {
|
||||
|
||||
@get:Rule val mainDispatcher = MainDispatcherRule()
|
||||
|
||||
private val api = FakePublicApi()
|
||||
private fun viewModel() = ContactViewModel(ContactRepository(api))
|
||||
|
||||
@Test fun blankFieldsProduceValidationError() {
|
||||
val vm = viewModel()
|
||||
vm.onNameChange("Gwen")
|
||||
vm.send() // email + message still blank
|
||||
assertEquals(ContactViewModel.Result.ValidationError, vm.state.value.result)
|
||||
assertEquals(null, api.lastContact) // never hit the API
|
||||
}
|
||||
|
||||
@Test fun successfulSendClearsFieldsAndReportsSent() {
|
||||
api.contactResponse = ContactResponse(sent = true)
|
||||
val vm = viewModel()
|
||||
vm.onNameChange(" Gwen ")
|
||||
vm.onEmailChange(" g@x.c ")
|
||||
vm.onMessageChange(" hello ")
|
||||
vm.send()
|
||||
assertEquals(ContactViewModel.Result.Sent, vm.state.value.result)
|
||||
assertEquals("", vm.state.value.name) // fields cleared on success
|
||||
// Repository trims before sending.
|
||||
assertEquals("Gwen", api.lastContact!!.name)
|
||||
assertEquals("g@x.c", api.lastContact!!.email)
|
||||
}
|
||||
|
||||
@Test fun mailerFallbackSurfacesEmail() {
|
||||
api.contactResponse = ContactResponse(sent = false, fallback = "mailto", email = "team@shard.gg")
|
||||
val vm = viewModel()
|
||||
vm.onNameChange("A"); vm.onEmailChange("a@b.c"); vm.onMessageChange("m")
|
||||
vm.send()
|
||||
val result = vm.state.value.result
|
||||
assertTrue(result is ContactViewModel.Result.Fallback)
|
||||
assertEquals("team@shard.gg", (result as ContactViewModel.Result.Fallback).email)
|
||||
// Fields kept (not a clean send) so the user can retry.
|
||||
assertEquals("A", vm.state.value.name)
|
||||
}
|
||||
|
||||
@Test fun serverErrorSurfacesFailedWithKind() {
|
||||
api.error = httpError(500)
|
||||
val vm = viewModel()
|
||||
vm.onNameChange("A"); vm.onEmailChange("a@b.c"); vm.onMessageChange("m")
|
||||
vm.send()
|
||||
val result = vm.state.value.result
|
||||
assertTrue(result is ContactViewModel.Result.Failed)
|
||||
assertEquals(ErrorKind.SERVER, (result as ContactViewModel.Result.Failed).kind)
|
||||
}
|
||||
}
|
||||
@@ -37,12 +37,16 @@ class MenuAccessTest {
|
||||
assertTrue(visible.contains(Routes.HOME))
|
||||
}
|
||||
|
||||
@Test fun staffSeeAccountButNoPlayerOnlyGroups() {
|
||||
val visible = routes(signedIn(Role.EDITOR))
|
||||
assertTrue(visible.contains(Routes.ACCOUNT))
|
||||
// No PLAYER-access entry (the M4 game-data groups) leaks to staff.
|
||||
val playerOnly = APP_MENU.filter { it.access == MenuAccess.PLAYER }.map { it.route }
|
||||
assertTrue(playerOnly.none { visible.contains(it) })
|
||||
@Test fun staffSeeThePlayerGameDataGroups() {
|
||||
// Staff are a superset of players: every staff role sees the PLAYER-access
|
||||
// game-data groups too (their own linked characters, via the role-agnostic
|
||||
// /player self-service surface), on top of their staff entries.
|
||||
val playerGroups = APP_MENU.filter { it.access == MenuAccess.PLAYER }.map { it.route }
|
||||
for (role in listOf(Role.ADMIN, Role.EDITOR, Role.MODERATOR)) {
|
||||
val visible = routes(signedIn(role))
|
||||
assertTrue("$role should see Account", visible.contains(Routes.ACCOUNT))
|
||||
assertTrue("$role should see the player game-data groups", playerGroups.all { visible.contains(it) })
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun publicEntryCountIsStableAcrossSessions() {
|
||||
@@ -58,10 +62,33 @@ class MenuAccessTest {
|
||||
}
|
||||
|
||||
@Test fun playerAccessGatedFunction() {
|
||||
// A synthetic PLAYER-gated entry is visible to a player, hidden from staff/anon.
|
||||
// A PLAYER-gated entry is visible to a player AND to every staff role
|
||||
// (staff superset), hidden only from an unrecognized role and anon.
|
||||
val entries = listOf(MenuEntry("game", 0, MenuAccess.PLAYER))
|
||||
assertTrue(visibleEntries(entries, signedIn(Role.PLAYER)).isNotEmpty())
|
||||
assertTrue(visibleEntries(entries, signedIn(Role.ADMIN)).isEmpty())
|
||||
for (role in listOf(Role.PLAYER, Role.ADMIN, Role.EDITOR, Role.MODERATOR)) {
|
||||
assertTrue("$role should see a PLAYER entry", visibleEntries(entries, signedIn(role)).isNotEmpty())
|
||||
}
|
||||
assertTrue(visibleEntries(entries, signedIn(Role.UNKNOWN)).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())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.ui.player
|
||||
|
||||
import com.runicgateway.app.R
|
||||
import com.runicgateway.app.data.api.dto.RosterCharDto
|
||||
import com.runicgateway.app.data.api.dto.RosterDto
|
||||
import com.runicgateway.app.data.api.dto.SettingsDto
|
||||
import com.runicgateway.app.data.api.dto.ShardLinkDto
|
||||
import com.runicgateway.app.data.api.dto.ShardLinkResultDto
|
||||
import com.runicgateway.app.data.api.fake.FakePlayerShardApi
|
||||
import com.runicgateway.app.data.api.fake.FakePublicApi
|
||||
import com.runicgateway.app.data.repository.PlayerShardRepository
|
||||
import com.runicgateway.app.data.repository.SettingsRepository
|
||||
import com.runicgateway.app.ui.UiState
|
||||
import com.runicgateway.app.util.MainDispatcherRule
|
||||
import com.runicgateway.app.util.httpError
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
|
||||
class CharactersViewModelTest {
|
||||
|
||||
@get:Rule val mainDispatcher = MainDispatcherRule()
|
||||
|
||||
private val playerApi = FakePlayerShardApi()
|
||||
private val publicApi = FakePublicApi()
|
||||
private fun viewModel() = CharactersViewModel(
|
||||
PlayerShardRepository(playerApi),
|
||||
SettingsRepository(publicApi),
|
||||
)
|
||||
|
||||
@Test fun loadsAccountsRostersAndSignupFlag() {
|
||||
playerApi.accounts = listOf(ShardLinkDto(account = "acct1"))
|
||||
playerApi.roster = RosterDto(acct = "acct1", chars = listOf(RosterCharDto(serial = "0x24C", name = "Gwen")))
|
||||
publicApi.settings = SettingsDto(gameAccountSignup = true)
|
||||
val vm = viewModel()
|
||||
|
||||
assertEquals(listOf("acct1"), (vm.state.value.accounts as UiState.Success).data)
|
||||
val roster = vm.state.value.rosters["acct1"]
|
||||
assertTrue(roster is UiState.Success)
|
||||
assertEquals("Gwen", (roster as UiState.Success).data.first().name)
|
||||
assertTrue(vm.state.value.signupEnabled)
|
||||
}
|
||||
|
||||
@Test fun accountsErrorSurfacesError() {
|
||||
playerApi.error = httpError(503)
|
||||
assertTrue(viewModel().state.value.accounts is UiState.Error)
|
||||
}
|
||||
|
||||
@Test fun linkBlankCodeIsIgnored() {
|
||||
val vm = viewModel()
|
||||
vm.link(" ")
|
||||
assertEquals(null, vm.state.value.feedback)
|
||||
}
|
||||
|
||||
@Test fun linkSuccessShowsOkAndReloads() {
|
||||
playerApi.linkResult = ShardLinkResultDto(linked = true, account = "acct1")
|
||||
val vm = viewModel()
|
||||
vm.link("CODE1")
|
||||
val fb = vm.state.value.feedback!!
|
||||
assertTrue(fb.ok)
|
||||
assertEquals(R.string.player_link_ok, fb.messageRes)
|
||||
assertFalse(vm.state.value.busy)
|
||||
}
|
||||
|
||||
@Test fun linkBadCodeMapsTo400Copy() {
|
||||
playerApi.error = httpError(400)
|
||||
val vm = viewModel()
|
||||
vm.link("BAD")
|
||||
assertEquals(R.string.player_link_bad_code, vm.state.value.feedback!!.messageRes)
|
||||
}
|
||||
|
||||
@Test fun createAccountRejectsShortPasswordWithoutApiCall() {
|
||||
val vm = viewModel()
|
||||
vm.createAccount(account = "acct1", password = "short") // < 8 chars
|
||||
assertEquals(null, vm.state.value.feedback) // guarded before any call/feedback
|
||||
}
|
||||
|
||||
@Test fun createAccountTakenMapsTo409Copy() {
|
||||
playerApi.error = httpError(409)
|
||||
val vm = viewModel()
|
||||
vm.createAccount(account = "acct1", password = "longenough")
|
||||
assertEquals(R.string.player_create_taken, vm.state.value.feedback!!.messageRes)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.ui.player
|
||||
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import com.runicgateway.app.data.api.dto.CharProfileDto
|
||||
import com.runicgateway.app.data.api.dto.PlayerHouseDto
|
||||
import com.runicgateway.app.data.api.dto.ShardLinkDto
|
||||
import com.runicgateway.app.data.api.dto.VendorDto
|
||||
import com.runicgateway.app.data.api.dto.VendorSaleDto
|
||||
import com.runicgateway.app.data.api.dto.VendorSnapshotDto
|
||||
import com.runicgateway.app.data.api.fake.FakePlayerShardApi
|
||||
import com.runicgateway.app.data.repository.PlayerShardRepository
|
||||
import com.runicgateway.app.ui.ErrorKind
|
||||
import com.runicgateway.app.ui.UiState
|
||||
import com.runicgateway.app.ui.navigation.Routes
|
||||
import com.runicgateway.app.util.MainDispatcherRule
|
||||
import com.runicgateway.app.util.httpError
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
|
||||
/** ViewModels over the player self-service game-data API (own chars, vendors, houses). */
|
||||
class PlayerViewModelTest {
|
||||
|
||||
@get:Rule val mainDispatcher = MainDispatcherRule()
|
||||
|
||||
private val api = FakePlayerShardApi()
|
||||
private val repository = PlayerShardRepository(api)
|
||||
|
||||
// ── My houses ───────────────────────────────────────────────────────────
|
||||
@Test fun myHousesLoadsOwnHouses() {
|
||||
api.houses = listOf(PlayerHouseDto(serial = "0x40001", name = "Keep", isIdoc = false))
|
||||
val vm = MyHousesViewModel(repository)
|
||||
assertEquals("Keep", (vm.state.value as UiState.Success).data.first().name)
|
||||
}
|
||||
|
||||
@Test fun myHousesShardOfflineIsShardOfflineError() {
|
||||
api.error = httpError(503)
|
||||
val state = MyHousesViewModel(repository).state.value
|
||||
assertEquals(ErrorKind.SHARD_OFFLINE, (state as UiState.Error).kind)
|
||||
}
|
||||
|
||||
// ── Character sheet (SavedStateHandle serial) ─────────────────────────────
|
||||
@Test fun characterLoadsBySerial() {
|
||||
api.char = CharProfileDto(serial = "0x24C", name = "Gwen", online = true)
|
||||
val vm = CharacterViewModel(repository, SavedStateHandle(mapOf(Routes.Args.SERIAL to "0x24C")))
|
||||
assertEquals("Gwen", (vm.state.value as UiState.Success).data.name)
|
||||
}
|
||||
|
||||
@Test fun characterForbiddenIsNotFound() {
|
||||
api.error = httpError(403)
|
||||
val vm = CharacterViewModel(repository, SavedStateHandle(mapOf(Routes.Args.SERIAL to "0x1")))
|
||||
// 403 isn't a mapped status -> SERVER bucket (only 404/429/503 are special-cased).
|
||||
assertTrue(vm.state.value is UiState.Error)
|
||||
}
|
||||
|
||||
// ── Vendors (per-account snapshots + sales) ───────────────────────────────
|
||||
@Test fun vendorsLoadsAccountsThenPerAccountSnapshots() {
|
||||
api.accounts = listOf(ShardLinkDto(account = "acct1"))
|
||||
api.vendors = VendorSnapshotDto(acct = "acct1", vendors = listOf(VendorDto(serial = "0x9", shopName = "Wares")))
|
||||
api.sales = listOf(VendorSaleDto(itemType = "sword", price = 100))
|
||||
val vm = VendorsViewModel(repository)
|
||||
|
||||
val accounts = vm.state.value.accounts
|
||||
assertTrue(accounts is UiState.Success)
|
||||
assertEquals(listOf("acct1"), (accounts as UiState.Success).data)
|
||||
// Each account's vendors loaded into the per-account map.
|
||||
val perAccount = vm.state.value.vendors["acct1"]
|
||||
assertTrue(perAccount is UiState.Success)
|
||||
assertEquals("Wares", (perAccount as UiState.Success).data.first().shopName)
|
||||
assertTrue(vm.state.value.sales is UiState.Success)
|
||||
}
|
||||
|
||||
@Test fun vendorsAccountsErrorSurfacesError() {
|
||||
api.error = java.io.IOException("offline")
|
||||
val vm = VendorsViewModel(repository)
|
||||
assertTrue(vm.state.value.accounts is UiState.Error)
|
||||
assertTrue(vm.state.value.sales is UiState.Error)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.ui.shard
|
||||
|
||||
import kotlinx.serialization.json.JsonNull
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.put
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* [FrameFields] does minimal typed reads from a raw SSE frame object for the
|
||||
* fields a `*.remove` / `*.decay` delta needs; it must return null (not crash) on
|
||||
* a missing or ill-typed field so a malformed frame is skipped.
|
||||
*/
|
||||
class FrameFieldsTest {
|
||||
|
||||
@Test fun longFieldParsesNumericPrimitive() {
|
||||
val obj = buildJsonObject { put("serial", "12345") }
|
||||
assertEquals(12345L, FrameFields.longField(obj, "serial"))
|
||||
}
|
||||
|
||||
@Test fun longFieldReturnsNullOnMissingOrNonNumeric() {
|
||||
val obj = buildJsonObject { put("serial", "0xNaN") }
|
||||
assertNull(FrameFields.longField(obj, "serial")) // not a Long
|
||||
assertNull(FrameFields.longField(obj, "absent")) // missing key
|
||||
}
|
||||
|
||||
@Test fun longFieldReturnsNullOnJsonNullOrObject() {
|
||||
val obj = JsonObject(mapOf("a" to JsonNull, "b" to JsonObject(emptyMap())))
|
||||
assertNull(FrameFields.longField(obj, "a"))
|
||||
assertNull(FrameFields.longField(obj, "b"))
|
||||
}
|
||||
|
||||
@Test fun stringFieldReadsPrimitiveContent() {
|
||||
val obj = JsonObject(mapOf("kind" to JsonPrimitive("champ.remove")))
|
||||
assertEquals("champ.remove", FrameFields.stringField(obj, "kind"))
|
||||
assertNull(FrameFields.stringField(obj, "missing"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.ui.shard
|
||||
|
||||
import com.runicgateway.app.core.net.ShardStreamEvent
|
||||
import com.runicgateway.app.data.api.dto.ChampDto
|
||||
import com.runicgateway.app.data.api.dto.GovernorDto
|
||||
import com.runicgateway.app.data.api.dto.GuildDto
|
||||
import com.runicgateway.app.data.api.dto.HouseDto
|
||||
import com.runicgateway.app.data.api.dto.PresenceDto
|
||||
import com.runicgateway.app.data.api.dto.ShardStatusDto
|
||||
import com.runicgateway.app.data.api.fake.FakePublicApi
|
||||
import com.runicgateway.app.data.api.fake.FakeShardStream
|
||||
import com.runicgateway.app.data.repository.ShardRepository
|
||||
import com.runicgateway.app.ui.UiState
|
||||
import com.runicgateway.app.util.MainDispatcherRule
|
||||
import com.runicgateway.app.util.httpError
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.put
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* The live-board ViewModels (§6.2): a snapshot load kept live by merging SSE
|
||||
* frames. Tests use a [FakeShardStream] that emits a fixed script and completes,
|
||||
* so both the snapshot path and the frame-merge path are covered deterministically.
|
||||
*/
|
||||
class ShardBoardViewModelTest {
|
||||
|
||||
@get:Rule val mainDispatcher = MainDispatcherRule()
|
||||
|
||||
private val api = FakePublicApi()
|
||||
private val json = Json { ignoreUnknownKeys = true; explicitNulls = false; coerceInputValues = true }
|
||||
|
||||
private fun repo(stream: FakeShardStream = FakeShardStream()) = ShardRepository(api, stream, json)
|
||||
|
||||
// ── Champs: snapshot + live upsert/remove ─────────────────────────────
|
||||
@Test fun champsSeedsSnapshotAndMergesLiveFrames() {
|
||||
api.champs = listOf(ChampDto(serial = "0x1", category = "champion", name = "Rikktor"))
|
||||
val stream = FakeShardStream(
|
||||
listOf(
|
||||
ShardStreamEvent.Open,
|
||||
ShardStreamEvent.Frame(
|
||||
"champ.update",
|
||||
buildJsonObject { put("serial", "0x2"); put("category", "mini"); put("name", "Barracoon") },
|
||||
),
|
||||
ShardStreamEvent.Frame("champ.remove", buildJsonObject { put("serial", "0x1") }),
|
||||
),
|
||||
)
|
||||
val vm = ChampsViewModel(repo(stream))
|
||||
val rows = (vm.state.value as UiState.Success).data
|
||||
// 0x1 removed, 0x2 upserted.
|
||||
assertEquals(listOf("0x2"), rows.map { it.serial })
|
||||
assertTrue(vm.connected.value) // Open was seen
|
||||
}
|
||||
|
||||
@Test fun champsServerErrorIsUiError() {
|
||||
api.error = httpError(503)
|
||||
assertTrue(ChampsViewModel(repo()).state.value is UiState.Error)
|
||||
}
|
||||
|
||||
// ── Guilds ────────────────────────────────────────────────────────────
|
||||
@Test fun guildsSnapshotThenLiveUpdate() {
|
||||
api.guilds = listOf(GuildDto(id = 1, name = "Knights"))
|
||||
val stream = FakeShardStream(
|
||||
listOf(ShardStreamEvent.Frame("guild.update", buildJsonObject { put("id", 2); put("name", "Mages") })),
|
||||
)
|
||||
val vm = GuildsViewModel(repo(stream))
|
||||
val names = (vm.state.value as UiState.Success).data.map { it.name }
|
||||
assertTrue(names.contains("Knights"))
|
||||
assertTrue(names.contains("Mages"))
|
||||
}
|
||||
|
||||
// ── Governors (+ history) ─────────────────────────────────────────────
|
||||
@Test fun governorsSnapshotAndHistory() {
|
||||
api.governors = listOf(GovernorDto(city = "Britain"))
|
||||
api.governorHistory = listOf() // empty is fine
|
||||
val vm = GovernorsViewModel(repo())
|
||||
assertTrue(vm.state.value is UiState.Success)
|
||||
vm.loadHistory("Britain")
|
||||
assertTrue(vm.history.value.containsKey("Britain"))
|
||||
}
|
||||
|
||||
// ── Houses (IDOC board) ───────────────────────────────────────────────
|
||||
@Test fun housesSnapshotLoads() {
|
||||
api.houses = listOf(HouseDto(serial = "0x40", name = "Tower", isIdoc = true))
|
||||
val vm = HousesViewModel(repo())
|
||||
assertEquals("Tower", (vm.state.value as UiState.Success).data.first().name)
|
||||
}
|
||||
|
||||
@Test fun housesNetworkErrorIsUiError() {
|
||||
api.error = java.io.IOException("offline")
|
||||
assertTrue(HousesViewModel(repo()).state.value is UiState.Error)
|
||||
}
|
||||
|
||||
// ── Shard hub (status primary, presence/online best-effort, live feed) ─
|
||||
@Test fun shardHubLoadsStatusPresenceOnlineAndSeedsFeed() {
|
||||
api.shardStatus = ShardStatusDto(enabled = true, pluginConnected = true, onlineCount = 5)
|
||||
api.shardPresence = PresenceDto(count = 5)
|
||||
val stream = FakeShardStream(
|
||||
listOf(
|
||||
ShardStreamEvent.Closed,
|
||||
ShardStreamEvent.Frame("presence.online", buildJsonObject { put("count", 9) }),
|
||||
),
|
||||
)
|
||||
val vm = ShardViewModel(repo(stream))
|
||||
val hub = (vm.state.value as UiState.Success).data
|
||||
assertTrue(hub.status.isOnline)
|
||||
// presence.online frame patched the count in place.
|
||||
assertEquals(9, hub.presence!!.count)
|
||||
assertFalse(vm.connected.value) // last lifecycle event was Closed
|
||||
}
|
||||
|
||||
@Test fun shardHubStatusErrorIsUiError() {
|
||||
api.error = httpError(503)
|
||||
assertTrue(ShardViewModel(repo()).state.value is UiState.Error)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.util
|
||||
|
||||
import okhttp3.MediaType.Companion.toMediaTypeOrNull
|
||||
import okhttp3.ResponseBody.Companion.toResponseBody
|
||||
import retrofit2.HttpException
|
||||
import retrofit2.Response
|
||||
|
||||
/**
|
||||
* Test doubles emulate Retrofit's error contract: a suspend API method throws
|
||||
* [HttpException] on a non-2xx and an [java.io.IOException] on a transport failure,
|
||||
* exactly what `safeApiCall` folds into `ApiResult.HttpError` / `NetworkError`.
|
||||
*/
|
||||
|
||||
private val JSON = "application/json".toMediaTypeOrNull()
|
||||
|
||||
/** An [HttpException] carrying [code] — what a fake API throws to drive an HTTP-error path. */
|
||||
fun httpError(code: Int): HttpException =
|
||||
HttpException(Response.error<Any>(code, "{}".toResponseBody(JSON)))
|
||||
|
||||
/** A successful bodyless [Response] (for `@DELETE`/moderation endpoints returning `Response<Unit>`). */
|
||||
fun okUnit(): Response<Unit> = Response.success(Unit)
|
||||
|
||||
/** A non-2xx bodyless [Response] with [code]. */
|
||||
fun errorUnit(code: Int): Response<Unit> = Response.error(code, "{}".toResponseBody(JSON))
|
||||
@@ -0,0 +1,31 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.util
|
||||
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.test.TestDispatcher
|
||||
import kotlinx.coroutines.test.UnconfinedTestDispatcher
|
||||
import kotlinx.coroutines.test.resetMain
|
||||
import kotlinx.coroutines.test.setMain
|
||||
import org.junit.rules.TestWatcher
|
||||
import org.junit.runner.Description
|
||||
|
||||
/**
|
||||
* Swaps `Dispatchers.Main` (which `viewModelScope` dispatches on) for a test
|
||||
* dispatcher for the duration of a test, so ViewModel coroutines run on the test
|
||||
* scheduler instead of a real Android main looper.
|
||||
*
|
||||
* Defaults to an [UnconfinedTestDispatcher] so work launched from a ViewModel's
|
||||
* `init {}` runs eagerly to its first real suspension — with fakes that never
|
||||
* truly suspend, that means the final state is settled by the time the constructor
|
||||
* returns, and a test can assert `state.value` directly without advancing time.
|
||||
*/
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class MainDispatcherRule(
|
||||
val dispatcher: TestDispatcher = UnconfinedTestDispatcher(),
|
||||
) : TestWatcher() {
|
||||
override fun starting(description: Description) = Dispatchers.setMain(dispatcher)
|
||||
override fun finished(description: Description) = Dispatchers.resetMain()
|
||||
}
|
||||
59
sonar-project.properties
Normal file
59
sonar-project.properties
Normal file
@@ -0,0 +1,59 @@
|
||||
# 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
|
||||
|
||||
# ── Coverage (JaCoCo) ──
|
||||
# sonarqube.yml runs `./gradlew testDebugUnitTest jacocoTestReport` before the scan;
|
||||
# that task (app/build.gradle.kts) writes this XML. Without it, Sonar reports 0%
|
||||
# coverage even though the JVM unit suite (app/src/test) exists.
|
||||
sonar.coverage.jacoco.xmlReportPaths=app/build/reports/jacoco/jacocoTestReport/jacocoTestReport.xml
|
||||
|
||||
# Exclude from *coverage* (not from analysis — bugs/smells are still reported): code a
|
||||
# JVM unit test physically can't execute, so counting its lines would unfairly sink
|
||||
# new-code coverage. Two kinds: pure-@Composable UI (needs Robolectric/instrumented
|
||||
# tests), and Android-framework glue (Keystore-backed stores, foreground push service,
|
||||
# notifications, Hilt modules). Testable logic — ViewModels, repositories, DTOs, and
|
||||
# pure core/ code — stays measured. See docs/android/COVERAGE_PLAN.md §1.
|
||||
sonar.coverage.exclusions=\
|
||||
app/src/main/java/**/ui/**/*Screen.kt,\
|
||||
app/src/main/java/**/ui/**/*Screen*.kt,\
|
||||
app/src/main/java/**/ui/theme/**,\
|
||||
app/src/main/java/**/ui/components/**,\
|
||||
app/src/main/java/**/ui/page/BlockRenderer.kt,\
|
||||
app/src/main/java/**/ui/shard/ShardComponents.kt,\
|
||||
app/src/main/java/**/ui/LocalAssetResolver.kt,\
|
||||
app/src/main/java/**/RunicApp.kt,\
|
||||
app/src/main/java/**/MainActivity.kt,\
|
||||
app/src/main/java/**/*Application.kt,\
|
||||
app/src/main/java/**/RunicGatewayApp.kt,\
|
||||
app/src/main/java/**/di/**,\
|
||||
app/src/main/java/**/core/push/PushService.kt,\
|
||||
app/src/main/java/**/core/push/PushManager.kt,\
|
||||
app/src/main/java/**/core/push/PushNotifier.kt,\
|
||||
app/src/main/java/**/core/push/NtfyStreamClient.kt,\
|
||||
app/src/main/java/**/core/auth/Encrypted*.kt
|
||||
|
||||
# ── Optional enrichment (enable once produced in CI) ──
|
||||
# • Android Lint: ./gradlew lintDebug → app/build/reports/lint-results-debug.xml
|
||||
# sonar.androidLint.reportPaths=app/build/reports/lint-results-debug.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