Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
123 changes: 123 additions & 0 deletions docs/superpowers/plans/2026-03-22-divine-login-banner-dismissal-ttl.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
# Divine Login Banner Dismissal TTL Implementation Plan

> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Make the profile session-expired banner snooze for 30 days instead of forever, and clear the snooze when Divine OAuth recovery succeeds.

**Architecture:** Add a tiny shared helper for the dismissal key and TTL logic, then reuse it from the profile header and auth service. Cover the behavior with one widget regression slice and one auth regression slice before implementation.

**Tech Stack:** Flutter, Riverpod, SharedPreferences, Flutter widget tests, auth service unit tests

---

## Chunk 1: TTL Read/Write Behavior

### Task 1: Add the failing widget regressions

**Files:**
- Modify: `mobile/test/widgets/profile/profile_header_widget_test.dart`

- [ ] **Step 1: Write the failing tests**

Add tests for:
- a recent dismissal timestamp hides the expired-session banner
- a dismissal older than 30 days shows the banner again

- [ ] **Step 2: Run the widget test file to verify the new case fails**

Run: `cd mobile && flutter test test/widgets/profile/profile_header_widget_test.dart`
Expected: the new TTL expectation fails because the current code treats dismissal as permanent.

### Task 2: Implement the TTL helper and widget update

**Files:**
- Create: `mobile/lib/utils/divine_login_banner_dismissal.dart`
- Modify: `mobile/lib/widgets/profile/profile_header_widget.dart`

- [ ] **Step 3: Add the minimal helper**

Add:
- per-pubkey preference key builder
- 30-day TTL constant
- `isDismissed(...)`
- `dismiss(...)`
- `clear(...)`

- [ ] **Step 4: Update the profile header to use the helper**

Read the active dismissal via the helper and write a timestamp on dismiss instead of a boolean.

- [ ] **Step 5: Re-run the widget test file**

Run: `cd mobile && flutter test test/widgets/profile/profile_header_widget_test.dart`
Expected: the TTL tests pass.

## Chunk 2: Shared Helper Coverage And Auth Reset

### Task 3: Add the failing helper regression

**Files:**
- Create: `mobile/test/utils/divine_login_banner_dismissal_test.dart`

- [ ] **Step 6: Write the failing helper test**

Cover:
- dismissal remains active within 30 days
- dismissal expires after 30 days
- clear removes the stored dismissal

- [ ] **Step 7: Run the helper test file to verify it fails**

Run: `cd mobile && flutter test test/utils/divine_login_banner_dismissal_test.dart`
Expected: compilation or test failure because the helper does not exist yet.

### Task 4: Clear dismissal on auth recovery

**Files:**
- Modify: `mobile/lib/services/auth_service.dart`
- Reuse: `mobile/lib/utils/divine_login_banner_dismissal.dart`

- [ ] **Step 8: Add the minimal auth-side reset**

Clear the stored dismissal when:
- a silent refresh succeeds
- Divine OAuth sign-in establishes the session for a pubkey

- [ ] **Step 9: Re-run the helper test file**

Run: `cd mobile && flutter test test/utils/divine_login_banner_dismissal_test.dart`
Expected: the helper regression passes, and the auth service compiles cleanly against it.

## Chunk 3: Focused Verification

### Task 5: Verify the final touched set

**Files:**
- Verify: `mobile/lib/widgets/profile/profile_header_widget.dart`
- Verify: `mobile/lib/services/auth_service.dart`
- Verify: `mobile/lib/utils/divine_login_banner_dismissal.dart`
- Verify: `mobile/test/widgets/profile/profile_header_widget_test.dart`
- Verify: `mobile/test/utils/divine_login_banner_dismissal_test.dart`

- [ ] **Step 10: Run focused analyzer**

Run: `cd mobile && flutter analyze --no-pub lib/widgets/profile/profile_header_widget.dart lib/services/auth_service.dart lib/utils/divine_login_banner_dismissal.dart test/widgets/profile/profile_header_widget_test.dart test/utils/divine_login_banner_dismissal_test.dart`
Expected: no issues found.

- [ ] **Step 11: Run focused tests together**

Run: `cd mobile && flutter test test/utils/divine_login_banner_dismissal_test.dart test/widgets/profile/profile_header_widget_test.dart`
Expected: all tests pass.

- [ ] **Step 12: Commit**

```bash
git add docs/superpowers/specs/2026-03-22-divine-login-banner-dismissal-ttl-design.md \
docs/superpowers/plans/2026-03-22-divine-login-banner-dismissal-ttl.md \
mobile/lib/utils/divine_login_banner_dismissal.dart \
mobile/lib/widgets/profile/profile_header_widget.dart \
mobile/lib/services/auth_service.dart \
mobile/test/widgets/profile/profile_header_widget_test.dart \
mobile/test/utils/divine_login_banner_dismissal_test.dart
git commit -m "fix(profile): expire dismissed session banner"
```
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
# Divine Login Banner Dismissal TTL Design

## Goal

Make the dismissible "Session Expired" banner on the profile page come back after 30 days if the account is still expired, and clear the dismissal as soon as the user successfully restores their Divine OAuth session.

## Current Problem

PR `#2214` stores a permanent boolean dismissal keyed by pubkey. Once dismissed, the banner never appears again for that account because nothing resets the preference when auth recovers or when enough time passes.

## Design

Store dismissal as a timestamp instead of a boolean. The profile banner should be hidden only when:

- the current account has an expired OAuth session, and
- a stored dismissal timestamp exists for that account, and
- that dismissal is less than 30 days old.

If the dismissal is missing or older than 30 days, the banner should render again.

Use one shared helper for:

- generating the per-pubkey preference key
- reading whether the dismissal is still active
- writing the current dismissal timestamp
- clearing the dismissal after auth recovery

## Reset Behavior

Clear the dismissal when the app successfully restores a valid Divine OAuth session:

- after a successful silent refresh path
- after a successful Divine OAuth sign-in path

This keeps dismissal scoped to the current expired-session incident rather than muting future incidents forever.

## Tests

Add regression coverage for:

- dismissal hides the banner within 30 days
- dismissal older than 30 days allows the banner to show again
- successful expired-session refresh clears the stored dismissal key
19 changes: 10 additions & 9 deletions mobile/lib/screens/settings/settings_screen.dart
Original file line number Diff line number Diff line change
Expand Up @@ -176,15 +176,7 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
if (isAuthenticated) ...[
_AccountHeader(onSwitchAccount: _handleSwitchAccount),
// Auth-state conditional tiles
if (authService.hasExpiredOAuthSession)
_SettingsTile(
icon: Icons.refresh,
title: 'Session Expired',
subtitle: 'Sign in again to restore full access',
onTap: _handleSessionExpired,
iconColor: VineTheme.accentOrange,
)
else if (authService.isAnonymous)
if (authService.isAnonymous)
_SettingsTile(
icon: Icons.security,
title: 'Secure Your Account',
Expand All @@ -193,6 +185,15 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
'account on any device',
onTap: () => context.push(SecureAccountScreen.path),
),
if (!authService.isAnonymous &&
authService.hasExpiredOAuthSession)
_SettingsTile(
icon: Icons.refresh,
title: 'Session Expired',
subtitle: 'Sign in again to restore full access',
onTap: _handleSessionExpired,
iconColor: VineTheme.accentOrange,
),
],

_SettingsTile(
Expand Down
23 changes: 23 additions & 0 deletions mobile/lib/services/auth_service.dart
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import 'package:openvine/services/crash_reporting_service.dart';
import 'package:openvine/services/pending_verification_service.dart';
import 'package:openvine/services/relay_discovery_service.dart';
import 'package:openvine/services/user_data_cleanup_service.dart';
import 'package:openvine/utils/divine_login_banner_dismissal.dart';
import 'package:openvine/utils/nostr_key_utils.dart';
import 'package:openvine/utils/nostr_timestamp.dart';
import 'package:openvine/utils/unified_logger.dart';
Expand Down Expand Up @@ -295,6 +296,7 @@ class AuthService implements BackgroundAwareService {
category: LogCategory.auth,
);
await refreshed.save(_flutterSecureStorage);
await _clearDismissedDivineLoginBannerForCurrentUser();
await signInWithDivineOAuth(refreshed);
return true;
}
Expand All @@ -308,6 +310,18 @@ class AuthService implements BackgroundAwareService {
return false;
}

Future<void> _clearDismissedDivineLoginBannerForCurrentUser([
String? publicKeyHex,
]) async {
final prefs = await SharedPreferences.getInstance();
final targetPubkey =
publicKeyHex ?? prefs.getString('current_user_pubkey_hex');
if (targetPubkey == null || targetPubkey.isEmpty) {
return;
}
await clearDivineLoginBannerDismissal(prefs, targetPubkey);
}

/// Get discovered user relays (NIP-65)
List<DiscoveredRelay> get userRelays => List.unmodifiable(_userRelays);

Expand Down Expand Up @@ -2277,6 +2291,7 @@ class AuthService implements BackgroundAwareService {

final prefs = await SharedPreferences.getInstance();
await prefs.setString('current_user_pubkey_hex', publicKeyHex);
await _clearDismissedDivineLoginBannerForCurrentUser(publicKeyHex);

Log.info(
'✅ Divine oauth listener setting auth state to authenticated.',
Expand Down Expand Up @@ -2951,6 +2966,14 @@ class AuthService implements BackgroundAwareService {
_currentKeyContainer = keyContainer;
_authSource = source;

// This flag only applies to degraded Divine OAuth sessions. Any switch to
// a local-key, Amber, or bunker-backed session should clear the stale
// expired-session state so the UI does not inherit "Session Expired"
// across auth-mode changes.
if (source != AuthenticationSource.divineOAuth) {
_hasExpiredOAuthSession = false;
}

// Clear any stale remote signers that don't match the new auth source.
// This prevents a Keycast RPC signer from a previous Divine OAuth session
// from being used when signing events for an anonymous/imported-key account.
Expand Down
41 changes: 41 additions & 0 deletions mobile/lib/utils/divine_login_banner_dismissal.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import 'package:shared_preferences/shared_preferences.dart';

const Duration divineLoginBannerDismissalTtl = Duration(days: 30);
const _dismissedDivineLoginBannerPrefix = 'dismissed_divine_login_banner_';

String divineLoginBannerDismissalKey(String userIdHex) =>
'$_dismissedDivineLoginBannerPrefix$userIdHex';

bool isDivineLoginBannerDismissed(
SharedPreferences prefs,
String userIdHex, {
DateTime? now,
}) {
final rawValue = prefs.get(divineLoginBannerDismissalKey(userIdHex));
if (rawValue is! int) {
return false;
}

final dismissedAt = DateTime.fromMillisecondsSinceEpoch(rawValue);
final comparisonTime = now ?? DateTime.now();
return comparisonTime.difference(dismissedAt) < divineLoginBannerDismissalTtl;
}

Future<void> dismissDivineLoginBanner(
SharedPreferences prefs,
String userIdHex, {
DateTime? now,
}) {
final dismissedAt = now ?? DateTime.now();
return prefs.setInt(
divineLoginBannerDismissalKey(userIdHex),
dismissedAt.millisecondsSinceEpoch,
);
}

Future<void> clearDivineLoginBannerDismissal(
SharedPreferences prefs,
String userIdHex,
) {
return prefs.remove(divineLoginBannerDismissalKey(userIdHex));
}
Loading
Loading