Skip to content

[PM-42110] feat: Make a copy of a policy-restricted Send - #7276

Merged
andrebispo5 merged 12 commits into
mainfrom
PM-42110-make-a-copy-of-a-policy-restricted-send
Aug 20, 2026
Merged

[PM-42110] feat: Make a copy of a policy-restricted Send#7276
andrebispo5 merged 12 commits into
mainfrom
PM-42110-make-a-copy-of-a-policy-restricted-send

Conversation

@andrebispo5

@andrebispo5 andrebispo5 commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

🎟️ Tracking

https://bitwarden.atlassian.net/browse/PM-42110

📔 Objective

Adds a "Make a copy" action to the policy restriction banner. It opens the Add Send screen prefilled
from the restricted Send, with the fields the policy enforces already set, so the user can create a
compliant replacement.

Copy mode

  • New ModeType.COPY and AddEditSendType.CopyItem. A copy loads like an edit does, then behaves
    like an add for everything else: it saves with createSend, so the original is never touched
  • Carried over: name, notes, text content, hide-text-by-default, max access count, recipients
  • Reset or dropped: password (never readable by design), deactivate, share URL, access count,
    expiration
  • From policy: deletion window and access type, reusing the helpers from PM-40531 and PM-40533
  • Recipients outside the policy's allowed domains are carried over anyway. The user gets the
    existing save-time error naming the allowed domains, which beats silently dropping recipients

The button

Worth flagging

  • isAddMode changed from "is AddItem" to "is not EditItem". I traced all 14 consumers: the
    overflow menu, delete button and current-access-count row all correctly hide for a copy, and both
    policy paths now apply to copies, which is what you want if the policy changes while the form is
    open
  • The four when blocks over AddEditSendType are each handled explicitly rather than with an
    else, so a future mode cannot slip through

Note for reviewers

Sixth in the stack: main <- #7266 <- #7269 <- #7272 <- #7275 <- this. Please review #7275 first.
The diff here is only the last six commits.

📸 Screenshots

Screen.Recording.2026-08-18.at.09.38.30.mov

@github-actions github-actions Bot added app:password-manager Bitwarden Password Manager app context app:authenticator Bitwarden Authenticator app context t:feature Change Type - Feature Development labels Aug 17, 2026
@codecov

codecov Bot commented Aug 17, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 85.45455% with 16 lines in your changes missing coverage. Please review.
✅ Project coverage is 86.30%. Comparing base (9887838) to head (df61e4c).
⚠️ Report is 1 commits behind head on main.

Files with missing lines Patch % Lines
...ls/feature/send/addedit/util/SendViewExtensions.kt 84.61% 1 Missing and 7 partials ⚠️
...tools/feature/send/addedit/AddEditSendViewModel.kt 83.33% 2 Missing and 3 partials ⚠️
...i/tools/feature/send/addedit/AddEditSendContent.kt 66.66% 0 Missing and 2 partials ⚠️
...ools/feature/send/addedit/AddEditSendNavigation.kt 50.00% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #7276      +/-   ##
==========================================
+ Coverage   86.07%   86.30%   +0.22%     
==========================================
  Files        1024      891     -133     
  Lines       67998    65479    -2519     
  Branches     9904     9857      -47     
==========================================
- Hits        58530    56511    -2019     
+ Misses       5940     5466     -474     
+ Partials     3528     3502      -26     
Flag Coverage Δ
app-data 17.83% <0.00%> (-0.17%) ⬇️
app-ui-auth-tools 18.96% <85.45%> (-0.25%) ⬇️
app-ui-platform 16.37% <0.90%> (-0.48%) ⬇️
app-ui-vault 27.29% <0.90%> (-0.59%) ⬇️
authenticator 6.09% <0.00%> (-0.01%) ⬇️
lib-core-network-bridge 4.08% <0.00%> (-0.01%) ⬇️
lib-data-ui 1.20% <0.00%> (-0.01%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@andrebispo5
andrebispo5 marked this pull request as ready for review August 17, 2026 18:59
@andrebispo5
andrebispo5 requested review from a team and david-livefront as code owners August 17, 2026 18:59
Copilot AI lite review requested due to automatic review settings August 17, 2026 18:59
@github-actions

github-actions Bot commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

🤖 Bitwarden Claude Code Review

Overall Assessment: REQUEST CHANGES

Reviewed the new copy mode for the add/edit Send screen (ModeType.COPY, AddEditSendType.CopyItem, toCopyViewState) and the "Make a copy" action on the view Send policy restriction banner. Traced the isAddMode change from "is AddItem" to "is not EditItem" across all consumers — the overflow menu, delete button, current-access-count row, deletion date chooser and both policy paths all behave correctly for a copy. Navigation, save routing to createSend, the isCopyable gating for file and disallowed-type Sends, and the accompanying test coverage all look sound. One finding on how a password-protected Send is carried into the copy.

Code Review Details
  • ⚠️ : A copy of a password-protected Send preselects Password access with an empty password field, and save has no blank-password check, so it can create a Send with authType = PASSWORD and no password
    • app/src/main/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/addedit/AddEditSendViewModel.kt:562

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

This PR adds a “Make a copy” call-to-action to the SendControls policy restriction banner on View Send, and introduces a copy mode for the Add/Edit Send screen that pre-fills a new Send from a restricted one while ensuring the original is never modified.

Changes:

  • Adds ModeType.COPY / AddEditSendType.CopyItem and routes View Send → Add/Edit Send in copy mode via a new NavigateToCopy event.
  • Implements copy-mode state derivation from SendView (toCopyViewState) and factors access-type derivation into SendView.toSendAuth().
  • Adds/updates unit + Compose tests covering the new CTA visibility, click behavior, navigation, and copy-mode policy application.

Reviewed changes

Copilot reviewed 14 out of 14 changed files in this pull request and generated no comments.

Show a summary per file
File Description
ui/src/main/res/values/strings.xml Adds the “Make a copy” string resource for the CTA.
app/src/main/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/viewsend/ViewSendViewModel.kt Adds action + event to trigger navigation into copy flow.
app/src/main/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/viewsend/ViewSendScreen.kt Wires the CTA into the policy restriction banner and handles NavigateToCopy → Add/Edit route (COPY).
app/src/main/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/viewsend/model/SendPolicyRestriction.kt Adds isCopyable to drive whether the CTA is shown/enabled.
app/src/main/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/addedit/model/AddEditSendType.kt Introduces CopyItem(sendItemId) for copy mode.
app/src/main/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/addedit/AddEditSendNavigation.kt Adds ModeType.COPY and maps it to AddEditSendType.CopyItem.
app/src/main/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/addedit/AddEditSendViewModel.kt Loads source Send for CopyItem, derives copy-mode content, and ensures Save creates a new Send.
app/src/main/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/addedit/util/SendViewExtensions.kt Extracts SendView.toSendAuth() and reuses it from toViewState().
app/src/main/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/addedit/util/SendViewCopyExtensions.kt Adds SendView.toCopyViewState() for copy-mode prefill behavior.
app/src/test/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/viewsend/ViewSendViewModelTest.kt Adds test for MakeACopyClick → NavigateToCopy.
app/src/test/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/viewsend/ViewSendScreenTest.kt Adds tests for CTA visibility rules, click dispatch, and navigation to COPY route.
app/src/test/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/addedit/util/SendViewExtensionsTest.kt Adds focused tests for toSendAuth() behavior.
app/src/test/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/addedit/util/SendViewCopyExtensionsTest.kt Adds coverage for copy-mode view-state mapping rules.
app/src/test/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/addedit/AddEditSendViewModelTest.kt Adds copy-mode tests for enforced policy application and save behavior (create vs update).

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

@andrebispo5
andrebispo5 force-pushed the PM-42110-make-a-copy-of-a-policy-restricted-send branch from be17bcd to d49520d Compare August 18, 2026 08:40
@andrebispo5
andrebispo5 force-pushed the PM-42110-make-a-copy-of-a-policy-restricted-send branch 2 times, most recently from a952968 to 66274b6 Compare August 18, 2026 09:37
@andrebispo5
andrebispo5 force-pushed the PM-42110-make-a-copy-of-a-policy-restricted-send branch from 66274b6 to ac8af0a Compare August 18, 2026 19:51
@andrebispo5
andrebispo5 force-pushed the PM-42110-make-a-copy-of-a-policy-restricted-send branch from ac8af0a to 60f9c9a Compare August 18, 2026 19:55
@andrebispo5
andrebispo5 requested a lite review from Copilot August 19, 2026 10:25
@andrebispo5
andrebispo5 force-pushed the PM-42110-make-a-copy-of-a-policy-restricted-send branch from 60f9c9a to 8a31213 Compare August 19, 2026 10:27

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 14 out of 14 changed files in this pull request and generated no new comments.

Suppressed comments (3)

app/src/main/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/viewsend/ViewSendViewModel.kt:178

  • handleMakeACopyClick() unconditionally emits NavigateToCopy. Since the copy CTA is only valid when the current policyRestriction is copyable, this should be guarded (e.g., return early unless state.policyRestriction?.isCopyable == true) to prevent accidental navigation to copy mode for non-copyable restrictions (file sends / type-not-allowed) or if the action is triggered from a future UI path.
    private fun handleMakeACopyClick() {
        sendEvent(ViewSendEvent.NavigateToCopy(sendType = state.sendType, sendId = state.sendId))
    }

app/src/main/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/viewsend/ViewSendScreen.kt:263

  • The nested use of it here makes the takeIf condition rely on Kotlin's lambda scoping rules (it.isCopyable refers to the outer policyRestriction), which is easy to misread. Consider naming the outer lambda parameter (e.g., policyRestriction?.let { restriction -> ... }) and using takeIf { restriction.isCopyable } or an explicit if to improve readability and reduce the risk of mistakes during refactors.
        policyRestriction?.let {
            BitwardenActionCard(
                cardTitle = stringResource(id = BitwardenString.organization_policy_restriction),
                cardSubtitle = it.message(),
                actionButton = BitwardenButtonData(
                    label = BitwardenString.make_a_copy.asText(),
                    onClick = onMakeACopyClick,
                )
                    .takeIf { _ -> it.isCopyable },
                leadingContent = {

app/src/main/kotlin/com/x8bit/bitwarden/ui/tools/feature/send/addedit/AddEditSendViewModel.kt:560

  • toSendAuth() is computed twice when building sendAuth for copy mode. Since toSendAuth() maps and allocates a new list for recipient emails, it would be clearer and avoid duplicate work to compute it once into a local val currentAuth = toSendAuth() and then use currentAuth for both the current = ... argument and the fallback.
    ): AddEditSendState.ViewState.Content {
        return when (state.addEditSendType) {
            is AddEditSendType.CopyItem -> {
                toCopyViewState(
                    deletionDate = clock.instant().plus(
                        state.enforcedDeletionHours?.toLong() ?: DEFAULT_DELETION_HOURS,
                        ChronoUnit.HOURS,
                    ),
                    isHideEmailAddressEnabled = isHideEmailAddressEnabled,
                    sendAuth = state
                        .enforcedWhoCanAccess
                        ?.toEnforcedSendAuth(current = toSendAuth())

@andrebispo5
andrebispo5 force-pushed the PM-42110-make-a-copy-of-a-policy-restricted-send branch from d63dbf1 to 8a31213 Compare August 19, 2026 10:55
@andrebispo5
andrebispo5 force-pushed the PM-42110-make-a-copy-of-a-policy-restricted-send branch from 8a31213 to 635bb1b Compare August 19, 2026 14:53
Base automatically changed from PM-42108-add-policy-restriction-banner-to-view-send to main August 19, 2026 15:50
assertNull(common.expirationDate)
assertEquals("", common.passwordInput)
assertFalse(common.hasPassword)
assertFalse(common.isDeactivateChecked)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

We should just assert the entire result

sendAuth = SendAuth.None,
)

assertEquals(COPY_DELETION_DATE, result.common.deletionDate)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Ditto

/**
* The deletion window applied to a new Send when no deletion date is enforced by policy (7 days).
*/
internal const val DEFAULT_DELETION_HOURS: Long = 7 * 24L

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Can we make this private now?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

It is being used in the extensions file and in the AddEditSendViewModel.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Gotcha

sendAuth = sendAuth,
)

assertEquals(sendAuth, result.common.sendAuth)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Can we make sure all these tests get updated to compare total state?

copyModeState(sendAuth = SendAuth.Password)
.copy(
isSendControlsEnabled = true,
whoCanAccess = SendAccessTypeJson.PASSWORD_PROTECTED,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Instead of copying it, can we just pass these into the copyModeState function?

.value

assertEquals(copyModeState(sendAuth = SendAuth.Password), state)
assertTrue(state.isAddMode)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Maybe change the isAddMode to isAddOrCopyMode?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

No need, in fact it is add mode prefilled, and if we went that route there would be a lot more to change like the name of the class AddCopyEditSend 😅

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Makes sense, I am a little confused how this works though:

    val isAddMode: Boolean get() = addEditSendType !is AddEditSendType.EditItem

Why would this be true?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

This particular one may be a bit misleading let me rename it.

* @param currentState The state being updated, which is the in-flight value rather than the
* [state] property so that the mapping sees the same policy data as the update it belongs to.
*/
private fun SendView.toCurrentModeViewState(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Should this go to the extensions file too?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

toAddEditViewState is already in the extensions file, this is just the bit that hands it the view model's clock, environmentRepo and isHideEmailAddressEnabled so the two call sites don't have to repeat them.

* @param isHideEmailAddressEnabled Whether hiding the sender's email is currently allowed.
* @param sendAuth The access type to apply to the copy, which the policy may have forced.
*/
fun SendView.toCopyViewState(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Can't this go in the main SendViewExtensions file

Move `toAddEditViewState` out of the view model into `SendViewExtensions`,
taking the `Clock`, base web send URL and hide-email allowance as parameters.
`DEFAULT_DELETION_HOURS` and `toEnforcedSendAuth` move alongside it as
`internal` so both the view model and the new extension can reach them.

Tighten the tests the reviewer flagged:

- `toSendAuth should carry over the original recipients` asserts the whole
  `SendAuth.Email` rather than only the email values, using the existing
  `UUID.randomUUID` static mock so `AuthEmail.id` is deterministic.
- The `toCopyViewState` tests assert the entire `Content` instead of picking
  fields off it, and the deletion date test now uses a date distinct from the
  shared fixture so it is not a duplicate of the first test.
- The copy mode view model tests drop the partial follow-up assertions that
  the complete-state assertion above them already covers.
- Qualify `this.toSendAuth()` and break `.copy(` onto its own line to match
  the surrounding style.
Update the remaining `toCopyViewState` tests to assert the whole
`ViewState.Content` rather than picking a single field off the result, so a
regression in any other field is caught rather than passing silently.

Give `copyModeState` the `isSendControlsEnabled`, `deletionHours` and
`whoCanAccess` parameters the copy mode tests were applying through a
trailing `.copy(`, so each test states its expected state in one call.
`isAddMode` covered both `AddItem` and `CopyItem`, but "add mode" already
means the strict single-case check elsewhere in the codebase: the vault
equivalent defines `isAddItemMode` as `is VaultAddEditType.AddItem` and gives
the clone case its own `isCloneMode`. Reusing the term for the union, behind a
`!is EditItem` negation that never names the cases it includes, left a
reviewer asking why the property is true for a copy.

Rename it to `isNewSend`, which is what every call site actually gates on:
the deletion date chooser, the delete button, the overflow menu, the current
access count, and adopting the policy enforced access type and deletion
window. None of them distinguish `AddItem` from `CopyItem`.

Define it with an exhaustive `when` so the `CopyItem` branch is stated at the
definition instead of inferred from an inverted check. This matches `title`
and `sourceSendItemIdOrNull` in the same file, and a future subtype now fails
to compile rather than silently defaulting to new.

Rename the `copy mode should be treated as add mode` test to
`copy mode should be treated as a new send` to drop the retired vocabulary.
The two remaining "add mode" test names keep it correctly, since both build
the view model with the default `AddItem` type.
Extension files in this codebase are named after the receiver type
(`CipherViewExtensions`, `AttachmentViewExtensions`, `AddEditSendStateExtensions`),
and nothing else splits a second file out per operation. `toCopyViewState` is a
`SendView` extension, so it belongs beside `toViewState` and `toAddEditViewState`
rather than in its own `SendViewCopyExtensions` file.

Move the function and its tests into `SendViewExtensions` and
`SendViewExtensionsTest`, then delete the copy-specific files. The merged test
fixtures drop the duplicate text-type constant, keeping `DEFAULT_TEXT_TYPE`, and
rename `EXPECTED_COMMON` to `COPY_COMMON` so it reads distinctly next to
`DEFAULT_COMMON` now that both live in one file.
@andrebispo5
andrebispo5 force-pushed the PM-42110-make-a-copy-of-a-policy-restricted-send branch from 8f00e07 to df61e4c Compare August 20, 2026 17:14
isSendsRestrictedByPolicy: Boolean,
shouldHideEmailAddressToggle: Boolean,
isAddMode: Boolean,
isNewSend: Boolean,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

👍

@andrebispo5
andrebispo5 added this pull request to the merge queue Aug 20, 2026
Merged via the queue into main with commit a26ddf4 Aug 20, 2026
32 checks passed
@andrebispo5
andrebispo5 deleted the PM-42110-make-a-copy-of-a-policy-restricted-send branch August 20, 2026 20:07
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

app:authenticator Bitwarden Authenticator app context app:password-manager Bitwarden Password Manager app context t:feature Change Type - Feature Development

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants