Compare commits

...

34 Commits

Author SHA1 Message Date
renovate[bot]
4316a9f1b9
[deps]: Update actions/setup-node action to v6 2025-12-10 03:21:15 +00:00
Bernd Schoolmann
3af19ad934
[PM-28813] Implement encryption diagnostics & recovery tool (#17673)
* Implement data recovery tool

* Fix tests

* Move Sdkloadservice call and use bit action
2025-12-10 04:03:31 +01:00
renovate[bot]
42c09b325c
[deps] Autofill: Update rimraf to v6.1.2 (#17295)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-09 21:45:44 -05:00
Shane Melton
f161a8c454
[PM-27662] Introduce vault item transfer service (#17876)
* [PM-27662] Add revision date to policy response

* [PM-27662] Introduce vault item transfer service

* [PM-27662] Add feature flag check

* [PM-27662] Add tests

* [PM-27662] Add basic implementation to Web vault

* [PM-27662] Remove redundant for loop

* [PM-27662] Remove unnecessary distinctUntilChanged

* [PM-27662] Avoid subscribing to userMigrationInfo$ if feature flag disabled

* [PM-27662] Make UserMigrationInfo type more strict

* [PM-27662] Typo

* [PM-27662] Fix missing i18n

* [PM-27662] Fix tests

* [PM-27662] Fix tests/types related to policy changes

* [PM-27662] Use getById operator
2025-12-09 15:14:40 -08:00
SmithThe4th
6dba3ac377
[PM-27663] Create VaultItemTransferModalComponent and confirmation dialogs (#17883)
* Created item transfer dialogs

* Added empty line
2025-12-09 17:28:04 -05:00
renovate[bot]
22338632be
[deps] Platform: Update Rust crate zbus to v5.12.0 (#17035)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-09 20:57:23 +00:00
renovate[bot]
717cf93cc8
[deps]: Update Rust crate cc to v1.2.49 (#17893)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-09 20:53:30 +00:00
renovate[bot]
d95dd709b1
[deps]: Update Rust crate thiserror to v2.0.17 (#17574)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-09 14:44:13 -06:00
Oscar Hinton
2cd12d9611
[CL-434] Swap extension to use tailwind preflight (#17054)
Co-authored-by: Vicki League <vleague@bitwarden.com>
2025-12-09 15:40:00 -05:00
renovate[bot]
488a786b86
[deps]: Update Rust crate glob to v0.3.3 (#17573)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-09 14:35:03 -06:00
renovate[bot]
e03e5f1b2b
[deps] Platform: Update Rust crate homedir to v0.3.6 (#17548)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Daniel James Smith <2670567+djsmith85@users.noreply.github.com>
2025-12-09 20:14:00 +00:00
renovate[bot]
c84ebc97da
[deps]: Update Rust crate cc to v1.2.48 (#17746)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Daniel James Smith <2670567+djsmith85@users.noreply.github.com>
2025-12-09 20:05:57 +00:00
Daniel Riera
5c64bf51fc
PM-28614 Set explicit protocols for isExtensionUrl function for inline menu (#17782) 2025-12-09 19:37:29 +00:00
Oscar Hinton
508131ac1e
[PM-294489 Extract send table to libs/tools (#17841)
* Extract send table to libs/tools so it can be re-used in desktop

* Remove *ngIf

* Add story

* Move story under sends

* Remove duplicate class

* Move the dates to prevent any changes in storybook

* Revert changes to web component to speed up merge
2025-12-09 19:26:26 +01:00
renovate[bot]
0af5e5630b
[deps] Platform: Update @types/node to v22.19.2 (#17878)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-09 11:24:44 -07:00
renovate[bot]
c6576ceec8
[deps] Platform: Update nx monorepo to v21.6.10 (#17885)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-09 17:51:25 +00:00
Alex
5dbcb18b6a
[PM-25037] add optional size input to app-vault-icon to prevent zoom issues (#17640) 2025-12-09 12:16:21 -05:00
renovate[bot]
ee582b2ebe
[deps] Platform: Update Rust crate sysinfo to v0.37.2 (#15699)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Daniel James Smith <2670567+djsmith85@users.noreply.github.com>
2025-12-09 16:07:01 +00:00
Mike Amirault
c1c3e432f7
[PM-20080] Add spacing to new cipher/send buttons, unify sizing (#17806)
* [PM-20080] Add spacing to new cipher/send buttons, unify sizing

* [PM-20080] Use logical css properties and increase spacing
2025-12-09 10:55:30 -05:00
neuronull
093e06e787
Bump Rust version to 1.91.1 (#17864)
* Bump Rust version to 1.91.1

* clippy

* clippy
2025-12-09 10:46:40 -05:00
Nick Krantz
456f02958a
account for pre-set value of an angular form before the options are set (#17872) 2025-12-09 09:31:12 -06:00
Kyle Denney
bbf9157ec0
[PM-24581] new styling for premium badge (#17793)
* [PM-24581] new styling for premium badge

* stories file

* translations for browser and desktop

* design review feedback

* color fixes, thanks claude
2025-12-09 09:27:37 -06:00
renovate[bot]
7a6c0394b8
[deps]: Pin dependencies (#17030)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-09 09:26:48 -06:00
renovate[bot]
3735f1c106
[deps] UI Foundation: Update tailwindcss to v3.4.18 (#17564)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-09 09:38:56 -05:00
renovate[bot]
cbbfca1f91
[deps]: Update actions/stale action to v10.1.1 (#17881)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-09 08:11:15 -06:00
renovate[bot]
b54230ba32
[deps] Platform: Update Rust crate security-framework to v3.5.1 (#17551)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-09 12:19:19 +00:00
renovate[bot]
ce7521b972
[deps] Platform: Update Rust crate libc to v0.2.178 (#17879)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-09 06:05:05 -06:00
Oscar Hinton
fda9a9d2b2
Rename deprecated killOthers to killOthersOn (#17856) 2025-12-09 09:55:25 +01:00
Kyle Denney
dfe2e283a0
[PM-29138] fix defect with pricing service on self host (#17819)
* [PM-29138] fix defect with pricing service on self host

* use iscloud instead of manually checking region

* fixing strict compile issues

* spacing updates from design review

* final spacing edits

* pr feedback

* typechecking
2025-12-08 19:24:37 -06:00
renovate[bot]
4c56a9693c
[deps] UI Foundation: Update chromatic to v13.3.4 (#17562)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-08 16:55:22 -05:00
renovate[bot]
d23d9f6087
[deps] UI Foundation: Update autoprefixer to v10.4.22 (#17561)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-08 16:27:18 -05:00
John Harrington
32ee9b3c6d
PM-24749 UI in progress dialog (#17675)
* initial progress spinner implementation

* respond to review suggestions

* revert to bwi-spinner to avoid build error
2025-12-08 12:48:04 -07:00
Mike Amirault
2d3b017cc2
[PM-24095] Ensure long Send file names do not overflow parent container (#17774)
* [PM-24095] Ensure long Send file names do not overflow parent container

* [PM-24095] Add styling to a couple other spots
2025-12-08 13:52:45 -05:00
renovate[bot]
97adae2864
[deps] UI Foundation: Update @types/react to v18.3.27 (#17558)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-08 13:46:54 -05:00
108 changed files with 4075 additions and 2620 deletions

View File

@ -152,7 +152,7 @@ jobs:
persist-credentials: false persist-credentials: false
- name: Set up Node - name: Set up Node
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
with: with:
cache: 'npm' cache: 'npm'
cache-dependency-path: '**/package-lock.json' cache-dependency-path: '**/package-lock.json'
@ -260,7 +260,7 @@ jobs:
persist-credentials: false persist-credentials: false
- name: Set up Node - name: Set up Node
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
with: with:
cache: 'npm' cache: 'npm'
cache-dependency-path: '**/package-lock.json' cache-dependency-path: '**/package-lock.json'
@ -392,7 +392,7 @@ jobs:
persist-credentials: false persist-credentials: false
- name: Set up Node - name: Set up Node
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
with: with:
cache: 'npm' cache: 'npm'
cache-dependency-path: '**/package-lock.json' cache-dependency-path: '**/package-lock.json'

View File

@ -130,7 +130,7 @@ jobs:
} >> "$GITHUB_ENV" } >> "$GITHUB_ENV"
- name: Set up Node - name: Set up Node
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
with: with:
cache: 'npm' cache: 'npm'
cache-dependency-path: '**/package-lock.json' cache-dependency-path: '**/package-lock.json'
@ -326,7 +326,7 @@ jobs:
choco install nasm --no-progress choco install nasm --no-progress
- name: Set up Node - name: Set up Node
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
with: with:
cache: 'npm' cache: 'npm'
cache-dependency-path: '**/package-lock.json' cache-dependency-path: '**/package-lock.json'

View File

@ -193,7 +193,7 @@ jobs:
sudo rm -rf /usr/local/aws-sam-cli sudo rm -rf /usr/local/aws-sam-cli
- name: Set up Node - name: Set up Node
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
with: with:
cache: 'npm' cache: 'npm'
cache-dependency-path: '**/package-lock.json' cache-dependency-path: '**/package-lock.json'
@ -351,7 +351,7 @@ jobs:
persist-credentials: false persist-credentials: false
- name: Set up Node - name: Set up Node
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
with: with:
cache: 'npm' cache: 'npm'
cache-dependency-path: '**/package-lock.json' cache-dependency-path: '**/package-lock.json'
@ -501,7 +501,7 @@ jobs:
persist-credentials: false persist-credentials: false
- name: Set up Node - name: Set up Node
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
with: with:
cache: 'npm' cache: 'npm'
cache-dependency-path: '**/package-lock.json' cache-dependency-path: '**/package-lock.json'
@ -767,7 +767,7 @@ jobs:
persist-credentials: false persist-credentials: false
- name: Set up Node - name: Set up Node
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
with: with:
cache: 'npm' cache: 'npm'
cache-dependency-path: '**/package-lock.json' cache-dependency-path: '**/package-lock.json'
@ -1010,7 +1010,7 @@ jobs:
persist-credentials: false persist-credentials: false
- name: Set up Node - name: Set up Node
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
with: with:
cache: 'npm' cache: 'npm'
cache-dependency-path: '**/package-lock.json' cache-dependency-path: '**/package-lock.json'
@ -1019,7 +1019,7 @@ jobs:
- name: Set up Python - name: Set up Python
uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0 uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0
with: with:
python-version: '3.14' python-version: '3.14.2'
- name: Set up Node-gyp - name: Set up Node-gyp
run: python -m pip install setuptools run: python -m pip install setuptools
@ -1248,7 +1248,7 @@ jobs:
persist-credentials: false persist-credentials: false
- name: Set up Node - name: Set up Node
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
with: with:
cache: 'npm' cache: 'npm'
cache-dependency-path: '**/package-lock.json' cache-dependency-path: '**/package-lock.json'
@ -1257,7 +1257,7 @@ jobs:
- name: Set up Python - name: Set up Python
uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0 uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0
with: with:
python-version: '3.14' python-version: '3.14.2'
- name: Set up Node-gyp - name: Set up Node-gyp
run: python -m pip install setuptools run: python -m pip install setuptools
@ -1521,7 +1521,7 @@ jobs:
persist-credentials: false persist-credentials: false
- name: Set up Node - name: Set up Node
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
with: with:
cache: 'npm' cache: 'npm'
cache-dependency-path: '**/package-lock.json' cache-dependency-path: '**/package-lock.json'
@ -1530,7 +1530,7 @@ jobs:
- name: Set up Python - name: Set up Python
uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0 uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0
with: with:
python-version: '3.14' python-version: '3.14.2'
- name: Set up Node-gyp - name: Set up Node-gyp
run: python -m pip install setuptools run: python -m pip install setuptools

View File

@ -58,7 +58,7 @@ jobs:
echo "node_version=$NODE_VERSION" >> "$GITHUB_OUTPUT" echo "node_version=$NODE_VERSION" >> "$GITHUB_OUTPUT"
- name: Set up Node - name: Set up Node
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
with: with:
node-version: ${{ steps.retrieve-node-version.outputs.node_version }} node-version: ${{ steps.retrieve-node-version.outputs.node_version }}
if: steps.get-changed-files-for-chromatic.outputs.storyFiles == 'true' if: steps.get-changed-files-for-chromatic.outputs.storyFiles == 'true'

View File

@ -64,7 +64,7 @@ jobs:
echo "node_version=$NODE_VERSION" >> "$GITHUB_OUTPUT" echo "node_version=$NODE_VERSION" >> "$GITHUB_OUTPUT"
- name: Set up Node - name: Set up Node
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
with: with:
cache: 'npm' cache: 'npm'
cache-dependency-path: '**/package-lock.json' cache-dependency-path: '**/package-lock.json'

View File

@ -26,7 +26,7 @@ jobs:
echo "node_version=$NODE_VERSION" >> "$GITHUB_OUTPUT" echo "node_version=$NODE_VERSION" >> "$GITHUB_OUTPUT"
- name: Set up Node - name: Set up Node
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
with: with:
cache: 'npm' cache: 'npm'
cache-dependency-path: '**/package-lock.json' cache-dependency-path: '**/package-lock.json'

View File

@ -216,7 +216,7 @@ jobs:
echo "node_version=$NODE_VERSION" >> "$GITHUB_OUTPUT" echo "node_version=$NODE_VERSION" >> "$GITHUB_OUTPUT"
- name: Set up Node - name: Set up Node
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
with: with:
node-version: ${{ steps.retrieve-node-version.outputs.node_version }} node-version: ${{ steps.retrieve-node-version.outputs.node_version }}
registry-url: "https://registry.npmjs.org/" registry-url: "https://registry.npmjs.org/"

View File

@ -76,7 +76,7 @@ jobs:
echo "node_version=$NODE_VERSION" >> "$GITHUB_OUTPUT" echo "node_version=$NODE_VERSION" >> "$GITHUB_OUTPUT"
- name: Set up Node - name: Set up Node
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
with: with:
cache: 'npm' cache: 'npm'
cache-dependency-path: '**/package-lock.json' cache-dependency-path: '**/package-lock.json'

View File

@ -15,7 +15,7 @@ jobs:
pull-requests: write pull-requests: write
steps: steps:
- name: 'Run stale action' - name: 'Run stale action'
uses: actions/stale@5f858e3efba33a5ca4407a664cc011ad407f2008 # v10.1.0 uses: actions/stale@997185467fa4f803885201cee163a9f38240193d # v10.1.1
with: with:
stale-issue-label: 'needs-reply' stale-issue-label: 'needs-reply'
stale-pr-label: 'needs-changes' stale-pr-label: 'needs-changes'

View File

@ -36,7 +36,7 @@ jobs:
echo "node_version=$NODE_VERSION" >> "$GITHUB_OUTPUT" echo "node_version=$NODE_VERSION" >> "$GITHUB_OUTPUT"
- name: Set up Node - name: Set up Node
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
with: with:
cache: 'npm' cache: 'npm'
cache-dependency-path: '**/package-lock.json' cache-dependency-path: '**/package-lock.json'

View File

@ -1475,6 +1475,9 @@
"selectFile": { "selectFile": {
"message": "Select a file" "message": "Select a file"
}, },
"itemsTransferred": {
"message": "Items transferred"
},
"maxFileSize": { "maxFileSize": {
"message": "Maximum file size is 500 MB." "message": "Maximum file size is 500 MB."
}, },
@ -5934,5 +5937,56 @@
}, },
"sessionTimeoutSettingsSetUnlockMethodToChangeTimeoutAction": { "sessionTimeoutSettingsSetUnlockMethodToChangeTimeoutAction": {
"message": "Set an unlock method to change your timeout action" "message": "Set an unlock method to change your timeout action"
},
"upgrade": {
"message": "Upgrade"
},
"leaveConfirmationDialogTitle": {
"message": "Are you sure you want to leave?"
},
"leaveConfirmationDialogContentOne": {
"message": "By declining, your personal items will stay in your account, but you'll lose access to shared items and organization features."
},
"leaveConfirmationDialogContentTwo": {
"message": "Contact your admin to regain access."
},
"leaveConfirmationDialogConfirmButton": {
"message": "Leave $ORGANIZATION$",
"placeholders": {
"organization": {
"content": "$1",
"example": "My Org Name"
}
}
},
"howToManageMyVault": {
"message": "How do I manage my vault?"
},
"transferItemsToOrganizationTitle": {
"message": "Transfer items to $ORGANIZATION$",
"placeholders": {
"organization": {
"content": "$1",
"example": "My Org Name"
}
}
},
"transferItemsToOrganizationContent": {
"message": "$ORGANIZATION$ is requiring all items to be owned by the organization for security and compliance. Click accept to transfer ownership of your items.",
"placeholders": {
"organization": {
"content": "$1",
"example": "My Org Name"
}
}
},
"acceptTransfer": {
"message": "Accept transfer"
},
"declineAndLeave": {
"message": "Decline and leave"
},
"whyAmISeeingThis": {
"message": "Why am I seeing this?"
} }
} }

View File

@ -2,7 +2,7 @@
<button <button
*ngIf="currentAccount$ | async as currentAccount; else defaultButton" *ngIf="currentAccount$ | async as currentAccount; else defaultButton"
type="button" type="button"
class="tw-rounded-full hover:tw-outline hover:tw-outline-1 hover:tw-outline-offset-1 hover:tw-outline-primary-600" class="tw-rounded-full hover:tw-outline hover:tw-outline-1 hover:tw-outline-primary-600"
(click)="currentAccountClicked()" (click)="currentAccountClicked()"
> >
<span class="tw-sr-only"> {{ "bitwardenAccount" | i18n }} {{ currentAccount.email }}</span> <span class="tw-sr-only"> {{ "bitwardenAccount" | i18n }} {{ currentAccount.email }}</span>

View File

@ -129,7 +129,12 @@ export class AutofillInlineMenuContainer {
} }
try { try {
const urlObj = new URL(url); const urlObj = new URL(url);
const isExtensionProtocol = /^[a-z]+(-[a-z]+)?-extension:$/i.test(urlObj.protocol); const extensionProtocols = new Set([
"chrome-extension:",
"moz-extension:",
"safari-web-extension:",
]);
const isExtensionProtocol = extensionProtocols.has(urlObj.protocol);
if (!isExtensionProtocol) { if (!isExtensionProtocol) {
return false; return false;

View File

@ -22,7 +22,7 @@ export type NavButton = {
templateUrl: "popup-tab-navigation.component.html", templateUrl: "popup-tab-navigation.component.html",
imports: [CommonModule, LinkModule, RouterModule, JslibModule, IconModule], imports: [CommonModule, LinkModule, RouterModule, JslibModule, IconModule],
host: { host: {
class: "tw-block tw-h-full tw-w-full tw-flex tw-flex-col", class: "tw-block tw-size-full tw-flex tw-flex-col",
}, },
}) })
export class PopupTabNavigationComponent { export class PopupTabNavigationComponent {

View File

@ -13,8 +13,11 @@
</bit-callout> </bit-callout>
</div> </div>
} @else { } @else {
<div [@routerTransition]="getRouteElevation(outlet)"> <!-- eslint-disable-next-line -->
<router-outlet #outlet="outlet"></router-outlet> <div class="tw-h-screen tw-w-screen">
<div [@routerTransition]="getRouteElevation(outlet)" class="tw-size-full">
<router-outlet #outlet="outlet"></router-outlet>
</div>
<bit-toast-container></bit-toast-container>
</div> </div>
<bit-toast-container></bit-toast-container>
} }

View File

@ -1,453 +0,0 @@
@import "variables.scss";
* {
box-sizing: border-box;
padding: 0;
margin: 0;
}
html {
overflow: hidden;
min-height: 600px;
height: 100%;
&.body-sm {
min-height: 500px;
}
&.body-xs {
min-height: 400px;
}
&.body-xxs {
min-height: 300px;
}
&.body-3xs {
min-height: 240px;
}
&.body-full {
min-height: unset;
width: 100%;
height: 100%;
& body {
width: 100%;
}
}
}
html,
body {
font-family: $font-family-sans-serif;
font-size: $font-size-base;
line-height: $line-height-base;
-webkit-font-smoothing: antialiased;
}
body {
width: 380px;
height: 100%;
position: relative;
min-height: inherit;
overflow: hidden;
color: $text-color;
background-color: $background-color;
@include themify($themes) {
color: themed("textColor");
background-color: themed("backgroundColor");
}
}
h1,
h2,
h3,
h4,
h5,
h6 {
font-family: $font-family-sans-serif;
font-size: $font-size-base;
font-weight: normal;
}
p {
margin-bottom: 10px;
}
ul,
ol {
margin-bottom: 10px;
}
img {
border: none;
}
a:not(popup-page a, popup-tab-navigation a) {
text-decoration: none;
@include themify($themes) {
color: themed("primaryColor");
}
&:hover,
&:focus {
@include themify($themes) {
color: darken(themed("primaryColor"), 6%);
}
}
}
input:not(bit-form-field input, bit-search input, input[bitcheckbox]),
select:not(bit-form-field select),
textarea:not(bit-form-field textarea) {
@include themify($themes) {
color: themed("textColor");
background-color: themed("inputBackgroundColor");
}
}
input:not(input[bitcheckbox]),
select,
textarea,
button:not(bit-chip-select button) {
font-size: $font-size-base;
font-family: $font-family-sans-serif;
}
input[type*="date"] {
@include themify($themes) {
color-scheme: themed("dateInputColorScheme");
}
}
::-webkit-calendar-picker-indicator {
@include themify($themes) {
filter: themed("webkitCalendarPickerFilter");
}
}
::-webkit-calendar-picker-indicator:hover {
@include themify($themes) {
filter: themed("webkitCalendarPickerHoverFilter");
}
cursor: pointer;
}
select {
width: 100%;
padding: 0.35rem;
}
button {
cursor: pointer;
}
textarea {
resize: vertical;
}
app-root > div {
height: 100%;
width: 100%;
}
main::-webkit-scrollbar,
cdk-virtual-scroll-viewport::-webkit-scrollbar,
.vault-select::-webkit-scrollbar {
width: 10px;
height: 10px;
}
main::-webkit-scrollbar-track,
.vault-select::-webkit-scrollbar-track {
background-color: transparent;
}
cdk-virtual-scroll-viewport::-webkit-scrollbar-track {
@include themify($themes) {
background-color: themed("backgroundColor");
}
}
main::-webkit-scrollbar-thumb,
cdk-virtual-scroll-viewport::-webkit-scrollbar-thumb,
.vault-select::-webkit-scrollbar-thumb {
border-radius: 10px;
margin-right: 1px;
@include themify($themes) {
background-color: themed("scrollbarColor");
}
&:hover {
@include themify($themes) {
background-color: themed("scrollbarHoverColor");
}
}
}
header:not(bit-callout header, bit-dialog header, popup-page header) {
height: 44px;
display: flex;
&:not(.no-theme) {
border-bottom: 1px solid #000000;
@include themify($themes) {
color: themed("headerColor");
background-color: themed("headerBackgroundColor");
border-bottom-color: themed("headerBorderColor");
}
}
.header-content {
display: flex;
flex: 1 1 auto;
}
.header-content > .right,
.header-content > .right > .right {
height: 100%;
}
.left,
.right {
flex: 1;
display: flex;
min-width: -webkit-min-content; /* Workaround to Chrome bug */
.header-icon {
margin-right: 5px;
}
}
.right {
justify-content: flex-end;
align-items: center;
app-avatar {
max-height: 30px;
margin-right: 5px;
}
}
.center {
display: flex;
align-items: center;
text-align: center;
min-width: 0;
}
.login-center {
margin: auto;
}
app-pop-out > button,
div > button:not(app-current-account button):not(.home-acc-switcher-btn),
div > a {
border: none;
padding: 0 10px;
text-decoration: none;
display: flex;
flex-direction: row;
justify-content: center;
align-items: center;
height: 100%;
white-space: pre;
&:not(.home-acc-switcher-btn):hover,
&:not(.home-acc-switcher-btn):focus {
@include themify($themes) {
background-color: themed("headerBackgroundHoverColor");
color: themed("headerColor");
}
}
&[disabled] {
opacity: 0.65;
cursor: default !important;
background-color: inherit !important;
}
i + span {
margin-left: 5px;
}
}
app-pop-out {
display: flex;
padding-right: 0.5em;
}
.title {
font-weight: bold;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.search {
padding: 7px 10px;
width: 100%;
text-align: left;
position: relative;
display: flex;
.bwi {
position: absolute;
top: 15px;
left: 20px;
@include themify($themes) {
color: themed("headerInputPlaceholderColor");
}
}
input:not(bit-form-field input) {
width: 100%;
margin: 0;
border: none;
padding: 5px 10px 5px 30px;
border-radius: $border-radius;
@include themify($themes) {
background-color: themed("headerInputBackgroundColor");
color: themed("headerInputColor");
}
&::selection {
@include themify($themes) {
// explicitly set text selection to invert foreground/background
background-color: themed("headerInputColor");
color: themed("headerInputBackgroundColor");
}
}
&:focus {
border-radius: $border-radius;
outline: none;
@include themify($themes) {
background-color: themed("headerInputBackgroundFocusColor");
}
}
&::-webkit-input-placeholder {
@include themify($themes) {
color: themed("headerInputPlaceholderColor");
}
}
/** make the cancel button visible in both dark/light themes **/
&[type="search"]::-webkit-search-cancel-button {
-webkit-appearance: none;
appearance: none;
height: 15px;
width: 15px;
background-repeat: no-repeat;
mask-image: url("../images/close-button-white.svg");
-webkit-mask-image: url("../images/close-button-white.svg");
@include themify($themes) {
background-color: themed("headerInputColor");
}
}
}
}
.left + .search,
.left + .sr-only + .search {
padding-left: 0;
.bwi {
left: 10px;
}
}
.search + .right {
margin-left: -10px;
}
}
.content {
padding: 15px 5px;
}
app-root {
width: 100%;
height: 100vh;
display: flex;
@include themify($themes) {
background-color: themed("backgroundColor");
}
}
main:not(popup-page main):not(auth-anon-layout main) {
position: absolute;
top: 44px;
bottom: 0;
left: 0;
right: 0;
overflow-y: auto;
overflow-x: hidden;
@include themify($themes) {
background-color: themed("backgroundColor");
}
&.no-header {
top: 0;
}
&.flex {
display: flex;
flex-flow: column;
height: calc(100% - 44px);
}
}
.center-content,
.no-items,
.full-loading-spinner {
display: flex;
justify-content: center;
align-items: center;
height: 100%;
flex-direction: column;
flex-grow: 1;
}
.no-items,
.full-loading-spinner {
text-align: center;
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
.no-items-image {
@include themify($themes) {
content: url("../images/search-desktop" + themed("svgSuffix"));
}
}
.bwi {
margin-bottom: 10px;
@include themify($themes) {
color: themed("disabledIconColor");
}
}
}
// cdk-virtual-scroll
.cdk-virtual-scroll-viewport {
width: 100%;
height: 100%;
overflow-y: auto;
overflow-x: hidden;
}
.cdk-virtual-scroll-content-wrapper {
width: 100%;
}

View File

@ -1,620 +0,0 @@
@import "variables.scss";
.box {
position: relative;
width: 100%;
&.first {
margin-top: 0;
}
.box-header {
margin: 0 10px 5px 10px;
text-transform: uppercase;
display: flex;
@include themify($themes) {
color: themed("headingColor");
}
}
.box-content {
@include themify($themes) {
background-color: themed("backgroundColor");
border-color: themed("borderColor");
}
&.box-content-padded {
padding: 10px 15px;
}
&.condensed .box-content-row,
.box-content-row.condensed {
padding-top: 5px;
padding-bottom: 5px;
}
&.no-hover .box-content-row,
.box-content-row.no-hover {
&:hover,
&:focus {
@include themify($themes) {
background-color: themed("boxBackgroundColor") !important;
}
}
}
&.single-line .box-content-row,
.box-content-row.single-line {
padding-top: 10px;
padding-bottom: 10px;
margin: 5px;
}
&.row-top-padding {
padding-top: 10px;
}
}
.box-footer {
margin: 0 5px 5px 5px;
padding: 0 10px 5px 10px;
font-size: $font-size-small;
button.btn {
font-size: $font-size-small;
padding: 0;
}
button.btn.primary {
font-size: $font-size-base;
padding: 7px 15px;
width: 100%;
&:hover {
@include themify($themes) {
border-color: themed("borderHoverColor") !important;
}
}
}
@include themify($themes) {
color: themed("mutedColor");
}
}
&.list {
margin: 10px 0 20px 0;
.box-content {
.virtual-scroll-item {
display: inline-block;
width: 100%;
}
.box-content-row {
text-decoration: none;
border-radius: $border-radius;
// background-color: $background-color;
@include themify($themes) {
color: themed("textColor");
background-color: themed("boxBackgroundColor");
}
&.padded {
padding-top: 10px;
padding-bottom: 10px;
}
&.no-hover {
&:hover {
@include themify($themes) {
background-color: themed("boxBackgroundColor") !important;
}
}
}
&:hover,
&:focus,
&.active {
@include themify($themes) {
background-color: themed("listItemBackgroundHoverColor");
}
}
&:focus {
border-left: 5px solid #000000;
padding-left: 5px;
@include themify($themes) {
border-left-color: themed("mutedColor");
}
}
.action-buttons {
.row-btn {
padding-left: 5px;
padding-right: 5px;
}
}
.text:not(.no-ellipsis),
.detail {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.row-main {
display: flex;
min-width: 0;
align-items: normal;
.row-main-content {
min-width: 0;
}
}
}
&.single-line {
.box-content-row {
display: flex;
padding-top: 10px;
padding-bottom: 10px;
margin: 5px;
border-radius: $border-radius;
}
}
}
}
}
.box-content-row {
display: block;
padding: 5px 10px;
position: relative;
z-index: 1;
border-radius: $border-radius;
margin: 3px 5px;
@include themify($themes) {
background-color: themed("boxBackgroundColor");
}
&:last-child {
&:before {
border: none;
height: 0;
}
}
&.override-last:last-child:before {
border-bottom: 1px solid #000000;
@include themify($themes) {
border-bottom-color: themed("boxBorderColor");
}
}
&.last:last-child:before {
border-bottom: 1px solid #000000;
@include themify($themes) {
border-bottom-color: themed("boxBorderColor");
}
}
&:after {
content: "";
display: table;
clear: both;
}
&:hover,
&:focus,
&.active {
@include themify($themes) {
background-color: themed("boxBackgroundHoverColor");
}
}
&.pre {
white-space: pre;
overflow-x: auto;
}
&.pre-wrap {
white-space: pre-wrap;
overflow-x: auto;
}
.row-label,
label {
font-size: $font-size-small;
display: block;
width: 100%;
margin-bottom: 5px;
@include themify($themes) {
color: themed("mutedColor");
}
.sub-label {
margin-left: 10px;
}
}
.flex-label {
font-size: $font-size-small;
display: flex;
flex-grow: 1;
margin-bottom: 5px;
@include themify($themes) {
color: themed("mutedColor");
}
> a {
flex-grow: 0;
}
}
.text,
.detail {
display: block;
text-align: left;
@include themify($themes) {
color: themed("textColor");
}
}
.detail {
font-size: $font-size-small;
@include themify($themes) {
color: themed("mutedColor");
}
}
.img-right,
.txt-right {
float: right;
margin-left: 10px;
}
.row-main {
flex-grow: 1;
min-width: 0;
}
&.box-content-row-flex,
.box-content-row-flex,
&.box-content-row-checkbox,
&.box-content-row-link,
&.box-content-row-input,
&.box-content-row-slider,
&.box-content-row-multi {
display: flex;
align-items: center;
word-break: break-all;
&.box-content-row-word-break {
word-break: normal;
}
}
&.box-content-row-multi {
input:not([type="checkbox"]) {
width: 100%;
}
input + label.sr-only + select {
margin-top: 5px;
}
> a,
> button {
padding: 8px 8px 8px 4px;
margin: 0;
@include themify($themes) {
color: themed("dangerColor");
}
}
}
&.box-content-row-multi,
&.box-content-row-newmulti {
padding-left: 10px;
}
&.box-content-row-newmulti {
@include themify($themes) {
color: themed("primaryColor");
}
}
&.box-content-row-checkbox,
&.box-content-row-link,
&.box-content-row-input,
&.box-content-row-slider {
padding-top: 10px;
padding-bottom: 10px;
margin: 5px;
label,
.row-label {
font-size: $font-size-base;
display: block;
width: initial;
margin-bottom: 0;
@include themify($themes) {
color: themed("textColor");
}
}
> span {
@include themify($themes) {
color: themed("mutedColor");
}
}
> input {
margin: 0 0 0 auto;
padding: 0;
}
> * {
margin-right: 15px;
&:last-child {
margin-right: 0;
}
}
}
&.box-content-row-checkbox-left {
justify-content: flex-start;
> input {
margin: 0 15px 0 0;
}
}
&.box-content-row-input {
label {
white-space: nowrap;
}
input {
text-align: right;
&[type="number"] {
max-width: 50px;
}
}
}
&.box-content-row-slider {
input[type="range"] {
height: 10px;
}
input[type="number"] {
width: 45px;
}
label {
white-space: nowrap;
}
}
input:not([type="checkbox"]):not([type="radio"]),
textarea {
border: none;
width: 100%;
background-color: transparent !important;
&::-webkit-input-placeholder {
@include themify($themes) {
color: themed("inputPlaceholderColor");
}
}
&:not([type="file"]):focus {
outline: none;
}
}
select {
width: 100%;
border: 1px solid #000000;
border-radius: $border-radius;
padding: 7px 4px;
@include themify($themes) {
border-color: themed("inputBorderColor");
}
}
.action-buttons {
display: flex;
margin-left: 5px;
&.action-buttons-fixed {
align-self: start;
margin-top: 2px;
}
.row-btn {
cursor: pointer;
padding: 10px 8px;
background: none;
border: none;
@include themify($themes) {
color: themed("boxRowButtonColor");
}
&:hover,
&:focus {
@include themify($themes) {
color: themed("boxRowButtonHoverColor");
}
}
&.disabled,
&[disabled] {
@include themify($themes) {
color: themed("disabledIconColor");
opacity: themed("disabledBoxOpacity");
}
&:hover {
@include themify($themes) {
color: themed("disabledIconColor");
opacity: themed("disabledBoxOpacity");
}
}
cursor: default !important;
}
}
&.no-pad .row-btn {
padding-top: 0;
padding-bottom: 0;
}
}
&:not(.box-draggable-row) {
.action-buttons .row-btn:last-child {
margin-right: -3px;
}
}
&.box-draggable-row {
&.box-content-row-checkbox {
input[type="checkbox"] + .drag-handle {
margin-left: 10px;
}
}
}
.drag-handle {
cursor: move;
padding: 10px 2px 10px 8px;
user-select: none;
@include themify($themes) {
color: themed("mutedColor");
}
}
&.cdk-drag-preview {
position: relative;
display: flex;
align-items: center;
opacity: 0.8;
@include themify($themes) {
background-color: themed("boxBackgroundColor");
}
}
select.field-type {
margin: 5px 0 0 25px;
width: calc(100% - 25px);
}
.icon {
display: flex;
justify-content: center;
align-items: center;
min-width: 34px;
margin-left: -5px;
@include themify($themes) {
color: themed("mutedColor");
}
&.icon-small {
min-width: 25px;
}
img {
border-radius: $border-radius;
max-height: 20px;
max-width: 20px;
}
}
.progress {
display: flex;
height: 5px;
overflow: hidden;
margin: 5px -15px -10px;
.progress-bar {
display: flex;
flex-direction: column;
justify-content: center;
white-space: nowrap;
background-color: $brand-primary;
}
}
.radio-group {
display: flex;
justify-content: flex-start;
align-items: center;
margin-bottom: 5px;
input {
flex-grow: 0;
}
label {
margin: 0 0 0 5px;
flex-grow: 1;
font-size: $font-size-base;
display: block;
width: 100%;
@include themify($themes) {
color: themed("textColor");
}
}
&.align-start {
align-items: start;
margin-top: 10px;
label {
margin-top: -4px;
}
}
}
}
.truncate {
display: inline-block;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
form {
.box {
.box-content {
.box-content-row {
&.no-hover {
&:hover {
@include themify($themes) {
background-color: themed("transparentColor") !important;
}
}
}
}
}
}
}

View File

@ -1,118 +0,0 @@
@import "variables.scss";
.btn {
border-radius: $border-radius;
padding: 7px 15px;
border: 1px solid #000000;
font-size: $font-size-base;
text-align: center;
cursor: pointer;
@include themify($themes) {
background-color: themed("buttonBackgroundColor");
border-color: themed("buttonBorderColor");
color: themed("buttonColor");
}
&.primary {
@include themify($themes) {
color: themed("buttonPrimaryColor");
}
}
&.danger {
@include themify($themes) {
color: themed("buttonDangerColor");
}
}
&.callout-half {
font-weight: bold;
max-width: 50%;
}
&:hover:not([disabled]) {
cursor: pointer;
@include themify($themes) {
background-color: darken(themed("buttonBackgroundColor"), 1.5%);
border-color: darken(themed("buttonBorderColor"), 17%);
color: darken(themed("buttonColor"), 10%);
}
&.primary {
@include themify($themes) {
color: darken(themed("buttonPrimaryColor"), 6%);
}
}
&.danger {
@include themify($themes) {
color: darken(themed("buttonDangerColor"), 6%);
}
}
}
&:focus:not([disabled]) {
cursor: pointer;
outline: 0;
@include themify($themes) {
background-color: darken(themed("buttonBackgroundColor"), 6%);
border-color: darken(themed("buttonBorderColor"), 25%);
}
}
&[disabled] {
opacity: 0.65;
cursor: default !important;
}
&.block {
display: block;
width: calc(100% - 10px);
margin: 0 auto;
}
&.link,
&.neutral {
border: none !important;
background: none !important;
&:focus {
text-decoration: underline;
}
}
}
.action-buttons {
.btn {
&:focus {
outline: auto;
}
}
}
button.box-content-row {
display: block;
width: calc(100% - 10px);
text-align: left;
border-color: none;
@include themify($themes) {
background-color: themed("boxBackgroundColor");
}
}
button {
border: none;
background: transparent;
color: inherit;
}
.login-buttons {
.btn.block {
width: 100%;
margin-bottom: 10px;
}
}

View File

@ -1,43 +0,0 @@
@import "variables.scss";
html.browser_safari {
&.safari_height_fix {
body {
height: 360px !important;
&.body-xs {
height: 300px !important;
}
&.body-full {
height: 100% !important;
}
}
}
header {
.search .bwi {
left: 20px;
}
.left + .search .bwi {
left: 10px;
}
}
.content {
&.login-page {
padding-top: 100px;
}
}
app-root {
border-width: 1px;
border-style: solid;
border-color: #000000;
}
&.theme_light app-root {
border-color: #777777;
}
}

View File

@ -1,11 +0,0 @@
.row {
display: flex;
margin: 0 -15px;
width: 100%;
}
.col {
flex-basis: 0;
flex-grow: 1;
padding: 0 15px;
}

View File

@ -1,348 +0,0 @@
@import "variables.scss";
small,
.small {
font-size: $font-size-small;
}
.bg-primary {
@include themify($themes) {
background-color: themed("primaryColor") !important;
}
}
.bg-success {
@include themify($themes) {
background-color: themed("successColor") !important;
}
}
.bg-danger {
@include themify($themes) {
background-color: themed("dangerColor") !important;
}
}
.bg-info {
@include themify($themes) {
background-color: themed("infoColor") !important;
}
}
.bg-warning {
@include themify($themes) {
background-color: themed("warningColor") !important;
}
}
.text-primary {
@include themify($themes) {
color: themed("primaryColor") !important;
}
}
.text-success {
@include themify($themes) {
color: themed("successColor") !important;
}
}
.text-muted {
@include themify($themes) {
color: themed("mutedColor") !important;
}
}
.text-default {
@include themify($themes) {
color: themed("textColor") !important;
}
}
.text-danger {
@include themify($themes) {
color: themed("dangerColor") !important;
}
}
.text-info {
@include themify($themes) {
color: themed("infoColor") !important;
}
}
.text-warning {
@include themify($themes) {
color: themed("warningColor") !important;
}
}
.text-center {
text-align: center;
}
.font-weight-semibold {
font-weight: 600;
}
p.lead {
font-size: $font-size-large;
margin-bottom: 20px;
font-weight: normal;
}
.flex-right {
margin-left: auto;
}
.flex-bottom {
margin-top: auto;
}
.no-margin {
margin: 0 !important;
}
.display-block {
display: block !important;
}
.monospaced {
font-family: $font-family-monospace;
}
.show-whitespace {
white-space: pre-wrap;
}
.img-responsive {
display: block;
max-width: 100%;
height: auto;
}
.img-rounded {
border-radius: $border-radius;
}
.select-index-top {
position: relative;
z-index: 100;
}
.sr-only {
position: absolute !important;
width: 1px !important;
height: 1px !important;
padding: 0 !important;
margin: -1px !important;
overflow: hidden !important;
clip: rect(0, 0, 0, 0) !important;
border: 0 !important;
}
:not(:focus) > .exists-only-on-parent-focus {
display: none;
}
.password-wrapper {
overflow-wrap: break-word;
white-space: pre-wrap;
min-width: 0;
}
.password-number {
@include themify($themes) {
color: themed("passwordNumberColor");
}
}
.password-special {
@include themify($themes) {
color: themed("passwordSpecialColor");
}
}
.password-character {
display: inline-flex;
flex-direction: column;
align-items: center;
width: 30px;
height: 36px;
font-weight: 600;
&:nth-child(odd) {
@include themify($themes) {
background-color: themed("backgroundColor");
}
}
}
.password-count {
white-space: nowrap;
font-size: 8px;
@include themify($themes) {
color: themed("passwordCountText") !important;
}
}
#duo-frame {
background: url("../images/loading.svg") 0 0 no-repeat;
width: 100%;
height: 470px;
margin-bottom: -10px;
iframe {
width: 100%;
height: 100%;
border: none;
}
}
#web-authn-frame {
width: 100%;
height: 40px;
iframe {
border: none;
height: 100%;
width: 100%;
}
}
body.linux-webauthn {
width: 485px !important;
#web-authn-frame {
iframe {
width: 375px;
margin: 0 55px;
}
}
}
app-root > #loading {
display: flex;
text-align: center;
justify-content: center;
align-items: center;
height: 100%;
width: 100%;
color: $text-muted;
@include themify($themes) {
color: themed("mutedColor");
}
}
app-vault-icon,
.app-vault-icon {
display: flex;
}
.logo-image {
margin: 0 auto;
width: 142px;
height: 21px;
background-size: 142px 21px;
background-repeat: no-repeat;
@include themify($themes) {
background-image: url("../images/logo-" + themed("logoSuffix") + "@2x.png");
}
@media (min-width: 219px) {
width: 189px;
height: 28px;
background-size: 189px 28px;
}
@media (min-width: 314px) {
width: 284px;
height: 43px;
background-size: 284px 43px;
}
}
[hidden] {
display: none !important;
}
.draggable {
cursor: move;
}
input[type="password"]::-ms-reveal {
display: none;
}
.flex {
display: flex;
&.flex-grow {
> * {
flex: 1;
}
}
}
// Text selection styles
// Set explicit selection styles (assumes primary accent color has sufficient
// contrast against the background, so its inversion is also still readable)
// and suppress user selection for most elements (to make it more app-like)
:not(bit-form-field input)::selection {
@include themify($themes) {
color: themed("backgroundColor");
background-color: themed("primaryAccentColor");
}
}
h1,
h2,
h3,
label,
a,
button,
p,
img,
.box-header,
.box-footer,
.callout,
.row-label,
.modal-title,
.overlay-container {
user-select: none;
&.user-select {
user-select: auto;
}
}
/* tweak for inconsistent line heights in cipher view */
.box-footer button,
.box-footer a {
line-height: 1;
}
// Workaround for slow performance on external monitors on Chrome + MacOS
// See: https://bugs.chromium.org/p/chromium/issues/detail?id=971701#c64
@keyframes redraw {
0% {
opacity: 0.99;
}
100% {
opacity: 1;
}
}
html.force_redraw {
animation: redraw 1s linear infinite;
}
/* override for vault icon in browser (pre extension refresh) */
app-vault-icon:not(app-vault-list-items-container app-vault-icon) > div {
display: flex;
justify-content: center;
align-items: center;
float: left;
height: 36px;
width: 34px;
margin-left: -5px;
}

View File

@ -1,144 +0,0 @@
@import "variables.scss";
app-home {
position: fixed;
height: 100%;
width: 100%;
.center-content {
margin-top: -50px;
height: calc(100% + 50px);
}
img {
width: 284px;
margin: 0 auto;
}
p.lead {
margin: 30px 0;
}
.btn + .btn {
margin-top: 10px;
}
button.settings-icon {
position: absolute;
top: 10px;
left: 10px;
@include themify($themes) {
color: themed("mutedColor");
}
&:not(:hover):not(:focus) {
span {
clip: rect(0 0 0 0);
clip-path: inset(50%);
height: 1px;
overflow: hidden;
position: absolute;
white-space: nowrap;
width: 1px;
}
}
&:hover,
&:focus {
text-decoration: none;
@include themify($themes) {
color: themed("primaryColor");
}
}
}
}
body.body-sm,
body.body-xs {
app-home {
.center-content {
margin-top: 0;
height: 100%;
}
p.lead {
margin: 15px 0;
}
}
}
body.body-full {
app-home {
.center-content {
margin-top: -80px;
height: calc(100% + 80px);
}
}
}
.createAccountLink {
padding: 30px 10px 0 10px;
}
.remember-email-check {
padding-top: 18px;
padding-left: 10px;
padding-bottom: 18px;
}
.login-buttons > button {
margin: 15px 0 15px 0;
}
.useBrowserlink {
margin-left: 5px;
margin-top: 20px;
span {
font-weight: 700;
font-size: $font-size-small;
}
}
.fido2-browser-selector-dropdown {
@include themify($themes) {
background-color: themed("boxBackgroundColor");
}
padding: 8px;
width: 100%;
box-shadow:
0 2px 2px 0 rgba(0, 0, 0, 0.14),
0 3px 1px -2px rgba(0, 0, 0, 0.12),
0 1px 5px 0 rgba(0, 0, 0, 0.2);
border-radius: $border-radius;
}
.fido2-browser-selector-dropdown-item {
@include themify($themes) {
color: themed("textColor") !important;
}
width: 100%;
text-align: left;
padding: 0px 15px 0px 5px;
margin-bottom: 5px;
border-radius: 3px;
border: 1px solid transparent;
transition: all 0.2s ease-in-out;
&:hover {
@include themify($themes) {
background-color: themed("listItemBackgroundHoverColor") !important;
}
}
&:last-child {
margin-bottom: 0;
}
}
/** Temporary fix for avatar, will not be required once we migrate to tailwind preflight **/
bit-avatar svg {
display: block;
}

View File

@ -1,23 +0,0 @@
@import "variables.scss";
@each $mfaType in $mfaTypes {
.mfaType#{$mfaType} {
content: url("../images/two-factor/" + $mfaType + ".png");
max-width: 100px;
}
}
.mfaType1 {
@include themify($themes) {
content: url("../images/two-factor/1" + themed("mfaLogoSuffix"));
max-width: 100px;
max-height: 45px;
}
}
.mfaType7 {
@include themify($themes) {
content: url("../images/two-factor/7" + themed("mfaLogoSuffix"));
max-width: 100px;
}
}

View File

@ -1,13 +1,50 @@
@import "../../../../../libs/angular/src/scss/bwicons/styles/style.scss"; @import "../../../../../libs/angular/src/scss/bwicons/styles/style.scss";
@import "variables.scss"; @import "variables.scss";
@import "../../../../../libs/angular/src/scss/icons.scss"; @import "../../../../../libs/angular/src/scss/icons.scss";
@import "base.scss";
@import "grid.scss";
@import "box.scss";
@import "buttons.scss";
@import "misc.scss";
@import "environment.scss";
@import "pages.scss";
@import "plugins.scss";
@import "@angular/cdk/overlay-prebuilt.css"; @import "@angular/cdk/overlay-prebuilt.css";
@import "../../../../../libs/components/src/multi-select/scss/bw.theme"; @import "../../../../../libs/components/src/multi-select/scss/bw.theme";
.cdk-virtual-scroll-content-wrapper {
width: 100%;
}
// MFA Types for logo styling with no dark theme alternative
$mfaTypes: 0, 2, 3, 4, 6;
@each $mfaType in $mfaTypes {
.mfaType#{$mfaType} {
content: url("../images/two-factor/" + $mfaType + ".png");
max-width: 100px;
}
}
.mfaType0 {
content: url("../images/two-factor/0.png");
max-width: 100px;
max-height: 45px;
}
.mfaType1 {
max-width: 100px;
max-height: 45px;
&:is(.theme_light *) {
content: url("../images/two-factor/1.png");
}
&:is(.theme_dark *) {
content: url("../images/two-factor/1-w.png");
}
}
.mfaType7 {
max-width: 100px;
&:is(.theme_light *) {
content: url("../images/two-factor/7.png");
}
&:is(.theme_dark *) {
content: url("../images/two-factor/7-w.png");
}
}

View File

@ -1,4 +1,104 @@
@import "../../../../../libs/components/src/tw-theme.css"; @import "../../../../../libs/components/src/tw-theme-preflight.css";
@layer base {
html {
overflow: hidden;
min-height: 600px;
height: 100%;
&.body-sm {
min-height: 500px;
}
&.body-xs {
min-height: 400px;
}
&.body-xxs {
min-height: 300px;
}
&.body-3xs {
min-height: 240px;
}
&.body-full {
min-height: unset;
width: 100%;
height: 100%;
& body {
width: 100%;
}
}
}
html.browser_safari {
&.safari_height_fix {
body {
height: 360px !important;
&.body-xs {
height: 300px !important;
}
&.body-full {
height: 100% !important;
}
}
}
app-root {
border-width: 1px;
border-style: solid;
border-color: #000000;
}
&.theme_light app-root {
border-color: #777777;
}
}
body {
width: 380px;
height: 100%;
position: relative;
min-height: inherit;
overflow: hidden;
@apply tw-bg-background-alt;
}
/**
* Workaround for slow performance on external monitors on Chrome + MacOS
* See: https://bugs.chromium.org/p/chromium/issues/detail?id=971701#c64
*/
@keyframes redraw {
0% {
opacity: 0.99;
}
100% {
opacity: 1;
}
}
html.force_redraw {
animation: redraw 1s linear infinite;
}
/**
* Text selection style:
* suppress user selection for most elements (to make it more app-like)
*/
h1,
h2,
h3,
label,
a,
button,
p,
img {
user-select: none;
}
}
@layer components { @layer components {
/** Safari Support */ /** Safari Support */
@ -19,4 +119,59 @@
html:not(.browser_safari) .tw-styled-scrollbar { html:not(.browser_safari) .tw-styled-scrollbar {
scrollbar-color: rgb(var(--color-secondary-500)) rgb(var(--color-background-alt)); scrollbar-color: rgb(var(--color-secondary-500)) rgb(var(--color-background-alt));
} }
#duo-frame {
background: url("../images/loading.svg") 0 0 no-repeat;
width: 100%;
height: 470px;
margin-bottom: -10px;
iframe {
width: 100%;
height: 100%;
border: none;
}
}
#web-authn-frame {
width: 100%;
height: 40px;
iframe {
border: none;
height: 100%;
width: 100%;
}
}
body.linux-webauthn {
width: 485px !important;
#web-authn-frame {
iframe {
width: 375px;
margin: 0 55px;
}
}
}
app-root > #loading {
display: flex;
text-align: center;
justify-content: center;
align-items: center;
height: 100%;
width: 100%;
@apply tw-text-muted;
}
/**
* Text selection style:
* Set explicit selection styles (assumes primary accent color has sufficient
* contrast against the background, so its inversion is also still readable)
*/
:not(bit-form-field input)::selection {
@apply tw-text-contrast;
@apply tw-bg-primary-700;
}
} }

View File

@ -1,178 +1,42 @@
$dark-icon-themes: "theme_dark"; /**
* DEPRECATED: DO NOT MODIFY OR USE!
*/
$dark-icon-themes: "theme_dark";
$font-family-sans-serif: Inter, "Helvetica Neue", Helvetica, Arial, sans-serif; $font-family-sans-serif: Inter, "Helvetica Neue", Helvetica, Arial, sans-serif;
$font-family-monospace: Menlo, Monaco, Consolas, "Courier New", monospace; $font-family-monospace: Menlo, Monaco, Consolas, "Courier New", monospace;
$font-size-base: 16px;
$font-size-large: 18px;
$font-size-xlarge: 22px;
$font-size-xxlarge: 28px;
$font-size-small: 12px;
$text-color: #000000; $text-color: #000000;
$border-color: #f0f0f0;
$border-color-dark: #ddd; $border-color-dark: #ddd;
$list-item-hover: #fbfbfb;
$list-icon-color: #767679;
$disabled-box-opacity: 1;
$border-radius: 6px;
$line-height-base: 1.42857143;
$icon-hover-color: lighten($text-color, 50%);
$mfaTypes: 0, 2, 3, 4, 6;
$gray: #555;
$gray-light: #777;
$text-muted: $gray-light;
$brand-primary: #175ddc; $brand-primary: #175ddc;
$brand-danger: #c83522;
$brand-success: #017e45; $brand-success: #017e45;
$brand-info: #555555;
$brand-warning: #8b6609;
$brand-primary-accent: #1252a3;
$background-color: #f0f0f0; $background-color: #f0f0f0;
$box-background-color: white;
$box-background-hover-color: $list-item-hover;
$box-border-color: $border-color;
$border-color-alt: #c3c5c7;
$button-border-color: darken($border-color-dark, 12%);
$button-background-color: white;
$button-color: lighten($text-color, 40%);
$button-color-primary: darken($brand-primary, 8%); $button-color-primary: darken($brand-primary, 8%);
$button-color-danger: darken($brand-danger, 10%);
$code-color: #c01176;
$code-color-dark: #f08dc7;
$themes: ( $themes: (
light: ( light: (
textColor: $text-color, textColor: $text-color,
hoverColorTransparent: rgba($text-color, 0.15),
borderColor: $border-color-dark, borderColor: $border-color-dark,
backgroundColor: $background-color, backgroundColor: $background-color,
borderColorAlt: $border-color-alt,
backgroundColorAlt: #ffffff,
scrollbarColor: rgba(100, 100, 100, 0.2),
scrollbarHoverColor: rgba(100, 100, 100, 0.4),
boxBackgroundColor: $box-background-color,
boxBackgroundHoverColor: $box-background-hover-color,
boxBorderColor: $box-border-color,
tabBackgroundColor: #ffffff,
tabBackgroundHoverColor: $list-item-hover,
headerColor: #ffffff,
headerBackgroundColor: $brand-primary,
headerBackgroundHoverColor: rgba(255, 255, 255, 0.1),
headerBorderColor: $brand-primary,
headerInputBackgroundColor: darken($brand-primary, 8%),
headerInputBackgroundFocusColor: darken($brand-primary, 10%),
headerInputColor: #ffffff,
headerInputPlaceholderColor: lighten($brand-primary, 35%),
listItemBackgroundHoverColor: $list-item-hover,
disabledIconColor: $list-icon-color,
disabledBoxOpacity: $disabled-box-opacity,
headingColor: $gray-light,
labelColor: $gray-light,
mutedColor: $text-muted,
totpStrokeColor: $brand-primary,
boxRowButtonColor: $brand-primary,
boxRowButtonHoverColor: darken($brand-primary, 10%),
inputBorderColor: darken($border-color-dark, 7%), inputBorderColor: darken($border-color-dark, 7%),
inputBackgroundColor: #ffffff, inputBackgroundColor: #ffffff,
inputPlaceholderColor: lighten($gray-light, 35%),
buttonBackgroundColor: $button-background-color,
buttonBorderColor: $button-border-color,
buttonColor: $button-color,
buttonPrimaryColor: $button-color-primary, buttonPrimaryColor: $button-color-primary,
buttonDangerColor: $button-color-danger,
primaryColor: $brand-primary, primaryColor: $brand-primary,
primaryAccentColor: $brand-primary-accent,
dangerColor: $brand-danger,
successColor: $brand-success, successColor: $brand-success,
infoColor: $brand-info,
warningColor: $brand-warning,
logoSuffix: "dark",
mfaLogoSuffix: ".png",
passwordNumberColor: #007fde, passwordNumberColor: #007fde,
passwordSpecialColor: #c40800, passwordSpecialColor: #c40800,
passwordCountText: #212529,
calloutBorderColor: $border-color-dark,
calloutBackgroundColor: $box-background-color,
toastTextColor: #ffffff,
svgSuffix: "-light.svg",
transparentColor: rgba(0, 0, 0, 0),
dateInputColorScheme: light,
// https://stackoverflow.com/a/53336754
webkitCalendarPickerFilter: invert(46%) sepia(69%) saturate(6397%) hue-rotate(211deg)
brightness(85%) contrast(103%),
// light has no hover so use same color
webkitCalendarPickerHoverFilter: invert(46%) sepia(69%) saturate(6397%) hue-rotate(211deg)
brightness(85%) contrast(103%),
codeColor: $code-color,
), ),
dark: ( dark: (
textColor: #ffffff, textColor: #ffffff,
hoverColorTransparent: rgba($text-color, 0.15),
borderColor: #161c26, borderColor: #161c26,
backgroundColor: #161c26, backgroundColor: #161c26,
borderColorAlt: #6e788a,
backgroundColorAlt: #2f343d,
scrollbarColor: #6e788a,
scrollbarHoverColor: #8d94a5,
boxBackgroundColor: #2f343d,
boxBackgroundHoverColor: #3c424e,
boxBorderColor: #4c525f,
tabBackgroundColor: #2f343d,
tabBackgroundHoverColor: #3c424e,
headerColor: #ffffff,
headerBackgroundColor: #2f343d,
headerBackgroundHoverColor: #3c424e,
headerBorderColor: #161c26,
headerInputBackgroundColor: #3c424e,
headerInputBackgroundFocusColor: #4c525f,
headerInputColor: #ffffff,
headerInputPlaceholderColor: #bac0ce,
listItemBackgroundHoverColor: #3c424e,
disabledIconColor: #bac0ce,
disabledBoxOpacity: 0.5,
headingColor: #bac0ce,
labelColor: #bac0ce,
mutedColor: #bac0ce,
totpStrokeColor: #4c525f,
boxRowButtonColor: #bac0ce,
boxRowButtonHoverColor: #ffffff,
inputBorderColor: #4c525f, inputBorderColor: #4c525f,
inputBackgroundColor: #2f343d, inputBackgroundColor: #2f343d,
inputPlaceholderColor: #bac0ce,
buttonBackgroundColor: #3c424e,
buttonBorderColor: #4c525f,
buttonColor: #bac0ce,
buttonPrimaryColor: #6f9df1, buttonPrimaryColor: #6f9df1,
buttonDangerColor: #ff8d85,
primaryColor: #6f9df1, primaryColor: #6f9df1,
primaryAccentColor: #6f9df1,
dangerColor: #ff8d85,
successColor: #52e07c, successColor: #52e07c,
infoColor: #a4b0c6,
warningColor: #ffeb66,
logoSuffix: "white",
mfaLogoSuffix: "-w.png",
passwordNumberColor: #6f9df1, passwordNumberColor: #6f9df1,
passwordSpecialColor: #ff8d85, passwordSpecialColor: #ff8d85,
passwordCountText: #ffffff,
calloutBorderColor: #4c525f,
calloutBackgroundColor: #3c424e,
toastTextColor: #1f242e,
svgSuffix: "-dark.svg",
transparentColor: rgba(0, 0, 0, 0),
dateInputColorScheme: dark,
// https://stackoverflow.com/a/53336754 - must prepend brightness(0) saturate(100%) to dark themed date inputs
webkitCalendarPickerFilter: brightness(0) saturate(100%) invert(86%) sepia(19%) saturate(152%)
hue-rotate(184deg) brightness(87%) contrast(93%),
webkitCalendarPickerHoverFilter: brightness(0) saturate(100%) invert(100%) sepia(0%)
saturate(0%) hue-rotate(93deg) brightness(103%) contrast(103%),
codeColor: $code-color-dark,
), ),
); );

View File

@ -1,5 +1,5 @@
<button bitButton size="small" [bitMenuTriggerFor]="itemOptions" buttonType="primary" type="button"> <button bitButton [bitMenuTriggerFor]="itemOptions" buttonType="primary" type="button">
<i class="bwi bwi-plus" aria-hidden="true"></i> <i class="bwi bwi-plus tw-me-2" aria-hidden="true"></i>
{{ "new" | i18n }} {{ "new" | i18n }}
</button> </button>
<bit-menu #itemOptions> <bit-menu #itemOptions>

View File

@ -12,5 +12,6 @@ config.content = [
"../../libs/vault/src/**/*.{html,ts}", "../../libs/vault/src/**/*.{html,ts}",
"../../libs/pricing/src/**/*.{html,ts}", "../../libs/pricing/src/**/*.{html,ts}",
]; ];
config.corePlugins.preflight = true;
module.exports = config; module.exports = config;

View File

@ -347,8 +347,8 @@ dependencies = [
"mockall", "mockall",
"serial_test", "serial_test",
"tracing", "tracing",
"windows 0.61.1", "windows",
"windows-core 0.61.0", "windows-core",
] ]
[[package]] [[package]]
@ -457,7 +457,7 @@ dependencies = [
"tokio", "tokio",
"tracing", "tracing",
"tracing-subscriber", "tracing-subscriber",
"windows 0.61.1", "windows",
] ]
[[package]] [[package]]
@ -501,6 +501,12 @@ dependencies = [
"cipher", "cipher",
] ]
[[package]]
name = "bumpalo"
version = "3.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43"
[[package]] [[package]]
name = "byteorder" name = "byteorder"
version = "1.5.0" version = "1.5.0"
@ -556,9 +562,9 @@ dependencies = [
[[package]] [[package]]
name = "cc" name = "cc"
version = "1.2.46" version = "1.2.49"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b97463e1064cb1b1c1384ad0a0b9c8abd0988e2a91f52606c80ef14aadb63e36" checksum = "90583009037521a116abf44494efecd645ba48b6622457080f080b85544e2215"
dependencies = [ dependencies = [
"find-msvc-tools", "find-msvc-tools",
"shlex", "shlex",
@ -623,7 +629,7 @@ dependencies = [
"tokio", "tokio",
"tracing", "tracing",
"verifysign", "verifysign",
"windows 0.61.1", "windows",
] ]
[[package]] [[package]]
@ -877,13 +883,13 @@ dependencies = [
"sha2", "sha2",
"ssh-key", "ssh-key",
"sysinfo", "sysinfo",
"thiserror 2.0.12", "thiserror 2.0.17",
"tokio", "tokio",
"tokio-util", "tokio-util",
"tracing", "tracing",
"typenum", "typenum",
"widestring", "widestring",
"windows 0.61.1", "windows",
"windows-future", "windows-future",
"zbus", "zbus",
"zbus_polkit", "zbus_polkit",
@ -1417,9 +1423,9 @@ checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f"
[[package]] [[package]]
name = "glob" name = "glob"
version = "0.3.2" version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a8d1add55171497b4705a648c6b583acafb01d58050a51727785f0b2c8e0a2b2" checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280"
[[package]] [[package]]
name = "goblin" name = "goblin"
@ -1499,14 +1505,14 @@ dependencies = [
[[package]] [[package]]
name = "homedir" name = "homedir"
version = "0.3.4" version = "0.3.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5bdbbd5bc8c5749697ccaa352fa45aff8730cf21c68029c0eef1ffed7c3d6ba2" checksum = "68df315d2857b2d8d2898be54a85e1d001bbbe0dbb5f8ef847b48dd3a23c4527"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"nix 0.29.0", "nix",
"widestring", "widestring",
"windows 0.57.0", "windows",
] ]
[[package]] [[package]]
@ -1663,6 +1669,16 @@ version = "1.0.15"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c"
[[package]]
name = "js-sys"
version = "0.3.83"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "464a3709c7f55f1f721e5389aa6ea4e3bc6aba669353300af094b29ffbdde1d8"
dependencies = [
"once_cell",
"wasm-bindgen",
]
[[package]] [[package]]
name = "lazy_static" name = "lazy_static"
version = "1.5.0" version = "1.5.0"
@ -1674,9 +1690,9 @@ dependencies = [
[[package]] [[package]]
name = "libc" name = "libc"
version = "0.2.177" version = "0.2.178"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2874a2af47a2325c2001a6e6fad9b16a53b802102b528163885171cf92b15976" checksum = "37c93d8daa9d8a012fd8ab92f088405fb202ea0b6ab73ee2482ae66af4f42091"
[[package]] [[package]]
name = "libloading" name = "libloading"
@ -1945,18 +1961,6 @@ dependencies = [
"libloading", "libloading",
] ]
[[package]]
name = "nix"
version = "0.29.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46"
dependencies = [
"bitflags",
"cfg-if",
"cfg_aliases",
"libc",
]
[[package]] [[package]]
name = "nix" name = "nix"
version = "0.30.1" version = "0.30.1"
@ -2660,7 +2664,7 @@ checksum = "dd6f9d3d47bdd2ad6945c5015a226ec6155d0bcdfd8f7cd29f86b71f8de99d2b"
dependencies = [ dependencies = [
"getrandom 0.2.16", "getrandom 0.2.16",
"libredox", "libredox",
"thiserror 2.0.12", "thiserror 2.0.17",
] ]
[[package]] [[package]]
@ -2798,6 +2802,12 @@ dependencies = [
"rustix 1.0.7", "rustix 1.0.7",
] ]
[[package]]
name = "rustversion"
version = "1.0.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
[[package]] [[package]]
name = "ryu" name = "ryu"
version = "1.0.20" version = "1.0.20"
@ -2870,15 +2880,15 @@ dependencies = [
"libc", "libc",
"rustix 1.0.7", "rustix 1.0.7",
"rustix-linux-procfs", "rustix-linux-procfs",
"thiserror 2.0.12", "thiserror 2.0.17",
"windows 0.61.1", "windows",
] ]
[[package]] [[package]]
name = "security-framework" name = "security-framework"
version = "3.5.0" version = "3.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cc198e42d9b7510827939c9a15f5062a0c913f3371d765977e586d2fe6c16f4a" checksum = "b3297343eaf830f66ede390ea39da1d462b6b0c1b000f420d0a83f898bbbe6ef"
dependencies = [ dependencies = [
"bitflags", "bitflags",
"core-foundation", "core-foundation",
@ -3188,16 +3198,16 @@ dependencies = [
[[package]] [[package]]
name = "sysinfo" name = "sysinfo"
version = "0.35.0" version = "0.37.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b897c8ea620e181c7955369a31be5f48d9a9121cb59fd33ecef9ff2a34323422" checksum = "16607d5caffd1c07ce073528f9ed972d88db15dd44023fa57142963be3feb11f"
dependencies = [ dependencies = [
"libc", "libc",
"memchr", "memchr",
"ntapi", "ntapi",
"objc2-core-foundation", "objc2-core-foundation",
"objc2-io-kit", "objc2-io-kit",
"windows 0.61.1", "windows",
] ]
[[package]] [[package]]
@ -3239,11 +3249,11 @@ dependencies = [
[[package]] [[package]]
name = "thiserror" name = "thiserror"
version = "2.0.12" version = "2.0.17"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "567b8a2dae586314f7be2a752ec7474332959c6460e02bde30d702a66d488708" checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8"
dependencies = [ dependencies = [
"thiserror-impl 2.0.12", "thiserror-impl 2.0.17",
] ]
[[package]] [[package]]
@ -3259,9 +3269,9 @@ dependencies = [
[[package]] [[package]]
name = "thiserror-impl" name = "thiserror-impl"
version = "2.0.12" version = "2.0.17"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f7cf42b4507d8ea322120659672cf1b9dbb93f8f2d4ecfd6e51350ff5b17a1d" checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
@ -3680,6 +3690,17 @@ version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
[[package]]
name = "uuid"
version = "1.18.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2f87b8aa10b915a06587d0dec516c282ff295b475d94abf425d62b57710070a2"
dependencies = [
"js-sys",
"serde",
"wasm-bindgen",
]
[[package]] [[package]]
name = "valuable" name = "valuable"
version = "0.1.1" version = "0.1.1"
@ -3745,6 +3766,51 @@ dependencies = [
"wit-bindgen-rt", "wit-bindgen-rt",
] ]
[[package]]
name = "wasm-bindgen"
version = "0.2.106"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d759f433fa64a2d763d1340820e46e111a7a5ab75f993d1852d70b03dbb80fd"
dependencies = [
"cfg-if",
"once_cell",
"rustversion",
"wasm-bindgen-macro",
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-macro"
version = "0.2.106"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "48cb0d2638f8baedbc542ed444afc0644a29166f1595371af4fecf8ce1e7eeb3"
dependencies = [
"quote",
"wasm-bindgen-macro-support",
]
[[package]]
name = "wasm-bindgen-macro-support"
version = "0.2.106"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cefb59d5cd5f92d9dcf80e4683949f15ca4b511f4ac0a6e14d4e1ac60c6ecd40"
dependencies = [
"bumpalo",
"proc-macro2",
"quote",
"syn",
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-shared"
version = "0.2.106"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cbc538057e648b67f72a982e708d485b2efa771e1ac05fec311f9f63e5800db4"
dependencies = [
"unicode-ident",
]
[[package]] [[package]]
name = "wayland-backend" name = "wayland-backend"
version = "0.3.10" version = "0.3.10"
@ -3852,16 +3918,6 @@ version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
[[package]]
name = "windows"
version = "0.57.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "12342cb4d8e3b046f3d80effd474a7a02447231330ef77d71daa6fbc40681143"
dependencies = [
"windows-core 0.57.0",
"windows-targets 0.52.6",
]
[[package]] [[package]]
name = "windows" name = "windows"
version = "0.61.1" version = "0.61.1"
@ -3869,7 +3925,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c5ee8f3d025738cb02bad7868bbb5f8a6327501e870bf51f1b455b0a2454a419" checksum = "c5ee8f3d025738cb02bad7868bbb5f8a6327501e870bf51f1b455b0a2454a419"
dependencies = [ dependencies = [
"windows-collections", "windows-collections",
"windows-core 0.61.0", "windows-core",
"windows-future", "windows-future",
"windows-link 0.1.3", "windows-link 0.1.3",
"windows-numerics", "windows-numerics",
@ -3881,19 +3937,7 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8" checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8"
dependencies = [ dependencies = [
"windows-core 0.61.0", "windows-core",
]
[[package]]
name = "windows-core"
version = "0.57.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d2ed2439a290666cd67ecce2b0ffaad89c2a56b976b736e6ece670297897832d"
dependencies = [
"windows-implement 0.57.0",
"windows-interface 0.57.0",
"windows-result 0.1.2",
"windows-targets 0.52.6",
] ]
[[package]] [[package]]
@ -3902,8 +3946,8 @@ version = "0.61.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4763c1de310c86d75a878046489e2e5ba02c649d185f21c67d4cf8a56d098980" checksum = "4763c1de310c86d75a878046489e2e5ba02c649d185f21c67d4cf8a56d098980"
dependencies = [ dependencies = [
"windows-implement 0.60.0", "windows-implement",
"windows-interface 0.59.1", "windows-interface",
"windows-link 0.1.3", "windows-link 0.1.3",
"windows-result 0.3.4", "windows-result 0.3.4",
"windows-strings 0.4.2", "windows-strings 0.4.2",
@ -3915,21 +3959,10 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7a1d6bbefcb7b60acd19828e1bc965da6fcf18a7e39490c5f8be71e54a19ba32" checksum = "7a1d6bbefcb7b60acd19828e1bc965da6fcf18a7e39490c5f8be71e54a19ba32"
dependencies = [ dependencies = [
"windows-core 0.61.0", "windows-core",
"windows-link 0.1.3", "windows-link 0.1.3",
] ]
[[package]]
name = "windows-implement"
version = "0.57.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9107ddc059d5b6fbfbffdfa7a7fe3e22a226def0b2608f72e9d552763d3e1ad7"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]] [[package]]
name = "windows-implement" name = "windows-implement"
version = "0.60.0" version = "0.60.0"
@ -3941,17 +3974,6 @@ dependencies = [
"syn", "syn",
] ]
[[package]]
name = "windows-interface"
version = "0.57.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29bee4b38ea3cde66011baa44dba677c432a78593e202392d1e9070cf2a7fca7"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]] [[package]]
name = "windows-interface" name = "windows-interface"
version = "0.59.1" version = "0.59.1"
@ -3981,7 +4003,7 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1" checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1"
dependencies = [ dependencies = [
"windows-core 0.61.0", "windows-core",
"windows-link 0.1.3", "windows-link 0.1.3",
] ]
@ -3996,15 +4018,6 @@ dependencies = [
"windows-strings 0.5.1", "windows-strings 0.5.1",
] ]
[[package]]
name = "windows-result"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e383302e8ec8515204254685643de10811af0ed97ea37210dc26fb0032647f8"
dependencies = [
"windows-targets 0.52.6",
]
[[package]] [[package]]
name = "windows-result" name = "windows-result"
version = "0.3.4" version = "0.3.4"
@ -4262,8 +4275,8 @@ name = "windows_plugin_authenticator"
version = "0.0.0" version = "0.0.0"
dependencies = [ dependencies = [
"hex", "hex",
"windows 0.61.1", "windows",
"windows-core 0.61.0", "windows-core",
] ]
[[package]] [[package]]
@ -4434,9 +4447,9 @@ dependencies = [
[[package]] [[package]]
name = "zbus" name = "zbus"
version = "5.11.0" version = "5.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2d07e46d035fb8e375b2ce63ba4e4ff90a7f73cf2ffb0138b29e1158d2eaadf7" checksum = "b622b18155f7a93d1cd2dc8c01d2d6a44e08fb9ebb7b3f9e6ed101488bad6c91"
dependencies = [ dependencies = [
"async-broadcast", "async-broadcast",
"async-executor", "async-executor",
@ -4452,14 +4465,15 @@ dependencies = [
"futures-core", "futures-core",
"futures-lite", "futures-lite",
"hex", "hex",
"nix 0.30.1", "nix",
"ordered-stream", "ordered-stream",
"serde", "serde",
"serde_repr", "serde_repr",
"tokio", "tokio",
"tracing", "tracing",
"uds_windows", "uds_windows",
"windows-sys 0.60.2", "uuid",
"windows-sys 0.61.2",
"winnow", "winnow",
"zbus_macros", "zbus_macros",
"zbus_names", "zbus_names",
@ -4468,9 +4482,9 @@ dependencies = [
[[package]] [[package]]
name = "zbus_macros" name = "zbus_macros"
version = "5.11.0" version = "5.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57e797a9c847ed3ccc5b6254e8bcce056494b375b511b3d6edcec0aeb4defaca" checksum = "1cdb94821ca8a87ca9c298b5d1cbd80e2a8b67115d99f6e4551ac49e42b6a314"
dependencies = [ dependencies = [
"proc-macro-crate", "proc-macro-crate",
"proc-macro2", "proc-macro2",

View File

@ -37,9 +37,9 @@ ed25519 = "=2.2.3"
embed_plist = "=1.2.2" embed_plist = "=1.2.2"
futures = "=0.3.31" futures = "=0.3.31"
hex = "=0.4.3" hex = "=0.4.3"
homedir = "=0.3.4" homedir = "=0.3.6"
interprocess = "=2.2.1" interprocess = "=2.2.1"
libc = "=0.2.177" libc = "=0.2.178"
linux-keyutils = "=0.2.4" linux-keyutils = "=0.2.4"
memsec = "=0.7.0" memsec = "=0.7.0"
napi = "=2.16.17" napi = "=2.16.17"
@ -53,15 +53,15 @@ rsa = "=0.9.6"
russh-cryptovec = "=0.7.3" russh-cryptovec = "=0.7.3"
scopeguard = "=1.2.0" scopeguard = "=1.2.0"
secmem-proc = "=0.3.7" secmem-proc = "=0.3.7"
security-framework = "=3.5.0" security-framework = "=3.5.1"
security-framework-sys = "=2.15.0" security-framework-sys = "=2.15.0"
serde = "=1.0.209" serde = "=1.0.209"
serde_json = "=1.0.127" serde_json = "=1.0.127"
sha2 = "=0.10.8" sha2 = "=0.10.8"
ssh-encoding = "=0.2.0" ssh-encoding = "=0.2.0"
ssh-key = { version = "=0.6.7", default-features = false } ssh-key = { version = "=0.6.7", default-features = false }
sysinfo = "=0.35.0" sysinfo = "=0.37.2"
thiserror = "=2.0.12" thiserror = "=2.0.17"
tokio = "=1.45.0" tokio = "=1.45.0"
tokio-util = "=0.7.13" tokio-util = "=0.7.13"
tracing = "=0.1.41" tracing = "=0.1.41"
@ -77,7 +77,7 @@ windows = "=0.61.1"
windows-core = "=0.61.0" windows-core = "=0.61.0"
windows-future = "=0.2.0" windows-future = "=0.2.0"
windows-registry = "=0.6.1" windows-registry = "=0.6.1"
zbus = "=5.11.0" zbus = "=5.12.0"
zbus_polkit = "=5.0.0" zbus_polkit = "=5.0.0"
zeroizing-alloc = "=0.1.0" zeroizing-alloc = "=0.1.0"

View File

@ -285,8 +285,8 @@ async fn windows_hello_authenticate_with_crypto(
return Err(anyhow!("Failed to sign data")); return Err(anyhow!("Failed to sign data"));
} }
let signature_buffer = signature.Result()?; let mut signature_buffer = signature.Result()?;
let signature_value = unsafe { as_mut_bytes(&signature_buffer)? }; let signature_value = unsafe { as_mut_bytes(&mut signature_buffer)? };
// The signature is deterministic based on the challenge and keychain key. Thus, it can be // The signature is deterministic based on the challenge and keychain key. Thus, it can be
// hashed to a key. It is unclear what entropy this key provides. // hashed to a key. It is unclear what entropy this key provides.
@ -368,7 +368,7 @@ fn decrypt_data(
Ok(plaintext) Ok(plaintext)
} }
unsafe fn as_mut_bytes(buffer: &IBuffer) -> Result<&mut [u8]> { unsafe fn as_mut_bytes(buffer: &mut IBuffer) -> Result<&mut [u8]> {
let interop = buffer.cast::<IBufferByteAccess>()?; let interop = buffer.cast::<IBufferByteAccess>()?;
unsafe { unsafe {

View File

@ -24,7 +24,7 @@ serde_json = { workspace = true }
tokio = { workspace = true, features = ["sync"] } tokio = { workspace = true, features = ["sync"] }
tracing = { workspace = true } tracing = { workspace = true }
tracing-subscriber = { workspace = true } tracing-subscriber = { workspace = true }
tracing-oslog = "0.3.0" tracing-oslog = "=0.3.0"
[build-dependencies] [build-dependencies]
uniffi = { workspace = true, features = ["build"] } uniffi = { workspace = true, features = ["build"] }

View File

@ -14,8 +14,8 @@ tokio = { workspace = true }
tracing = { workspace = true } tracing = { workspace = true }
[target.'cfg(target_os = "macos")'.build-dependencies] [target.'cfg(target_os = "macos")'.build-dependencies]
cc = "=1.2.46" cc = "=1.2.49"
glob = "=0.3.2" glob = "=0.3.3"
[lints] [lints]
workspace = true workspace = true

View File

@ -1,4 +1,4 @@
[toolchain] [toolchain]
channel = "1.87.0" channel = "1.91.1"
components = [ "rustfmt", "clippy" ] components = [ "rustfmt", "clippy" ]
profile = "minimal" profile = "minimal"

View File

@ -153,7 +153,7 @@ fn add_authenticator() -> std::result::Result<(), String> {
} }
} }
type EXPERIMENTAL_WebAuthNPluginAddAuthenticatorFnDeclaration = unsafe extern "cdecl" fn( type EXPERIMENTAL_WebAuthNPluginAddAuthenticatorFnDeclaration = unsafe extern "C" fn(
pPluginAddAuthenticatorOptions: *const webauthn::ExperimentalWebAuthnPluginAddAuthenticatorOptions, pPluginAddAuthenticatorOptions: *const webauthn::ExperimentalWebAuthnPluginAddAuthenticatorOptions,
ppPluginAddAuthenticatorResponse: *mut *mut webauthn::ExperimentalWebAuthnPluginAddAuthenticatorResponse, ppPluginAddAuthenticatorResponse: *mut *mut webauthn::ExperimentalWebAuthnPluginAddAuthenticatorResponse,
) -> HRESULT; ) -> HRESULT;

View File

@ -19,7 +19,7 @@
"yargs": "18.0.0" "yargs": "18.0.0"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "22.19.1", "@types/node": "22.19.2",
"typescript": "5.4.2" "typescript": "5.4.2"
} }
}, },
@ -117,9 +117,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/node": { "node_modules/@types/node": {
"version": "22.19.1", "version": "22.19.2",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.1.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.2.tgz",
"integrity": "sha512-LCCV0HdSZZZb34qifBsyWlUmok6W7ouER+oQIGBScS8EsZsQbrtFTUrDX4hOl+CS6p7cnNC4td+qrSVGSCTUfQ==", "integrity": "sha512-LPM2G3Syo1GLzXLGJAKdqoU35XvrWzGJ21/7sgZTUpbkBaOasTj8tjwn6w+hCkqaa1TfJ/w67rJSwYItlJ2mYw==",
"license": "MIT", "license": "MIT",
"peer": true, "peer": true,
"dependencies": { "dependencies": {

View File

@ -24,7 +24,7 @@
"yargs": "18.0.0" "yargs": "18.0.0"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "22.19.1", "@types/node": "22.19.2",
"typescript": "5.4.2" "typescript": "5.4.2"
}, },
"_moduleAliases": { "_moduleAliases": {

View File

@ -37,6 +37,6 @@ concurrently(
{ {
prefix: "name", prefix: "name",
outputStream: process.stdout, outputStream: process.stdout,
killOthers: ["success", "failure"], killOthersOn: ["success", "failure"],
}, },
); );

View File

@ -34,6 +34,6 @@ concurrently(
{ {
prefix: "name", prefix: "name",
outputStream: process.stdout, outputStream: process.stdout,
killOthers: ["success", "failure"], killOthersOn: ["success", "failure"],
}, },
); );

View File

@ -1,13 +1,21 @@
<bit-dialog #dialog dialogSize="large" background="alt"> <bit-dialog #dialog dialogSize="large" background="alt">
<span bitDialogTitle>{{ "importData" | i18n }}</span> <span bitDialogTitle>{{ "importData" | i18n }}</span>
<ng-container bitDialogContent> <ng-container bitDialogContent>
<tools-import <div class="tw-relative">
(formLoading)="this.loading = $event" <tools-import
(formDisabled)="this.disabled = $event" (formLoading)="this.loading = $event"
(onSuccessfulImport)="this.onSuccessfulImport($event)" (formDisabled)="this.disabled = $event"
[onImportFromBrowser]="this.onImportFromBrowser" (onSuccessfulImport)="this.onSuccessfulImport($event)"
[onLoadProfilesFromBrowser]="this.onLoadProfilesFromBrowser" [onImportFromBrowser]="this.onImportFromBrowser"
></tools-import> [onLoadProfilesFromBrowser]="this.onLoadProfilesFromBrowser"
[class.tw-invisible]="loading"
></tools-import>
@if (loading) {
<div class="tw-absolute tw-inset-0 tw-flex tw-items-center tw-justify-center">
<i class="bwi bwi-spinner bwi-spin bwi-3x tw-text-primary-600" aria-hidden="true"></i>
</div>
}
</div>
</ng-container> </ng-container>
<ng-container bitDialogFooter> <ng-container bitDialogFooter>
<button <button

View File

@ -46,7 +46,9 @@
</div> </div>
<div class="box-content-row" appBoxRow *ngIf="editMode && type === sendType.File"> <div class="box-content-row" appBoxRow *ngIf="editMode && type === sendType.File">
<label for="file">{{ "file" | i18n }}</label> <label for="file">{{ "file" | i18n }}</label>
<div class="row-main">{{ send.file.fileName }} ({{ send.file.sizeName }})</div> <div class="row-main tw-text-wrap tw-break-all">
{{ send.file.fileName }} ({{ send.file.sizeName }})
</div>
</div> </div>
<div class="box-content-row" appBoxRow *ngIf="type === sendType.Text"> <div class="box-content-row" appBoxRow *ngIf="type === sendType.Text">
<label for="text">{{ "text" | i18n }}</label> <label for="text">{{ "text" | i18n }}</label>

View File

@ -708,6 +708,9 @@
"addAttachment": { "addAttachment": {
"message": "Add attachment" "message": "Add attachment"
}, },
"itemsTransferred": {
"message": "Items transferred"
},
"fixEncryption": { "fixEncryption": {
"message": "Fix encryption" "message": "Fix encryption"
}, },
@ -4380,5 +4383,56 @@
}, },
"sessionTimeoutSettingsSetUnlockMethodToChangeTimeoutAction": { "sessionTimeoutSettingsSetUnlockMethodToChangeTimeoutAction": {
"message": "Set an unlock method to change your timeout action" "message": "Set an unlock method to change your timeout action"
},
"upgrade": {
"message": "Upgrade"
},
"leaveConfirmationDialogTitle": {
"message": "Are you sure you want to leave?"
},
"leaveConfirmationDialogContentOne": {
"message": "By declining, your personal items will stay in your account, but you'll lose access to shared items and organization features."
},
"leaveConfirmationDialogContentTwo": {
"message": "Contact your admin to regain access."
},
"leaveConfirmationDialogConfirmButton": {
"message": "Leave $ORGANIZATION$",
"placeholders": {
"organization": {
"content": "$1",
"example": "My Org Name"
}
}
},
"howToManageMyVault": {
"message": "How do I manage my vault?"
},
"transferItemsToOrganizationTitle": {
"message": "Transfer items to $ORGANIZATION$",
"placeholders": {
"organization": {
"content": "$1",
"example": "My Org Name"
}
}
},
"transferItemsToOrganizationContent": {
"message": "$ORGANIZATION$ is requiring all items to be owned by the organization for security and compliance. Click accept to transfer ownership of your items.",
"placeholders": {
"organization": {
"content": "$1",
"example": "My Org Name"
}
}
},
"acceptTransfer": {
"message": "Accept transfer"
},
"declineAndLeave": {
"message": "Decline and leave"
},
"whyAmISeeingThis": {
"message": "Why am I seeing this?"
} }
} }

View File

@ -157,7 +157,7 @@ export class CloudHostedPremiumVNextComponent {
return { return {
tier, tier,
price: price:
tier?.passwordManager.type === "standalone" tier?.passwordManager.type === "standalone" && tier.passwordManager.annualPrice
? Number((tier.passwordManager.annualPrice / 12).toFixed(2)) ? Number((tier.passwordManager.annualPrice / 12).toFixed(2))
: 0, : 0,
features: tier?.passwordManager.features.map((f) => f.value) || [], features: tier?.passwordManager.features.map((f) => f.value) || [],
@ -172,7 +172,7 @@ export class CloudHostedPremiumVNextComponent {
return { return {
tier, tier,
price: price:
tier?.passwordManager.type === "packaged" tier?.passwordManager.type === "packaged" && tier.passwordManager.annualPrice
? Number((tier.passwordManager.annualPrice / 12).toFixed(2)) ? Number((tier.passwordManager.annualPrice / 12).toFixed(2))
: 0, : 0,
features: tier?.passwordManager.features.map((f) => f.value) || [], features: tier?.passwordManager.features.map((f) => f.value) || [],

View File

@ -1,15 +1,15 @@
import { CdkTrapFocus } from "@angular/cdk/a11y"; import { CdkTrapFocus } from "@angular/cdk/a11y";
import { CommonModule } from "@angular/common"; import { CommonModule } from "@angular/common";
import { Component, DestroyRef, OnInit, computed, input, output, signal } from "@angular/core"; import { Component, computed, DestroyRef, input, OnInit, output, signal } from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { catchError, of } from "rxjs"; import { catchError, of } from "rxjs";
import { SubscriptionPricingCardDetails } from "@bitwarden/angular/billing/types/subscription-pricing-card-details";
import { SubscriptionPricingServiceAbstraction } from "@bitwarden/common/billing/abstractions/subscription-pricing.service.abstraction"; import { SubscriptionPricingServiceAbstraction } from "@bitwarden/common/billing/abstractions/subscription-pricing.service.abstraction";
import { import {
PersonalSubscriptionPricingTier, PersonalSubscriptionPricingTier,
PersonalSubscriptionPricingTierId, PersonalSubscriptionPricingTierId,
PersonalSubscriptionPricingTierIds, PersonalSubscriptionPricingTierIds,
SubscriptionCadence,
SubscriptionCadenceIds, SubscriptionCadenceIds,
} from "@bitwarden/common/billing/types/subscription-pricing-tier"; } from "@bitwarden/common/billing/types/subscription-pricing-tier";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
@ -32,14 +32,6 @@ export type UpgradeAccountResult = {
plan: PersonalSubscriptionPricingTierId | null; plan: PersonalSubscriptionPricingTierId | null;
}; };
type CardDetails = {
title: string;
tagline: string;
price: { amount: number; cadence: SubscriptionCadence };
button: { text: string; type: ButtonType };
features: string[];
};
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush // FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection // eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({ @Component({
@ -60,8 +52,8 @@ export class UpgradeAccountComponent implements OnInit {
planSelected = output<PersonalSubscriptionPricingTierId>(); planSelected = output<PersonalSubscriptionPricingTierId>();
closeClicked = output<UpgradeAccountStatus>(); closeClicked = output<UpgradeAccountStatus>();
protected readonly loading = signal(true); protected readonly loading = signal(true);
protected premiumCardDetails!: CardDetails; protected premiumCardDetails!: SubscriptionPricingCardDetails;
protected familiesCardDetails!: CardDetails; protected familiesCardDetails!: SubscriptionPricingCardDetails;
protected familiesPlanType = PersonalSubscriptionPricingTierIds.Families; protected familiesPlanType = PersonalSubscriptionPricingTierIds.Families;
protected premiumPlanType = PersonalSubscriptionPricingTierIds.Premium; protected premiumPlanType = PersonalSubscriptionPricingTierIds.Premium;
@ -122,14 +114,16 @@ export class UpgradeAccountComponent implements OnInit {
private createCardDetails( private createCardDetails(
tier: PersonalSubscriptionPricingTier, tier: PersonalSubscriptionPricingTier,
buttonType: ButtonType, buttonType: ButtonType,
): CardDetails { ): SubscriptionPricingCardDetails {
return { return {
title: tier.name, title: tier.name,
tagline: tier.description, tagline: tier.description,
price: { price: tier.passwordManager.annualPrice
amount: tier.passwordManager.annualPrice / 12, ? {
cadence: SubscriptionCadenceIds.Monthly, amount: tier.passwordManager.annualPrice / 12,
}, cadence: SubscriptionCadenceIds.Monthly,
}
: undefined,
button: { button: {
text: this.i18nService.t( text: this.i18nService.t(
this.isFamiliesPlan(tier.id) ? "startFreeFamiliesTrial" : "upgradeToPremium", this.isFamiliesPlan(tier.id) ? "startFreeFamiliesTrial" : "upgradeToPremium",

View File

@ -200,7 +200,8 @@ export class UpgradePaymentService {
} }
private getPasswordManagerSeats(planDetails: PlanDetails): number { private getPasswordManagerSeats(planDetails: PlanDetails): number {
return "users" in planDetails.details.passwordManager return "users" in planDetails.details.passwordManager &&
planDetails.details.passwordManager.users
? planDetails.details.passwordManager.users ? planDetails.details.passwordManager.users
: 0; : 0;
} }

View File

@ -0,0 +1,75 @@
<h2 class="tw-mt-6 tw-mb-2 tw-pb-2.5">{{ "dataRecoveryTitle" | i18n }}</h2>
<div class="tw-max-w-lg">
<p bitTypography="body1" class="tw-mb-4">
{{ "dataRecoveryDescription" | i18n }}
</p>
@if (!diagnosticsCompleted() && !recoveryCompleted()) {
<button
type="button"
bitButton
buttonType="primary"
[bitAction]="runDiagnostics"
class="tw-mb-6"
>
{{ "runDiagnostics" | i18n }}
</button>
}
<div class="tw-space-y-3 tw-mb-6">
@for (step of steps(); track $index) {
@if (
($index === 0 && hasStarted()) ||
($index > 0 &&
(steps()[$index - 1].status === StepStatus.Completed ||
steps()[$index - 1].status === StepStatus.Failed))
) {
<div class="tw-flex tw-items-start tw-gap-3">
<div class="tw-mt-1">
@if (step.status === StepStatus.Failed) {
<i class="bwi bwi-close tw-text-danger" aria-hidden="true"></i>
} @else if (step.status === StepStatus.Completed) {
<i class="bwi bwi-check tw-text-success" aria-hidden="true"></i>
} @else if (step.status === StepStatus.InProgress) {
<i class="bwi bwi-spinner bwi-spin tw-text-primary-600" aria-hidden="true"></i>
} @else {
<i class="bwi bwi-circle tw-text-secondary-300" aria-hidden="true"></i>
}
</div>
<div>
<span
[class.tw-text-danger]="step.status === StepStatus.Failed"
[class.tw-text-success]="step.status === StepStatus.Completed"
[class.tw-text-primary-600]="step.status === StepStatus.InProgress"
[class.tw-font-semibold]="step.status === StepStatus.InProgress"
[class.tw-text-secondary-500]="step.status === StepStatus.NotStarted"
>
{{ step.title }}
</span>
</div>
</div>
}
}
</div>
@if (diagnosticsCompleted()) {
<div class="tw-flex tw-gap-3">
@if (hasIssues() && !recoveryCompleted()) {
<button
type="button"
bitButton
buttonType="primary"
[disabled]="status() === StepStatus.InProgress"
[bitAction]="runRecovery"
>
{{ "repairIssues" | i18n }}
</button>
}
<button type="button" bitButton buttonType="secondary" [bitAction]="saveDiagnosticLogs">
<i class="bwi bwi-download" aria-hidden="true"></i>
{{ "saveDiagnosticLogs" | i18n }}
</button>
</div>
}
</div>

View File

@ -0,0 +1,348 @@
import { ComponentFixture, TestBed } from "@angular/core/testing";
import { mock, MockProxy } from "jest-mock-extended";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service";
import { FileDownloadService } from "@bitwarden/common/platform/abstractions/file-download/file-download.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { FakeAccountService, mockAccountServiceWith } from "@bitwarden/common/spec";
import { UserId } from "@bitwarden/common/types/guid";
import { CipherEncryptionService } from "@bitwarden/common/vault/abstractions/cipher-encryption.service";
import { FolderApiServiceAbstraction } from "@bitwarden/common/vault/abstractions/folder/folder-api.service.abstraction";
import { DialogService } from "@bitwarden/components";
import { KeyService, UserAsymmetricKeysRegenerationService } from "@bitwarden/key-management";
import { LogService } from "@bitwarden/logging";
import { DataRecoveryComponent, StepStatus } from "./data-recovery.component";
import { RecoveryStep, RecoveryWorkingData } from "./steps";
// Mock SdkLoadService
jest.mock("@bitwarden/common/platform/abstractions/sdk/sdk-load.service", () => ({
SdkLoadService: {
Ready: Promise.resolve(),
},
}));
describe("DataRecoveryComponent", () => {
let component: DataRecoveryComponent;
let fixture: ComponentFixture<DataRecoveryComponent>;
// Mock Services
let mockI18nService: MockProxy<I18nService>;
let mockApiService: MockProxy<ApiService>;
let mockAccountService: FakeAccountService;
let mockKeyService: MockProxy<KeyService>;
let mockFolderApiService: MockProxy<FolderApiServiceAbstraction>;
let mockCipherEncryptService: MockProxy<CipherEncryptionService>;
let mockDialogService: MockProxy<DialogService>;
let mockPrivateKeyRegenerationService: MockProxy<UserAsymmetricKeysRegenerationService>;
let mockLogService: MockProxy<LogService>;
let mockCryptoFunctionService: MockProxy<CryptoFunctionService>;
let mockFileDownloadService: MockProxy<FileDownloadService>;
const mockUserId = "user-id" as UserId;
beforeEach(async () => {
mockI18nService = mock<I18nService>();
mockApiService = mock<ApiService>();
mockAccountService = mockAccountServiceWith(mockUserId);
mockKeyService = mock<KeyService>();
mockFolderApiService = mock<FolderApiServiceAbstraction>();
mockCipherEncryptService = mock<CipherEncryptionService>();
mockDialogService = mock<DialogService>();
mockPrivateKeyRegenerationService = mock<UserAsymmetricKeysRegenerationService>();
mockLogService = mock<LogService>();
mockCryptoFunctionService = mock<CryptoFunctionService>();
mockFileDownloadService = mock<FileDownloadService>();
mockI18nService.t.mockImplementation((key) => `${key}_used-i18n`);
await TestBed.configureTestingModule({
imports: [DataRecoveryComponent],
providers: [
{ provide: I18nService, useValue: mockI18nService },
{ provide: ApiService, useValue: mockApiService },
{ provide: AccountService, useValue: mockAccountService },
{ provide: KeyService, useValue: mockKeyService },
{ provide: FolderApiServiceAbstraction, useValue: mockFolderApiService },
{ provide: CipherEncryptionService, useValue: mockCipherEncryptService },
{ provide: DialogService, useValue: mockDialogService },
{
provide: UserAsymmetricKeysRegenerationService,
useValue: mockPrivateKeyRegenerationService,
},
{ provide: LogService, useValue: mockLogService },
{ provide: CryptoFunctionService, useValue: mockCryptoFunctionService },
{ provide: FileDownloadService, useValue: mockFileDownloadService },
],
}).compileComponents();
fixture = TestBed.createComponent(DataRecoveryComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
describe("Component Initialization", () => {
it("should create", () => {
expect(component).toBeTruthy();
});
it("should initialize with default signal values", () => {
expect(component.status()).toBe(StepStatus.NotStarted);
expect(component.hasStarted()).toBe(false);
expect(component.diagnosticsCompleted()).toBe(false);
expect(component.recoveryCompleted()).toBe(false);
expect(component.hasIssues()).toBe(false);
});
it("should initialize steps in correct order", () => {
const steps = component.steps();
expect(steps.length).toBe(5);
expect(steps[0].title).toBe("recoveryStepUserInfoTitle_used-i18n");
expect(steps[1].title).toBe("recoveryStepSyncTitle_used-i18n");
expect(steps[2].title).toBe("recoveryStepPrivateKeyTitle_used-i18n");
expect(steps[3].title).toBe("recoveryStepFoldersTitle_used-i18n");
expect(steps[4].title).toBe("recoveryStepCipherTitle_used-i18n");
});
});
describe("runDiagnostics", () => {
let mockSteps: MockProxy<RecoveryStep>[];
beforeEach(() => {
// Create mock steps
mockSteps = Array(5)
.fill(null)
.map(() => {
const mockStep = mock<RecoveryStep>();
mockStep.title = "mockStep";
mockStep.runDiagnostics.mockResolvedValue(true);
mockStep.canRecover.mockReturnValue(false);
return mockStep;
});
// Replace recovery steps with mocks
component["recoverySteps"] = mockSteps;
});
it("should not run if already running", async () => {
component["status"].set(StepStatus.InProgress);
await component.runDiagnostics();
expect(mockSteps[0].runDiagnostics).not.toHaveBeenCalled();
});
it("should set hasStarted, isRunning and initialize workingData", async () => {
await component.runDiagnostics();
expect(component.hasStarted()).toBe(true);
expect(component["workingData"]).toBeDefined();
expect(component["workingData"]?.userId).toBeNull();
expect(component["workingData"]?.userKey).toBeNull();
});
it("should run diagnostics for all steps", async () => {
await component.runDiagnostics();
mockSteps.forEach((step) => {
expect(step.runDiagnostics).toHaveBeenCalledWith(
component["workingData"],
expect.anything(),
);
});
});
it("should mark steps as completed when diagnostics succeed", async () => {
await component.runDiagnostics();
const steps = component.steps();
steps.forEach((step) => {
expect(step.status).toBe(StepStatus.Completed);
});
});
it("should mark steps as failed when diagnostics return false", async () => {
mockSteps[2].runDiagnostics.mockResolvedValue(false);
await component.runDiagnostics();
const steps = component.steps();
expect(steps[2].status).toBe(StepStatus.Failed);
});
it("should mark steps as failed when diagnostics throw error", async () => {
mockSteps[3].runDiagnostics.mockRejectedValue(new Error("Test error"));
await component.runDiagnostics();
const steps = component.steps();
expect(steps[3].status).toBe(StepStatus.Failed);
expect(steps[3].message).toBe("Test error");
});
it("should continue diagnostics even if a step fails", async () => {
mockSteps[1].runDiagnostics.mockRejectedValue(new Error("Step 1 failed"));
mockSteps[3].runDiagnostics.mockResolvedValue(false);
await component.runDiagnostics();
// All steps should have been called despite failures
mockSteps.forEach((step) => {
expect(step.runDiagnostics).toHaveBeenCalled();
});
});
it("should set hasIssues to true when a step can recover", async () => {
mockSteps[2].runDiagnostics.mockResolvedValue(false);
mockSteps[2].canRecover.mockReturnValue(true);
await component.runDiagnostics();
expect(component.hasIssues()).toBe(true);
});
it("should set hasIssues to false when no step can recover", async () => {
mockSteps.forEach((step) => {
step.runDiagnostics.mockResolvedValue(true);
step.canRecover.mockReturnValue(false);
});
await component.runDiagnostics();
expect(component.hasIssues()).toBe(false);
});
it("should set diagnosticsCompleted and status to completed when complete", async () => {
await component.runDiagnostics();
expect(component.diagnosticsCompleted()).toBe(true);
expect(component.status()).toBe(StepStatus.Completed);
});
});
describe("runRecovery", () => {
let mockSteps: MockProxy<RecoveryStep>[];
let mockWorkingData: RecoveryWorkingData;
beforeEach(() => {
mockWorkingData = {
userId: mockUserId,
userKey: null as any,
isPrivateKeyCorrupt: false,
encryptedPrivateKey: null,
ciphers: [],
folders: [],
};
mockSteps = Array(5)
.fill(null)
.map(() => {
const mockStep = mock<RecoveryStep>();
mockStep.title = "mockStep";
mockStep.canRecover.mockReturnValue(false);
mockStep.runRecovery.mockResolvedValue();
mockStep.runDiagnostics.mockResolvedValue(true);
return mockStep;
});
component["recoverySteps"] = mockSteps;
component["workingData"] = mockWorkingData;
});
it("should not run if already running", async () => {
component["status"].set(StepStatus.InProgress);
await component.runRecovery();
expect(mockSteps[0].runRecovery).not.toHaveBeenCalled();
});
it("should not run if workingData is null", async () => {
component["workingData"] = null;
await component.runRecovery();
expect(mockSteps[0].runRecovery).not.toHaveBeenCalled();
});
it("should only run recovery for steps that can recover", async () => {
mockSteps[1].canRecover.mockReturnValue(true);
mockSteps[3].canRecover.mockReturnValue(true);
await component.runRecovery();
expect(mockSteps[0].runRecovery).not.toHaveBeenCalled();
expect(mockSteps[1].runRecovery).toHaveBeenCalled();
expect(mockSteps[2].runRecovery).not.toHaveBeenCalled();
expect(mockSteps[3].runRecovery).toHaveBeenCalled();
expect(mockSteps[4].runRecovery).not.toHaveBeenCalled();
});
it("should set recoveryCompleted and status when successful", async () => {
mockSteps[1].canRecover.mockReturnValue(true);
await component.runRecovery();
expect(component.recoveryCompleted()).toBe(true);
expect(component.status()).toBe(StepStatus.Completed);
});
it("should set status to failed if recovery is cancelled", async () => {
mockSteps[1].canRecover.mockReturnValue(true);
mockSteps[1].runRecovery.mockRejectedValue(new Error("User cancelled"));
await component.runRecovery();
expect(component.status()).toBe(StepStatus.Failed);
expect(component.recoveryCompleted()).toBe(false);
});
it("should re-run diagnostics after recovery completes", async () => {
mockSteps[1].canRecover.mockReturnValue(true);
await component.runRecovery();
// Diagnostics should be called twice: once for initial diagnostic scan
mockSteps.forEach((step) => {
expect(step.runDiagnostics).toHaveBeenCalledWith(mockWorkingData, expect.anything());
});
});
it("should update hasIssues after re-running diagnostics", async () => {
// Setup initial state with an issue
mockSteps[1].canRecover.mockReturnValue(true);
mockSteps[1].runDiagnostics.mockResolvedValue(false);
// After recovery completes, the issue should be fixed
mockSteps[1].runRecovery.mockImplementation(() => {
// Simulate recovery fixing the issue
mockSteps[1].canRecover.mockReturnValue(false);
mockSteps[1].runDiagnostics.mockResolvedValue(true);
return Promise.resolve();
});
await component.runRecovery();
// Verify hasIssues is updated after re-running diagnostics
expect(component.hasIssues()).toBe(false);
});
});
describe("saveDiagnosticLogs", () => {
it("should call fileDownloadService with log content", () => {
component.saveDiagnosticLogs();
expect(mockFileDownloadService.download).toHaveBeenCalledWith({
fileName: expect.stringContaining("data-recovery-logs-"),
blobData: expect.any(String),
blobOptions: { type: "text/plain" },
});
});
it("should include timestamp in filename", () => {
component.saveDiagnosticLogs();
const downloadCall = mockFileDownloadService.download.mock.calls[0][0];
expect(downloadCall.fileName).toMatch(/data-recovery-logs-\d{4}-\d{2}-\d{2}T.*\.txt/);
});
});
});

View File

@ -0,0 +1,208 @@
import { CommonModule } from "@angular/common";
import { ChangeDetectionStrategy, Component, inject, signal } from "@angular/core";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service";
import { FileDownloadService } from "@bitwarden/common/platform/abstractions/file-download/file-download.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { CipherEncryptionService } from "@bitwarden/common/vault/abstractions/cipher-encryption.service";
import { FolderApiServiceAbstraction } from "@bitwarden/common/vault/abstractions/folder/folder-api.service.abstraction";
import { ButtonModule, DialogService } from "@bitwarden/components";
import { KeyService, UserAsymmetricKeysRegenerationService } from "@bitwarden/key-management";
import { LogService } from "@bitwarden/logging";
import { SharedModule } from "../../shared";
import { LogRecorder } from "./log-recorder";
import {
SyncStep,
UserInfoStep,
RecoveryStep,
PrivateKeyStep,
RecoveryWorkingData,
FolderStep,
CipherStep,
} from "./steps";
export const StepStatus = Object.freeze({
NotStarted: 0,
InProgress: 1,
Completed: 2,
Failed: 3,
} as const);
export type StepStatus = (typeof StepStatus)[keyof typeof StepStatus];
interface StepState {
title: string;
status: StepStatus;
message?: string;
}
@Component({
selector: "app-data-recovery",
templateUrl: "data-recovery.component.html",
standalone: true,
imports: [JslibModule, ButtonModule, CommonModule, SharedModule],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class DataRecoveryComponent {
protected readonly StepStatus = StepStatus;
private i18nService = inject(I18nService);
private apiService = inject(ApiService);
private accountService = inject(AccountService);
private keyService = inject(KeyService);
private folderApiService = inject(FolderApiServiceAbstraction);
private cipherEncryptService = inject(CipherEncryptionService);
private dialogService = inject(DialogService);
private privateKeyRegenerationService = inject(UserAsymmetricKeysRegenerationService);
private cryptoFunctionService = inject(CryptoFunctionService);
private logService = inject(LogService);
private fileDownloadService = inject(FileDownloadService);
private logger: LogRecorder = new LogRecorder(this.logService);
private recoverySteps: RecoveryStep[] = [
new UserInfoStep(this.accountService, this.keyService),
new SyncStep(this.apiService),
new PrivateKeyStep(
this.privateKeyRegenerationService,
this.dialogService,
this.cryptoFunctionService,
),
new FolderStep(this.folderApiService, this.dialogService),
new CipherStep(this.apiService, this.cipherEncryptService, this.dialogService),
];
private workingData: RecoveryWorkingData | null = null;
readonly status = signal<StepStatus>(StepStatus.NotStarted);
readonly hasStarted = signal(false);
readonly diagnosticsCompleted = signal(false);
readonly recoveryCompleted = signal(false);
readonly steps = signal<StepState[]>(
this.recoverySteps.map((step) => ({
title: this.i18nService.t(step.title),
status: StepStatus.NotStarted,
})),
);
readonly hasIssues = signal(false);
runDiagnostics = async () => {
if (this.status() === StepStatus.InProgress) {
return;
}
this.hasStarted.set(true);
this.status.set(StepStatus.InProgress);
this.diagnosticsCompleted.set(false);
this.logger.record("Starting diagnostics...");
this.workingData = {
userId: null,
userKey: null,
isPrivateKeyCorrupt: false,
encryptedPrivateKey: null,
ciphers: [],
folders: [],
};
await this.runDiagnosticsInternal();
this.status.set(StepStatus.Completed);
this.diagnosticsCompleted.set(true);
};
private async runDiagnosticsInternal() {
if (!this.workingData) {
this.logger.record("No working data available");
return;
}
const currentSteps = this.steps();
let hasAnyFailures = false;
for (let i = 0; i < this.recoverySteps.length; i++) {
const step = this.recoverySteps[i];
currentSteps[i].status = StepStatus.InProgress;
this.steps.set([...currentSteps]);
this.logger.record(`Running diagnostics for step: ${step.title}`);
try {
const success = await step.runDiagnostics(this.workingData, this.logger);
currentSteps[i].status = success ? StepStatus.Completed : StepStatus.Failed;
if (!success) {
hasAnyFailures = true;
}
this.steps.set([...currentSteps]);
this.logger.record(`Diagnostics completed for step: ${step.title}`);
} catch (error) {
currentSteps[i].status = StepStatus.Failed;
currentSteps[i].message = (error as Error).message;
this.steps.set([...currentSteps]);
this.logger.record(
`Diagnostics failed for step: ${step.title} with error: ${(error as Error).message}`,
);
hasAnyFailures = true;
}
}
if (hasAnyFailures) {
this.logger.record("Diagnostics completed with errors");
} else {
this.logger.record("Diagnostics completed successfully");
}
// Check if any recovery can be performed
const canRecoverAnyStep = this.recoverySteps.some((step) => step.canRecover(this.workingData!));
this.hasIssues.set(canRecoverAnyStep);
}
runRecovery = async () => {
if (this.status() === StepStatus.InProgress || !this.workingData) {
return;
}
this.status.set(StepStatus.InProgress);
this.recoveryCompleted.set(false);
this.logger.record("Starting recovery process...");
try {
for (let i = 0; i < this.recoverySteps.length; i++) {
const step = this.recoverySteps[i];
if (step.canRecover(this.workingData)) {
this.logger.record(`Running recovery for step: ${step.title}`);
await step.runRecovery(this.workingData, this.logger);
}
}
this.logger.record("Recovery process completed");
this.recoveryCompleted.set(true);
// Re-run diagnostics after recovery
this.logger.record("Re-running diagnostics to verify recovery...");
await this.runDiagnosticsInternal();
this.status.set(StepStatus.Completed);
} catch (error) {
this.logger.record(`Recovery process cancelled or failed: ${(error as Error).message}`);
this.status.set(StepStatus.Failed);
}
};
saveDiagnosticLogs = () => {
const logs = this.logger.getLogs();
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
const filename = `data-recovery-logs-${timestamp}.txt`;
const logContent = logs.join("\n");
this.fileDownloadService.download({
fileName: filename,
blobData: logContent,
blobOptions: { type: "text/plain" },
});
this.logger.record("Diagnostic logs saved");
};
}

View File

@ -0,0 +1,19 @@
import { LogService } from "@bitwarden/logging";
/**
* Record logs during the data recovery process. This only keeps them in memory and does not persist them anywhere.
*/
export class LogRecorder {
private logs: string[] = [];
constructor(private logService: LogService) {}
record(message: string) {
this.logs.push(message);
this.logService.info(`[DataRecovery] ${message}`);
}
getLogs(): string[] {
return [...this.logs];
}
}

View File

@ -0,0 +1,81 @@
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { CipherEncryptionService } from "@bitwarden/common/vault/abstractions/cipher-encryption.service";
import { DialogService } from "@bitwarden/components";
import { LogRecorder } from "../log-recorder";
import { RecoveryStep, RecoveryWorkingData } from "./recovery-step";
export class CipherStep implements RecoveryStep {
title = "recoveryStepCipherTitle";
private undecryptableCipherIds: string[] = [];
constructor(
private apiService: ApiService,
private cipherService: CipherEncryptionService,
private dialogService: DialogService,
) {}
async runDiagnostics(workingData: RecoveryWorkingData, logger: LogRecorder): Promise<boolean> {
if (!workingData.userId) {
logger.record("Missing user ID");
return false;
}
this.undecryptableCipherIds = [];
for (const cipher of workingData.ciphers) {
try {
await this.cipherService.decrypt(cipher, workingData.userId);
} catch {
logger.record(`Cipher ID ${cipher.id} was undecryptable`);
this.undecryptableCipherIds.push(cipher.id);
}
}
logger.record(`Found ${this.undecryptableCipherIds.length} undecryptable ciphers`);
return this.undecryptableCipherIds.length == 0;
}
canRecover(workingData: RecoveryWorkingData): boolean {
return this.undecryptableCipherIds.length > 0;
}
async runRecovery(workingData: RecoveryWorkingData, logger: LogRecorder): Promise<void> {
// Recovery means deleting the broken ciphers.
if (this.undecryptableCipherIds.length === 0) {
logger.record("No undecryptable ciphers to recover");
return;
}
logger.record(`Showing confirmation dialog for ${this.undecryptableCipherIds.length} ciphers`);
const confirmed = await this.dialogService.openSimpleDialog({
title: { key: "recoveryDeleteCiphersTitle" },
content: { key: "recoveryDeleteCiphersDesc" },
acceptButtonText: { key: "ok" },
cancelButtonText: { key: "cancel" },
type: "danger",
});
if (!confirmed) {
logger.record("User cancelled cipher deletion");
throw new Error("Cipher recovery cancelled by user");
}
logger.record(`Deleting ${this.undecryptableCipherIds.length} ciphers`);
for (const cipherId of this.undecryptableCipherIds) {
try {
await this.apiService.deleteCipher(cipherId);
logger.record(`Deleted cipher ${cipherId}`);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
logger.record(`Failed to delete cipher ${cipherId}: ${errorMessage}`);
throw error;
}
}
logger.record(`Successfully deleted ${this.undecryptableCipherIds.length} ciphers`);
}
}

View File

@ -0,0 +1,97 @@
import { SdkLoadService } from "@bitwarden/common/platform/abstractions/sdk/sdk-load.service";
import { FolderApiServiceAbstraction } from "@bitwarden/common/vault/abstractions/folder/folder-api.service.abstraction";
import { DialogService } from "@bitwarden/components";
import { PureCrypto } from "@bitwarden/sdk-internal";
import { LogRecorder } from "../log-recorder";
import { RecoveryStep, RecoveryWorkingData } from "./recovery-step";
export class FolderStep implements RecoveryStep {
title = "recoveryStepFoldersTitle";
private undecryptableFolderIds: string[] = [];
constructor(
private folderService: FolderApiServiceAbstraction,
private dialogService: DialogService,
) {}
async runDiagnostics(workingData: RecoveryWorkingData, logger: LogRecorder): Promise<boolean> {
if (!workingData.userKey) {
logger.record("Missing user key");
return false;
}
this.undecryptableFolderIds = [];
for (const folder of workingData.folders) {
if (!folder.name?.encryptedString) {
logger.record(`Folder ID ${folder.id} has no name`);
this.undecryptableFolderIds.push(folder.id);
continue;
}
try {
await SdkLoadService.Ready;
PureCrypto.symmetric_decrypt_string(
folder.name.encryptedString,
workingData.userKey.toEncoded(),
);
} catch {
logger.record(`Folder name for folder ID ${folder.id} was undecryptable`);
this.undecryptableFolderIds.push(folder.id);
}
}
logger.record(`Found ${this.undecryptableFolderIds.length} undecryptable folders`);
return this.undecryptableFolderIds.length == 0;
}
canRecover(workingData: RecoveryWorkingData): boolean {
return this.undecryptableFolderIds.length > 0;
}
async runRecovery(workingData: RecoveryWorkingData, logger: LogRecorder): Promise<void> {
// Recovery means deleting the broken folders.
if (this.undecryptableFolderIds.length === 0) {
logger.record("No undecryptable folders to recover");
return;
}
if (!workingData.userId) {
logger.record("Missing user ID");
throw new Error("Missing user ID");
}
logger.record(`Showing confirmation dialog for ${this.undecryptableFolderIds.length} folders`);
const confirmed = await this.dialogService.openSimpleDialog({
title: { key: "recoveryDeleteFoldersTitle" },
content: { key: "recoveryDeleteFoldersDesc" },
acceptButtonText: { key: "ok" },
cancelButtonText: { key: "cancel" },
type: "danger",
});
if (!confirmed) {
logger.record("User cancelled folder deletion");
throw new Error("Folder recovery cancelled by user");
}
logger.record(`Deleting ${this.undecryptableFolderIds.length} folders`);
for (const folderId of this.undecryptableFolderIds) {
try {
await this.folderService.delete(folderId, workingData.userId);
logger.record(`Deleted folder ${folderId}`);
} catch (error) {
logger.record(`Failed to delete folder ${folderId}: ${error}`);
}
}
logger.record(`Successfully deleted ${this.undecryptableFolderIds.length} folders`);
}
getUndecryptableFolderIds(): string[] {
return this.undecryptableFolderIds;
}
}

View File

@ -0,0 +1,6 @@
export * from "./sync-step";
export * from "./user-info-step";
export * from "./recovery-step";
export * from "./private-key-step";
export * from "./folder-step";
export * from "./cipher-step";

View File

@ -0,0 +1,93 @@
import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service";
import { SdkLoadService } from "@bitwarden/common/platform/abstractions/sdk/sdk-load.service";
import { EncryptionType } from "@bitwarden/common/platform/enums";
import { DialogService } from "@bitwarden/components";
import { UserAsymmetricKeysRegenerationService } from "@bitwarden/key-management";
import { PureCrypto } from "@bitwarden/sdk-internal";
import { LogRecorder } from "../log-recorder";
import { RecoveryStep, RecoveryWorkingData } from "./recovery-step";
export class PrivateKeyStep implements RecoveryStep {
title = "recoveryStepPrivateKeyTitle";
constructor(
private privateKeyRegenerationService: UserAsymmetricKeysRegenerationService,
private dialogService: DialogService,
private cryptoFunctionService: CryptoFunctionService,
) {}
async runDiagnostics(workingData: RecoveryWorkingData, logger: LogRecorder): Promise<boolean> {
if (!workingData.userId || !workingData.userKey) {
logger.record("Missing user ID or user key");
return false;
}
// Make sure the private key decrypts properly and is not somehow encrypted by a different user key / broken during key rotation.
const encryptedPrivateKey = workingData.encryptedPrivateKey;
if (!encryptedPrivateKey) {
logger.record("No encrypted private key found");
return false;
}
logger.record("Private key length: " + encryptedPrivateKey.length);
let privateKey: Uint8Array;
try {
await SdkLoadService.Ready;
privateKey = PureCrypto.unwrap_decapsulation_key(
encryptedPrivateKey,
workingData.userKey.toEncoded(),
);
} catch {
logger.record("Private key was un-decryptable");
workingData.isPrivateKeyCorrupt = true;
return false;
}
// Make sure the contained private key can be parsed and the public key can be derived. If not, then the private key may be corrupt / generated with an incompatible ASN.1 representation / with incompatible padding.
try {
const publicKey = await this.cryptoFunctionService.rsaExtractPublicKey(privateKey);
logger.record("Public key length: " + publicKey.length);
} catch {
logger.record("Public key could not be derived; private key is corrupt");
workingData.isPrivateKeyCorrupt = true;
return false;
}
return true;
}
canRecover(workingData: RecoveryWorkingData): boolean {
// Only support recovery on V1 users.
return (
workingData.isPrivateKeyCorrupt &&
workingData.userKey !== null &&
workingData.userKey.inner().type === EncryptionType.AesCbc256_HmacSha256_B64
);
}
async runRecovery(workingData: RecoveryWorkingData, logger: LogRecorder): Promise<void> {
// The recovery step is to replace the key pair. Currently, this only works if the user is not using emergency access or is part of an organization.
// This is because this will break emergency access enrollments / organization memberships / provider memberships.
logger.record("Showing confirmation dialog for private key replacement");
const confirmed = await this.dialogService.openSimpleDialog({
title: { key: "recoveryReplacePrivateKeyTitle" },
content: { key: "recoveryReplacePrivateKeyDesc" },
acceptButtonText: { key: "ok" },
cancelButtonText: { key: "cancel" },
type: "danger",
});
if (!confirmed) {
logger.record("User cancelled private key replacement");
throw new Error("Private key recovery cancelled by user");
}
logger.record("Replacing private key");
await this.privateKeyRegenerationService.regenerateUserPublicKeyEncryptionKeyPair(
workingData.userId!,
);
logger.record("Private key replaced successfully");
}
}

View File

@ -0,0 +1,43 @@
import { WrappedPrivateKey } from "@bitwarden/common/key-management/types";
import { UserKey } from "@bitwarden/common/types/key";
import { Cipher } from "@bitwarden/common/vault/models/domain/cipher";
import { Folder } from "@bitwarden/common/vault/models/domain/folder";
import { UserId } from "@bitwarden/user-core";
import { LogRecorder } from "../log-recorder";
/**
* A recovery step performs diagnostics and recovery actions on a specific domain, such as ciphers.
*/
export abstract class RecoveryStep {
/** Title of the recovery step, as an i18n key. */
abstract title: string;
/**
* Runs diagnostics on the provided working data.
* Returns true if no issues were found, false otherwise.
*/
abstract runDiagnostics(workingData: RecoveryWorkingData, logger: LogRecorder): Promise<boolean>;
/**
* Returns whether recovery can be performed
*/
abstract canRecover(workingData: RecoveryWorkingData): boolean;
/**
* Performs recovery on the provided working data.
*/
abstract runRecovery(workingData: RecoveryWorkingData, logger: LogRecorder): Promise<void>;
}
/**
* Data used during the recovery process, passed between steps.
*/
export type RecoveryWorkingData = {
userId: UserId | null;
userKey: UserKey | null;
encryptedPrivateKey: WrappedPrivateKey | null;
isPrivateKeyCorrupt: boolean;
ciphers: Cipher[];
folders: Folder[];
};

View File

@ -0,0 +1,43 @@
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { CipherData } from "@bitwarden/common/vault/models/data/cipher.data";
import { FolderData } from "@bitwarden/common/vault/models/data/folder.data";
import { Cipher } from "@bitwarden/common/vault/models/domain/cipher";
import { Folder } from "@bitwarden/common/vault/models/domain/folder";
import { LogRecorder } from "../log-recorder";
import { RecoveryStep, RecoveryWorkingData } from "./recovery-step";
export class SyncStep implements RecoveryStep {
title = "recoveryStepSyncTitle";
constructor(private apiService: ApiService) {}
async runDiagnostics(workingData: RecoveryWorkingData, logger: LogRecorder): Promise<boolean> {
// The intent of this step is to fetch the latest data from the server. Diagnostics does not
// ever run on local data but only remote data that is recent.
const response = await this.apiService.getSync();
workingData.ciphers = response.ciphers.map((c) => new Cipher(new CipherData(c)));
logger.record(`Fetched ${workingData.ciphers.length} ciphers from server`);
workingData.folders = response.folders.map((f) => new Folder(new FolderData(f)));
logger.record(`Fetched ${workingData.folders.length} folders from server`);
workingData.encryptedPrivateKey =
response.profile?.accountKeys?.publicKeyEncryptionKeyPair?.wrappedPrivateKey ?? null;
logger.record(
`Fetched encrypted private key of length ${workingData.encryptedPrivateKey?.length ?? 0} from server`,
);
return true;
}
canRecover(workingData: RecoveryWorkingData): boolean {
return false;
}
runRecovery(workingData: RecoveryWorkingData, logger: LogRecorder): Promise<void> {
return Promise.resolve();
}
}

View File

@ -0,0 +1,49 @@
import { firstValueFrom } from "rxjs";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { EncryptionType } from "@bitwarden/common/platform/enums";
import { KeyService } from "@bitwarden/key-management";
import { LogRecorder } from "../log-recorder";
import { RecoveryStep, RecoveryWorkingData } from "./recovery-step";
export class UserInfoStep implements RecoveryStep {
title = "recoveryStepUserInfoTitle";
constructor(
private accountService: AccountService,
private keyService: KeyService,
) {}
async runDiagnostics(workingData: RecoveryWorkingData, logger: LogRecorder): Promise<boolean> {
const activeAccount = await firstValueFrom(this.accountService.activeAccount$);
if (!activeAccount) {
logger.record("No active account found");
return false;
}
const userId = activeAccount.id;
workingData.userId = userId;
logger.record(`User ID: ${userId}`);
const userKey = await firstValueFrom(this.keyService.userKey$(userId));
if (!userKey) {
logger.record("No user key found");
return false;
}
workingData.userKey = userKey;
logger.record(
`User encryption type: ${userKey.inner().type === EncryptionType.AesCbc256_HmacSha256_B64 ? "V1" : userKey.inner().type === EncryptionType.CoseEncrypt0 ? "Cose" : "Unknown"}`,
);
return true;
}
canRecover(workingData: RecoveryWorkingData): boolean {
return false;
}
runRecovery(workingData: RecoveryWorkingData, logger: LogRecorder): Promise<void> {
return Promise.resolve();
}
}

View File

@ -78,6 +78,7 @@ import { freeTrialTextResolver } from "./billing/trial-initiation/complete-trial
import { EnvironmentSelectorComponent } from "./components/environment-selector/environment-selector.component"; import { EnvironmentSelectorComponent } from "./components/environment-selector/environment-selector.component";
import { RouteDataProperties } from "./core"; import { RouteDataProperties } from "./core";
import { ReportsModule } from "./dirt/reports"; import { ReportsModule } from "./dirt/reports";
import { DataRecoveryComponent } from "./key-management/data-recovery/data-recovery.component";
import { ConfirmKeyConnectorDomainComponent } from "./key-management/key-connector/confirm-key-connector-domain.component"; import { ConfirmKeyConnectorDomainComponent } from "./key-management/key-connector/confirm-key-connector-domain.component";
import { RemovePasswordComponent } from "./key-management/key-connector/remove-password.component"; import { RemovePasswordComponent } from "./key-management/key-connector/remove-password.component";
import { FrontendLayoutComponent } from "./layouts/frontend-layout.component"; import { FrontendLayoutComponent } from "./layouts/frontend-layout.component";
@ -696,6 +697,12 @@ const routes: Routes = [
path: "security", path: "security",
loadChildren: () => SecurityRoutingModule, loadChildren: () => SecurityRoutingModule,
}, },
{
path: "data-recovery",
component: DataRecoveryComponent,
canActivate: [canAccessFeature(FeatureFlag.DataRecoveryTool)],
data: { titleId: "dataRecovery" } satisfies RouteDataProperties,
},
{ {
path: "domain-rules", path: "domain-rules",
component: DomainRulesComponent, component: DomainRulesComponent,

View File

@ -1,4 +1,4 @@
<p>{{ send.file.fileName }}</p> <p class="tw-text-wrap tw-break-all">{{ send.file.fileName }}</p>
<button bitButton type="button" buttonType="primary" [bitAction]="download" [block]="true"> <button bitButton type="button" buttonType="primary" [bitAction]="download" [block]="true">
<i class="bwi bwi-download" aria-hidden="true"></i> <i class="bwi bwi-download" aria-hidden="true"></i>
{{ "downloadAttachments" | i18n }} ({{ send.file.sizeName }}) {{ "downloadAttachments" | i18n }} ({{ send.file.sizeName }})

View File

@ -84,7 +84,7 @@ import {
CipherViewLikeUtils, CipherViewLikeUtils,
} from "@bitwarden/common/vault/utils/cipher-view-like-utils"; } from "@bitwarden/common/vault/utils/cipher-view-like-utils";
import { filterOutNullish } from "@bitwarden/common/vault/utils/observable-utilities"; import { filterOutNullish } from "@bitwarden/common/vault/utils/observable-utilities";
import { DialogRef, DialogService, ToastService, BannerComponent } from "@bitwarden/components"; import { DialogRef, DialogService, ToastService } from "@bitwarden/components";
import { CipherListView } from "@bitwarden/sdk-internal"; import { CipherListView } from "@bitwarden/sdk-internal";
import { import {
AddEditFolderDialogComponent, AddEditFolderDialogComponent,
@ -97,6 +97,8 @@ import {
DecryptionFailureDialogComponent, DecryptionFailureDialogComponent,
DefaultCipherFormConfigService, DefaultCipherFormConfigService,
PasswordRepromptService, PasswordRepromptService,
VaultItemsTransferService,
DefaultVaultItemsTransferService,
} from "@bitwarden/vault"; } from "@bitwarden/vault";
import { UnifiedUpgradePromptService } from "@bitwarden/web-vault/app/billing/individual/upgrade/services"; import { UnifiedUpgradePromptService } from "@bitwarden/web-vault/app/billing/individual/upgrade/services";
import { OrganizationWarningsModule } from "@bitwarden/web-vault/app/billing/organizations/warnings/organization-warnings.module"; import { OrganizationWarningsModule } from "@bitwarden/web-vault/app/billing/organizations/warnings/organization-warnings.module";
@ -177,12 +179,12 @@ type EmptyStateMap = Record<EmptyStateType, EmptyStateItem>;
VaultItemsModule, VaultItemsModule,
SharedModule, SharedModule,
OrganizationWarningsModule, OrganizationWarningsModule,
BannerComponent,
], ],
providers: [ providers: [
RoutedVaultFilterService, RoutedVaultFilterService,
RoutedVaultFilterBridgeService, RoutedVaultFilterBridgeService,
DefaultCipherFormConfigService, DefaultCipherFormConfigService,
{ provide: VaultItemsTransferService, useClass: DefaultVaultItemsTransferService },
], ],
}) })
export class VaultComponent<C extends CipherViewLike> implements OnInit, OnDestroy { export class VaultComponent<C extends CipherViewLike> implements OnInit, OnDestroy {
@ -349,6 +351,7 @@ export class VaultComponent<C extends CipherViewLike> implements OnInit, OnDestr
private premiumUpgradePromptService: PremiumUpgradePromptService, private premiumUpgradePromptService: PremiumUpgradePromptService,
private autoConfirmService: AutomaticUserConfirmationService, private autoConfirmService: AutomaticUserConfirmationService,
private configService: ConfigService, private configService: ConfigService,
private vaultItemTransferService: VaultItemsTransferService,
) {} ) {}
async ngOnInit() { async ngOnInit() {
@ -644,6 +647,8 @@ export class VaultComponent<C extends CipherViewLike> implements OnInit, OnDestr
void this.unifiedUpgradePromptService.displayUpgradePromptConditionally(); void this.unifiedUpgradePromptService.displayUpgradePromptConditionally();
this.setupAutoConfirm(); this.setupAutoConfirm();
void this.vaultItemTransferService.enforceOrganizationDataOwnership(activeUserId);
} }
ngOnDestroy() { ngOnDestroy() {

View File

@ -5185,6 +5185,9 @@
"oldAttachmentsNeedFixDesc": { "oldAttachmentsNeedFixDesc": {
"message": "There are old file attachments in your vault that need to be fixed before you can rotate your account's encryption key." "message": "There are old file attachments in your vault that need to be fixed before you can rotate your account's encryption key."
}, },
"itemsTransferred": {
"message": "Items transferred"
},
"yourAccountsFingerprint": { "yourAccountsFingerprint": {
"message": "Your account's fingerprint phrase", "message": "Your account's fingerprint phrase",
"description": "A 'fingerprint phrase' is a unique word phrase (similar to a passphrase) that a user can use to authenticate their public key with another user, for the purposes of sharing." "description": "A 'fingerprint phrase' is a unique word phrase (similar to a passphrase) that a user can use to authenticate their public key with another user, for the purposes of sharing."
@ -12250,6 +12253,54 @@
"userVerificationFailed": { "userVerificationFailed": {
"message": "User verification failed." "message": "User verification failed."
}, },
"recoveryDeleteCiphersTitle": {
"message": "Delete unrecoverable vault items"
},
"recoveryDeleteCiphersDesc": {
"message": "Some of your vault items could not be recovered. Do you want to delete these unrecoverable items from your vault?"
},
"recoveryDeleteFoldersTitle": {
"message": "Delete unrecoverable folders"
},
"recoveryDeleteFoldersDesc": {
"message": "Some of your folders could not be recovered. Do you want to delete these unrecoverable folders from your vault?"
},
"recoveryReplacePrivateKeyTitle": {
"message": "Replace encryption key"
},
"recoveryReplacePrivateKeyDesc": {
"message": "Your public-key encryption key pair could not be recovered. Do you want to replace your encryption key with a new key pair? This will require you to set up existing emergency-access and organization memberships again."
},
"recoveryStepSyncTitle": {
"message": "Synchronizing data"
},
"recoveryStepPrivateKeyTitle": {
"message": "Verifying encryption key integrity"
},
"recoveryStepUserInfoTitle": {
"message": "Verifying user information"
},
"recoveryStepCipherTitle": {
"message": "Verifying vault item integrity"
},
"recoveryStepFoldersTitle": {
"message": "Verifying folder integrity"
},
"dataRecoveryTitle": {
"message": "Data Recovery and Diagnostics"
},
"dataRecoveryDescription": {
"message": "Use the data recovery tool to diagnose and repair issues with your account. After running diagnostics you have the option to save diagnostic logs for support and the option to repair any detected issues."
},
"runDiagnostics": {
"message": "Run Diagnostics"
},
"repairIssues": {
"message": "Repair Issues"
},
"saveDiagnosticLogs": {
"message": "Save Diagnostic Logs"
},
"sessionTimeoutSettingsManagedByOrganization": { "sessionTimeoutSettingsManagedByOrganization": {
"message": "This setting is managed by your organization." "message": "This setting is managed by your organization."
}, },
@ -12287,5 +12338,53 @@
}, },
"sessionTimeoutSettingsSetUnlockMethodToChangeTimeoutAction": { "sessionTimeoutSettingsSetUnlockMethodToChangeTimeoutAction": {
"message": "Set an unlock method to change your timeout action" "message": "Set an unlock method to change your timeout action"
},
"leaveConfirmationDialogTitle": {
"message": "Are you sure you want to leave?"
},
"leaveConfirmationDialogContentOne": {
"message": "By declining, your personal items will stay in your account, but you'll lose access to shared items and organization features."
},
"leaveConfirmationDialogContentTwo": {
"message": "Contact your admin to regain access."
},
"leaveConfirmationDialogConfirmButton": {
"message": "Leave $ORGANIZATION$",
"placeholders": {
"organization": {
"content": "$1",
"example": "My Org Name"
}
}
},
"howToManageMyVault": {
"message": "How do I manage my vault?"
},
"transferItemsToOrganizationTitle": {
"message": "Transfer items to $ORGANIZATION$",
"placeholders": {
"organization": {
"content": "$1",
"example": "My Org Name"
}
}
},
"transferItemsToOrganizationContent": {
"message": "$ORGANIZATION$ is requiring all items to be owned by the organization for security and compliance. Click accept to transfer ownership of your items.",
"placeholders": {
"organization": {
"content": "$1",
"example": "My Org Name"
}
}
},
"acceptTransfer": {
"message": "Accept transfer"
},
"declineAndLeave": {
"message": "Decline and leave"
},
"whyAmISeeingThis": {
"message": "Why am I seeing this?"
} }
} }

View File

@ -45,7 +45,11 @@
tabindex="0" tabindex="0"
[attr.aria-label]="'viewItem' | i18n" [attr.aria-label]="'viewItem' | i18n"
> >
<app-vault-icon *ngIf="row.iconCipher" [cipher]="row.iconCipher"></app-vault-icon> <app-vault-icon
*ngIf="row.iconCipher"
[cipher]="row.iconCipher"
[size]="24"
></app-vault-icon>
</td> </td>
<td <td
class="tw-cursor-pointer" class="tw-cursor-pointer"

View File

@ -14,10 +14,11 @@ import { BadgeModule } from "@bitwarden/components";
type="button" type="button"
*appNotPremium *appNotPremium
bitBadge bitBadge
variant="success" [variant]="'primary'"
class="!tw-text-primary-600 !tw-border-primary-600"
(click)="promptForPremium($event)" (click)="promptForPremium($event)"
> >
{{ "premium" | i18n }} <i class="bwi bwi-premium tw-pe-1"></i>{{ "upgrade" | i18n }}
</button> </button>
`, `,
imports: [BadgeModule, JslibModule], imports: [BadgeModule, JslibModule],

View File

@ -29,7 +29,7 @@ export default {
provide: I18nService, provide: I18nService,
useFactory: () => { useFactory: () => {
return new I18nMockService({ return new I18nMockService({
premium: "Premium", upgrade: "Upgrade",
}); });
}, },
}, },

View File

@ -20,33 +20,35 @@
<div <div
class="tw-box-border tw-bg-background tw-text-main tw-size-full tw-flex tw-flex-col tw-px-8 tw-pb-2 tw-w-full tw-max-w-md" class="tw-box-border tw-bg-background tw-text-main tw-size-full tw-flex tw-flex-col tw-px-8 tw-pb-2 tw-w-full tw-max-w-md"
> >
<div class="tw-flex tw-items-center tw-justify-between tw-mb-2"> <div class="tw-flex tw-items-center tw-justify-between">
<h3 slot="title" class="tw-m-0" bitTypography="h3"> <h3 slot="title" class="tw-m-0" bitTypography="h3">
{{ "upgradeToPremium" | i18n }} {{ "upgradeToPremium" | i18n }}
</h3> </h3>
</div> </div>
<!-- Tagline with consistent height (exactly 2 lines) --> <!-- Tagline with consistent height (exactly 2 lines) -->
<div class="tw-mb-6 tw-h-6"> <div class="tw-h-6">
<p bitTypography="helper" class="tw-text-muted tw-m-0 tw-leading-relaxed tw-line-clamp-2"> <p bitTypography="helper" class="tw-text-muted tw-m-0 tw-leading-relaxed tw-line-clamp-2">
{{ cardDetails.tagline }} {{ cardDetails.tagline }}
</p> </p>
</div> </div>
<!-- Price Section --> <!-- Price Section -->
<div class="tw-mb-6"> @if (cardDetails.price) {
<div class="tw-flex tw-items-baseline tw-gap-1 tw-flex-wrap"> <div class="tw-mt-5">
<span class="tw-text-3xl tw-font-medium tw-leading-none tw-m-0">{{ <div class="tw-flex tw-items-baseline tw-gap-1 tw-flex-wrap">
cardDetails.price.amount | currency: "$" <span class="tw-text-3xl tw-font-medium tw-leading-none tw-m-0">{{
}}</span> cardDetails.price.amount | currency: "$"
<span bitTypography="helper" class="tw-text-muted"> }}</span>
/ {{ cardDetails.price.cadence | i18n }} <span bitTypography="helper" class="tw-text-muted">
</span> / {{ cardDetails.price.cadence | i18n }}
</span>
</div>
</div> </div>
</div> }
<!-- Button space (always reserved) --> <!-- Button space (always reserved) -->
<div class="tw-mb-6 tw-h-12"> <div class="tw-my-5 tw-h-12">
<button <button
bitButton bitButton
[buttonType]="cardDetails.button.type" [buttonType]="cardDetails.button.type"

View File

@ -206,4 +206,39 @@ describe("PremiumUpgradeDialogComponent", () => {
}); });
}); });
}); });
describe("self-hosted environment", () => {
it("should handle null price data for self-hosted environment", async () => {
const selfHostedPremiumTier: PersonalSubscriptionPricingTier = {
id: PersonalSubscriptionPricingTierIds.Premium,
name: "Premium",
description: "Advanced features for power users",
availableCadences: [SubscriptionCadenceIds.Annually],
passwordManager: {
type: "standalone",
annualPrice: undefined as any, // self-host will have these prices empty
annualPricePerAdditionalStorageGB: undefined as any,
providedStorageGB: undefined as any,
features: [
{ key: "feature1", value: "Feature 1" },
{ key: "feature2", value: "Feature 2" },
],
},
};
mockSubscriptionPricingService.getPersonalSubscriptionPricingTiers$.mockReturnValue(
of([selfHostedPremiumTier]),
);
const selfHostedFixture = TestBed.createComponent(PremiumUpgradeDialogComponent);
const selfHostedComponent = selfHostedFixture.componentInstance;
selfHostedFixture.detectChanges();
const cardDetails = await firstValueFrom(selfHostedComponent["cardDetails$"]);
expect(cardDetails?.title).toBe("Premium");
expect(cardDetails?.price).toBeUndefined();
expect(cardDetails?.features).toEqual(["Feature 1", "Feature 2"]);
});
});
}); });

View File

@ -42,6 +42,23 @@ const mockPremiumTier: PersonalSubscriptionPricingTier = {
}, },
}; };
const mockPremiumTierNoPricingData: PersonalSubscriptionPricingTier = {
id: PersonalSubscriptionPricingTierIds.Premium,
name: "Premium",
description: "Complete online security",
availableCadences: [SubscriptionCadenceIds.Annually],
passwordManager: {
type: "standalone",
features: [
{ key: "builtInAuthenticator", value: "Built-in authenticator" },
{ key: "secureFileStorage", value: "Secure file storage" },
{ key: "emergencyAccess", value: "Emergency access" },
{ key: "breachMonitoring", value: "Breach monitoring" },
{ key: "andMoreFeatures", value: "And more!" },
],
},
};
export default { export default {
title: "Billing/Premium Upgrade Dialog", title: "Billing/Premium Upgrade Dialog",
component: PremiumUpgradeDialogComponent, component: PremiumUpgradeDialogComponent,
@ -86,11 +103,11 @@ export default {
t: (key: string) => { t: (key: string) => {
switch (key) { switch (key) {
case "upgradeNow": case "upgradeNow":
return "Upgrade Now"; return "Upgrade now";
case "month": case "month":
return "month"; return "month";
case "upgradeToPremium": case "upgradeToPremium":
return "Upgrade To Premium"; return "Upgrade to Premium";
default: default:
return key; return key;
} }
@ -116,3 +133,18 @@ export default {
type Story = StoryObj<PremiumUpgradeDialogComponent>; type Story = StoryObj<PremiumUpgradeDialogComponent>;
export const Default: Story = {}; export const Default: Story = {};
export const NoPricingData: Story = {
decorators: [
moduleMetadata({
providers: [
{
provide: SubscriptionPricingServiceAbstraction,
useValue: {
getPersonalSubscriptionPricingTiers$: () => of([mockPremiumTierNoPricingData]),
},
},
],
}),
],
};

View File

@ -3,12 +3,12 @@ import { CommonModule } from "@angular/common";
import { ChangeDetectionStrategy, Component } from "@angular/core"; import { ChangeDetectionStrategy, Component } from "@angular/core";
import { catchError, EMPTY, firstValueFrom, map, Observable } from "rxjs"; import { catchError, EMPTY, firstValueFrom, map, Observable } from "rxjs";
import { SubscriptionPricingCardDetails } from "@bitwarden/angular/billing/types/subscription-pricing-card-details";
import { JslibModule } from "@bitwarden/angular/jslib.module"; import { JslibModule } from "@bitwarden/angular/jslib.module";
import { SubscriptionPricingServiceAbstraction } from "@bitwarden/common/billing/abstractions/subscription-pricing.service.abstraction"; import { SubscriptionPricingServiceAbstraction } from "@bitwarden/common/billing/abstractions/subscription-pricing.service.abstraction";
import { import {
PersonalSubscriptionPricingTier, PersonalSubscriptionPricingTier,
PersonalSubscriptionPricingTierIds, PersonalSubscriptionPricingTierIds,
SubscriptionCadence,
SubscriptionCadenceIds, SubscriptionCadenceIds,
} from "@bitwarden/common/billing/types/subscription-pricing-tier"; } from "@bitwarden/common/billing/types/subscription-pricing-tier";
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
@ -16,7 +16,6 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { import {
ButtonModule, ButtonModule,
ButtonType,
CenterPositionStrategy, CenterPositionStrategy,
DialogModule, DialogModule,
DialogRef, DialogRef,
@ -27,14 +26,6 @@ import {
} from "@bitwarden/components"; } from "@bitwarden/components";
import { LogService } from "@bitwarden/logging"; import { LogService } from "@bitwarden/logging";
type CardDetails = {
title: string;
tagline: string;
price: { amount: number; cadence: SubscriptionCadence };
button: { text: string; type: ButtonType; icon?: { type: string; position: "before" | "after" } };
features: string[];
};
@Component({ @Component({
selector: "billing-premium-upgrade-dialog", selector: "billing-premium-upgrade-dialog",
standalone: true, standalone: true,
@ -51,9 +42,8 @@ type CardDetails = {
templateUrl: "./premium-upgrade-dialog.component.html", templateUrl: "./premium-upgrade-dialog.component.html",
}) })
export class PremiumUpgradeDialogComponent { export class PremiumUpgradeDialogComponent {
protected cardDetails$: Observable<CardDetails | null> = this.subscriptionPricingService protected cardDetails$: Observable<SubscriptionPricingCardDetails | null> =
.getPersonalSubscriptionPricingTiers$() this.subscriptionPricingService.getPersonalSubscriptionPricingTiers$().pipe(
.pipe(
map((tiers) => tiers.find((tier) => tier.id === PersonalSubscriptionPricingTierIds.Premium)), map((tiers) => tiers.find((tier) => tier.id === PersonalSubscriptionPricingTierIds.Premium)),
map((tier) => this.mapPremiumTierToCardDetails(tier!)), map((tier) => this.mapPremiumTierToCardDetails(tier!)),
catchError((error: unknown) => { catchError((error: unknown) => {
@ -91,14 +81,18 @@ export class PremiumUpgradeDialogComponent {
this.dialogRef.close(); this.dialogRef.close();
} }
private mapPremiumTierToCardDetails(tier: PersonalSubscriptionPricingTier): CardDetails { private mapPremiumTierToCardDetails(
tier: PersonalSubscriptionPricingTier,
): SubscriptionPricingCardDetails {
return { return {
title: tier.name, title: tier.name,
tagline: tier.description, tagline: tier.description,
price: { price: tier.passwordManager.annualPrice
amount: tier.passwordManager.annualPrice / 12, ? {
cadence: SubscriptionCadenceIds.Monthly, amount: tier.passwordManager.annualPrice / 12,
}, cadence: SubscriptionCadenceIds.Monthly,
}
: undefined,
button: { button: {
text: this.i18nService.t("upgradeNow"), text: this.i18nService.t("upgradeNow"),
type: "primary", type: "primary",

View File

@ -0,0 +1,10 @@
import { SubscriptionCadence } from "@bitwarden/common/billing/types/subscription-pricing-tier";
import { ButtonType } from "@bitwarden/components";
export type SubscriptionPricingCardDetails = {
title: string;
tagline: string;
price?: { amount: number; cadence: SubscriptionCadence };
button: { text: string; type: ButtonType; icon?: { type: string; position: "before" | "after" } };
features: string[];
};

View File

@ -1498,7 +1498,13 @@ const safeProviders: SafeProvider[] = [
safeProvider({ safeProvider({
provide: SubscriptionPricingServiceAbstraction, provide: SubscriptionPricingServiceAbstraction,
useClass: DefaultSubscriptionPricingService, useClass: DefaultSubscriptionPricingService,
deps: [BillingApiServiceAbstraction, ConfigService, I18nServiceAbstraction, LogService], deps: [
BillingApiServiceAbstraction,
ConfigService,
I18nServiceAbstraction,
LogService,
EnvironmentService,
],
}), }),
safeProvider({ safeProvider({
provide: OrganizationManagementPreferencesService, provide: OrganizationManagementPreferencesService,

View File

@ -1,9 +1,5 @@
<!-- Applying width and height styles directly to synchronize icon sizing between web/browser/desktop --> <!-- Applying width and height styles directly to synchronize icon sizing between web/browser/desktop -->
<div <div class="tw-flex tw-justify-center tw-items-center" [ngStyle]="iconStyle()" aria-hidden="true">
class="tw-flex tw-justify-center tw-items-center"
[ngStyle]="coloredIcon() ? { width: '36px', height: '36px' } : {}"
aria-hidden="true"
>
<ng-container *ngIf="data$ | async as data"> <ng-container *ngIf="data$ | async as data">
@if (data.imageEnabled && data.image) { @if (data.imageEnabled && data.image) {
<img <img
@ -16,7 +12,7 @@
'tw-invisible tw-absolute': !imageLoaded(), 'tw-invisible tw-absolute': !imageLoaded(),
'tw-size-6': !coloredIcon(), 'tw-size-6': !coloredIcon(),
}" }"
[ngStyle]="coloredIcon() ? { width: '36px', height: '36px' } : {}" [ngStyle]="iconStyle()"
(load)="imageLoaded.set(true)" (load)="imageLoaded.set(true)"
(error)="imageLoaded.set(false)" (error)="imageLoaded.set(false)"
/> />
@ -28,7 +24,7 @@
'tw-bg-illustration-bg-primary tw-rounded-full': 'tw-bg-illustration-bg-primary tw-rounded-full':
data.icon?.startsWith('bwi-') && coloredIcon(), data.icon?.startsWith('bwi-') && coloredIcon(),
}" }"
[ngStyle]="coloredIcon() ? { width: '36px', height: '36px' } : {}" [ngStyle]="iconStyle()"
> >
<i <i
class="tw-text-muted bwi bwi-lg {{ data.icon }}" class="tw-text-muted bwi bwi-lg {{ data.icon }}"
@ -36,6 +32,7 @@
color: coloredIcon() ? 'rgb(var(--color-illustration-outline))' : null, color: coloredIcon() ? 'rgb(var(--color-illustration-outline))' : null,
width: data.icon?.startsWith('credit-card') && coloredIcon() ? '36px' : null, width: data.icon?.startsWith('credit-card') && coloredIcon() ? '36px' : null,
height: data.icon?.startsWith('credit-card') && coloredIcon() ? '30px' : null, height: data.icon?.startsWith('credit-card') && coloredIcon() ? '30px' : null,
fontSize: size() ? size() + 'px' : null,
}" }"
></i> ></i>
</div> </div>

View File

@ -1,4 +1,4 @@
import { ChangeDetectionStrategy, Component, input, signal } from "@angular/core"; import { ChangeDetectionStrategy, Component, computed, input, signal } from "@angular/core";
import { toObservable } from "@angular/core/rxjs-interop"; import { toObservable } from "@angular/core/rxjs-interop";
import { import {
combineLatest, combineLatest,
@ -32,8 +32,32 @@ export class IconComponent {
*/ */
readonly coloredIcon = input<boolean>(false); readonly coloredIcon = input<boolean>(false);
/**
* Optional custom size for the icon in pixels.
* When provided, forces explicit dimensions on the icon wrapper to prevent layout collapse at different zoom levels.
* If not provided, the wrapper has no explicit dimensions and relies on CSS classes (tw-size-6/24px for images).
* This can cause the wrapper to collapse when images are loading/hidden, especially at high browser zoom levels.
* Reference: default image size is tw-size-6 (24px), coloredIcon uses 36px.
*/
readonly size = input<number>();
readonly imageLoaded = signal(false); readonly imageLoaded = signal(false);
/**
* Computed style object for icon dimensions.
* Centralizes the sizing logic to avoid repetition in the template.
*/
protected readonly iconStyle = computed(() => {
if (this.coloredIcon()) {
return { width: "36px", height: "36px" };
}
const size = this.size();
if (size) {
return { width: size + "px", height: size + "px" };
}
return {};
});
protected data$: Observable<CipherIconDetails>; protected data$: Observable<CipherIconDetails>;
constructor( constructor(

View File

@ -11,6 +11,7 @@ export class PolicyData {
type: PolicyType; type: PolicyType;
data: Record<string, string | number | boolean>; data: Record<string, string | number | boolean>;
enabled: boolean; enabled: boolean;
revisionDate: string;
constructor(response?: PolicyResponse) { constructor(response?: PolicyResponse) {
if (response == null) { if (response == null) {
@ -22,6 +23,7 @@ export class PolicyData {
this.type = response.type; this.type = response.type;
this.data = response.data; this.data = response.data;
this.enabled = response.enabled; this.enabled = response.enabled;
this.revisionDate = response.revisionDate;
} }
static fromPolicy(policy: Policy): PolicyData { static fromPolicy(policy: Policy): PolicyData {

View File

@ -19,6 +19,8 @@ export class Policy extends Domain {
*/ */
enabled: boolean; enabled: boolean;
revisionDate: Date;
constructor(obj?: PolicyData) { constructor(obj?: PolicyData) {
super(); super();
if (obj == null) { if (obj == null) {
@ -30,6 +32,7 @@ export class Policy extends Domain {
this.type = obj.type; this.type = obj.type;
this.data = obj.data; this.data = obj.data;
this.enabled = obj.enabled; this.enabled = obj.enabled;
this.revisionDate = new Date(obj.revisionDate);
} }
static fromResponse(response: PolicyResponse): Policy { static fromResponse(response: PolicyResponse): Policy {

View File

@ -9,6 +9,7 @@ export class PolicyResponse extends BaseResponse {
data: any; data: any;
enabled: boolean; enabled: boolean;
canToggleState: boolean; canToggleState: boolean;
revisionDate: string;
constructor(response: any) { constructor(response: any) {
super(response); super(response);
@ -18,5 +19,6 @@ export class PolicyResponse extends BaseResponse {
this.data = this.getResponseProperty("Data"); this.data = this.getResponseProperty("Data");
this.enabled = this.getResponseProperty("Enabled"); this.enabled = this.getResponseProperty("Enabled");
this.canToggleState = this.getResponseProperty("CanToggleState") ?? true; this.canToggleState = this.getResponseProperty("CanToggleState") ?? true;
this.revisionDate = this.getResponseProperty("RevisionDate");
} }
} }

View File

@ -83,12 +83,15 @@ describe("PolicyService", () => {
type: PolicyType.MaximumVaultTimeout, type: PolicyType.MaximumVaultTimeout,
enabled: true, enabled: true,
data: { minutes: 14 }, data: { minutes: 14 },
revisionDate: expect.any(Date),
}, },
{ {
id: "99", id: "99",
organizationId: "test-organization", organizationId: "test-organization",
type: PolicyType.DisableSend, type: PolicyType.DisableSend,
enabled: true, enabled: true,
data: undefined,
revisionDate: expect.any(Date),
}, },
]); ]);
}); });
@ -113,6 +116,8 @@ describe("PolicyService", () => {
organizationId: "test-organization", organizationId: "test-organization",
type: PolicyType.DisableSend, type: PolicyType.DisableSend,
enabled: true, enabled: true,
data: undefined,
revisionDate: expect.any(Date),
}, },
]); ]);
}); });
@ -242,6 +247,8 @@ describe("PolicyService", () => {
organizationId: "org1", organizationId: "org1",
type: PolicyType.DisablePersonalVaultExport, type: PolicyType.DisablePersonalVaultExport,
enabled: true, enabled: true,
data: undefined,
revisionDate: expect.any(Date),
}); });
}); });
@ -331,24 +338,32 @@ describe("PolicyService", () => {
organizationId: "org4", organizationId: "org4",
type: PolicyType.DisablePersonalVaultExport, type: PolicyType.DisablePersonalVaultExport,
enabled: true, enabled: true,
data: undefined,
revisionDate: expect.any(Date),
}, },
{ {
id: "policy2", id: "policy2",
organizationId: "org1", organizationId: "org1",
type: PolicyType.ActivateAutofill, type: PolicyType.ActivateAutofill,
enabled: true, enabled: true,
data: undefined,
revisionDate: expect.any(Date),
}, },
{ {
id: "policy3", id: "policy3",
organizationId: "org5", organizationId: "org5",
type: PolicyType.DisablePersonalVaultExport, type: PolicyType.DisablePersonalVaultExport,
enabled: true, enabled: true,
data: undefined,
revisionDate: expect.any(Date),
}, },
{ {
id: "policy4", id: "policy4",
organizationId: "org1", organizationId: "org1",
type: PolicyType.DisablePersonalVaultExport, type: PolicyType.DisablePersonalVaultExport,
enabled: true, enabled: true,
data: undefined,
revisionDate: expect.any(Date),
}, },
]); ]);
}); });
@ -371,24 +386,32 @@ describe("PolicyService", () => {
organizationId: "org4", organizationId: "org4",
type: PolicyType.DisablePersonalVaultExport, type: PolicyType.DisablePersonalVaultExport,
enabled: true, enabled: true,
data: undefined,
revisionDate: expect.any(Date),
}, },
{ {
id: "policy2", id: "policy2",
organizationId: "org1", organizationId: "org1",
type: PolicyType.ActivateAutofill, type: PolicyType.ActivateAutofill,
enabled: true, enabled: true,
data: undefined,
revisionDate: expect.any(Date),
}, },
{ {
id: "policy3", id: "policy3",
organizationId: "org5", organizationId: "org5",
type: PolicyType.DisablePersonalVaultExport, type: PolicyType.DisablePersonalVaultExport,
enabled: false, enabled: false,
data: undefined,
revisionDate: expect.any(Date),
}, },
{ {
id: "policy4", id: "policy4",
organizationId: "org1", organizationId: "org1",
type: PolicyType.DisablePersonalVaultExport, type: PolicyType.DisablePersonalVaultExport,
enabled: true, enabled: true,
data: undefined,
revisionDate: expect.any(Date),
}, },
]); ]);
}); });
@ -411,24 +434,32 @@ describe("PolicyService", () => {
organizationId: "org4", organizationId: "org4",
type: PolicyType.DisablePersonalVaultExport, type: PolicyType.DisablePersonalVaultExport,
enabled: true, enabled: true,
data: undefined,
revisionDate: expect.any(Date),
}, },
{ {
id: "policy2", id: "policy2",
organizationId: "org1", organizationId: "org1",
type: PolicyType.ActivateAutofill, type: PolicyType.ActivateAutofill,
enabled: true, enabled: true,
data: undefined,
revisionDate: expect.any(Date),
}, },
{ {
id: "policy3", id: "policy3",
organizationId: "org5", organizationId: "org5",
type: PolicyType.DisablePersonalVaultExport, type: PolicyType.DisablePersonalVaultExport,
enabled: true, enabled: true,
data: undefined,
revisionDate: expect.any(Date),
}, },
{ {
id: "policy4", id: "policy4",
organizationId: "org2", organizationId: "org2",
type: PolicyType.DisablePersonalVaultExport, type: PolicyType.DisablePersonalVaultExport,
enabled: true, enabled: true,
data: undefined,
revisionDate: expect.any(Date),
}, },
]); ]);
}); });
@ -451,24 +482,32 @@ describe("PolicyService", () => {
organizationId: "org4", organizationId: "org4",
type: PolicyType.DisablePersonalVaultExport, type: PolicyType.DisablePersonalVaultExport,
enabled: true, enabled: true,
data: undefined,
revisionDate: expect.any(Date),
}, },
{ {
id: "policy2", id: "policy2",
organizationId: "org1", organizationId: "org1",
type: PolicyType.ActivateAutofill, type: PolicyType.ActivateAutofill,
enabled: true, enabled: true,
data: undefined,
revisionDate: expect.any(Date),
}, },
{ {
id: "policy3", id: "policy3",
organizationId: "org3", organizationId: "org3",
type: PolicyType.DisablePersonalVaultExport, type: PolicyType.DisablePersonalVaultExport,
enabled: true, enabled: true,
data: undefined,
revisionDate: expect.any(Date),
}, },
{ {
id: "policy4", id: "policy4",
organizationId: "org1", organizationId: "org1",
type: PolicyType.DisablePersonalVaultExport, type: PolicyType.DisablePersonalVaultExport,
enabled: true, enabled: true,
data: undefined,
revisionDate: expect.any(Date),
}, },
]); ]);
}); });
@ -788,6 +827,7 @@ describe("PolicyService", () => {
policyData.type = type; policyData.type = type;
policyData.enabled = enabled; policyData.enabled = enabled;
policyData.data = data; policyData.data = data;
policyData.revisionDate = new Date().toISOString();
return policyData; return policyData;
} }

View File

@ -6,6 +6,10 @@ import { PlanType, ProductTierType } from "@bitwarden/common/billing/enums";
import { PlanResponse } from "@bitwarden/common/billing/models/response/plan.response"; import { PlanResponse } from "@bitwarden/common/billing/models/response/plan.response";
import { PremiumPlanResponse } from "@bitwarden/common/billing/models/response/premium-plan.response"; import { PremiumPlanResponse } from "@bitwarden/common/billing/models/response/premium-plan.response";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import {
EnvironmentService,
Region,
} from "@bitwarden/common/platform/abstractions/environment.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/logging"; import { LogService } from "@bitwarden/logging";
@ -23,6 +27,7 @@ describe("DefaultSubscriptionPricingService", () => {
let configService: MockProxy<ConfigService>; let configService: MockProxy<ConfigService>;
let i18nService: MockProxy<I18nService>; let i18nService: MockProxy<I18nService>;
let logService: MockProxy<LogService>; let logService: MockProxy<LogService>;
let environmentService: MockProxy<EnvironmentService>;
const mockFamiliesPlan = { const mockFamiliesPlan = {
type: PlanType.FamiliesAnnually2025, type: PlanType.FamiliesAnnually2025,
@ -328,19 +333,32 @@ describe("DefaultSubscriptionPricingService", () => {
}); });
}); });
const setupEnvironmentService = (
envService: MockProxy<EnvironmentService>,
region: Region = Region.US,
) => {
envService.environment$ = of({
getRegion: () => region,
isCloud: () => region !== Region.SelfHosted,
} as any);
};
beforeEach(() => { beforeEach(() => {
billingApiService = mock<BillingApiServiceAbstraction>(); billingApiService = mock<BillingApiServiceAbstraction>();
configService = mock<ConfigService>(); configService = mock<ConfigService>();
environmentService = mock<EnvironmentService>();
billingApiService.getPlans.mockResolvedValue(mockPlansResponse); billingApiService.getPlans.mockResolvedValue(mockPlansResponse);
billingApiService.getPremiumPlan.mockResolvedValue(mockPremiumPlanResponse); billingApiService.getPremiumPlan.mockResolvedValue(mockPremiumPlanResponse);
configService.getFeatureFlag$.mockReturnValue(of(false)); // Default to false (use hardcoded value) configService.getFeatureFlag$.mockReturnValue(of(false)); // Default to false (use hardcoded value)
setupEnvironmentService(environmentService);
service = new DefaultSubscriptionPricingService( service = new DefaultSubscriptionPricingService(
billingApiService, billingApiService,
configService, configService,
i18nService, i18nService,
logService, logService,
environmentService,
); );
}); });
@ -419,11 +437,13 @@ describe("DefaultSubscriptionPricingService", () => {
const errorConfigService = mock<ConfigService>(); const errorConfigService = mock<ConfigService>();
const errorI18nService = mock<I18nService>(); const errorI18nService = mock<I18nService>();
const errorLogService = mock<LogService>(); const errorLogService = mock<LogService>();
const errorEnvironmentService = mock<EnvironmentService>();
const testError = new Error("API error"); const testError = new Error("API error");
errorBillingApiService.getPlans.mockRejectedValue(testError); errorBillingApiService.getPlans.mockRejectedValue(testError);
errorBillingApiService.getPremiumPlan.mockResolvedValue(mockPremiumPlanResponse); errorBillingApiService.getPremiumPlan.mockResolvedValue(mockPremiumPlanResponse);
errorConfigService.getFeatureFlag$.mockReturnValue(of(false)); errorConfigService.getFeatureFlag$.mockReturnValue(of(false));
setupEnvironmentService(errorEnvironmentService);
errorI18nService.t.mockImplementation((key: string) => key); errorI18nService.t.mockImplementation((key: string) => key);
@ -432,6 +452,7 @@ describe("DefaultSubscriptionPricingService", () => {
errorConfigService, errorConfigService,
errorI18nService, errorI18nService,
errorLogService, errorLogService,
errorEnvironmentService,
); );
errorService.getPersonalSubscriptionPricingTiers$().subscribe({ errorService.getPersonalSubscriptionPricingTiers$().subscribe({
@ -605,11 +626,13 @@ describe("DefaultSubscriptionPricingService", () => {
const errorConfigService = mock<ConfigService>(); const errorConfigService = mock<ConfigService>();
const errorI18nService = mock<I18nService>(); const errorI18nService = mock<I18nService>();
const errorLogService = mock<LogService>(); const errorLogService = mock<LogService>();
const errorEnvironmentService = mock<EnvironmentService>();
const testError = new Error("API error"); const testError = new Error("API error");
errorBillingApiService.getPlans.mockRejectedValue(testError); errorBillingApiService.getPlans.mockRejectedValue(testError);
errorBillingApiService.getPremiumPlan.mockResolvedValue(mockPremiumPlanResponse); errorBillingApiService.getPremiumPlan.mockResolvedValue(mockPremiumPlanResponse);
errorConfigService.getFeatureFlag$.mockReturnValue(of(false)); errorConfigService.getFeatureFlag$.mockReturnValue(of(false));
setupEnvironmentService(errorEnvironmentService);
errorI18nService.t.mockImplementation((key: string) => key); errorI18nService.t.mockImplementation((key: string) => key);
@ -618,6 +641,7 @@ describe("DefaultSubscriptionPricingService", () => {
errorConfigService, errorConfigService,
errorI18nService, errorI18nService,
errorLogService, errorLogService,
errorEnvironmentService,
); );
errorService.getBusinessSubscriptionPricingTiers$().subscribe({ errorService.getBusinessSubscriptionPricingTiers$().subscribe({
@ -848,11 +872,13 @@ describe("DefaultSubscriptionPricingService", () => {
const errorConfigService = mock<ConfigService>(); const errorConfigService = mock<ConfigService>();
const errorI18nService = mock<I18nService>(); const errorI18nService = mock<I18nService>();
const errorLogService = mock<LogService>(); const errorLogService = mock<LogService>();
const errorEnvironmentService = mock<EnvironmentService>();
const testError = new Error("API error"); const testError = new Error("API error");
errorBillingApiService.getPlans.mockRejectedValue(testError); errorBillingApiService.getPlans.mockRejectedValue(testError);
errorBillingApiService.getPremiumPlan.mockResolvedValue(mockPremiumPlanResponse); errorBillingApiService.getPremiumPlan.mockResolvedValue(mockPremiumPlanResponse);
errorConfigService.getFeatureFlag$.mockReturnValue(of(false)); errorConfigService.getFeatureFlag$.mockReturnValue(of(false));
setupEnvironmentService(errorEnvironmentService);
errorI18nService.t.mockImplementation((key: string) => key); errorI18nService.t.mockImplementation((key: string) => key);
@ -861,6 +887,7 @@ describe("DefaultSubscriptionPricingService", () => {
errorConfigService, errorConfigService,
errorI18nService, errorI18nService,
errorLogService, errorLogService,
errorEnvironmentService,
); );
errorService.getDeveloperSubscriptionPricingTiers$().subscribe({ errorService.getDeveloperSubscriptionPricingTiers$().subscribe({
@ -883,17 +910,20 @@ describe("DefaultSubscriptionPricingService", () => {
it("should handle getPremiumPlan() error when getPlans() succeeds", (done) => { it("should handle getPremiumPlan() error when getPlans() succeeds", (done) => {
const errorBillingApiService = mock<BillingApiServiceAbstraction>(); const errorBillingApiService = mock<BillingApiServiceAbstraction>();
const errorConfigService = mock<ConfigService>(); const errorConfigService = mock<ConfigService>();
const errorEnvironmentService = mock<EnvironmentService>();
const testError = new Error("Premium plan API error"); const testError = new Error("Premium plan API error");
errorBillingApiService.getPlans.mockResolvedValue(mockPlansResponse); errorBillingApiService.getPlans.mockResolvedValue(mockPlansResponse);
errorBillingApiService.getPremiumPlan.mockRejectedValue(testError); errorBillingApiService.getPremiumPlan.mockRejectedValue(testError);
errorConfigService.getFeatureFlag$.mockReturnValue(of(true)); // Enable feature flag to use premium plan API errorConfigService.getFeatureFlag$.mockReturnValue(of(true)); // Enable feature flag to use premium plan API
setupEnvironmentService(errorEnvironmentService);
const errorService = new DefaultSubscriptionPricingService( const errorService = new DefaultSubscriptionPricingService(
errorBillingApiService, errorBillingApiService,
errorConfigService, errorConfigService,
i18nService, i18nService,
logService, logService,
errorEnvironmentService,
); );
errorService.getPersonalSubscriptionPricingTiers$().subscribe({ errorService.getPersonalSubscriptionPricingTiers$().subscribe({
@ -914,88 +944,6 @@ describe("DefaultSubscriptionPricingService", () => {
}, },
}); });
}); });
it("should handle malformed premium plan API response", (done) => {
const errorBillingApiService = mock<BillingApiServiceAbstraction>();
const errorConfigService = mock<ConfigService>();
const testError = new TypeError("Cannot read properties of undefined (reading 'price')");
// Malformed response missing the Seat property
const malformedResponse = {
Storage: {
StripePriceId: "price_storage",
Price: 4,
},
};
errorBillingApiService.getPlans.mockResolvedValue(mockPlansResponse);
errorBillingApiService.getPremiumPlan.mockResolvedValue(malformedResponse as any);
errorConfigService.getFeatureFlag$.mockReturnValue(of(true)); // Enable feature flag
const errorService = new DefaultSubscriptionPricingService(
errorBillingApiService,
errorConfigService,
i18nService,
logService,
);
errorService.getPersonalSubscriptionPricingTiers$().subscribe({
next: () => {
fail("Observable should error, not return a value");
},
error: (error: unknown) => {
expect(logService.error).toHaveBeenCalledWith(
"Failed to load personal subscription pricing tiers",
testError,
);
expect(error).toEqual(testError);
done();
},
});
});
it("should handle malformed premium plan with invalid price types", (done) => {
const errorBillingApiService = mock<BillingApiServiceAbstraction>();
const errorConfigService = mock<ConfigService>();
const testError = new TypeError("Cannot read properties of undefined (reading 'price')");
// Malformed response with price as string instead of number
const malformedResponse = {
Seat: {
StripePriceId: "price_seat",
Price: "10", // Should be a number
},
Storage: {
StripePriceId: "price_storage",
Price: 4,
},
};
errorBillingApiService.getPlans.mockResolvedValue(mockPlansResponse);
errorBillingApiService.getPremiumPlan.mockResolvedValue(malformedResponse as any);
errorConfigService.getFeatureFlag$.mockReturnValue(of(true)); // Enable feature flag
const errorService = new DefaultSubscriptionPricingService(
errorBillingApiService,
errorConfigService,
i18nService,
logService,
);
errorService.getPersonalSubscriptionPricingTiers$().subscribe({
next: () => {
fail("Observable should error, not return a value");
},
error: (error: unknown) => {
expect(logService.error).toHaveBeenCalledWith(
"Failed to load personal subscription pricing tiers",
testError,
);
expect(error).toEqual(testError);
done();
},
});
});
}); });
describe("Observable behavior and caching", () => { describe("Observable behavior and caching", () => {
@ -1015,10 +963,12 @@ describe("DefaultSubscriptionPricingService", () => {
// Create a new mock to avoid conflicts with beforeEach setup // Create a new mock to avoid conflicts with beforeEach setup
const newBillingApiService = mock<BillingApiServiceAbstraction>(); const newBillingApiService = mock<BillingApiServiceAbstraction>();
const newConfigService = mock<ConfigService>(); const newConfigService = mock<ConfigService>();
const newEnvironmentService = mock<EnvironmentService>();
newBillingApiService.getPlans.mockResolvedValue(mockPlansResponse); newBillingApiService.getPlans.mockResolvedValue(mockPlansResponse);
newBillingApiService.getPremiumPlan.mockResolvedValue(mockPremiumPlanResponse); newBillingApiService.getPremiumPlan.mockResolvedValue(mockPremiumPlanResponse);
newConfigService.getFeatureFlag$.mockReturnValue(of(true)); newConfigService.getFeatureFlag$.mockReturnValue(of(true));
setupEnvironmentService(newEnvironmentService);
const getPremiumPlanSpy = jest.spyOn(newBillingApiService, "getPremiumPlan"); const getPremiumPlanSpy = jest.spyOn(newBillingApiService, "getPremiumPlan");
@ -1028,6 +978,7 @@ describe("DefaultSubscriptionPricingService", () => {
newConfigService, newConfigService,
i18nService, i18nService,
logService, logService,
newEnvironmentService,
); );
// Subscribe to the premium pricing tier multiple times // Subscribe to the premium pricing tier multiple times
@ -1042,6 +993,7 @@ describe("DefaultSubscriptionPricingService", () => {
// Create a new mock to test from scratch // Create a new mock to test from scratch
const newBillingApiService = mock<BillingApiServiceAbstraction>(); const newBillingApiService = mock<BillingApiServiceAbstraction>();
const newConfigService = mock<ConfigService>(); const newConfigService = mock<ConfigService>();
const newEnvironmentService = mock<EnvironmentService>();
newBillingApiService.getPlans.mockResolvedValue(mockPlansResponse); newBillingApiService.getPlans.mockResolvedValue(mockPlansResponse);
newBillingApiService.getPremiumPlan.mockResolvedValue({ newBillingApiService.getPremiumPlan.mockResolvedValue({
@ -1049,6 +1001,7 @@ describe("DefaultSubscriptionPricingService", () => {
storage: { price: 999 }, storage: { price: 999 },
} as PremiumPlanResponse); } as PremiumPlanResponse);
newConfigService.getFeatureFlag$.mockReturnValue(of(false)); newConfigService.getFeatureFlag$.mockReturnValue(of(false));
setupEnvironmentService(newEnvironmentService);
// Create a new service instance with the feature flag disabled // Create a new service instance with the feature flag disabled
const newService = new DefaultSubscriptionPricingService( const newService = new DefaultSubscriptionPricingService(
@ -1056,6 +1009,7 @@ describe("DefaultSubscriptionPricingService", () => {
newConfigService, newConfigService,
i18nService, i18nService,
logService, logService,
newEnvironmentService,
); );
// Subscribe with feature flag disabled // Subscribe with feature flag disabled
@ -1071,4 +1025,66 @@ describe("DefaultSubscriptionPricingService", () => {
}); });
}); });
}); });
describe("Self-hosted environment behavior", () => {
it("should not call API for self-hosted environment", () => {
const selfHostedBillingApiService = mock<BillingApiServiceAbstraction>();
const selfHostedConfigService = mock<ConfigService>();
const selfHostedEnvironmentService = mock<EnvironmentService>();
const getPlansSpy = jest.spyOn(selfHostedBillingApiService, "getPlans");
const getPremiumPlanSpy = jest.spyOn(selfHostedBillingApiService, "getPremiumPlan");
selfHostedConfigService.getFeatureFlag$.mockReturnValue(of(true));
setupEnvironmentService(selfHostedEnvironmentService, Region.SelfHosted);
const selfHostedService = new DefaultSubscriptionPricingService(
selfHostedBillingApiService,
selfHostedConfigService,
i18nService,
logService,
selfHostedEnvironmentService,
);
// Trigger subscriptions by calling the methods
selfHostedService.getPersonalSubscriptionPricingTiers$().subscribe();
selfHostedService.getBusinessSubscriptionPricingTiers$().subscribe();
selfHostedService.getDeveloperSubscriptionPricingTiers$().subscribe();
// API should not be called for self-hosted environments
expect(getPlansSpy).not.toHaveBeenCalled();
expect(getPremiumPlanSpy).not.toHaveBeenCalled();
});
it("should return valid tier structure with undefined prices for self-hosted", (done) => {
const selfHostedBillingApiService = mock<BillingApiServiceAbstraction>();
const selfHostedConfigService = mock<ConfigService>();
const selfHostedEnvironmentService = mock<EnvironmentService>();
selfHostedConfigService.getFeatureFlag$.mockReturnValue(of(true));
setupEnvironmentService(selfHostedEnvironmentService, Region.SelfHosted);
const selfHostedService = new DefaultSubscriptionPricingService(
selfHostedBillingApiService,
selfHostedConfigService,
i18nService,
logService,
selfHostedEnvironmentService,
);
selfHostedService.getPersonalSubscriptionPricingTiers$().subscribe((tiers) => {
expect(tiers).toHaveLength(2); // Premium and Families
const premiumTier = tiers.find((t) => t.id === PersonalSubscriptionPricingTierIds.Premium);
expect(premiumTier).toBeDefined();
expect(premiumTier?.passwordManager.annualPrice).toBeUndefined();
expect(premiumTier?.passwordManager.annualPricePerAdditionalStorageGB).toBeUndefined();
expect(premiumTier?.passwordManager.providedStorageGB).toBeUndefined();
expect(premiumTier?.passwordManager.features).toBeDefined();
expect(premiumTier?.passwordManager.features.length).toBeGreaterThan(0);
done();
});
});
});
}); });

View File

@ -19,6 +19,7 @@ import { PremiumPlanResponse } from "@bitwarden/common/billing/models/response/p
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ListResponse } from "@bitwarden/common/models/response/list.response"; import { ListResponse } from "@bitwarden/common/models/response/list.response";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/logging"; import { LogService } from "@bitwarden/logging";
@ -47,11 +48,13 @@ export class DefaultSubscriptionPricingService implements SubscriptionPricingSer
private configService: ConfigService, private configService: ConfigService,
private i18nService: I18nService, private i18nService: I18nService,
private logService: LogService, private logService: LogService,
private environmentService: EnvironmentService,
) {} ) {}
/** /**
* Gets personal subscription pricing tiers (Premium and Families). * Gets personal subscription pricing tiers (Premium and Families).
* Throws any errors that occur during api request so callers must handle errors. * Throws any errors that occur during api request so callers must handle errors.
* Pricing information will be undefined if current environment is self-hosted.
* @returns An observable of an array of personal subscription pricing tiers. * @returns An observable of an array of personal subscription pricing tiers.
* @throws Error if any errors occur during api request. * @throws Error if any errors occur during api request.
*/ */
@ -66,6 +69,7 @@ export class DefaultSubscriptionPricingService implements SubscriptionPricingSer
/** /**
* Gets business subscription pricing tiers (Teams, Enterprise, and Custom). * Gets business subscription pricing tiers (Teams, Enterprise, and Custom).
* Throws any errors that occur during api request so callers must handle errors. * Throws any errors that occur during api request so callers must handle errors.
* Pricing information will be undefined if current environment is self-hosted.
* @returns An observable of an array of business subscription pricing tiers. * @returns An observable of an array of business subscription pricing tiers.
* @throws Error if any errors occur during api request. * @throws Error if any errors occur during api request.
*/ */
@ -80,6 +84,7 @@ export class DefaultSubscriptionPricingService implements SubscriptionPricingSer
/** /**
* Gets developer subscription pricing tiers (Free, Teams, and Enterprise). * Gets developer subscription pricing tiers (Free, Teams, and Enterprise).
* Throws any errors that occur during api request so callers must handle errors. * Throws any errors that occur during api request so callers must handle errors.
* Pricing information will be undefined if current environment is self-hosted.
* @returns An observable of an array of business subscription pricing tiers for developers. * @returns An observable of an array of business subscription pricing tiers for developers.
* @throws Error if any errors occur during api request. * @throws Error if any errors occur during api request.
*/ */
@ -91,19 +96,32 @@ export class DefaultSubscriptionPricingService implements SubscriptionPricingSer
}), }),
); );
private plansResponse$: Observable<ListResponse<PlanResponse>> = from( private organizationPlansResponse$: Observable<ListResponse<PlanResponse>> =
this.billingApiService.getPlans(), this.environmentService.environment$.pipe(
).pipe(shareReplay({ bufferSize: 1, refCount: false })); take(1),
switchMap((environment) =>
!environment.isCloud()
? of({ data: [] } as unknown as ListResponse<PlanResponse>)
: from(this.billingApiService.getPlans()),
),
shareReplay({ bufferSize: 1, refCount: false }),
);
private premiumPlanResponse$: Observable<PremiumPlanResponse> = from( private premiumPlanResponse$: Observable<PremiumPlanResponse> =
this.billingApiService.getPremiumPlan(), this.environmentService.environment$.pipe(
).pipe( take(1),
catchError((error: unknown) => { switchMap((environment) =>
this.logService.error("Failed to fetch premium plan from API", error); !environment.isCloud()
return throwError(() => error); // Re-throw to propagate to higher-level error handler ? of({ seat: undefined, storage: undefined } as unknown as PremiumPlanResponse)
}), : from(this.billingApiService.getPremiumPlan()).pipe(
shareReplay({ bufferSize: 1, refCount: false }), catchError((error: unknown) => {
); this.logService.error("Failed to fetch premium plan from API", error);
return throwError(() => error); // Re-throw to propagate to higher-level error handler
}),
),
),
shareReplay({ bufferSize: 1, refCount: false }),
);
private premium$: Observable<PersonalSubscriptionPricingTier> = this.configService private premium$: Observable<PersonalSubscriptionPricingTier> = this.configService
.getFeatureFlag$(FeatureFlag.PM26793_FetchPremiumPriceFromPricingService) .getFeatureFlag$(FeatureFlag.PM26793_FetchPremiumPriceFromPricingService)
@ -113,9 +131,9 @@ export class DefaultSubscriptionPricingService implements SubscriptionPricingSer
fetchPremiumFromPricingService fetchPremiumFromPricingService
? this.premiumPlanResponse$.pipe( ? this.premiumPlanResponse$.pipe(
map((premiumPlan) => ({ map((premiumPlan) => ({
seat: premiumPlan.seat.price, seat: premiumPlan.seat?.price,
storage: premiumPlan.storage.price, storage: premiumPlan.storage?.price,
provided: premiumPlan.storage.provided, provided: premiumPlan.storage?.provided,
})), })),
) )
: of({ : of({
@ -145,41 +163,42 @@ export class DefaultSubscriptionPricingService implements SubscriptionPricingSer
})), })),
); );
private families$: Observable<PersonalSubscriptionPricingTier> = this.plansResponse$.pipe( private families$: Observable<PersonalSubscriptionPricingTier> =
combineLatestWith(this.configService.getFeatureFlag$(FeatureFlag.PM26462_Milestone_3)), this.organizationPlansResponse$.pipe(
map(([plans, milestone3FeatureEnabled]) => { combineLatestWith(this.configService.getFeatureFlag$(FeatureFlag.PM26462_Milestone_3)),
const familiesPlan = plans.data.find( map(([plans, milestone3FeatureEnabled]) => {
(plan) => const familiesPlan = plans.data.find(
plan.type === (plan) =>
(milestone3FeatureEnabled ? PlanType.FamiliesAnnually : PlanType.FamiliesAnnually2025), plan.type ===
)!; (milestone3FeatureEnabled ? PlanType.FamiliesAnnually : PlanType.FamiliesAnnually2025),
);
return { return {
id: PersonalSubscriptionPricingTierIds.Families, id: PersonalSubscriptionPricingTierIds.Families,
name: this.i18nService.t("planNameFamilies"), name: this.i18nService.t("planNameFamilies"),
description: this.i18nService.t("planDescFamiliesV2"), description: this.i18nService.t("planDescFamiliesV2"),
availableCadences: [SubscriptionCadenceIds.Annually], availableCadences: [SubscriptionCadenceIds.Annually],
passwordManager: { passwordManager: {
type: "packaged", type: "packaged",
users: familiesPlan.PasswordManager.baseSeats, users: familiesPlan?.PasswordManager?.baseSeats,
annualPrice: familiesPlan.PasswordManager.basePrice, annualPrice: familiesPlan?.PasswordManager?.basePrice,
annualPricePerAdditionalStorageGB: annualPricePerAdditionalStorageGB:
familiesPlan.PasswordManager.additionalStoragePricePerGb, familiesPlan?.PasswordManager?.additionalStoragePricePerGb,
providedStorageGB: familiesPlan.PasswordManager.baseStorageGb, providedStorageGB: familiesPlan?.PasswordManager?.baseStorageGb,
features: [ features: [
this.featureTranslations.premiumAccounts(), this.featureTranslations.premiumAccounts(),
this.featureTranslations.familiesUnlimitedSharing(), this.featureTranslations.familiesUnlimitedSharing(),
this.featureTranslations.familiesUnlimitedCollections(), this.featureTranslations.familiesUnlimitedCollections(),
this.featureTranslations.familiesSharedStorage(), this.featureTranslations.familiesSharedStorage(),
], ],
}, },
}; };
}), }),
); );
private free$: Observable<BusinessSubscriptionPricingTier> = this.plansResponse$.pipe( private free$: Observable<BusinessSubscriptionPricingTier> = this.organizationPlansResponse$.pipe(
map((plans): BusinessSubscriptionPricingTier => { map((plans): BusinessSubscriptionPricingTier => {
const freePlan = plans.data.find((plan) => plan.type === PlanType.Free)!; const freePlan = plans.data.find((plan) => plan.type === PlanType.Free);
return { return {
id: BusinessSubscriptionPricingTierIds.Free, id: BusinessSubscriptionPricingTierIds.Free,
@ -189,8 +208,10 @@ export class DefaultSubscriptionPricingService implements SubscriptionPricingSer
passwordManager: { passwordManager: {
type: "free", type: "free",
features: [ features: [
this.featureTranslations.limitedUsersV2(freePlan.PasswordManager.maxSeats), this.featureTranslations.limitedUsersV2(freePlan?.PasswordManager?.maxSeats),
this.featureTranslations.limitedCollectionsV2(freePlan.PasswordManager.maxCollections), this.featureTranslations.limitedCollectionsV2(
freePlan?.PasswordManager?.maxCollections,
),
this.featureTranslations.alwaysFree(), this.featureTranslations.alwaysFree(),
], ],
}, },
@ -198,110 +219,113 @@ export class DefaultSubscriptionPricingService implements SubscriptionPricingSer
type: "free", type: "free",
features: [ features: [
this.featureTranslations.twoSecretsIncluded(), this.featureTranslations.twoSecretsIncluded(),
this.featureTranslations.projectsIncludedV2(freePlan.SecretsManager.maxProjects), this.featureTranslations.projectsIncludedV2(freePlan?.SecretsManager?.maxProjects),
], ],
}, },
}; };
}), }),
); );
private teams$: Observable<BusinessSubscriptionPricingTier> = this.plansResponse$.pipe( private teams$: Observable<BusinessSubscriptionPricingTier> =
map((plans) => { this.organizationPlansResponse$.pipe(
const annualTeamsPlan = plans.data.find((plan) => plan.type === PlanType.TeamsAnnually)!; map((plans) => {
const annualTeamsPlan = plans.data.find((plan) => plan.type === PlanType.TeamsAnnually);
return { return {
id: BusinessSubscriptionPricingTierIds.Teams, id: BusinessSubscriptionPricingTierIds.Teams,
name: this.i18nService.t("planNameTeams"), name: this.i18nService.t("planNameTeams"),
description: this.i18nService.t("teamsPlanUpgradeMessage"), description: this.i18nService.t("teamsPlanUpgradeMessage"),
availableCadences: [SubscriptionCadenceIds.Annually, SubscriptionCadenceIds.Monthly], availableCadences: [SubscriptionCadenceIds.Annually, SubscriptionCadenceIds.Monthly],
passwordManager: { passwordManager: {
type: "scalable", type: "scalable",
annualPricePerUser: annualTeamsPlan.PasswordManager.seatPrice, annualPricePerUser: annualTeamsPlan?.PasswordManager?.seatPrice,
annualPricePerAdditionalStorageGB: annualPricePerAdditionalStorageGB:
annualTeamsPlan.PasswordManager.additionalStoragePricePerGb, annualTeamsPlan?.PasswordManager?.additionalStoragePricePerGb,
providedStorageGB: annualTeamsPlan.PasswordManager.baseStorageGb, providedStorageGB: annualTeamsPlan?.PasswordManager?.baseStorageGb,
features: [ features: [
this.featureTranslations.secureItemSharing(), this.featureTranslations.secureItemSharing(),
this.featureTranslations.eventLogMonitoring(), this.featureTranslations.eventLogMonitoring(),
this.featureTranslations.directoryIntegration(), this.featureTranslations.directoryIntegration(),
this.featureTranslations.scimSupport(), this.featureTranslations.scimSupport(),
], ],
}, },
secretsManager: { secretsManager: {
type: "scalable", type: "scalable",
annualPricePerUser: annualTeamsPlan.SecretsManager.seatPrice, annualPricePerUser: annualTeamsPlan?.SecretsManager?.seatPrice,
annualPricePerAdditionalServiceAccount: annualPricePerAdditionalServiceAccount:
annualTeamsPlan.SecretsManager.additionalPricePerServiceAccount, annualTeamsPlan?.SecretsManager?.additionalPricePerServiceAccount,
features: [ features: [
this.featureTranslations.unlimitedSecretsAndProjects(), this.featureTranslations.unlimitedSecretsAndProjects(),
this.featureTranslations.includedMachineAccountsV2( this.featureTranslations.includedMachineAccountsV2(
annualTeamsPlan.SecretsManager.baseServiceAccount, annualTeamsPlan?.SecretsManager?.baseServiceAccount,
), ),
], ],
}, },
}; };
}),
);
private enterprise$: Observable<BusinessSubscriptionPricingTier> = this.plansResponse$.pipe(
map((plans) => {
const annualEnterprisePlan = plans.data.find(
(plan) => plan.type === PlanType.EnterpriseAnnually,
)!;
return {
id: BusinessSubscriptionPricingTierIds.Enterprise,
name: this.i18nService.t("planNameEnterprise"),
description: this.i18nService.t("planDescEnterpriseV2"),
availableCadences: [SubscriptionCadenceIds.Annually, SubscriptionCadenceIds.Monthly],
passwordManager: {
type: "scalable",
annualPricePerUser: annualEnterprisePlan.PasswordManager.seatPrice,
annualPricePerAdditionalStorageGB:
annualEnterprisePlan.PasswordManager.additionalStoragePricePerGb,
providedStorageGB: annualEnterprisePlan.PasswordManager.baseStorageGb,
features: [
this.featureTranslations.enterpriseSecurityPolicies(),
this.featureTranslations.passwordLessSso(),
this.featureTranslations.accountRecovery(),
this.featureTranslations.selfHostOption(),
this.featureTranslations.complimentaryFamiliesPlan(),
],
},
secretsManager: {
type: "scalable",
annualPricePerUser: annualEnterprisePlan.SecretsManager.seatPrice,
annualPricePerAdditionalServiceAccount:
annualEnterprisePlan.SecretsManager.additionalPricePerServiceAccount,
features: [
this.featureTranslations.unlimitedUsers(),
this.featureTranslations.includedMachineAccountsV2(
annualEnterprisePlan.SecretsManager.baseServiceAccount,
),
],
},
};
}),
);
private custom$: Observable<BusinessSubscriptionPricingTier> = this.plansResponse$.pipe(
map(
(): BusinessSubscriptionPricingTier => ({
id: BusinessSubscriptionPricingTierIds.Custom,
name: this.i18nService.t("planNameCustom"),
description: this.i18nService.t("planDescCustom"),
availableCadences: [],
passwordManager: {
type: "custom",
features: [
this.featureTranslations.strengthenCybersecurity(),
this.featureTranslations.boostProductivity(),
this.featureTranslations.seamlessIntegration(),
],
},
}), }),
), );
);
private enterprise$: Observable<BusinessSubscriptionPricingTier> =
this.organizationPlansResponse$.pipe(
map((plans) => {
const annualEnterprisePlan = plans.data.find(
(plan) => plan.type === PlanType.EnterpriseAnnually,
);
return {
id: BusinessSubscriptionPricingTierIds.Enterprise,
name: this.i18nService.t("planNameEnterprise"),
description: this.i18nService.t("planDescEnterpriseV2"),
availableCadences: [SubscriptionCadenceIds.Annually, SubscriptionCadenceIds.Monthly],
passwordManager: {
type: "scalable",
annualPricePerUser: annualEnterprisePlan?.PasswordManager?.seatPrice,
annualPricePerAdditionalStorageGB:
annualEnterprisePlan?.PasswordManager?.additionalStoragePricePerGb,
providedStorageGB: annualEnterprisePlan?.PasswordManager?.baseStorageGb,
features: [
this.featureTranslations.enterpriseSecurityPolicies(),
this.featureTranslations.passwordLessSso(),
this.featureTranslations.accountRecovery(),
this.featureTranslations.selfHostOption(),
this.featureTranslations.complimentaryFamiliesPlan(),
],
},
secretsManager: {
type: "scalable",
annualPricePerUser: annualEnterprisePlan?.SecretsManager?.seatPrice,
annualPricePerAdditionalServiceAccount:
annualEnterprisePlan?.SecretsManager?.additionalPricePerServiceAccount,
features: [
this.featureTranslations.unlimitedUsers(),
this.featureTranslations.includedMachineAccountsV2(
annualEnterprisePlan?.SecretsManager?.baseServiceAccount,
),
],
},
};
}),
);
private custom$: Observable<BusinessSubscriptionPricingTier> =
this.organizationPlansResponse$.pipe(
map(
(): BusinessSubscriptionPricingTier => ({
id: BusinessSubscriptionPricingTierIds.Custom,
name: this.i18nService.t("planNameCustom"),
description: this.i18nService.t("planDescCustom"),
availableCadences: [],
passwordManager: {
type: "custom",
features: [
this.featureTranslations.strengthenCybersecurity(),
this.featureTranslations.boostProductivity(),
this.featureTranslations.seamlessIntegration(),
],
},
}),
),
);
private featureTranslations = { private featureTranslations = {
builtInAuthenticator: () => ({ builtInAuthenticator: () => ({
@ -340,11 +364,11 @@ export class DefaultSubscriptionPricingService implements SubscriptionPricingSer
key: "familiesSharedStorage", key: "familiesSharedStorage",
value: this.i18nService.t("familiesSharedStorage"), value: this.i18nService.t("familiesSharedStorage"),
}), }),
limitedUsersV2: (users: number) => ({ limitedUsersV2: (users?: number) => ({
key: "limitedUsersV2", key: "limitedUsersV2",
value: this.i18nService.t("limitedUsersV2", users), value: this.i18nService.t("limitedUsersV2", users),
}), }),
limitedCollectionsV2: (collections: number) => ({ limitedCollectionsV2: (collections?: number) => ({
key: "limitedCollectionsV2", key: "limitedCollectionsV2",
value: this.i18nService.t("limitedCollectionsV2", collections), value: this.i18nService.t("limitedCollectionsV2", collections),
}), }),
@ -356,7 +380,7 @@ export class DefaultSubscriptionPricingService implements SubscriptionPricingSer
key: "twoSecretsIncluded", key: "twoSecretsIncluded",
value: this.i18nService.t("twoSecretsIncluded"), value: this.i18nService.t("twoSecretsIncluded"),
}), }),
projectsIncludedV2: (projects: number) => ({ projectsIncludedV2: (projects?: number) => ({
key: "projectsIncludedV2", key: "projectsIncludedV2",
value: this.i18nService.t("projectsIncludedV2", projects), value: this.i18nService.t("projectsIncludedV2", projects),
}), }),
@ -380,7 +404,7 @@ export class DefaultSubscriptionPricingService implements SubscriptionPricingSer
key: "unlimitedSecretsAndProjects", key: "unlimitedSecretsAndProjects",
value: this.i18nService.t("unlimitedSecretsAndProjects"), value: this.i18nService.t("unlimitedSecretsAndProjects"),
}), }),
includedMachineAccountsV2: (included: number) => ({ includedMachineAccountsV2: (included?: number) => ({
key: "includedMachineAccountsV2", key: "includedMachineAccountsV2",
value: this.i18nService.t("includedMachineAccountsV2", included), value: this.i18nService.t("includedMachineAccountsV2", included),
}), }),

View File

@ -27,26 +27,26 @@ type HasFeatures = {
}; };
type HasAdditionalStorage = { type HasAdditionalStorage = {
annualPricePerAdditionalStorageGB: number; annualPricePerAdditionalStorageGB?: number;
}; };
type HasProvidedStorage = { type HasProvidedStorage = {
providedStorageGB: number; providedStorageGB?: number;
}; };
type StandalonePasswordManager = HasFeatures & type StandalonePasswordManager = HasFeatures &
HasAdditionalStorage & HasAdditionalStorage &
HasProvidedStorage & { HasProvidedStorage & {
type: "standalone"; type: "standalone";
annualPrice: number; annualPrice?: number;
}; };
type PackagedPasswordManager = HasFeatures & type PackagedPasswordManager = HasFeatures &
HasProvidedStorage & HasProvidedStorage &
HasAdditionalStorage & { HasAdditionalStorage & {
type: "packaged"; type: "packaged";
users: number; users?: number;
annualPrice: number; annualPrice?: number;
}; };
type FreePasswordManager = HasFeatures & { type FreePasswordManager = HasFeatures & {
@ -61,7 +61,7 @@ type ScalablePasswordManager = HasFeatures &
HasProvidedStorage & HasProvidedStorage &
HasAdditionalStorage & { HasAdditionalStorage & {
type: "scalable"; type: "scalable";
annualPricePerUser: number; annualPricePerUser?: number;
}; };
type FreeSecretsManager = HasFeatures & { type FreeSecretsManager = HasFeatures & {
@ -70,8 +70,8 @@ type FreeSecretsManager = HasFeatures & {
type ScalableSecretsManager = HasFeatures & { type ScalableSecretsManager = HasFeatures & {
type: "scalable"; type: "scalable";
annualPricePerUser: number; annualPricePerUser?: number;
annualPricePerAdditionalServiceAccount: number; annualPricePerAdditionalServiceAccount?: number;
}; };
export type PersonalSubscriptionPricingTier = { export type PersonalSubscriptionPricingTier = {

View File

@ -43,6 +43,7 @@ export enum FeatureFlag {
LinuxBiometricsV2 = "pm-26340-linux-biometrics-v2", LinuxBiometricsV2 = "pm-26340-linux-biometrics-v2",
UnlockWithMasterPasswordUnlockData = "pm-23246-unlock-with-master-password-unlock-data", UnlockWithMasterPasswordUnlockData = "pm-23246-unlock-with-master-password-unlock-data",
NoLogoutOnKdfChange = "pm-23995-no-logout-on-kdf-change", NoLogoutOnKdfChange = "pm-23995-no-logout-on-kdf-change",
DataRecoveryTool = "pm-28813-data-recovery-tool",
ConsolidatedSessionTimeoutComponent = "pm-26056-consolidated-session-timeout-component", ConsolidatedSessionTimeoutComponent = "pm-26056-consolidated-session-timeout-component",
/* Tools */ /* Tools */
@ -64,6 +65,7 @@ export enum FeatureFlag {
RiskInsightsForPremium = "pm-23904-risk-insights-for-premium", RiskInsightsForPremium = "pm-23904-risk-insights-for-premium",
VaultLoadingSkeletons = "pm-25081-vault-skeleton-loaders", VaultLoadingSkeletons = "pm-25081-vault-skeleton-loaders",
BrowserPremiumSpotlight = "pm-23384-browser-premium-spotlight", BrowserPremiumSpotlight = "pm-23384-browser-premium-spotlight",
MigrateMyVaultToMyItems = "pm-20558-migrate-myvault-to-myitems",
/* Platform */ /* Platform */
IpcChannelFramework = "ipc-channel-framework", IpcChannelFramework = "ipc-channel-framework",
@ -123,6 +125,7 @@ export const DefaultFeatureFlagValue = {
[FeatureFlag.RiskInsightsForPremium]: FALSE, [FeatureFlag.RiskInsightsForPremium]: FALSE,
[FeatureFlag.VaultLoadingSkeletons]: FALSE, [FeatureFlag.VaultLoadingSkeletons]: FALSE,
[FeatureFlag.BrowserPremiumSpotlight]: FALSE, [FeatureFlag.BrowserPremiumSpotlight]: FALSE,
[FeatureFlag.MigrateMyVaultToMyItems]: FALSE,
/* Auth */ /* Auth */
[FeatureFlag.PM23801_PrefetchPasswordPrelogin]: FALSE, [FeatureFlag.PM23801_PrefetchPasswordPrelogin]: FALSE,
@ -147,6 +150,7 @@ export const DefaultFeatureFlagValue = {
[FeatureFlag.LinuxBiometricsV2]: FALSE, [FeatureFlag.LinuxBiometricsV2]: FALSE,
[FeatureFlag.UnlockWithMasterPasswordUnlockData]: FALSE, [FeatureFlag.UnlockWithMasterPasswordUnlockData]: FALSE,
[FeatureFlag.NoLogoutOnKdfChange]: FALSE, [FeatureFlag.NoLogoutOnKdfChange]: FALSE,
[FeatureFlag.DataRecoveryTool]: FALSE,
[FeatureFlag.ConsolidatedSessionTimeoutComponent]: FALSE, [FeatureFlag.ConsolidatedSessionTimeoutComponent]: FALSE,
/* Platform */ /* Platform */

View File

@ -451,6 +451,24 @@ describe("ChipSelectComponent", () => {
expect(disabledMenuItem?.disabled).toBe(true); expect(disabledMenuItem?.disabled).toBe(true);
}); });
it("should handle writeValue called before options are initialized", async () => {
const testApp = fixture.componentInstance;
component["rootTree"] = null;
component.writeValue("opt1");
expect(component["pendingValue"]).toBe("opt1");
expect(component["selectedOption"]).toBeUndefined();
testApp.options.set(testOptions);
fixture.detectChanges();
await fixture.whenStable();
expect(component["selectedOption"]?.value).toBe("opt1");
expect(component["pendingValue"]).toBeUndefined();
});
}); });
}); });

View File

@ -100,10 +100,21 @@ export class ChipSelectComponent<T = unknown> implements ControlValueAccessor {
/** Tree constructed from `this.options` */ /** Tree constructed from `this.options` */
private rootTree?: ChipSelectOption<T> | null; private rootTree?: ChipSelectOption<T> | null;
/** Store the pending value when writeValue is called before options are initialized */
private pendingValue?: T;
constructor() { constructor() {
// Initialize the root tree whenever options change // Initialize the root tree whenever options change
effect(() => { effect(() => {
this.initializeRootTree(this.options()); this.initializeRootTree(this.options());
// If there's a pending value, apply it now that options are available
if (this.pendingValue !== undefined) {
this.selectedOption = this.findOption(this.rootTree, this.pendingValue);
this.setOrResetRenderedOptions();
this.pendingValue = undefined;
this.cdr.markForCheck();
}
}); });
// Focus the first menu item when menuItems change (e.g., navigating submenus) // Focus the first menu item when menuItems change (e.g., navigating submenus)
@ -255,6 +266,12 @@ export class ChipSelectComponent<T = unknown> implements ControlValueAccessor {
/** Implemented as part of NG_VALUE_ACCESSOR */ /** Implemented as part of NG_VALUE_ACCESSOR */
writeValue(obj: T): void { writeValue(obj: T): void {
// If rootTree is not yet initialized, store the value to apply it later
if (!this.rootTree) {
this.pendingValue = obj;
return;
}
this.selectedOption = this.findOption(this.rootTree, obj); this.selectedOption = this.findOption(this.rootTree, obj);
this.setOrResetRenderedOptions(); this.setOrResetRenderedOptions();
// OnPush components require manual change detection when writeValue() is called // OnPush components require manual change detection when writeValue() is called

View File

@ -7,4 +7,11 @@ export abstract class UserAsymmetricKeysRegenerationService {
* @param userId The user id. * @param userId The user id.
*/ */
abstract regenerateIfNeeded(userId: UserId): Promise<void>; abstract regenerateIfNeeded(userId: UserId): Promise<void>;
/**
* Performs the regeneration of the user's public/private key pair without checking any preconditions.
* This should only be used for V1 encryption accounts
* @param userId The user id.
*/
abstract regenerateUserPublicKeyEncryptionKeyPair(userId: UserId): Promise<void>;
} }

View File

@ -370,3 +370,52 @@ describe("regenerateIfNeeded", () => {
); );
}); });
}); });
describe("regenerateUserPublicKeyEncryptionKeyPair", () => {
let sut: DefaultUserAsymmetricKeysRegenerationService;
const userId = "userId" as UserId;
let keyService: MockProxy<KeyService>;
let cipherService: MockProxy<CipherService>;
let userAsymmetricKeysRegenerationApiService: MockProxy<UserAsymmetricKeysRegenerationApiService>;
let logService: MockProxy<LogService>;
let sdkService: MockSdkService;
let apiService: MockProxy<ApiService>;
let configService: MockProxy<ConfigService>;
beforeEach(() => {
keyService = mock<KeyService>();
cipherService = mock<CipherService>();
userAsymmetricKeysRegenerationApiService = mock<UserAsymmetricKeysRegenerationApiService>();
logService = mock<LogService>();
sdkService = new MockSdkService();
apiService = mock<ApiService>();
configService = mock<ConfigService>();
sut = new DefaultUserAsymmetricKeysRegenerationService(
keyService,
cipherService,
userAsymmetricKeysRegenerationApiService,
logService,
sdkService,
apiService,
configService,
);
});
afterEach(() => {
jest.resetAllMocks();
});
it("should throw error when user key is not V1 encryption type", async () => {
const mockUserKey = {
keyB64: "mockKeyB64",
inner: () => ({ type: 7 }),
} as unknown as UserKey;
keyService.userKey$.mockReturnValue(of(mockUserKey));
await expect(sut.regenerateUserPublicKeyEncryptionKeyPair(userId)).rejects.toThrow(
"User key is not V1 encryption type",
);
});
});

View File

@ -37,7 +37,7 @@ export class DefaultUserAsymmetricKeysRegenerationService
if (privateKeyRegenerationFlag) { if (privateKeyRegenerationFlag) {
const shouldRegenerate = await this.shouldRegenerate(userId); const shouldRegenerate = await this.shouldRegenerate(userId);
if (shouldRegenerate) { if (shouldRegenerate) {
await this.regenerateUserAsymmetricKeys(userId); await this.regenerateUserPublicKeyEncryptionKeyPair(userId);
} }
} }
} catch (error) { } catch (error) {
@ -125,11 +125,14 @@ export class DefaultUserAsymmetricKeysRegenerationService
return false; return false;
} }
private async regenerateUserAsymmetricKeys(userId: UserId): Promise<void> { async regenerateUserPublicKeyEncryptionKeyPair(userId: UserId): Promise<void> {
const userKey = await firstValueFrom(this.keyService.userKey$(userId)); const userKey = await firstValueFrom(this.keyService.userKey$(userId));
if (userKey == null) { if (userKey == null) {
throw new Error("User key not found"); throw new Error("User key not found");
} }
if (userKey.inner().type !== EncryptionType.AesCbc256_HmacSha256_B64) {
throw new Error("User key is not V1 encryption type");
}
const makeKeyPairResponse = await firstValueFrom( const makeKeyPairResponse = await firstValueFrom(
this.sdkService.client$.pipe( this.sdkService.client$.pipe(
map((sdk) => { map((sdk) => {

View File

@ -24,6 +24,7 @@ describe("availableAlgorithms_vNextPolicy", () => {
overridePasswordType: override, overridePasswordType: override,
}, },
enabled: true, enabled: true,
revisionDate: new Date().toISOString(),
}); });
const result = availableAlgorithms([policy]); const result = availableAlgorithms([policy]);
@ -44,6 +45,7 @@ describe("availableAlgorithms_vNextPolicy", () => {
overridePasswordType: override, overridePasswordType: override,
}, },
enabled: true, enabled: true,
revisionDate: new Date().toISOString(),
}); });
const result = availableAlgorithms([policy, policy]); const result = availableAlgorithms([policy, policy]);
@ -64,6 +66,7 @@ describe("availableAlgorithms_vNextPolicy", () => {
overridePasswordType: "password", overridePasswordType: "password",
}, },
enabled: true, enabled: true,
revisionDate: new Date().toISOString(),
}); });
const passphrase = new Policy({ const passphrase = new Policy({
id: "" as PolicyId, id: "" as PolicyId,
@ -73,6 +76,7 @@ describe("availableAlgorithms_vNextPolicy", () => {
overridePasswordType: "passphrase", overridePasswordType: "passphrase",
}, },
enabled: true, enabled: true,
revisionDate: new Date().toISOString(),
}); });
const result = availableAlgorithms([password, passphrase]); const result = availableAlgorithms([password, passphrase]);
@ -93,6 +97,7 @@ describe("availableAlgorithms_vNextPolicy", () => {
some: "policy", some: "policy",
}, },
enabled: true, enabled: true,
revisionDate: new Date().toISOString(),
}); });
const result = availableAlgorithms([policy]); const result = availableAlgorithms([policy]);
@ -111,6 +116,7 @@ describe("availableAlgorithms_vNextPolicy", () => {
some: "policy", some: "policy",
}, },
enabled: false, enabled: false,
revisionDate: new Date().toISOString(),
}); });
const result = availableAlgorithms([policy]); const result = availableAlgorithms([policy]);
@ -129,6 +135,7 @@ describe("availableAlgorithms_vNextPolicy", () => {
some: "policy", some: "policy",
}, },
enabled: true, enabled: true,
revisionDate: new Date().toISOString(),
}); });
const result = availableAlgorithms([policy]); const result = availableAlgorithms([policy]);

View File

@ -17,6 +17,7 @@ function createPolicy(
data, data,
enabled, enabled,
type, type,
revisionDate: new Date().toISOString(),
}); });
} }

View File

@ -17,6 +17,7 @@ function createPolicy(
data, data,
enabled, enabled,
type, type,
revisionDate: new Date().toISOString(),
}); });
} }

View File

@ -57,6 +57,7 @@ const somePolicy = new Policy({
id: "" as PolicyId, id: "" as PolicyId,
organizationId: "" as OrganizationId, organizationId: "" as OrganizationId,
enabled: true, enabled: true,
revisionDate: new Date().toISOString(),
}); });
const stateProvider = new FakeStateProvider(accountService); const stateProvider = new FakeStateProvider(accountService);

View File

@ -70,6 +70,7 @@ describe("DefaultGeneratorNavigationService", () => {
enabled: true, enabled: true,
type: PolicyType.PasswordGenerator, type: PolicyType.PasswordGenerator,
data: { overridePasswordType: "password" }, data: { overridePasswordType: "password" },
revisionDate: new Date().toISOString(),
}), }),
]); ]);
}, },

View File

@ -17,6 +17,7 @@ function createPolicy(
data, data,
enabled, enabled,
type, type,
revisionDate: new Date().toISOString(),
}); });
} }

View File

@ -6,3 +6,4 @@ export { SendItemsService } from "./services/send-items.service";
export { SendSearchComponent } from "./send-search/send-search.component"; export { SendSearchComponent } from "./send-search/send-search.component";
export { SendListFiltersComponent } from "./send-list-filters/send-list-filters.component"; export { SendListFiltersComponent } from "./send-list-filters/send-list-filters.component";
export { SendListFiltersService } from "./services/send-list-filters.service"; export { SendListFiltersService } from "./services/send-list-filters.service";
export { SendTableComponent } from "./send-table/send-table.component";

View File

@ -1,5 +1,5 @@
<button bitButton [bitMenuTriggerFor]="itemOptions" [buttonType]="buttonType" type="button"> <button bitButton [bitMenuTriggerFor]="itemOptions" [buttonType]="buttonType" type="button">
<i *ngIf="!hideIcon" class="bwi bwi-plus" aria-hidden="true"></i> <i *ngIf="!hideIcon" class="bwi bwi-plus tw-me-2" aria-hidden="true"></i>
{{ (hideIcon ? "createSend" : "new") | i18n }} {{ (hideIcon ? "createSend" : "new") | i18n }}
</button> </button>
<bit-menu #itemOptions> <bit-menu #itemOptions>

View File

@ -1,7 +1,9 @@
<bit-section [formGroup]="sendFileDetailsForm"> <bit-section [formGroup]="sendFileDetailsForm">
<div *ngIf="config().mode === 'edit'"> <div *ngIf="config().mode === 'edit'">
<div bitTypography="body2" class="tw-text-muted">{{ "file" | i18n }}</div> <div bitTypography="body2" class="tw-text-muted">{{ "file" | i18n }}</div>
<div data-testid="file-name">{{ originalSendView().file.fileName }}</div> <div class="tw-text-wrap tw-break-all" data-testid="file-name">
{{ originalSendView().file.fileName }}
</div>
<div data-testid="file-size" class="tw-text-muted">{{ originalSendView().file.sizeName }}</div> <div data-testid="file-size" class="tw-text-muted">{{ originalSendView().file.sizeName }}</div>
</div> </div>
<bit-form-field *ngIf="config().mode !== 'edit'"> <bit-form-field *ngIf="config().mode !== 'edit'">

View File

@ -0,0 +1,102 @@
<bit-table [dataSource]="dataSource()">
<ng-container header>
<tr>
<th bitCell bitSortable="name" default>{{ "name" | i18n }}</th>
<th bitCell bitSortable="deletionDate">{{ "deletionDate" | i18n }}</th>
<th bitCell>{{ "options" | i18n }}</th>
</tr>
</ng-container>
<ng-template body let-rows$>
<tr bitRow *ngFor="let s of rows$ | async">
<td bitCell (click)="onEditSend(s)" class="tw-cursor-pointer">
<div class="tw-flex tw-gap-2 tw-items-center">
<span aria-hidden="true">
@if (s.type == sendType.File) {
<i class="bwi bwi-fw bwi-lg bwi-file"></i>
}
@if (s.type == sendType.Text) {
<i class="bwi bwi-fw bwi-lg bwi-file-text"></i>
}
</span>
<button type="button" bitLink>
{{ s.name }}
</button>
@if (s.disabled) {
<i
class="bwi bwi-exclamation-triangle"
appStopProp
title="{{ 'disabled' | i18n }}"
aria-hidden="true"
></i>
<span class="tw-sr-only">{{ "disabled" | i18n }}</span>
}
@if (s.password) {
<i
class="bwi bwi-key"
appStopProp
title="{{ 'password' | i18n }}"
aria-hidden="true"
></i>
<span class="tw-sr-only">{{ "password" | i18n }}</span>
}
@if (s.maxAccessCountReached) {
<i
class="bwi bwi-exclamation-triangle"
appStopProp
title="{{ 'maxAccessCountReached' | i18n }}"
aria-hidden="true"
></i>
<span class="tw-sr-only">{{ "maxAccessCountReached" | i18n }}</span>
}
@if (s.expired) {
<i
class="bwi bwi-clock"
appStopProp
title="{{ 'expired' | i18n }}"
aria-hidden="true"
></i>
<span class="tw-sr-only">{{ "expired" | i18n }}</span>
}
@if (s.pendingDelete) {
<i
class="bwi bwi-trash"
appStopProp
title="{{ 'pendingDeletion' | i18n }}"
aria-hidden="true"
></i>
<span class="tw-sr-only">{{ "pendingDeletion" | i18n }}</span>
}
</div>
</td>
<td bitCell (click)="onEditSend(s)" class="tw-text-muted tw-cursor-pointer">
<small bitTypography="body2" appStopProp>{{ s.deletionDate | date: "medium" }}</small>
</td>
<td bitCell class="tw-w-0 tw-text-right">
<button
type="button"
[bitMenuTriggerFor]="sendOptions"
bitIconButton="bwi-ellipsis-v"
label="{{ 'options' | i18n }}"
></button>
<bit-menu #sendOptions>
<button type="button" bitMenuItem (click)="onCopy(s)">
<i class="bwi bwi-fw bwi-clone" aria-hidden="true"></i>
{{ "copySendLink" | i18n }}
</button>
@if (s.password && !disableSend()) {
<button type="button" bitMenuItem (click)="onRemovePassword(s)">
<i class="bwi bwi-fw bwi-close" aria-hidden="true"></i>
{{ "removePassword" | i18n }}
</button>
}
<button type="button" bitMenuItem (click)="onDelete(s)">
<span class="tw-text-danger">
<i class="bwi bwi-fw bwi-trash" aria-hidden="true"></i>
{{ "delete" | i18n }}
</span>
</button>
</bit-menu>
</td>
</tr>
</ng-template>
</bit-table>

View File

@ -0,0 +1,100 @@
import { Meta, StoryObj, moduleMetadata } from "@storybook/angular";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { SendType } from "@bitwarden/common/tools/send/enums/send-type";
import { SendView } from "@bitwarden/common/tools/send/models/view/send.view";
import { TableDataSource, I18nMockService } from "@bitwarden/components";
import { SendTableComponent } from "./send-table.component";
function createMockSend(id: number, overrides: Partial<SendView> = {}): SendView {
const send = new SendView();
send.id = `send-${id}`;
send.name = "My Send";
send.type = SendType.Text;
send.deletionDate = new Date("2030-01-01T12:00:00Z");
send.password = null as any;
Object.assign(send, overrides);
return send;
}
const dataSource = new TableDataSource<SendView>();
dataSource.data = [
createMockSend(0, {
name: "Project Documentation",
type: SendType.Text,
}),
createMockSend(1, {
name: "Meeting Notes",
type: SendType.File,
}),
createMockSend(2, {
name: "Password Protected Send",
type: SendType.Text,
password: "123",
}),
createMockSend(3, {
name: "Disabled Send",
type: SendType.Text,
disabled: true,
}),
createMockSend(4, {
name: "Expired Send",
type: SendType.File,
expirationDate: new Date("2025-12-01T00:00:00Z"),
}),
createMockSend(5, {
name: "Max Access Reached",
type: SendType.Text,
maxAccessCount: 5,
accessCount: 5,
password: "123",
}),
];
export default {
title: "Tools/Sends/Send Table",
component: SendTableComponent,
decorators: [
moduleMetadata({
providers: [
{
provide: I18nService,
useFactory: () => {
return new I18nMockService({
name: "Name",
deletionDate: "Deletion Date",
options: "Options",
disabled: "Disabled",
password: "Password",
maxAccessCountReached: "Max access count reached",
expired: "Expired",
pendingDeletion: "Pending deletion",
copySendLink: "Copy Send link",
removePassword: "Remove password",
delete: "Delete",
loading: "Loading",
});
},
},
],
}),
],
args: {
dataSource,
disableSend: false,
},
argTypes: {
editSend: { action: "editSend" },
copySend: { action: "copySend" },
removePassword: { action: "removePassword" },
deleteSend: { action: "deleteSend" },
},
} as Meta<SendTableComponent>;
type Story = StoryObj<SendTableComponent>;
export const Default: Story = {};

View File

@ -0,0 +1,92 @@
import { CommonModule } from "@angular/common";
import { ChangeDetectionStrategy, Component, input, output } from "@angular/core";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { SendType } from "@bitwarden/common/tools/send/enums/send-type";
import { SendView } from "@bitwarden/common/tools/send/models/view/send.view";
import {
BadgeModule,
ButtonModule,
IconButtonModule,
LinkModule,
MenuModule,
TableDataSource,
TableModule,
TypographyModule,
} from "@bitwarden/components";
/**
* A table component for displaying Send items with sorting, status indicators, and action menus. Handles the presentation of sends in a tabular format with options
* for editing, copying links, removing passwords, and deleting.
*/
@Component({
selector: "tools-send-table",
templateUrl: "./send-table.component.html",
imports: [
CommonModule,
JslibModule,
TableModule,
ButtonModule,
LinkModule,
IconButtonModule,
MenuModule,
BadgeModule,
TypographyModule,
],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class SendTableComponent {
protected readonly sendType = SendType;
/**
* The data source containing the Send items to display in the table.
*/
readonly dataSource = input<TableDataSource<SendView>>();
/**
* Whether Send functionality is disabled by policy.
* When true, the "Remove Password" option is hidden from the action menu.
*/
readonly disableSend = input(false);
/**
* Emitted when a user clicks on a Send item to edit it.
* The clicked SendView is passed as the event payload.
*/
readonly editSend = output<SendView>();
/**
* Emitted when a user clicks the "Copy Send Link" action.
* The SendView is passed as the event payload for generating and copying the link.
*/
readonly copySend = output<SendView>();
/**
* Emitted when a user clicks the "Remove Password" action.
* The SendView is passed as the event payload for password removal.
* This action is only available if the Send has a password and Send is not disabled.
*/
readonly removePassword = output<SendView>();
/**
* Emitted when a user clicks the "Delete" action.
* The SendView is passed as the event payload for deletion.
*/
readonly deleteSend = output<SendView>();
protected onEditSend(send: SendView): void {
this.editSend.emit(send);
}
protected onCopy(send: SendView): void {
this.copySend.emit(send);
}
protected onRemovePassword(send: SendView): void {
this.removePassword.emit(send);
}
protected onDelete(send: SendView): void {
this.deleteSend.emit(send);
}
}

View File

@ -0,0 +1,59 @@
import { Observable } from "rxjs";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { OrganizationId, CollectionId } from "@bitwarden/common/types/guid";
import { UserId } from "@bitwarden/user-core";
export type UserMigrationInfo =
| {
/**
* Whether the user requires migration of their vault items from My Vault to a My Items collection due to an
* organizational policy change. (Enforce organization data ownership policy enabled)
*/
requiresMigration: false;
}
| {
/**
* Whether the user requires migration of their vault items from My Vault to a My Items collection due to an
* organizational policy change. (Enforce organization data ownership policy enabled)
*/
requiresMigration: true;
/**
* The organization that is enforcing data ownership policies for the given user.
*/
enforcingOrganization: Organization;
/**
* The default collection ID for the user in the enforcing organization, if available.
*/
defaultCollectionId?: CollectionId;
};
export abstract class VaultItemsTransferService {
/**
* Gets information about whether the given user requires migration of their vault items
* from My Vault to a My Items collection, and whether they are capable of performing that migration.
* @param userId
*/
abstract userMigrationInfo$(userId: UserId): Observable<UserMigrationInfo>;
/**
* Enforces organization data ownership for the given user by transferring vault items.
* Checks if any organization policies require the transfer, and if so, prompts the user to confirm before proceeding.
*
* Rejecting the transfer will result in the user being revoked from the organization.
*
* @param userId
*/
abstract enforceOrganizationDataOwnership(userId: UserId): Promise<void>;
/**
* Begins transfer of vault items from My Vault to the specified default collection for the given user.
*/
abstract transferPersonalItems(
userId: UserId,
organizationId: OrganizationId,
defaultCollectionId: CollectionId,
): Promise<void>;
}

View File

@ -122,7 +122,7 @@
</bit-form-field> </bit-form-field>
<bit-form-field *ngIf="cipher.login.totp"> <bit-form-field *ngIf="cipher.login.totp">
<bit-label [appTextDrag]="totpCodeCopyObj?.totpCode"> <bit-label [appTextDrag]="totpCodeCopyObj?.totpCode">
<div class="tw-flex tw-items-center tw-gap-3"> <div class="tw-flex tw-items-center tw-gap-2">
{{ "verificationCodeTotp" | i18n }} {{ "verificationCodeTotp" | i18n }}
<app-premium-badge [organizationId]="cipher.organizationId"></app-premium-badge> <app-premium-badge [organizationId]="cipher.organizationId"></app-premium-badge>
</div> </div>

View File

@ -0,0 +1,13 @@
export {
TransferItemsDialogComponent,
TransferItemsDialogParams,
TransferItemsDialogResult,
TransferItemsDialogResultType,
} from "./transfer-items-dialog.component";
export {
LeaveConfirmationDialogComponent,
LeaveConfirmationDialogParams,
LeaveConfirmationDialogResult,
LeaveConfirmationDialogResultType,
} from "./leave-confirmation-dialog.component";

View File

@ -0,0 +1,33 @@
<bit-simple-dialog>
<i
bitDialogIcon
class="bwi bwi-exclamation-triangle tw-text-warning tw-text-3xl"
aria-hidden="true"
></i>
<span bitDialogTitle>{{ "leaveConfirmationDialogTitle" | i18n }}</span>
<ng-container bitDialogContent>
<p bitTypography="body1">
{{ "leaveConfirmationDialogContentOne" | i18n }}
</p>
<p bitTypography="body1">
{{ "leaveConfirmationDialogContentTwo" | i18n }}
</p>
</ng-container>
<ng-container bitDialogFooter>
<button bitButton type="button" buttonType="dangerPrimary" (click)="confirmLeave()">
{{ "leaveConfirmationDialogConfirmButton" | i18n: organizationName }}
</button>
<button bitButton type="button" buttonType="secondary" (click)="goBack()">
{{ "goBack" | i18n }}
</button>
<a bitLink href="#" (click)="openLearnMore($event)" class="tw-w-full tw-text-center">
{{ "howToManageMyVault" | i18n }}
<i class="bwi bwi-external-link tw-ml-1" aria-hidden="true"></i>
</a>
</ng-container>
</bit-simple-dialog>

Some files were not shown because too many files have changed in this diff Show More