Add license revoke action and Russian app title.

Support POST /v1/admin/revoke from the license list with confirmation dialog.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Ivan Fontosh
2026-06-28 13:05:20 +08:00
parent bbb861f920
commit 0632974954
7 changed files with 131 additions and 3 deletions
@@ -10,4 +10,7 @@ interface LicenseApi {
@POST("v1/admin/product-keys")
suspend fun createProductKey(@Body body: CreateProductKeyRequest): ProductKeyResponse
@POST("v1/admin/revoke")
suspend fun revokeLicense(@Body body: RevokeLicenseRequest): RevokeLicenseResponse
}
@@ -31,6 +31,14 @@ data class ProductKeyResponse(
val expiresAtSec: Long,
)
data class RevokeLicenseRequest(
val sub: String,
)
data class RevokeLicenseResponse(
val ok: Boolean,
)
data class ApiErrorBody(
@SerializedName("error") val error: String?,
)
@@ -60,6 +60,10 @@ class LicenseRepository(
createApi(settings).createProductKey(request)
}
suspend fun revokeLicense(settings: AppSettings, sub: String): Result<Unit> = runCatching {
createApi(settings).revokeLicense(RevokeLicenseRequest(sub))
}
private fun createApi(settings: AppSettings): LicenseApi {
require(settings.adminToken.isNotBlank()) { "Укажите admin token в настройках" }
val baseUrl = settings.serverUrl
@@ -23,6 +23,7 @@ import java.time.ZoneOffset
data class LicenseListUiState(
val licenses: List<LicenseEntry> = emptyList(),
val isLoading: Boolean = false,
val revokingSub: String? = null,
val error: String? = null,
)
@@ -84,6 +85,25 @@ class LicenseViewModel(application: Application) : AndroidViewModel(application)
}
}
fun revokeLicense(sub: String) {
viewModelScope.launch {
_listState.update { it.copy(revokingSub = sub, error = null) }
licenseRepository.revokeLicense(settings.value, sub)
.onSuccess {
loadLicenses()
_listState.update { it.copy(revokingSub = null) }
}
.onFailure { e ->
_listState.update {
it.copy(
revokingSub = null,
error = e.message ?: "Не удалось отозвать лицензию",
)
}
}
}
}
fun updatePid(value: String) {
_generateState.update { it.copy(pid = value, error = null) }
}
@@ -4,6 +4,7 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
@@ -13,15 +14,21 @@ import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material.pullrefresh.PullRefreshIndicator
import androidx.compose.material.pullrefresh.pullRefresh
import androidx.compose.material.pullrefresh.rememberPullRefreshState
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.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.text.font.FontFamily
@@ -36,6 +43,7 @@ import ru.mailib.ttrpg.licensemanager.ui.formatEpochSec
@Composable
fun LicenseListScreen(viewModel: LicenseViewModel) {
val state by viewModel.listState.collectAsStateWithLifecycle()
var confirmRevoke by remember { mutableStateOf<LicenseEntry?>(null) }
val refreshState = rememberPullRefreshState(
refreshing = state.isLoading,
onRefresh = viewModel::loadLicenses,
@@ -45,6 +53,31 @@ fun LicenseListScreen(viewModel: LicenseViewModel) {
viewModel.loadLicenses()
}
confirmRevoke?.let { license ->
AlertDialog(
onDismissRequest = { confirmRevoke = null },
title = { Text("Отозвать лицензию?") },
text = {
Text("Ключ ${license.key} перестанет работать у всех пользователей.")
},
confirmButton = {
TextButton(
onClick = {
viewModel.revokeLicense(license.sub)
confirmRevoke = null
},
) {
Text("Отозвать")
}
},
dismissButton = {
TextButton(onClick = { confirmRevoke = null }) {
Text("Отмена")
}
},
)
}
Box(
modifier = Modifier
.fillMaxSize()
@@ -75,8 +108,17 @@ fun LicenseListScreen(viewModel: LicenseViewModel) {
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
if (state.error != null) {
item(key = "error") {
ErrorMessage(message = state.error ?: "")
}
}
items(state.licenses, key = { it.sub }) { license ->
LicenseCard(license)
LicenseCard(
license = license,
isRevoking = state.revokingSub == license.sub,
onRevoke = { confirmRevoke = license },
)
}
}
}
@@ -91,7 +133,11 @@ fun LicenseListScreen(viewModel: LicenseViewModel) {
}
@Composable
private fun LicenseCard(license: LicenseEntry) {
private fun LicenseCard(
license: LicenseEntry,
isRevoking: Boolean,
onRevoke: () -> Unit,
) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
@@ -117,6 +163,20 @@ private fun LicenseCard(license: LicenseEntry) {
if (license.activatedDevices.isNotEmpty()) {
DetailRow("deviceId", license.activatedDevices.joinToString(", "))
}
if (!license.revoked) {
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {
OutlinedButton(
onClick = onRevoke,
enabled = !isRevoking,
) {
if (isRevoking) {
CircularProgressIndicator(strokeWidth = 2.dp)
} else {
Text("Отозвать")
}
}
}
}
}
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">TTRPG License Manager</string>
<string name="app_name">Менеджер лицензий TTRPG</string>
<string name="tab_licenses">Лицензии</string>
<string name="tab_generate">Новый ключ</string>
<string name="settings">Настройки</string>