Skip to content

Conversation

nick-livefront
Copy link
Collaborator

@nick-livefront nick-livefront commented Oct 13, 2025

๐ŸŽŸ๏ธ Tracking

PM-24535

๐Ÿ“” Objective

Add steps for the user to upgrade to if they do not have access to premium:

  • Show premium badge in the more options menu
  • Show premium badge in left-hand filter menu
  • When the user does not have archived items and is not a premium user, clicking on the archive feature should show the upgrade modal.
  • When the user has archived items and is not a premium user the filter should allow them to view their archived items with a callout.

๐Ÿ“ธ Screenshots

No Premium Lost premium with archived items
no-premium.mov
lost-premium.mov

๐Ÿฆฎ Reviewer guidelines

  • ๐Ÿ‘ (:+1:) or similar for great changes
  • ๐Ÿ“ (:memo:) or โ„น๏ธ (:information_source:) for notes or general info
  • โ“ (:question:) for questions
  • ๐Ÿค” (:thinking:) or ๐Ÿ’ญ (:thought_balloon:) for more open inquiry that's not quite a confirmed issue and could potentially benefit from discussion
  • ๐ŸŽจ (:art:) for suggestions / improvements
  • โŒ (:x:) or โš ๏ธ (:warning:) for more significant problems or concerns needing attention
  • ๐ŸŒฑ (:seedling:) or โ™ป๏ธ (:recycle:) for future improvements or indications of technical debt
  • โ› (:pick:) for minor or nitpick changes

Comment on lines +50 to +55
premiumOptions?: {
/** When true, the premium badge will show on the filter for non-premium users. */
showPremiumBadge?: true;
/** Action to be called instead of applying the filter. */
blockFilterAction?: () => Promise<void>;
};
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I tried to make this somewhat reusable if it were to ever apply to other filters, but I'm not supper happy with the configuration options here. I'm open to ideas!

Copy link
Contributor

github-actions bot commented Oct 13, 2025

Logo
Checkmarx One โ€“ Scan Summary & Details โ€“ b7750172-6de0-4c84-9d6d-b77f86168181

Great job! No new security vulnerabilities introduced in this pull request

@nick-livefront nick-livefront force-pushed the vault/pm-24535/web-premium-archive branch from 1c1f901 to 759126f Compare October 13, 2025 16:50
Copy link

claude bot commented Oct 13, 2025

Claude finished @nick-livefront's task โ€”โ€” View job


Code Review Complete

Summary of Changes

This PR implements a premium upgrade path for the archive feature in the web client. It adds:

  • Premium badge display in vault filter and cipher row menus
  • Premium upgrade prompts when non-premium users try to access archive
  • Access to archived items for users who lost premium (view-only with messaging)
  • Archive/unarchive functionality with premium checks
  • New service methods and observables for managing archive state

Critical Issues

1. Type Safety Violation in Filter Configuration

File: apps/web/src/app/vault/individual-vault/vault-filter/shared/models/vault-filter-section.type.ts:47

options?: {
  component: any;  // โŒ Using 'any' type
};

Issue: Using any defeats TypeScript's type safety. This is especially problematic for a component reference.

Recommendation:

options?: {
  component: Type<unknown>;  // Import Type from @angular/core
};

2. Conditional Logic Inconsistency

File: apps/web/src/app/vault/individual-vault/vault-filter/components/vault-filter.component.ts:424

const premiumPromptOnFilter = !userHasPremium && !hasArchivedCiphers;

Issue: The logic determines when to block the filter action, but the condition is confusing. If a user has no premium AND no archived ciphers, they're blocked. However, if they have archived ciphers but no premium, they're NOT blocked (they can view). This works correctly but could be clearer.

Recommendation: Add a comment explaining the business logic:

// Block archive filter if user lacks premium AND has no archived items
// Users who lost premium but have archived items can still view them
const premiumPromptOnFilter = !userHasPremium && !hasArchivedCiphers;

3. Missing Error Handling in Archive Service

File: libs/common/src/vault/services/default-cipher-archive.service.ts:82-101 and 103-122

async archiveWithServer(ids: CipherId | CipherId[], userId: UserId): Promise<void> {
  const request = new CipherBulkArchiveRequest(Array.isArray(ids) ? ids : [ids]);
  const r = await this.apiService.send("PUT", "/ciphers/archive", request, true, true);
  // No try-catch, errors propagate to caller

Issue: No error handling for API failures. If the API call fails, the local cipher state won't be updated, leading to inconsistency.

Recommendation: Add error handling or document that callers must handle errors:

async archiveWithServer(ids: CipherId | CipherId[], userId: UserId): Promise<void> {
  try {
    const request = new CipherBulkArchiveRequest(Array.isArray(ids) ? ids : [ids]);
    const r = await this.apiService.send("PUT", "/ciphers/archive", request, true, true);
    const response = new ListResponse(r, CipherResponse);
    
    const currentCiphers = await firstValueFrom(this.cipherService.ciphers$(userId));
    // ... update logic
    await this.cipherService.replace(currentCiphers, userId);
  } catch (error) {
    // Re-throw with context or handle appropriately
    throw new Error(`Failed to archive ciphers: ${error.message}`);
  }
}

4. Premium Badge Template Reference Without Safety Check

File: apps/web/src/app/vault/components/vault-items/vault-cipher-row.component.html:162

<button bitMenuItem (click)="badge.promptForPremium()" type="button">
  <i class="bwi bwi-fw bwi-archive" aria-hidden="true"></i>
  {{ "archiveVerb" | i18n }}
  <div slot="end" class="-tw-mt-0.5" aria-hidden>
    <app-premium-badge #badge></app-premium-badge>
  </div>
</button>

Issue: The template reference #badge is used to call badge.promptForPremium() but there's no null check. If the component hasn't rendered yet, this could fail.

Recommendation: Add safe navigation or ensure the component is always rendered:

<button bitMenuItem (click)="badge?.promptForPremium()" type="button">

Or better, handle this in the component TypeScript:

@ViewChild('badge') badgeComponent: PremiumBadgeComponent;

protected promptForPremium() {
  this.badgeComponent?.promptForPremium();
}

Suggested Improvements

5. Configuration Design Concern (Author's Question)

File: apps/web/src/app/vault/individual-vault/vault-filter/shared/models/vault-filter-section.type.ts:50-55

The author asked about the reusability of premiumOptions configuration. Current implementation:

premiumOptions?: {
  /** When true, the premium badge will show on the filter for non-premium users. */
  showPremiumBadge?: true;  // โŒ Type is literally `true`, not boolean
  /** Action to be called instead of applying the filter. */
  blockFilterAction?: () => Promise<void>;
};

Issues:

  1. showPremiumBadge?: true is not a boolean type - it can only be true or undefined, never false
  2. The naming is confusing: "block" vs "show" semantics
  3. Limited reusability due to tight coupling to premium features

Recommendation: Make it more generic and flexible:

featureGate?: {
  /** Badge to display when feature is gated (e.g., premium, enterprise) */
  showBadge?: boolean;
  /** Badge type/variant if needed */
  badgeType?: 'premium' | 'enterprise' | 'custom';
  /** Action to execute instead of the default filter action when feature is gated */
  gatedAction?: () => Promise<void>;
  /** Optional predicate to determine if feature should be gated */
  shouldGate?: () => boolean | Observable<boolean>;
};

This would support:

  • Different badge types (not just premium)
  • Runtime gate conditions
  • Clear action vs. badge separation
  • Better extensibility for future feature gates

6. Observable Subscription Management

File: apps/web/src/app/vault/individual-vault/vault-filter/shared/components/vault-filter-section.component.ts:92-99

async onFilterSelect(filterNode: TreeNode<VaultFilterType>) {
  if (this.section?.premiumOptions?.blockFilterAction) {
    await this.section.premiumOptions.blockFilterAction();
    return;
  }
  await this.section?.action(filterNode);
}

Observation: Good implementation, but consider adding loading state to prevent double-clicks during async operations.

Recommendation:

protected isProcessing = false;

async onFilterSelect(filterNode: TreeNode<VaultFilterType>) {
  if (this.isProcessing) return;
  
  this.isProcessing = true;
  try {
    if (this.section?.premiumOptions?.blockFilterAction) {
      await this.section.premiumOptions.blockFilterAction();
      return;
    }
    await this.section?.action(filterNode);
  } finally {
    this.isProcessing = false;
  }
}

7. Duplicate Observable Pipe Operations

File: apps/web/src/app/vault/individual-vault/vault-filter/components/vault-filter.component.ts:415-423

const hasArchivedCiphers = await firstValueFrom(
  this.cipherArchiveService
    .archivedCiphers$(userId)
    .pipe(map((archivedCiphers) => archivedCiphers.length > 0)),
);

const userHasPremium = await firstValueFrom(this.cipherArchiveService.userHasPremium$(userId));

Issue: Both of these observables are re-evaluated every time addArchiveFilter is called. Consider caching or combining these checks.

Recommendation: Create a combined observable at the service level:

archiveFilterConfig$(userId: UserId): Observable<{hasArchivedCiphers: boolean, hasPremium: boolean}> {
  return combineLatest([
    this.archivedCiphers$(userId).pipe(map(c => c.length > 0)),
    this.userHasPremium$(userId)
  ]).pipe(
    map(([hasArchived, hasPremium]) => ({ hasArchivedCiphers: hasArchived, hasPremium })),
    shareReplay({ refCount: true, bufferSize: 1 })
  );
}

8. Accessibility Improvement Needed

File: apps/web/src/app/vault/individual-vault/vault-filter/shared/components/vault-filter-section.component.html:108-110

<ng-container *ngIf="premiumFeature">
  <app-premium-badge></app-premium-badge>
</ng-container>

Issue: The premium badge appears in the filter UI without screen reader context.

Recommendation: Add aria-label or accessible text:

<ng-container *ngIf="premiumFeature">
  <app-premium-badgepremiumFeatureRequired' | i18n }}"></app-premium-badge>
</ng-container>

9. Inconsistent Naming Conventions

File: Multiple locations

The PR uses both "Archive" (noun) and "archive" (verb) but the distinction isn't always clear in variable names:

  • archiveEnabled - is this about the feature or the action?
  • showArchiveButton vs showUnArchiveButton - inconsistent capitalization
  • bulkArchive() vs bulkUnarchive() - better as bulkArchiveItems() / bulkUnarchiveItems()

Recommendation: Use consistent naming:

  • archiveFeatureEnabled or canArchive for capability
  • showArchiveAction / showUnarchiveAction for UI
  • bulkArchiveItems() / bulkUnarchiveItems() for methods

10. Missing Test Coverage for Edge Cases

File: libs/common/src/vault/services/default-cipher-archive.service.spec.ts

Good: Comprehensive tests exist for main flows

Missing: Tests for edge cases:

  1. What happens if archiveWithServer receives an empty array?
  2. What if the API returns ciphers not in the local cache?
  3. What if revisionDate is missing in the response?

Recommendation: Add edge case tests:

it("should handle empty array gracefully", async () => {
  await service.archiveWithServer([], userId);
  expect(mockApiService.send).toHaveBeenCalledWith(
    "PUT",
    "/ciphers/archive",
    expect.objectContaining({ ids: [] }),
    true,
    true
  );
});

it("should skip ciphers not found locally", async () => {
  const unknownCipherId = "unknown-id" as CipherId;
  mockApiService.send.mockResolvedValue({
    data: [{ id: unknownCipherId, archivedDate: "2024-01-15" }]
  });
  mockCipherService.ciphers$.mockReturnValue(of({}));
  
  await service.archiveWithServer(unknownCipherId, userId);
  
  // Should not throw, should complete successfully
  expect(mockCipherService.replace).toHaveBeenCalled();
});

11. Performance Consideration

File: apps/web/src/app/vault/individual-vault/vault-filter/components/vault-filter.component.ts:254-256

if (await firstValueFrom(this.cipherArchiveService.showArchiveFeatures$())) {
  builderFilter.archiveFilter = await this.addArchiveFilter(userId);
}

Issue: This check happens on every filter rebuild. The feature flag likely doesn't change during a session, so this could be cached.

Recommendation: Store the feature flag result once at initialization:

private archiveFeatureEnabled$: Observable<boolean>;

async ngOnInit(): Promise<void> {
  this.archiveFeatureEnabled$ = this.cipherArchiveService
    .showArchiveFeatures$()
    .pipe(shareReplay({ refCount: false, bufferSize: 1 }));
  
  // ... rest of init
}

async buildAllFilters(): Promise<VaultFilterList> {
  // ...
  if (await firstValueFrom(this.archiveFeatureEnabled$)) {
    builderFilter.archiveFilter = await this.addArchiveFilter(userId);
  }
  // ...
}

12. Potential Memory Leak in Browser Component

File: apps/browser/src/vault/popup/settings/vault-settings-v2.component.ts:40-53

protected userCanArchive = toSignal(
  this.userId$.pipe(switchMap((userId) => this.cipherArchiveService.userCanArchive$(userId))),
);

protected showArchiveFilter = toSignal(
  this.userId$.pipe(
    switchMap((userId) =>
      this.cipherArchiveService
        .archivedCiphers$(userId)
        .pipe(map((ciphers) => ciphers.length > 0)),
    ),
  ),
);

Issue: toSignal() creates subscriptions that may not be properly cleaned up if the component is destroyed before the signal completes.

Recommendation: Use toSignal() with cleanup options or use takeUntilDestroyed():

protected userCanArchive = toSignal(
  this.userId$.pipe(
    switchMap((userId) => this.cipherArchiveService.userCanArchive$(userId)),
    takeUntilDestroyed()
  ),
  { requireSync: false }
);

Good Practices Observed

  • โœ… Comprehensive unit tests for the archive service
  • โœ… Proper use of RxJS observables for reactive state management (ADR-0003)
  • โœ… Internationalization for all UI strings
  • โœ… Accessibility attributes (aria-labels) in most places
  • โœ… Proper separation of concerns (service layer, component layer)
  • โœ… Feature flag usage for gradual rollout
  • โœ… Password reprompt integration for sensitive operations

Action Items for Author

Must Fix (Blocking)

  1. Replace any type in vault-filter-section.type.ts:47 with proper typing
  2. Add error handling to archive/unarchive service methods
  3. Fix showPremiumBadge?: true type to showPremiumBadge?: boolean

Should Fix (High Priority)

  1. Add safe navigation for badge.promptForPremium() template reference
  2. Improve premiumOptions configuration design for better reusability
  3. Add loading state to prevent double-clicks in filter selection
  4. Add missing accessibility labels for premium badges

Nice to Have (Low Priority)

  1. Refactor naming conventions for consistency (archive vs Archive)
  2. Add edge case tests to archive service
  3. Optimize observable subscriptions and feature flag checks
  4. Add takeUntilDestroyed() to signals in browser component

Regarding Configuration Design (Author's Concern)

Your concern about the premiumOptions configuration in vault-filter-section.type.ts:55 is valid. The current implementation is functional but not very reusable. See Suggested Improvement #5 above for a more generic featureGate approach that would:

  • Support multiple feature types (not just premium)
  • Allow runtime conditions
  • Be more extensible for future features

However, if this is the only use case for now, the current implementation is acceptable with the type fixes mentioned in Must Fix #3.


Copy link

Copy link

codecov bot commented Oct 13, 2025

Codecov Report

โŒ Patch coverage is 9.09091% with 40 lines in your changes missing coverage. Please review.
โœ… Project coverage is 38.91%. Comparing base (d7479e0) to head (759126f).

Files with missing lines Patch % Lines
.../vault-filter/components/vault-filter.component.ts 0.00% 11 Missing โš ๏ธ
.../src/app/vault/individual-vault/vault.component.ts 0.00% 10 Missing โš ๏ธ
...lt/components/vault-items/vault-items.component.ts 0.00% 5 Missing โš ๏ธ
...mponents/vault-items/vault-cipher-row.component.ts 0.00% 4 Missing โš ๏ธ
...hared/components/vault-filter-section.component.ts 0.00% 4 Missing โš ๏ธ
...ault/popup/settings/vault-settings-v2.component.ts 0.00% 3 Missing โš ๏ธ
...collections/vault-filter/vault-filter.component.ts 0.00% 1 Missing โš ๏ธ
...vault/components/vault-items/vault-items.module.ts 0.00% 1 Missing โš ๏ธ
.../vault-filter/shared/vault-filter-shared.module.ts 0.00% 1 Missing โš ๏ธ
Additional details and impacted files
@@                   Coverage Diff                    @@
##           PM-19152-web-archive   #16854      +/-   ##
========================================================
- Coverage                 38.92%   38.91%   -0.02%     
========================================================
  Files                      3437     3437              
  Lines                     97593    97622      +29     
  Branches                  14681    14686       +5     
========================================================
+ Hits                      37989    37990       +1     
- Misses                    57944    57972      +28     
  Partials                   1660     1660              

โ˜” View full report in Codecov by Sentry.
๐Ÿ“ข Have feedback on the report? Share it here.

๐Ÿš€ New features to boost your workflow:
  • ๐Ÿ“ฆ JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant