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:
@@ -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,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>
|
||||
|
||||
Reference in New Issue
Block a user