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