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") @POST("v1/admin/product-keys")
suspend fun createProductKey(@Body body: CreateProductKeyRequest): ProductKeyResponse 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, val expiresAtSec: Long,
) )
data class RevokeLicenseRequest(
val sub: String,
)
data class RevokeLicenseResponse(
val ok: Boolean,
)
data class ApiErrorBody( data class ApiErrorBody(
@SerializedName("error") val error: String?, @SerializedName("error") val error: String?,
) )
@@ -60,6 +60,10 @@ class LicenseRepository(
createApi(settings).createProductKey(request) 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 { private fun createApi(settings: AppSettings): LicenseApi {
require(settings.adminToken.isNotBlank()) { "Укажите admin token в настройках" } require(settings.adminToken.isNotBlank()) { "Укажите admin token в настройках" }
val baseUrl = settings.serverUrl val baseUrl = settings.serverUrl
@@ -23,6 +23,7 @@ import java.time.ZoneOffset
data class LicenseListUiState( data class LicenseListUiState(
val licenses: List<LicenseEntry> = emptyList(), val licenses: List<LicenseEntry> = emptyList(),
val isLoading: Boolean = false, val isLoading: Boolean = false,
val revokingSub: String? = null,
val error: 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) { fun updatePid(value: String) {
_generateState.update { it.copy(pid = value, error = null) } _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.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding 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.PullRefreshIndicator
import androidx.compose.material.pullrefresh.pullRefresh import androidx.compose.material.pullrefresh.pullRefresh
import androidx.compose.material.pullrefresh.rememberPullRefreshState import androidx.compose.material.pullrefresh.rememberPullRefreshState
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue 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.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontFamily
@@ -36,6 +43,7 @@ import ru.mailib.ttrpg.licensemanager.ui.formatEpochSec
@Composable @Composable
fun LicenseListScreen(viewModel: LicenseViewModel) { fun LicenseListScreen(viewModel: LicenseViewModel) {
val state by viewModel.listState.collectAsStateWithLifecycle() val state by viewModel.listState.collectAsStateWithLifecycle()
var confirmRevoke by remember { mutableStateOf<LicenseEntry?>(null) }
val refreshState = rememberPullRefreshState( val refreshState = rememberPullRefreshState(
refreshing = state.isLoading, refreshing = state.isLoading,
onRefresh = viewModel::loadLicenses, onRefresh = viewModel::loadLicenses,
@@ -45,6 +53,31 @@ fun LicenseListScreen(viewModel: LicenseViewModel) {
viewModel.loadLicenses() 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( Box(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
@@ -75,8 +108,17 @@ fun LicenseListScreen(viewModel: LicenseViewModel) {
contentPadding = PaddingValues(16.dp), contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp), verticalArrangement = Arrangement.spacedBy(12.dp),
) { ) {
if (state.error != null) {
item(key = "error") {
ErrorMessage(message = state.error ?: "")
}
}
items(state.licenses, key = { it.sub }) { license -> 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 @Composable
private fun LicenseCard(license: LicenseEntry) { private fun LicenseCard(
license: LicenseEntry,
isRevoking: Boolean,
onRevoke: () -> Unit,
) {
Card( Card(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors( colors = CardDefaults.cardColors(
@@ -117,6 +163,20 @@ private fun LicenseCard(license: LicenseEntry) {
if (license.activatedDevices.isNotEmpty()) { if (license.activatedDevices.isNotEmpty()) {
DetailRow("deviceId", license.activatedDevices.joinToString(", ")) 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"?> <?xml version="1.0" encoding="utf-8"?>
<resources> <resources>
<string name="app_name">TTRPG License Manager</string> <string name="app_name">Менеджер лицензий TTRPG</string>
<string name="tab_licenses">Лицензии</string> <string name="tab_licenses">Лицензии</string>
<string name="tab_generate">Новый ключ</string> <string name="tab_generate">Новый ключ</string>
<string name="settings">Настройки</string> <string name="settings">Настройки</string>
+33
View File
@@ -0,0 +1,33 @@
# Install Android SDK components for headless builds.
$ErrorActionPreference = "Stop"
$sdkRoot = "$env:LOCALAPPDATA\Android\Sdk"
$cmdline = Join-Path $sdkRoot "cmdline-tools\latest\bin"
$sdkmanager = Join-Path $cmdline "sdkmanager.bat"
if (-not (Test-Path $sdkmanager)) {
Write-Host "Downloading Android command-line tools..."
New-Item -ItemType Directory -Force -Path "$sdkRoot\cmdline-tools" | Out-Null
$zip = Join-Path $env:TEMP "cmdline-tools.zip"
Invoke-WebRequest -Uri "https://dl.google.com/android/repository/commandlinetools-win-11076708_latest.zip" -OutFile $zip
Expand-Archive -Path $zip -DestinationPath "$sdkRoot\cmdline-tools\tmp" -Force
if (Test-Path "$sdkRoot\cmdline-tools\latest") {
Remove-Item "$sdkRoot\cmdline-tools\latest" -Recurse -Force
}
Move-Item "$sdkRoot\cmdline-tools\tmp\cmdline-tools" "$sdkRoot\cmdline-tools\latest"
Remove-Item "$sdkRoot\cmdline-tools\tmp" -Recurse -Force
}
Write-Host "Installing SDK packages..."
$packages = @(
"platform-tools",
"platforms;android-35",
"build-tools;35.0.0"
)
foreach ($pkg in $packages) {
cmd /c "echo y| `"$sdkmanager`" `"$pkg`""
}
Write-Host "ANDROID_HOME=$sdkRoot"
Write-Host "Done."