feat(commons,desktop): inline AUTH approval banner with [Once] [Always] [Never]

Adds AuthApprovalBanner in commons.relayClient.auth — a Compose-
Multiplatform composable that renders one row per pending tier-2
NIP-42 AUTH challenge with three actions matching the AuthApprovalScope:

  [Once]    — sign this challenge, don't persist
  [Always]  — sign + persist ALWAYS via the store
  [Never]   — drop + persist BLOCKED via the store

Wired into desktop Main.kt as a global top-of-content banner reading
authCoordinator.pendingApprovals and calling authCoordinator.resolve.
Now tier-2 challenges actually have a UI to resolve — desktop AUTH is
end-to-end usable.

Up to 3 rows stack inline; the rest collapse into a "+N more pending"
row (click-to-expand can come later). Each row shows the relay's
display URL plus message-count when multiple challenges from the same
relay have coalesced.

The composable itself is in commons so Android picks it up free when
its AccountAuthApprovals VM wire-up lands — only the Main.kt-level
wiring (where to mount the banner in the layout) is platform-specific.

Lifecycle:
- Banner subscribes to pendingApprovals via collectAsState; recomposes
  only when the PersistentMap identity changes (per the substrate
  built in earlier commits).
- onResolve calls authCoordinator.resolve(url, scope), which completes
  the underlying CompletableDeferred + removes the entry from the
  pending map; the suspended signer wakes up and signs (or doesn't).
This commit is contained in:
nrobi144
2026-07-09 07:33:21 +03:00
parent 3ab3642757
commit 2f3805bbfa
3 changed files with 197 additions and 33 deletions
@@ -0,0 +1,156 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.relayClient.auth
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl
/**
* Inline AUTH approval banner.
*
* Renders one row per pending tier-2 NIP-42 AUTH challenge with three
* actions: `[Once]` `[Always]` `[Never]`. Each press calls [onResolve]
* with the user's choice, which the parent (typically a coordinator)
* uses to complete the underlying [PendingAuthApproval.decision]
* deferred and persist the scope.
*
* Stacks up to 3 entries inline; the rest collapse into a `+N more` row
* (a future iteration may expand them on click — keep simple for now).
*
* The component is platform-agnostic and lives in `commons` so Android
* and Desktop can render the same UX once the wire-up is built on each
* platform.
*/
@Composable
fun AuthApprovalBanner(
pending: List<PendingAuthApproval>,
onResolve: (NormalizedRelayUrl, AuthApprovalScope) -> Unit,
modifier: Modifier = Modifier,
) {
AnimatedVisibility(
visible = pending.isNotEmpty(),
enter = expandVertically() + fadeIn(),
exit = shrinkVertically() + fadeOut(),
modifier = modifier,
) {
Column(modifier = Modifier.fillMaxWidth()) {
val visible = pending.take(3)
val hidden = pending.size - visible.size
visible.forEach { approval ->
AuthApprovalRow(approval = approval, onResolve = onResolve)
}
if (hidden > 0) {
Surface(
color = MaterialTheme.colorScheme.surfaceVariant,
modifier = Modifier.fillMaxWidth(),
) {
Text(
text = "+$hidden more relay${if (hidden == 1) "" else "s"} pending approval",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp),
)
}
}
}
}
}
@Composable
private fun AuthApprovalRow(
approval: PendingAuthApproval,
onResolve: (NormalizedRelayUrl, AuthApprovalScope) -> Unit,
) {
Surface(
color = MaterialTheme.colorScheme.tertiaryContainer.copy(alpha = 0.6f),
modifier = Modifier.fillMaxWidth().background(MaterialTheme.colorScheme.tertiaryContainer.copy(alpha = 0.4f)),
) {
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
symbol = MaterialSymbols.Lock,
contentDescription = null,
tint = MaterialTheme.colorScheme.onTertiaryContainer,
modifier = Modifier.size(16.dp),
)
Spacer(Modifier.width(8.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = approval.relayUrl.displayUrl(),
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.onTertiaryContainer,
)
Text(
text =
if (approval.pendingCount > 1) {
"requires authentication for ${approval.pendingCount} messages"
} else {
"requires authentication to deliver this message"
},
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onTertiaryContainer.copy(alpha = 0.8f),
)
}
Spacer(Modifier.width(8.dp))
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
TextButton(onClick = { onResolve(approval.relayUrl, AuthApprovalScope.ONCE) }) {
Text("Once", style = MaterialTheme.typography.labelMedium)
}
TextButton(onClick = { onResolve(approval.relayUrl, AuthApprovalScope.ALWAYS) }) {
Text("Always", style = MaterialTheme.typography.labelMedium)
}
TextButton(onClick = { onResolve(approval.relayUrl, AuthApprovalScope.BLOCKED) }) {
Text("Never", style = MaterialTheme.typography.labelMedium)
}
}
}
}
}
@@ -77,6 +77,7 @@ import com.vitorpamplona.amethyst.commons.icons.symbols.ProvideMaterialSymbols
import com.vitorpamplona.amethyst.commons.moderation.LocalHashtagSpamSettings
import com.vitorpamplona.amethyst.commons.moderation.LocalSpamExemptKeys
import com.vitorpamplona.amethyst.commons.moderation.PreferencesHashtagSpamSettings
import com.vitorpamplona.amethyst.commons.relayClient.auth.AuthApprovalBanner
import com.vitorpamplona.amethyst.commons.relayClient.nip17Dm.unwrapAndUnsealOrNull
import com.vitorpamplona.amethyst.commons.wot.LocalWoTReady
import com.vitorpamplona.amethyst.commons.wot.LocalWoTService
@@ -1283,32 +1284,41 @@ private fun AppInner(
LocalNamecoinService provides namecoinService,
LocalSpamExemptKeys provides spamExemptKeys,
) {
MainContent(
layoutMode = layoutMode,
deckState = deckState,
workspaceManager = workspaceManager,
singlePaneState = singlePaneState,
pinnedNavBarState = pinnedNavBarState,
relayManager = relayManager,
localCache = localCache,
accountManager = accountManager,
account = account,
nwcConnection = nwcConnection,
subscriptionsCoordinator = subscriptionsCoordinator,
indexRelaysStore = indexRelaysStore,
nip11Fetcher = nip11Fetcher,
appScope = scope,
torStatus = currentTorStatus,
onShowComposeDialog = onShowComposeDialog,
onShowReplyDialog = onShowReplyDialog,
onShowAppDrawer = onShowAppDrawer,
onOpenFeedsDrawer = {
appDrawerInitialTab =
com.vitorpamplona.amethyst.desktop.ui.deck.AppDrawerTab.FEEDS
onShowAppDrawer()
},
onShowImportFollowListDialog = onShowImportFollowListDialog,
)
val pendingAuthApprovals by authCoordinator.pendingApprovals.collectAsState()
Column(modifier = Modifier.fillMaxSize()) {
AuthApprovalBanner(
pending = pendingAuthApprovals.values.toList(),
onResolve = { url, scope -> authCoordinator.resolve(url, scope) },
)
Box(modifier = Modifier.weight(1f)) {
MainContent(
layoutMode = layoutMode,
deckState = deckState,
workspaceManager = workspaceManager,
singlePaneState = singlePaneState,
pinnedNavBarState = pinnedNavBarState,
relayManager = relayManager,
localCache = localCache,
accountManager = accountManager,
account = account,
nwcConnection = nwcConnection,
subscriptionsCoordinator = subscriptionsCoordinator,
indexRelaysStore = indexRelaysStore,
nip11Fetcher = nip11Fetcher,
appScope = scope,
torStatus = currentTorStatus,
onShowComposeDialog = onShowComposeDialog,
onShowReplyDialog = onShowReplyDialog,
onShowAppDrawer = onShowAppDrawer,
onOpenFeedsDrawer = {
appDrawerInitialTab =
com.vitorpamplona.amethyst.desktop.ui.deck.AppDrawerTab.FEEDS
onShowAppDrawer()
},
onShowImportFollowListDialog = onShowImportFollowListDialog,
)
}
}
// Import Follow List dialog (triggered from File menu /
// Cmd+Shift+I). Rendered inside this CompositionLocalProvider
@@ -32,7 +32,6 @@ import com.vitorpamplona.quartz.nip01Core.relay.client.auth.RelayAuthenticator
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate
import com.vitorpamplona.quartz.nip42RelayAuth.RelayAuthEvent
import com.vitorpamplona.quartz.nip42RelayAuth.tags.RelayTag
import com.vitorpamplona.quartz.utils.Log
import kotlinx.collections.immutable.PersistentMap
import kotlinx.collections.immutable.persistentMapOf
@@ -106,8 +105,8 @@ class DesktopAuthCoordinator(
RelayAuthenticator(
client = relayManager.client,
scope = scope,
signWithAllLoggedInUsers = { template ->
val signed = signWithPolicy(account, template, policy)
signWithAllLoggedInUsers = { relayUrl, template ->
val signed = signWithPolicy(account, relayUrl, template, policy)
signed?.let { listOf(it) } ?: emptyList()
},
)
@@ -156,11 +155,11 @@ class DesktopAuthCoordinator(
private suspend fun signWithPolicy(
account: AccountState.LoggedIn,
relayUrl: NormalizedRelayUrl,
template: EventTemplate<RelayAuthEvent>,
policy: AuthApprovalPolicy,
): RelayAuthEvent? {
val relayUrl = template.tags.firstNotNullOfOrNull(RelayTag::parse) ?: return null
return when (val decision = policy.classify(relayUrl)) {
): RelayAuthEvent? =
when (val decision = policy.classify(relayUrl)) {
AuthApprovalDecision.Allow -> account.signer.sign(template)
AuthApprovalDecision.Block -> null
is AuthApprovalDecision.Pending -> {
@@ -171,7 +170,6 @@ class DesktopAuthCoordinator(
if (resolved == AuthApprovalScope.BLOCKED) null else account.signer.sign(template)
}
}
}
private data class ActiveAuth(
val pubKeyHex: String,