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")
|
@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,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>
|
||||||
|
|||||||
@@ -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