mirror of
https://github.com/bitwarden/clients.git
synced 2025-12-10 00:08:42 -06:00
Compare commits
34 Commits
1e6605f195
...
4316a9f1b9
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4316a9f1b9 | ||
|
|
3af19ad934 | ||
|
|
42c09b325c | ||
|
|
f161a8c454 | ||
|
|
6dba3ac377 | ||
|
|
22338632be | ||
|
|
717cf93cc8 | ||
|
|
d95dd709b1 | ||
|
|
2cd12d9611 | ||
|
|
488a786b86 | ||
|
|
e03e5f1b2b | ||
|
|
c84ebc97da | ||
|
|
5c64bf51fc | ||
|
|
508131ac1e | ||
|
|
0af5e5630b | ||
|
|
c6576ceec8 | ||
|
|
5dbcb18b6a | ||
|
|
ee582b2ebe | ||
|
|
c1c3e432f7 | ||
|
|
093e06e787 | ||
|
|
456f02958a | ||
|
|
bbf9157ec0 | ||
|
|
7a6c0394b8 | ||
|
|
3735f1c106 | ||
|
|
cbbfca1f91 | ||
|
|
b54230ba32 | ||
|
|
ce7521b972 | ||
|
|
fda9a9d2b2 | ||
|
|
dfe2e283a0 | ||
|
|
4c56a9693c | ||
|
|
d23d9f6087 | ||
|
|
32ee9b3c6d | ||
|
|
2d3b017cc2 | ||
|
|
97adae2864 |
6
.github/workflows/build-browser.yml
vendored
6
.github/workflows/build-browser.yml
vendored
@ -152,7 +152,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
|
||||
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
|
||||
with:
|
||||
cache: 'npm'
|
||||
cache-dependency-path: '**/package-lock.json'
|
||||
@ -260,7 +260,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
|
||||
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
|
||||
with:
|
||||
cache: 'npm'
|
||||
cache-dependency-path: '**/package-lock.json'
|
||||
@ -392,7 +392,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
|
||||
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
|
||||
with:
|
||||
cache: 'npm'
|
||||
cache-dependency-path: '**/package-lock.json'
|
||||
|
||||
4
.github/workflows/build-cli.yml
vendored
4
.github/workflows/build-cli.yml
vendored
@ -130,7 +130,7 @@ jobs:
|
||||
} >> "$GITHUB_ENV"
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
|
||||
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
|
||||
with:
|
||||
cache: 'npm'
|
||||
cache-dependency-path: '**/package-lock.json'
|
||||
@ -326,7 +326,7 @@ jobs:
|
||||
choco install nasm --no-progress
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
|
||||
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
|
||||
with:
|
||||
cache: 'npm'
|
||||
cache-dependency-path: '**/package-lock.json'
|
||||
|
||||
20
.github/workflows/build-desktop.yml
vendored
20
.github/workflows/build-desktop.yml
vendored
@ -193,7 +193,7 @@ jobs:
|
||||
sudo rm -rf /usr/local/aws-sam-cli
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
|
||||
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
|
||||
with:
|
||||
cache: 'npm'
|
||||
cache-dependency-path: '**/package-lock.json'
|
||||
@ -351,7 +351,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
|
||||
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
|
||||
with:
|
||||
cache: 'npm'
|
||||
cache-dependency-path: '**/package-lock.json'
|
||||
@ -501,7 +501,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
|
||||
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
|
||||
with:
|
||||
cache: 'npm'
|
||||
cache-dependency-path: '**/package-lock.json'
|
||||
@ -767,7 +767,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
|
||||
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
|
||||
with:
|
||||
cache: 'npm'
|
||||
cache-dependency-path: '**/package-lock.json'
|
||||
@ -1010,7 +1010,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
|
||||
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
|
||||
with:
|
||||
cache: 'npm'
|
||||
cache-dependency-path: '**/package-lock.json'
|
||||
@ -1019,7 +1019,7 @@ jobs:
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0
|
||||
with:
|
||||
python-version: '3.14'
|
||||
python-version: '3.14.2'
|
||||
|
||||
- name: Set up Node-gyp
|
||||
run: python -m pip install setuptools
|
||||
@ -1248,7 +1248,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
|
||||
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
|
||||
with:
|
||||
cache: 'npm'
|
||||
cache-dependency-path: '**/package-lock.json'
|
||||
@ -1257,7 +1257,7 @@ jobs:
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0
|
||||
with:
|
||||
python-version: '3.14'
|
||||
python-version: '3.14.2'
|
||||
|
||||
- name: Set up Node-gyp
|
||||
run: python -m pip install setuptools
|
||||
@ -1521,7 +1521,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
|
||||
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
|
||||
with:
|
||||
cache: 'npm'
|
||||
cache-dependency-path: '**/package-lock.json'
|
||||
@ -1530,7 +1530,7 @@ jobs:
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0
|
||||
with:
|
||||
python-version: '3.14'
|
||||
python-version: '3.14.2'
|
||||
|
||||
- name: Set up Node-gyp
|
||||
run: python -m pip install setuptools
|
||||
|
||||
2
.github/workflows/chromatic.yml
vendored
2
.github/workflows/chromatic.yml
vendored
@ -58,7 +58,7 @@ jobs:
|
||||
echo "node_version=$NODE_VERSION" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
|
||||
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
|
||||
with:
|
||||
node-version: ${{ steps.retrieve-node-version.outputs.node_version }}
|
||||
if: steps.get-changed-files-for-chromatic.outputs.storyFiles == 'true'
|
||||
|
||||
2
.github/workflows/lint.yml
vendored
2
.github/workflows/lint.yml
vendored
@ -64,7 +64,7 @@ jobs:
|
||||
echo "node_version=$NODE_VERSION" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
|
||||
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
|
||||
with:
|
||||
cache: 'npm'
|
||||
cache-dependency-path: '**/package-lock.json'
|
||||
|
||||
2
.github/workflows/nx.yml
vendored
2
.github/workflows/nx.yml
vendored
@ -26,7 +26,7 @@ jobs:
|
||||
echo "node_version=$NODE_VERSION" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
|
||||
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
|
||||
with:
|
||||
cache: 'npm'
|
||||
cache-dependency-path: '**/package-lock.json'
|
||||
|
||||
2
.github/workflows/publish-cli.yml
vendored
2
.github/workflows/publish-cli.yml
vendored
@ -216,7 +216,7 @@ jobs:
|
||||
echo "node_version=$NODE_VERSION" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
|
||||
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
|
||||
with:
|
||||
node-version: ${{ steps.retrieve-node-version.outputs.node_version }}
|
||||
registry-url: "https://registry.npmjs.org/"
|
||||
|
||||
@ -76,7 +76,7 @@ jobs:
|
||||
echo "node_version=$NODE_VERSION" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
|
||||
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
|
||||
with:
|
||||
cache: 'npm'
|
||||
cache-dependency-path: '**/package-lock.json'
|
||||
|
||||
2
.github/workflows/stale-bot.yml
vendored
2
.github/workflows/stale-bot.yml
vendored
@ -15,7 +15,7 @@ jobs:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: 'Run stale action'
|
||||
uses: actions/stale@5f858e3efba33a5ca4407a664cc011ad407f2008 # v10.1.0
|
||||
uses: actions/stale@997185467fa4f803885201cee163a9f38240193d # v10.1.1
|
||||
with:
|
||||
stale-issue-label: 'needs-reply'
|
||||
stale-pr-label: 'needs-changes'
|
||||
|
||||
2
.github/workflows/test.yml
vendored
2
.github/workflows/test.yml
vendored
@ -36,7 +36,7 @@ jobs:
|
||||
echo "node_version=$NODE_VERSION" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
|
||||
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
|
||||
with:
|
||||
cache: 'npm'
|
||||
cache-dependency-path: '**/package-lock.json'
|
||||
|
||||
@ -1475,6 +1475,9 @@
|
||||
"selectFile": {
|
||||
"message": "Select a file"
|
||||
},
|
||||
"itemsTransferred": {
|
||||
"message": "Items transferred"
|
||||
},
|
||||
"maxFileSize": {
|
||||
"message": "Maximum file size is 500 MB."
|
||||
},
|
||||
@ -5934,5 +5937,56 @@
|
||||
},
|
||||
"sessionTimeoutSettingsSetUnlockMethodToChangeTimeoutAction": {
|
||||
"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?"
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
<button
|
||||
*ngIf="currentAccount$ | async as currentAccount; else defaultButton"
|
||||
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()"
|
||||
>
|
||||
<span class="tw-sr-only"> {{ "bitwardenAccount" | i18n }} {{ currentAccount.email }}</span>
|
||||
|
||||
@ -129,7 +129,12 @@ export class AutofillInlineMenuContainer {
|
||||
}
|
||||
try {
|
||||
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) {
|
||||
return false;
|
||||
|
||||
@ -22,7 +22,7 @@ export type NavButton = {
|
||||
templateUrl: "popup-tab-navigation.component.html",
|
||||
imports: [CommonModule, LinkModule, RouterModule, JslibModule, IconModule],
|
||||
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 {
|
||||
|
||||
@ -13,8 +13,11 @@
|
||||
</bit-callout>
|
||||
</div>
|
||||
} @else {
|
||||
<div [@routerTransition]="getRouteElevation(outlet)">
|
||||
<router-outlet #outlet="outlet"></router-outlet>
|
||||
<!-- eslint-disable-next-line -->
|
||||
<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>
|
||||
<bit-toast-container></bit-toast-container>
|
||||
}
|
||||
|
||||
@ -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%;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -1,11 +0,0 @@
|
||||
.row {
|
||||
display: flex;
|
||||
margin: 0 -15px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.col {
|
||||
flex-basis: 0;
|
||||
flex-grow: 1;
|
||||
padding: 0 15px;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -1,13 +1,50 @@
|
||||
@import "../../../../../libs/angular/src/scss/bwicons/styles/style.scss";
|
||||
@import "variables.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 "../../../../../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");
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
/** Safari Support */
|
||||
@ -19,4 +119,59 @@
|
||||
html:not(.browser_safari) .tw-styled-scrollbar {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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-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;
|
||||
$border-color: #f0f0f0;
|
||||
$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-danger: #c83522;
|
||||
$brand-success: #017e45;
|
||||
$brand-info: #555555;
|
||||
$brand-warning: #8b6609;
|
||||
$brand-primary-accent: #1252a3;
|
||||
|
||||
$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-danger: darken($brand-danger, 10%);
|
||||
|
||||
$code-color: #c01176;
|
||||
$code-color-dark: #f08dc7;
|
||||
|
||||
$themes: (
|
||||
light: (
|
||||
textColor: $text-color,
|
||||
hoverColorTransparent: rgba($text-color, 0.15),
|
||||
borderColor: $border-color-dark,
|
||||
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%),
|
||||
inputBackgroundColor: #ffffff,
|
||||
inputPlaceholderColor: lighten($gray-light, 35%),
|
||||
buttonBackgroundColor: $button-background-color,
|
||||
buttonBorderColor: $button-border-color,
|
||||
buttonColor: $button-color,
|
||||
buttonPrimaryColor: $button-color-primary,
|
||||
buttonDangerColor: $button-color-danger,
|
||||
primaryColor: $brand-primary,
|
||||
primaryAccentColor: $brand-primary-accent,
|
||||
dangerColor: $brand-danger,
|
||||
successColor: $brand-success,
|
||||
infoColor: $brand-info,
|
||||
warningColor: $brand-warning,
|
||||
logoSuffix: "dark",
|
||||
mfaLogoSuffix: ".png",
|
||||
passwordNumberColor: #007fde,
|
||||
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: (
|
||||
textColor: #ffffff,
|
||||
hoverColorTransparent: rgba($text-color, 0.15),
|
||||
borderColor: #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,
|
||||
inputBackgroundColor: #2f343d,
|
||||
inputPlaceholderColor: #bac0ce,
|
||||
buttonBackgroundColor: #3c424e,
|
||||
buttonBorderColor: #4c525f,
|
||||
buttonColor: #bac0ce,
|
||||
buttonPrimaryColor: #6f9df1,
|
||||
buttonDangerColor: #ff8d85,
|
||||
primaryColor: #6f9df1,
|
||||
primaryAccentColor: #6f9df1,
|
||||
dangerColor: #ff8d85,
|
||||
successColor: #52e07c,
|
||||
infoColor: #a4b0c6,
|
||||
warningColor: #ffeb66,
|
||||
logoSuffix: "white",
|
||||
mfaLogoSuffix: "-w.png",
|
||||
passwordNumberColor: #6f9df1,
|
||||
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,
|
||||
),
|
||||
);
|
||||
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
<button bitButton size="small" [bitMenuTriggerFor]="itemOptions" buttonType="primary" type="button">
|
||||
<i class="bwi bwi-plus" aria-hidden="true"></i>
|
||||
<button bitButton [bitMenuTriggerFor]="itemOptions" buttonType="primary" type="button">
|
||||
<i class="bwi bwi-plus tw-me-2" aria-hidden="true"></i>
|
||||
{{ "new" | i18n }}
|
||||
</button>
|
||||
<bit-menu #itemOptions>
|
||||
|
||||
@ -12,5 +12,6 @@ config.content = [
|
||||
"../../libs/vault/src/**/*.{html,ts}",
|
||||
"../../libs/pricing/src/**/*.{html,ts}",
|
||||
];
|
||||
config.corePlugins.preflight = true;
|
||||
|
||||
module.exports = config;
|
||||
|
||||
230
apps/desktop/desktop_native/Cargo.lock
generated
230
apps/desktop/desktop_native/Cargo.lock
generated
@ -347,8 +347,8 @@ dependencies = [
|
||||
"mockall",
|
||||
"serial_test",
|
||||
"tracing",
|
||||
"windows 0.61.1",
|
||||
"windows-core 0.61.0",
|
||||
"windows",
|
||||
"windows-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -457,7 +457,7 @@ dependencies = [
|
||||
"tokio",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"windows 0.61.1",
|
||||
"windows",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -501,6 +501,12 @@ dependencies = [
|
||||
"cipher",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bumpalo"
|
||||
version = "3.19.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43"
|
||||
|
||||
[[package]]
|
||||
name = "byteorder"
|
||||
version = "1.5.0"
|
||||
@ -556,9 +562,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cc"
|
||||
version = "1.2.46"
|
||||
version = "1.2.49"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b97463e1064cb1b1c1384ad0a0b9c8abd0988e2a91f52606c80ef14aadb63e36"
|
||||
checksum = "90583009037521a116abf44494efecd645ba48b6622457080f080b85544e2215"
|
||||
dependencies = [
|
||||
"find-msvc-tools",
|
||||
"shlex",
|
||||
@ -623,7 +629,7 @@ dependencies = [
|
||||
"tokio",
|
||||
"tracing",
|
||||
"verifysign",
|
||||
"windows 0.61.1",
|
||||
"windows",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -877,13 +883,13 @@ dependencies = [
|
||||
"sha2",
|
||||
"ssh-key",
|
||||
"sysinfo",
|
||||
"thiserror 2.0.12",
|
||||
"thiserror 2.0.17",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
"tracing",
|
||||
"typenum",
|
||||
"widestring",
|
||||
"windows 0.61.1",
|
||||
"windows",
|
||||
"windows-future",
|
||||
"zbus",
|
||||
"zbus_polkit",
|
||||
@ -1417,9 +1423,9 @@ checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f"
|
||||
|
||||
[[package]]
|
||||
name = "glob"
|
||||
version = "0.3.2"
|
||||
version = "0.3.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a8d1add55171497b4705a648c6b583acafb01d58050a51727785f0b2c8e0a2b2"
|
||||
checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280"
|
||||
|
||||
[[package]]
|
||||
name = "goblin"
|
||||
@ -1499,14 +1505,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "homedir"
|
||||
version = "0.3.4"
|
||||
version = "0.3.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5bdbbd5bc8c5749697ccaa352fa45aff8730cf21c68029c0eef1ffed7c3d6ba2"
|
||||
checksum = "68df315d2857b2d8d2898be54a85e1d001bbbe0dbb5f8ef847b48dd3a23c4527"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"nix 0.29.0",
|
||||
"nix",
|
||||
"widestring",
|
||||
"windows 0.57.0",
|
||||
"windows",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -1663,6 +1669,16 @@ version = "1.0.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "lazy_static"
|
||||
version = "1.5.0"
|
||||
@ -1674,9 +1690,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.177"
|
||||
version = "0.2.178"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2874a2af47a2325c2001a6e6fad9b16a53b802102b528163885171cf92b15976"
|
||||
checksum = "37c93d8daa9d8a012fd8ab92f088405fb202ea0b6ab73ee2482ae66af4f42091"
|
||||
|
||||
[[package]]
|
||||
name = "libloading"
|
||||
@ -1945,18 +1961,6 @@ dependencies = [
|
||||
"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]]
|
||||
name = "nix"
|
||||
version = "0.30.1"
|
||||
@ -2660,7 +2664,7 @@ checksum = "dd6f9d3d47bdd2ad6945c5015a226ec6155d0bcdfd8f7cd29f86b71f8de99d2b"
|
||||
dependencies = [
|
||||
"getrandom 0.2.16",
|
||||
"libredox",
|
||||
"thiserror 2.0.12",
|
||||
"thiserror 2.0.17",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -2798,6 +2802,12 @@ dependencies = [
|
||||
"rustix 1.0.7",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustversion"
|
||||
version = "1.0.22"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
|
||||
|
||||
[[package]]
|
||||
name = "ryu"
|
||||
version = "1.0.20"
|
||||
@ -2870,15 +2880,15 @@ dependencies = [
|
||||
"libc",
|
||||
"rustix 1.0.7",
|
||||
"rustix-linux-procfs",
|
||||
"thiserror 2.0.12",
|
||||
"windows 0.61.1",
|
||||
"thiserror 2.0.17",
|
||||
"windows",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "security-framework"
|
||||
version = "3.5.0"
|
||||
version = "3.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cc198e42d9b7510827939c9a15f5062a0c913f3371d765977e586d2fe6c16f4a"
|
||||
checksum = "b3297343eaf830f66ede390ea39da1d462b6b0c1b000f420d0a83f898bbbe6ef"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"core-foundation",
|
||||
@ -3188,16 +3198,16 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "sysinfo"
|
||||
version = "0.35.0"
|
||||
version = "0.37.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b897c8ea620e181c7955369a31be5f48d9a9121cb59fd33ecef9ff2a34323422"
|
||||
checksum = "16607d5caffd1c07ce073528f9ed972d88db15dd44023fa57142963be3feb11f"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"memchr",
|
||||
"ntapi",
|
||||
"objc2-core-foundation",
|
||||
"objc2-io-kit",
|
||||
"windows 0.61.1",
|
||||
"windows",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -3239,11 +3249,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "thiserror"
|
||||
version = "2.0.12"
|
||||
version = "2.0.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "567b8a2dae586314f7be2a752ec7474332959c6460e02bde30d702a66d488708"
|
||||
checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8"
|
||||
dependencies = [
|
||||
"thiserror-impl 2.0.12",
|
||||
"thiserror-impl 2.0.17",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -3259,9 +3269,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "thiserror-impl"
|
||||
version = "2.0.12"
|
||||
version = "2.0.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7f7cf42b4507d8ea322120659672cf1b9dbb93f8f2d4ecfd6e51350ff5b17a1d"
|
||||
checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@ -3680,6 +3690,17 @@ version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "valuable"
|
||||
version = "0.1.1"
|
||||
@ -3745,6 +3766,51 @@ dependencies = [
|
||||
"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]]
|
||||
name = "wayland-backend"
|
||||
version = "0.3.10"
|
||||
@ -3852,16 +3918,6 @@ version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "windows"
|
||||
version = "0.61.1"
|
||||
@ -3869,7 +3925,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c5ee8f3d025738cb02bad7868bbb5f8a6327501e870bf51f1b455b0a2454a419"
|
||||
dependencies = [
|
||||
"windows-collections",
|
||||
"windows-core 0.61.0",
|
||||
"windows-core",
|
||||
"windows-future",
|
||||
"windows-link 0.1.3",
|
||||
"windows-numerics",
|
||||
@ -3881,19 +3937,7 @@ version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8"
|
||||
dependencies = [
|
||||
"windows-core 0.61.0",
|
||||
]
|
||||
|
||||
[[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",
|
||||
"windows-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -3902,8 +3946,8 @@ version = "0.61.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4763c1de310c86d75a878046489e2e5ba02c649d185f21c67d4cf8a56d098980"
|
||||
dependencies = [
|
||||
"windows-implement 0.60.0",
|
||||
"windows-interface 0.59.1",
|
||||
"windows-implement",
|
||||
"windows-interface",
|
||||
"windows-link 0.1.3",
|
||||
"windows-result 0.3.4",
|
||||
"windows-strings 0.4.2",
|
||||
@ -3915,21 +3959,10 @@ version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7a1d6bbefcb7b60acd19828e1bc965da6fcf18a7e39490c5f8be71e54a19ba32"
|
||||
dependencies = [
|
||||
"windows-core 0.61.0",
|
||||
"windows-core",
|
||||
"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]]
|
||||
name = "windows-implement"
|
||||
version = "0.60.0"
|
||||
@ -3941,17 +3974,6 @@ dependencies = [
|
||||
"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]]
|
||||
name = "windows-interface"
|
||||
version = "0.59.1"
|
||||
@ -3981,7 +4003,7 @@ version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1"
|
||||
dependencies = [
|
||||
"windows-core 0.61.0",
|
||||
"windows-core",
|
||||
"windows-link 0.1.3",
|
||||
]
|
||||
|
||||
@ -3996,15 +4018,6 @@ dependencies = [
|
||||
"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]]
|
||||
name = "windows-result"
|
||||
version = "0.3.4"
|
||||
@ -4262,8 +4275,8 @@ name = "windows_plugin_authenticator"
|
||||
version = "0.0.0"
|
||||
dependencies = [
|
||||
"hex",
|
||||
"windows 0.61.1",
|
||||
"windows-core 0.61.0",
|
||||
"windows",
|
||||
"windows-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -4434,9 +4447,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "zbus"
|
||||
version = "5.11.0"
|
||||
version = "5.12.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2d07e46d035fb8e375b2ce63ba4e4ff90a7f73cf2ffb0138b29e1158d2eaadf7"
|
||||
checksum = "b622b18155f7a93d1cd2dc8c01d2d6a44e08fb9ebb7b3f9e6ed101488bad6c91"
|
||||
dependencies = [
|
||||
"async-broadcast",
|
||||
"async-executor",
|
||||
@ -4452,14 +4465,15 @@ dependencies = [
|
||||
"futures-core",
|
||||
"futures-lite",
|
||||
"hex",
|
||||
"nix 0.30.1",
|
||||
"nix",
|
||||
"ordered-stream",
|
||||
"serde",
|
||||
"serde_repr",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"uds_windows",
|
||||
"windows-sys 0.60.2",
|
||||
"uuid",
|
||||
"windows-sys 0.61.2",
|
||||
"winnow",
|
||||
"zbus_macros",
|
||||
"zbus_names",
|
||||
@ -4468,9 +4482,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "zbus_macros"
|
||||
version = "5.11.0"
|
||||
version = "5.12.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "57e797a9c847ed3ccc5b6254e8bcce056494b375b511b3d6edcec0aeb4defaca"
|
||||
checksum = "1cdb94821ca8a87ca9c298b5d1cbd80e2a8b67115d99f6e4551ac49e42b6a314"
|
||||
dependencies = [
|
||||
"proc-macro-crate",
|
||||
"proc-macro2",
|
||||
|
||||
@ -37,9 +37,9 @@ ed25519 = "=2.2.3"
|
||||
embed_plist = "=1.2.2"
|
||||
futures = "=0.3.31"
|
||||
hex = "=0.4.3"
|
||||
homedir = "=0.3.4"
|
||||
homedir = "=0.3.6"
|
||||
interprocess = "=2.2.1"
|
||||
libc = "=0.2.177"
|
||||
libc = "=0.2.178"
|
||||
linux-keyutils = "=0.2.4"
|
||||
memsec = "=0.7.0"
|
||||
napi = "=2.16.17"
|
||||
@ -53,15 +53,15 @@ rsa = "=0.9.6"
|
||||
russh-cryptovec = "=0.7.3"
|
||||
scopeguard = "=1.2.0"
|
||||
secmem-proc = "=0.3.7"
|
||||
security-framework = "=3.5.0"
|
||||
security-framework = "=3.5.1"
|
||||
security-framework-sys = "=2.15.0"
|
||||
serde = "=1.0.209"
|
||||
serde_json = "=1.0.127"
|
||||
sha2 = "=0.10.8"
|
||||
ssh-encoding = "=0.2.0"
|
||||
ssh-key = { version = "=0.6.7", default-features = false }
|
||||
sysinfo = "=0.35.0"
|
||||
thiserror = "=2.0.12"
|
||||
sysinfo = "=0.37.2"
|
||||
thiserror = "=2.0.17"
|
||||
tokio = "=1.45.0"
|
||||
tokio-util = "=0.7.13"
|
||||
tracing = "=0.1.41"
|
||||
@ -77,7 +77,7 @@ windows = "=0.61.1"
|
||||
windows-core = "=0.61.0"
|
||||
windows-future = "=0.2.0"
|
||||
windows-registry = "=0.6.1"
|
||||
zbus = "=5.11.0"
|
||||
zbus = "=5.12.0"
|
||||
zbus_polkit = "=5.0.0"
|
||||
zeroizing-alloc = "=0.1.0"
|
||||
|
||||
|
||||
@ -285,8 +285,8 @@ async fn windows_hello_authenticate_with_crypto(
|
||||
return Err(anyhow!("Failed to sign data"));
|
||||
}
|
||||
|
||||
let signature_buffer = signature.Result()?;
|
||||
let signature_value = unsafe { as_mut_bytes(&signature_buffer)? };
|
||||
let mut signature_buffer = signature.Result()?;
|
||||
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
|
||||
// hashed to a key. It is unclear what entropy this key provides.
|
||||
@ -368,7 +368,7 @@ fn decrypt_data(
|
||||
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>()?;
|
||||
|
||||
unsafe {
|
||||
|
||||
@ -24,7 +24,7 @@ serde_json = { workspace = true }
|
||||
tokio = { workspace = true, features = ["sync"] }
|
||||
tracing = { workspace = true }
|
||||
tracing-subscriber = { workspace = true }
|
||||
tracing-oslog = "0.3.0"
|
||||
tracing-oslog = "=0.3.0"
|
||||
|
||||
[build-dependencies]
|
||||
uniffi = { workspace = true, features = ["build"] }
|
||||
|
||||
@ -14,8 +14,8 @@ tokio = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
|
||||
[target.'cfg(target_os = "macos")'.build-dependencies]
|
||||
cc = "=1.2.46"
|
||||
glob = "=0.3.2"
|
||||
cc = "=1.2.49"
|
||||
glob = "=0.3.3"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
[toolchain]
|
||||
channel = "1.87.0"
|
||||
channel = "1.91.1"
|
||||
components = [ "rustfmt", "clippy" ]
|
||||
profile = "minimal"
|
||||
|
||||
@ -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,
|
||||
ppPluginAddAuthenticatorResponse: *mut *mut webauthn::ExperimentalWebAuthnPluginAddAuthenticatorResponse,
|
||||
) -> HRESULT;
|
||||
|
||||
@ -19,7 +19,7 @@
|
||||
"yargs": "18.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "22.19.1",
|
||||
"@types/node": "22.19.2",
|
||||
"typescript": "5.4.2"
|
||||
}
|
||||
},
|
||||
@ -117,9 +117,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "22.19.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.1.tgz",
|
||||
"integrity": "sha512-LCCV0HdSZZZb34qifBsyWlUmok6W7ouER+oQIGBScS8EsZsQbrtFTUrDX4hOl+CS6p7cnNC4td+qrSVGSCTUfQ==",
|
||||
"version": "22.19.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.2.tgz",
|
||||
"integrity": "sha512-LPM2G3Syo1GLzXLGJAKdqoU35XvrWzGJ21/7sgZTUpbkBaOasTj8tjwn6w+hCkqaa1TfJ/w67rJSwYItlJ2mYw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
|
||||
@ -24,7 +24,7 @@
|
||||
"yargs": "18.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "22.19.1",
|
||||
"@types/node": "22.19.2",
|
||||
"typescript": "5.4.2"
|
||||
},
|
||||
"_moduleAliases": {
|
||||
|
||||
@ -37,6 +37,6 @@ concurrently(
|
||||
{
|
||||
prefix: "name",
|
||||
outputStream: process.stdout,
|
||||
killOthers: ["success", "failure"],
|
||||
killOthersOn: ["success", "failure"],
|
||||
},
|
||||
);
|
||||
|
||||
@ -34,6 +34,6 @@ concurrently(
|
||||
{
|
||||
prefix: "name",
|
||||
outputStream: process.stdout,
|
||||
killOthers: ["success", "failure"],
|
||||
killOthersOn: ["success", "failure"],
|
||||
},
|
||||
);
|
||||
|
||||
@ -1,13 +1,21 @@
|
||||
<bit-dialog #dialog dialogSize="large" background="alt">
|
||||
<span bitDialogTitle>{{ "importData" | i18n }}</span>
|
||||
<ng-container bitDialogContent>
|
||||
<tools-import
|
||||
(formLoading)="this.loading = $event"
|
||||
(formDisabled)="this.disabled = $event"
|
||||
(onSuccessfulImport)="this.onSuccessfulImport($event)"
|
||||
[onImportFromBrowser]="this.onImportFromBrowser"
|
||||
[onLoadProfilesFromBrowser]="this.onLoadProfilesFromBrowser"
|
||||
></tools-import>
|
||||
<div class="tw-relative">
|
||||
<tools-import
|
||||
(formLoading)="this.loading = $event"
|
||||
(formDisabled)="this.disabled = $event"
|
||||
(onSuccessfulImport)="this.onSuccessfulImport($event)"
|
||||
[onImportFromBrowser]="this.onImportFromBrowser"
|
||||
[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 bitDialogFooter>
|
||||
<button
|
||||
|
||||
@ -46,7 +46,9 @@
|
||||
</div>
|
||||
<div class="box-content-row" appBoxRow *ngIf="editMode && type === sendType.File">
|
||||
<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 class="box-content-row" appBoxRow *ngIf="type === sendType.Text">
|
||||
<label for="text">{{ "text" | i18n }}</label>
|
||||
|
||||
@ -708,6 +708,9 @@
|
||||
"addAttachment": {
|
||||
"message": "Add attachment"
|
||||
},
|
||||
"itemsTransferred": {
|
||||
"message": "Items transferred"
|
||||
},
|
||||
"fixEncryption": {
|
||||
"message": "Fix encryption"
|
||||
},
|
||||
@ -4380,5 +4383,56 @@
|
||||
},
|
||||
"sessionTimeoutSettingsSetUnlockMethodToChangeTimeoutAction": {
|
||||
"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?"
|
||||
}
|
||||
}
|
||||
|
||||
@ -157,7 +157,7 @@ export class CloudHostedPremiumVNextComponent {
|
||||
return {
|
||||
tier,
|
||||
price:
|
||||
tier?.passwordManager.type === "standalone"
|
||||
tier?.passwordManager.type === "standalone" && tier.passwordManager.annualPrice
|
||||
? Number((tier.passwordManager.annualPrice / 12).toFixed(2))
|
||||
: 0,
|
||||
features: tier?.passwordManager.features.map((f) => f.value) || [],
|
||||
@ -172,7 +172,7 @@ export class CloudHostedPremiumVNextComponent {
|
||||
return {
|
||||
tier,
|
||||
price:
|
||||
tier?.passwordManager.type === "packaged"
|
||||
tier?.passwordManager.type === "packaged" && tier.passwordManager.annualPrice
|
||||
? Number((tier.passwordManager.annualPrice / 12).toFixed(2))
|
||||
: 0,
|
||||
features: tier?.passwordManager.features.map((f) => f.value) || [],
|
||||
|
||||
@ -1,15 +1,15 @@
|
||||
import { CdkTrapFocus } from "@angular/cdk/a11y";
|
||||
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 { 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 {
|
||||
PersonalSubscriptionPricingTier,
|
||||
PersonalSubscriptionPricingTierId,
|
||||
PersonalSubscriptionPricingTierIds,
|
||||
SubscriptionCadence,
|
||||
SubscriptionCadenceIds,
|
||||
} from "@bitwarden/common/billing/types/subscription-pricing-tier";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
@ -32,14 +32,6 @@ export type UpgradeAccountResult = {
|
||||
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
|
||||
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
|
||||
@Component({
|
||||
@ -60,8 +52,8 @@ export class UpgradeAccountComponent implements OnInit {
|
||||
planSelected = output<PersonalSubscriptionPricingTierId>();
|
||||
closeClicked = output<UpgradeAccountStatus>();
|
||||
protected readonly loading = signal(true);
|
||||
protected premiumCardDetails!: CardDetails;
|
||||
protected familiesCardDetails!: CardDetails;
|
||||
protected premiumCardDetails!: SubscriptionPricingCardDetails;
|
||||
protected familiesCardDetails!: SubscriptionPricingCardDetails;
|
||||
|
||||
protected familiesPlanType = PersonalSubscriptionPricingTierIds.Families;
|
||||
protected premiumPlanType = PersonalSubscriptionPricingTierIds.Premium;
|
||||
@ -122,14 +114,16 @@ export class UpgradeAccountComponent implements OnInit {
|
||||
private createCardDetails(
|
||||
tier: PersonalSubscriptionPricingTier,
|
||||
buttonType: ButtonType,
|
||||
): CardDetails {
|
||||
): SubscriptionPricingCardDetails {
|
||||
return {
|
||||
title: tier.name,
|
||||
tagline: tier.description,
|
||||
price: {
|
||||
amount: tier.passwordManager.annualPrice / 12,
|
||||
cadence: SubscriptionCadenceIds.Monthly,
|
||||
},
|
||||
price: tier.passwordManager.annualPrice
|
||||
? {
|
||||
amount: tier.passwordManager.annualPrice / 12,
|
||||
cadence: SubscriptionCadenceIds.Monthly,
|
||||
}
|
||||
: undefined,
|
||||
button: {
|
||||
text: this.i18nService.t(
|
||||
this.isFamiliesPlan(tier.id) ? "startFreeFamiliesTrial" : "upgradeToPremium",
|
||||
|
||||
@ -200,7 +200,8 @@ export class UpgradePaymentService {
|
||||
}
|
||||
|
||||
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
|
||||
: 0;
|
||||
}
|
||||
|
||||
@ -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>
|
||||
@ -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/);
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -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");
|
||||
};
|
||||
}
|
||||
@ -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];
|
||||
}
|
||||
}
|
||||
@ -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`);
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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";
|
||||
@ -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");
|
||||
}
|
||||
}
|
||||
@ -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[];
|
||||
};
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
@ -78,6 +78,7 @@ import { freeTrialTextResolver } from "./billing/trial-initiation/complete-trial
|
||||
import { EnvironmentSelectorComponent } from "./components/environment-selector/environment-selector.component";
|
||||
import { RouteDataProperties } from "./core";
|
||||
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 { RemovePasswordComponent } from "./key-management/key-connector/remove-password.component";
|
||||
import { FrontendLayoutComponent } from "./layouts/frontend-layout.component";
|
||||
@ -696,6 +697,12 @@ const routes: Routes = [
|
||||
path: "security",
|
||||
loadChildren: () => SecurityRoutingModule,
|
||||
},
|
||||
{
|
||||
path: "data-recovery",
|
||||
component: DataRecoveryComponent,
|
||||
canActivate: [canAccessFeature(FeatureFlag.DataRecoveryTool)],
|
||||
data: { titleId: "dataRecovery" } satisfies RouteDataProperties,
|
||||
},
|
||||
{
|
||||
path: "domain-rules",
|
||||
component: DomainRulesComponent,
|
||||
|
||||
@ -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">
|
||||
<i class="bwi bwi-download" aria-hidden="true"></i>
|
||||
{{ "downloadAttachments" | i18n }} ({{ send.file.sizeName }})
|
||||
|
||||
@ -84,7 +84,7 @@ import {
|
||||
CipherViewLikeUtils,
|
||||
} from "@bitwarden/common/vault/utils/cipher-view-like-utils";
|
||||
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 {
|
||||
AddEditFolderDialogComponent,
|
||||
@ -97,6 +97,8 @@ import {
|
||||
DecryptionFailureDialogComponent,
|
||||
DefaultCipherFormConfigService,
|
||||
PasswordRepromptService,
|
||||
VaultItemsTransferService,
|
||||
DefaultVaultItemsTransferService,
|
||||
} from "@bitwarden/vault";
|
||||
import { UnifiedUpgradePromptService } from "@bitwarden/web-vault/app/billing/individual/upgrade/services";
|
||||
import { OrganizationWarningsModule } from "@bitwarden/web-vault/app/billing/organizations/warnings/organization-warnings.module";
|
||||
@ -177,12 +179,12 @@ type EmptyStateMap = Record<EmptyStateType, EmptyStateItem>;
|
||||
VaultItemsModule,
|
||||
SharedModule,
|
||||
OrganizationWarningsModule,
|
||||
BannerComponent,
|
||||
],
|
||||
providers: [
|
||||
RoutedVaultFilterService,
|
||||
RoutedVaultFilterBridgeService,
|
||||
DefaultCipherFormConfigService,
|
||||
{ provide: VaultItemsTransferService, useClass: DefaultVaultItemsTransferService },
|
||||
],
|
||||
})
|
||||
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 autoConfirmService: AutomaticUserConfirmationService,
|
||||
private configService: ConfigService,
|
||||
private vaultItemTransferService: VaultItemsTransferService,
|
||||
) {}
|
||||
|
||||
async ngOnInit() {
|
||||
@ -644,6 +647,8 @@ export class VaultComponent<C extends CipherViewLike> implements OnInit, OnDestr
|
||||
void this.unifiedUpgradePromptService.displayUpgradePromptConditionally();
|
||||
|
||||
this.setupAutoConfirm();
|
||||
|
||||
void this.vaultItemTransferService.enforceOrganizationDataOwnership(activeUserId);
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
|
||||
@ -5185,6 +5185,9 @@
|
||||
"oldAttachmentsNeedFixDesc": {
|
||||
"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": {
|
||||
"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."
|
||||
@ -12250,6 +12253,54 @@
|
||||
"userVerificationFailed": {
|
||||
"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": {
|
||||
"message": "This setting is managed by your organization."
|
||||
},
|
||||
@ -12287,5 +12338,53 @@
|
||||
},
|
||||
"sessionTimeoutSettingsSetUnlockMethodToChangeTimeoutAction": {
|
||||
"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?"
|
||||
}
|
||||
}
|
||||
|
||||
@ -45,7 +45,11 @@
|
||||
tabindex="0"
|
||||
[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
|
||||
class="tw-cursor-pointer"
|
||||
|
||||
@ -14,10 +14,11 @@ import { BadgeModule } from "@bitwarden/components";
|
||||
type="button"
|
||||
*appNotPremium
|
||||
bitBadge
|
||||
variant="success"
|
||||
[variant]="'primary'"
|
||||
class="!tw-text-primary-600 !tw-border-primary-600"
|
||||
(click)="promptForPremium($event)"
|
||||
>
|
||||
{{ "premium" | i18n }}
|
||||
<i class="bwi bwi-premium tw-pe-1"></i>{{ "upgrade" | i18n }}
|
||||
</button>
|
||||
`,
|
||||
imports: [BadgeModule, JslibModule],
|
||||
|
||||
@ -29,7 +29,7 @@ export default {
|
||||
provide: I18nService,
|
||||
useFactory: () => {
|
||||
return new I18nMockService({
|
||||
premium: "Premium",
|
||||
upgrade: "Upgrade",
|
||||
});
|
||||
},
|
||||
},
|
||||
|
||||
@ -20,33 +20,35 @@
|
||||
<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"
|
||||
>
|
||||
<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">
|
||||
{{ "upgradeToPremium" | i18n }}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<!-- 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">
|
||||
{{ cardDetails.tagline }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Price Section -->
|
||||
<div class="tw-mb-6">
|
||||
<div class="tw-flex tw-items-baseline tw-gap-1 tw-flex-wrap">
|
||||
<span class="tw-text-3xl tw-font-medium tw-leading-none tw-m-0">{{
|
||||
cardDetails.price.amount | currency: "$"
|
||||
}}</span>
|
||||
<span bitTypography="helper" class="tw-text-muted">
|
||||
/ {{ cardDetails.price.cadence | i18n }}
|
||||
</span>
|
||||
@if (cardDetails.price) {
|
||||
<div class="tw-mt-5">
|
||||
<div class="tw-flex tw-items-baseline tw-gap-1 tw-flex-wrap">
|
||||
<span class="tw-text-3xl tw-font-medium tw-leading-none tw-m-0">{{
|
||||
cardDetails.price.amount | currency: "$"
|
||||
}}</span>
|
||||
<span bitTypography="helper" class="tw-text-muted">
|
||||
/ {{ cardDetails.price.cadence | i18n }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Button space (always reserved) -->
|
||||
<div class="tw-mb-6 tw-h-12">
|
||||
<div class="tw-my-5 tw-h-12">
|
||||
<button
|
||||
bitButton
|
||||
[buttonType]="cardDetails.button.type"
|
||||
|
||||
@ -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"]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -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 {
|
||||
title: "Billing/Premium Upgrade Dialog",
|
||||
component: PremiumUpgradeDialogComponent,
|
||||
@ -86,11 +103,11 @@ export default {
|
||||
t: (key: string) => {
|
||||
switch (key) {
|
||||
case "upgradeNow":
|
||||
return "Upgrade Now";
|
||||
return "Upgrade now";
|
||||
case "month":
|
||||
return "month";
|
||||
case "upgradeToPremium":
|
||||
return "Upgrade To Premium";
|
||||
return "Upgrade to Premium";
|
||||
default:
|
||||
return key;
|
||||
}
|
||||
@ -116,3 +133,18 @@ export default {
|
||||
|
||||
type Story = StoryObj<PremiumUpgradeDialogComponent>;
|
||||
export const Default: Story = {};
|
||||
|
||||
export const NoPricingData: Story = {
|
||||
decorators: [
|
||||
moduleMetadata({
|
||||
providers: [
|
||||
{
|
||||
provide: SubscriptionPricingServiceAbstraction,
|
||||
useValue: {
|
||||
getPersonalSubscriptionPricingTiers$: () => of([mockPremiumTierNoPricingData]),
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
],
|
||||
};
|
||||
|
||||
@ -3,12 +3,12 @@ import { CommonModule } from "@angular/common";
|
||||
import { ChangeDetectionStrategy, Component } from "@angular/core";
|
||||
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 { SubscriptionPricingServiceAbstraction } from "@bitwarden/common/billing/abstractions/subscription-pricing.service.abstraction";
|
||||
import {
|
||||
PersonalSubscriptionPricingTier,
|
||||
PersonalSubscriptionPricingTierIds,
|
||||
SubscriptionCadence,
|
||||
SubscriptionCadenceIds,
|
||||
} from "@bitwarden/common/billing/types/subscription-pricing-tier";
|
||||
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 {
|
||||
ButtonModule,
|
||||
ButtonType,
|
||||
CenterPositionStrategy,
|
||||
DialogModule,
|
||||
DialogRef,
|
||||
@ -27,14 +26,6 @@ import {
|
||||
} from "@bitwarden/components";
|
||||
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({
|
||||
selector: "billing-premium-upgrade-dialog",
|
||||
standalone: true,
|
||||
@ -51,9 +42,8 @@ type CardDetails = {
|
||||
templateUrl: "./premium-upgrade-dialog.component.html",
|
||||
})
|
||||
export class PremiumUpgradeDialogComponent {
|
||||
protected cardDetails$: Observable<CardDetails | null> = this.subscriptionPricingService
|
||||
.getPersonalSubscriptionPricingTiers$()
|
||||
.pipe(
|
||||
protected cardDetails$: Observable<SubscriptionPricingCardDetails | null> =
|
||||
this.subscriptionPricingService.getPersonalSubscriptionPricingTiers$().pipe(
|
||||
map((tiers) => tiers.find((tier) => tier.id === PersonalSubscriptionPricingTierIds.Premium)),
|
||||
map((tier) => this.mapPremiumTierToCardDetails(tier!)),
|
||||
catchError((error: unknown) => {
|
||||
@ -91,14 +81,18 @@ export class PremiumUpgradeDialogComponent {
|
||||
this.dialogRef.close();
|
||||
}
|
||||
|
||||
private mapPremiumTierToCardDetails(tier: PersonalSubscriptionPricingTier): CardDetails {
|
||||
private mapPremiumTierToCardDetails(
|
||||
tier: PersonalSubscriptionPricingTier,
|
||||
): SubscriptionPricingCardDetails {
|
||||
return {
|
||||
title: tier.name,
|
||||
tagline: tier.description,
|
||||
price: {
|
||||
amount: tier.passwordManager.annualPrice / 12,
|
||||
cadence: SubscriptionCadenceIds.Monthly,
|
||||
},
|
||||
price: tier.passwordManager.annualPrice
|
||||
? {
|
||||
amount: tier.passwordManager.annualPrice / 12,
|
||||
cadence: SubscriptionCadenceIds.Monthly,
|
||||
}
|
||||
: undefined,
|
||||
button: {
|
||||
text: this.i18nService.t("upgradeNow"),
|
||||
type: "primary",
|
||||
|
||||
@ -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[];
|
||||
};
|
||||
@ -1498,7 +1498,13 @@ const safeProviders: SafeProvider[] = [
|
||||
safeProvider({
|
||||
provide: SubscriptionPricingServiceAbstraction,
|
||||
useClass: DefaultSubscriptionPricingService,
|
||||
deps: [BillingApiServiceAbstraction, ConfigService, I18nServiceAbstraction, LogService],
|
||||
deps: [
|
||||
BillingApiServiceAbstraction,
|
||||
ConfigService,
|
||||
I18nServiceAbstraction,
|
||||
LogService,
|
||||
EnvironmentService,
|
||||
],
|
||||
}),
|
||||
safeProvider({
|
||||
provide: OrganizationManagementPreferencesService,
|
||||
|
||||
@ -1,9 +1,5 @@
|
||||
<!-- Applying width and height styles directly to synchronize icon sizing between web/browser/desktop -->
|
||||
<div
|
||||
class="tw-flex tw-justify-center tw-items-center"
|
||||
[ngStyle]="coloredIcon() ? { width: '36px', height: '36px' } : {}"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<div class="tw-flex tw-justify-center tw-items-center" [ngStyle]="iconStyle()" aria-hidden="true">
|
||||
<ng-container *ngIf="data$ | async as data">
|
||||
@if (data.imageEnabled && data.image) {
|
||||
<img
|
||||
@ -16,7 +12,7 @@
|
||||
'tw-invisible tw-absolute': !imageLoaded(),
|
||||
'tw-size-6': !coloredIcon(),
|
||||
}"
|
||||
[ngStyle]="coloredIcon() ? { width: '36px', height: '36px' } : {}"
|
||||
[ngStyle]="iconStyle()"
|
||||
(load)="imageLoaded.set(true)"
|
||||
(error)="imageLoaded.set(false)"
|
||||
/>
|
||||
@ -28,7 +24,7 @@
|
||||
'tw-bg-illustration-bg-primary tw-rounded-full':
|
||||
data.icon?.startsWith('bwi-') && coloredIcon(),
|
||||
}"
|
||||
[ngStyle]="coloredIcon() ? { width: '36px', height: '36px' } : {}"
|
||||
[ngStyle]="iconStyle()"
|
||||
>
|
||||
<i
|
||||
class="tw-text-muted bwi bwi-lg {{ data.icon }}"
|
||||
@ -36,6 +32,7 @@
|
||||
color: coloredIcon() ? 'rgb(var(--color-illustration-outline))' : null,
|
||||
width: data.icon?.startsWith('credit-card') && coloredIcon() ? '36px' : null,
|
||||
height: data.icon?.startsWith('credit-card') && coloredIcon() ? '30px' : null,
|
||||
fontSize: size() ? size() + 'px' : null,
|
||||
}"
|
||||
></i>
|
||||
</div>
|
||||
|
||||
@ -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 {
|
||||
combineLatest,
|
||||
@ -32,8 +32,32 @@ export class IconComponent {
|
||||
*/
|
||||
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);
|
||||
|
||||
/**
|
||||
* 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>;
|
||||
|
||||
constructor(
|
||||
|
||||
@ -11,6 +11,7 @@ export class PolicyData {
|
||||
type: PolicyType;
|
||||
data: Record<string, string | number | boolean>;
|
||||
enabled: boolean;
|
||||
revisionDate: string;
|
||||
|
||||
constructor(response?: PolicyResponse) {
|
||||
if (response == null) {
|
||||
@ -22,6 +23,7 @@ export class PolicyData {
|
||||
this.type = response.type;
|
||||
this.data = response.data;
|
||||
this.enabled = response.enabled;
|
||||
this.revisionDate = response.revisionDate;
|
||||
}
|
||||
|
||||
static fromPolicy(policy: Policy): PolicyData {
|
||||
|
||||
@ -19,6 +19,8 @@ export class Policy extends Domain {
|
||||
*/
|
||||
enabled: boolean;
|
||||
|
||||
revisionDate: Date;
|
||||
|
||||
constructor(obj?: PolicyData) {
|
||||
super();
|
||||
if (obj == null) {
|
||||
@ -30,6 +32,7 @@ export class Policy extends Domain {
|
||||
this.type = obj.type;
|
||||
this.data = obj.data;
|
||||
this.enabled = obj.enabled;
|
||||
this.revisionDate = new Date(obj.revisionDate);
|
||||
}
|
||||
|
||||
static fromResponse(response: PolicyResponse): Policy {
|
||||
|
||||
@ -9,6 +9,7 @@ export class PolicyResponse extends BaseResponse {
|
||||
data: any;
|
||||
enabled: boolean;
|
||||
canToggleState: boolean;
|
||||
revisionDate: string;
|
||||
|
||||
constructor(response: any) {
|
||||
super(response);
|
||||
@ -18,5 +19,6 @@ export class PolicyResponse extends BaseResponse {
|
||||
this.data = this.getResponseProperty("Data");
|
||||
this.enabled = this.getResponseProperty("Enabled");
|
||||
this.canToggleState = this.getResponseProperty("CanToggleState") ?? true;
|
||||
this.revisionDate = this.getResponseProperty("RevisionDate");
|
||||
}
|
||||
}
|
||||
|
||||
@ -83,12 +83,15 @@ describe("PolicyService", () => {
|
||||
type: PolicyType.MaximumVaultTimeout,
|
||||
enabled: true,
|
||||
data: { minutes: 14 },
|
||||
revisionDate: expect.any(Date),
|
||||
},
|
||||
{
|
||||
id: "99",
|
||||
organizationId: "test-organization",
|
||||
type: PolicyType.DisableSend,
|
||||
enabled: true,
|
||||
data: undefined,
|
||||
revisionDate: expect.any(Date),
|
||||
},
|
||||
]);
|
||||
});
|
||||
@ -113,6 +116,8 @@ describe("PolicyService", () => {
|
||||
organizationId: "test-organization",
|
||||
type: PolicyType.DisableSend,
|
||||
enabled: true,
|
||||
data: undefined,
|
||||
revisionDate: expect.any(Date),
|
||||
},
|
||||
]);
|
||||
});
|
||||
@ -242,6 +247,8 @@ describe("PolicyService", () => {
|
||||
organizationId: "org1",
|
||||
type: PolicyType.DisablePersonalVaultExport,
|
||||
enabled: true,
|
||||
data: undefined,
|
||||
revisionDate: expect.any(Date),
|
||||
});
|
||||
});
|
||||
|
||||
@ -331,24 +338,32 @@ describe("PolicyService", () => {
|
||||
organizationId: "org4",
|
||||
type: PolicyType.DisablePersonalVaultExport,
|
||||
enabled: true,
|
||||
data: undefined,
|
||||
revisionDate: expect.any(Date),
|
||||
},
|
||||
{
|
||||
id: "policy2",
|
||||
organizationId: "org1",
|
||||
type: PolicyType.ActivateAutofill,
|
||||
enabled: true,
|
||||
data: undefined,
|
||||
revisionDate: expect.any(Date),
|
||||
},
|
||||
{
|
||||
id: "policy3",
|
||||
organizationId: "org5",
|
||||
type: PolicyType.DisablePersonalVaultExport,
|
||||
enabled: true,
|
||||
data: undefined,
|
||||
revisionDate: expect.any(Date),
|
||||
},
|
||||
{
|
||||
id: "policy4",
|
||||
organizationId: "org1",
|
||||
type: PolicyType.DisablePersonalVaultExport,
|
||||
enabled: true,
|
||||
data: undefined,
|
||||
revisionDate: expect.any(Date),
|
||||
},
|
||||
]);
|
||||
});
|
||||
@ -371,24 +386,32 @@ describe("PolicyService", () => {
|
||||
organizationId: "org4",
|
||||
type: PolicyType.DisablePersonalVaultExport,
|
||||
enabled: true,
|
||||
data: undefined,
|
||||
revisionDate: expect.any(Date),
|
||||
},
|
||||
{
|
||||
id: "policy2",
|
||||
organizationId: "org1",
|
||||
type: PolicyType.ActivateAutofill,
|
||||
enabled: true,
|
||||
data: undefined,
|
||||
revisionDate: expect.any(Date),
|
||||
},
|
||||
{
|
||||
id: "policy3",
|
||||
organizationId: "org5",
|
||||
type: PolicyType.DisablePersonalVaultExport,
|
||||
enabled: false,
|
||||
data: undefined,
|
||||
revisionDate: expect.any(Date),
|
||||
},
|
||||
{
|
||||
id: "policy4",
|
||||
organizationId: "org1",
|
||||
type: PolicyType.DisablePersonalVaultExport,
|
||||
enabled: true,
|
||||
data: undefined,
|
||||
revisionDate: expect.any(Date),
|
||||
},
|
||||
]);
|
||||
});
|
||||
@ -411,24 +434,32 @@ describe("PolicyService", () => {
|
||||
organizationId: "org4",
|
||||
type: PolicyType.DisablePersonalVaultExport,
|
||||
enabled: true,
|
||||
data: undefined,
|
||||
revisionDate: expect.any(Date),
|
||||
},
|
||||
{
|
||||
id: "policy2",
|
||||
organizationId: "org1",
|
||||
type: PolicyType.ActivateAutofill,
|
||||
enabled: true,
|
||||
data: undefined,
|
||||
revisionDate: expect.any(Date),
|
||||
},
|
||||
{
|
||||
id: "policy3",
|
||||
organizationId: "org5",
|
||||
type: PolicyType.DisablePersonalVaultExport,
|
||||
enabled: true,
|
||||
data: undefined,
|
||||
revisionDate: expect.any(Date),
|
||||
},
|
||||
{
|
||||
id: "policy4",
|
||||
organizationId: "org2",
|
||||
type: PolicyType.DisablePersonalVaultExport,
|
||||
enabled: true,
|
||||
data: undefined,
|
||||
revisionDate: expect.any(Date),
|
||||
},
|
||||
]);
|
||||
});
|
||||
@ -451,24 +482,32 @@ describe("PolicyService", () => {
|
||||
organizationId: "org4",
|
||||
type: PolicyType.DisablePersonalVaultExport,
|
||||
enabled: true,
|
||||
data: undefined,
|
||||
revisionDate: expect.any(Date),
|
||||
},
|
||||
{
|
||||
id: "policy2",
|
||||
organizationId: "org1",
|
||||
type: PolicyType.ActivateAutofill,
|
||||
enabled: true,
|
||||
data: undefined,
|
||||
revisionDate: expect.any(Date),
|
||||
},
|
||||
{
|
||||
id: "policy3",
|
||||
organizationId: "org3",
|
||||
type: PolicyType.DisablePersonalVaultExport,
|
||||
enabled: true,
|
||||
data: undefined,
|
||||
revisionDate: expect.any(Date),
|
||||
},
|
||||
{
|
||||
id: "policy4",
|
||||
organizationId: "org1",
|
||||
type: PolicyType.DisablePersonalVaultExport,
|
||||
enabled: true,
|
||||
data: undefined,
|
||||
revisionDate: expect.any(Date),
|
||||
},
|
||||
]);
|
||||
});
|
||||
@ -788,6 +827,7 @@ describe("PolicyService", () => {
|
||||
policyData.type = type;
|
||||
policyData.enabled = enabled;
|
||||
policyData.data = data;
|
||||
policyData.revisionDate = new Date().toISOString();
|
||||
|
||||
return policyData;
|
||||
}
|
||||
|
||||
@ -6,6 +6,10 @@ import { PlanType, ProductTierType } from "@bitwarden/common/billing/enums";
|
||||
import { PlanResponse } from "@bitwarden/common/billing/models/response/plan.response";
|
||||
import { PremiumPlanResponse } from "@bitwarden/common/billing/models/response/premium-plan.response";
|
||||
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 { LogService } from "@bitwarden/logging";
|
||||
|
||||
@ -23,6 +27,7 @@ describe("DefaultSubscriptionPricingService", () => {
|
||||
let configService: MockProxy<ConfigService>;
|
||||
let i18nService: MockProxy<I18nService>;
|
||||
let logService: MockProxy<LogService>;
|
||||
let environmentService: MockProxy<EnvironmentService>;
|
||||
|
||||
const mockFamiliesPlan = {
|
||||
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(() => {
|
||||
billingApiService = mock<BillingApiServiceAbstraction>();
|
||||
configService = mock<ConfigService>();
|
||||
environmentService = mock<EnvironmentService>();
|
||||
|
||||
billingApiService.getPlans.mockResolvedValue(mockPlansResponse);
|
||||
billingApiService.getPremiumPlan.mockResolvedValue(mockPremiumPlanResponse);
|
||||
configService.getFeatureFlag$.mockReturnValue(of(false)); // Default to false (use hardcoded value)
|
||||
setupEnvironmentService(environmentService);
|
||||
|
||||
service = new DefaultSubscriptionPricingService(
|
||||
billingApiService,
|
||||
configService,
|
||||
i18nService,
|
||||
logService,
|
||||
environmentService,
|
||||
);
|
||||
});
|
||||
|
||||
@ -419,11 +437,13 @@ describe("DefaultSubscriptionPricingService", () => {
|
||||
const errorConfigService = mock<ConfigService>();
|
||||
const errorI18nService = mock<I18nService>();
|
||||
const errorLogService = mock<LogService>();
|
||||
const errorEnvironmentService = mock<EnvironmentService>();
|
||||
|
||||
const testError = new Error("API error");
|
||||
errorBillingApiService.getPlans.mockRejectedValue(testError);
|
||||
errorBillingApiService.getPremiumPlan.mockResolvedValue(mockPremiumPlanResponse);
|
||||
errorConfigService.getFeatureFlag$.mockReturnValue(of(false));
|
||||
setupEnvironmentService(errorEnvironmentService);
|
||||
|
||||
errorI18nService.t.mockImplementation((key: string) => key);
|
||||
|
||||
@ -432,6 +452,7 @@ describe("DefaultSubscriptionPricingService", () => {
|
||||
errorConfigService,
|
||||
errorI18nService,
|
||||
errorLogService,
|
||||
errorEnvironmentService,
|
||||
);
|
||||
|
||||
errorService.getPersonalSubscriptionPricingTiers$().subscribe({
|
||||
@ -605,11 +626,13 @@ describe("DefaultSubscriptionPricingService", () => {
|
||||
const errorConfigService = mock<ConfigService>();
|
||||
const errorI18nService = mock<I18nService>();
|
||||
const errorLogService = mock<LogService>();
|
||||
const errorEnvironmentService = mock<EnvironmentService>();
|
||||
|
||||
const testError = new Error("API error");
|
||||
errorBillingApiService.getPlans.mockRejectedValue(testError);
|
||||
errorBillingApiService.getPremiumPlan.mockResolvedValue(mockPremiumPlanResponse);
|
||||
errorConfigService.getFeatureFlag$.mockReturnValue(of(false));
|
||||
setupEnvironmentService(errorEnvironmentService);
|
||||
|
||||
errorI18nService.t.mockImplementation((key: string) => key);
|
||||
|
||||
@ -618,6 +641,7 @@ describe("DefaultSubscriptionPricingService", () => {
|
||||
errorConfigService,
|
||||
errorI18nService,
|
||||
errorLogService,
|
||||
errorEnvironmentService,
|
||||
);
|
||||
|
||||
errorService.getBusinessSubscriptionPricingTiers$().subscribe({
|
||||
@ -848,11 +872,13 @@ describe("DefaultSubscriptionPricingService", () => {
|
||||
const errorConfigService = mock<ConfigService>();
|
||||
const errorI18nService = mock<I18nService>();
|
||||
const errorLogService = mock<LogService>();
|
||||
const errorEnvironmentService = mock<EnvironmentService>();
|
||||
|
||||
const testError = new Error("API error");
|
||||
errorBillingApiService.getPlans.mockRejectedValue(testError);
|
||||
errorBillingApiService.getPremiumPlan.mockResolvedValue(mockPremiumPlanResponse);
|
||||
errorConfigService.getFeatureFlag$.mockReturnValue(of(false));
|
||||
setupEnvironmentService(errorEnvironmentService);
|
||||
|
||||
errorI18nService.t.mockImplementation((key: string) => key);
|
||||
|
||||
@ -861,6 +887,7 @@ describe("DefaultSubscriptionPricingService", () => {
|
||||
errorConfigService,
|
||||
errorI18nService,
|
||||
errorLogService,
|
||||
errorEnvironmentService,
|
||||
);
|
||||
|
||||
errorService.getDeveloperSubscriptionPricingTiers$().subscribe({
|
||||
@ -883,17 +910,20 @@ describe("DefaultSubscriptionPricingService", () => {
|
||||
it("should handle getPremiumPlan() error when getPlans() succeeds", (done) => {
|
||||
const errorBillingApiService = mock<BillingApiServiceAbstraction>();
|
||||
const errorConfigService = mock<ConfigService>();
|
||||
const errorEnvironmentService = mock<EnvironmentService>();
|
||||
|
||||
const testError = new Error("Premium plan API error");
|
||||
errorBillingApiService.getPlans.mockResolvedValue(mockPlansResponse);
|
||||
errorBillingApiService.getPremiumPlan.mockRejectedValue(testError);
|
||||
errorConfigService.getFeatureFlag$.mockReturnValue(of(true)); // Enable feature flag to use premium plan API
|
||||
setupEnvironmentService(errorEnvironmentService);
|
||||
|
||||
const errorService = new DefaultSubscriptionPricingService(
|
||||
errorBillingApiService,
|
||||
errorConfigService,
|
||||
i18nService,
|
||||
logService,
|
||||
errorEnvironmentService,
|
||||
);
|
||||
|
||||
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", () => {
|
||||
@ -1015,10 +963,12 @@ describe("DefaultSubscriptionPricingService", () => {
|
||||
// Create a new mock to avoid conflicts with beforeEach setup
|
||||
const newBillingApiService = mock<BillingApiServiceAbstraction>();
|
||||
const newConfigService = mock<ConfigService>();
|
||||
const newEnvironmentService = mock<EnvironmentService>();
|
||||
|
||||
newBillingApiService.getPlans.mockResolvedValue(mockPlansResponse);
|
||||
newBillingApiService.getPremiumPlan.mockResolvedValue(mockPremiumPlanResponse);
|
||||
newConfigService.getFeatureFlag$.mockReturnValue(of(true));
|
||||
setupEnvironmentService(newEnvironmentService);
|
||||
|
||||
const getPremiumPlanSpy = jest.spyOn(newBillingApiService, "getPremiumPlan");
|
||||
|
||||
@ -1028,6 +978,7 @@ describe("DefaultSubscriptionPricingService", () => {
|
||||
newConfigService,
|
||||
i18nService,
|
||||
logService,
|
||||
newEnvironmentService,
|
||||
);
|
||||
|
||||
// Subscribe to the premium pricing tier multiple times
|
||||
@ -1042,6 +993,7 @@ describe("DefaultSubscriptionPricingService", () => {
|
||||
// Create a new mock to test from scratch
|
||||
const newBillingApiService = mock<BillingApiServiceAbstraction>();
|
||||
const newConfigService = mock<ConfigService>();
|
||||
const newEnvironmentService = mock<EnvironmentService>();
|
||||
|
||||
newBillingApiService.getPlans.mockResolvedValue(mockPlansResponse);
|
||||
newBillingApiService.getPremiumPlan.mockResolvedValue({
|
||||
@ -1049,6 +1001,7 @@ describe("DefaultSubscriptionPricingService", () => {
|
||||
storage: { price: 999 },
|
||||
} as PremiumPlanResponse);
|
||||
newConfigService.getFeatureFlag$.mockReturnValue(of(false));
|
||||
setupEnvironmentService(newEnvironmentService);
|
||||
|
||||
// Create a new service instance with the feature flag disabled
|
||||
const newService = new DefaultSubscriptionPricingService(
|
||||
@ -1056,6 +1009,7 @@ describe("DefaultSubscriptionPricingService", () => {
|
||||
newConfigService,
|
||||
i18nService,
|
||||
logService,
|
||||
newEnvironmentService,
|
||||
);
|
||||
|
||||
// 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -19,6 +19,7 @@ import { PremiumPlanResponse } from "@bitwarden/common/billing/models/response/p
|
||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
import { ListResponse } from "@bitwarden/common/models/response/list.response";
|
||||
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 { LogService } from "@bitwarden/logging";
|
||||
|
||||
@ -47,11 +48,13 @@ export class DefaultSubscriptionPricingService implements SubscriptionPricingSer
|
||||
private configService: ConfigService,
|
||||
private i18nService: I18nService,
|
||||
private logService: LogService,
|
||||
private environmentService: EnvironmentService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Gets personal subscription pricing tiers (Premium and Families).
|
||||
* 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.
|
||||
* @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).
|
||||
* 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.
|
||||
* @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).
|
||||
* 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.
|
||||
* @throws Error if any errors occur during api request.
|
||||
*/
|
||||
@ -91,19 +96,32 @@ export class DefaultSubscriptionPricingService implements SubscriptionPricingSer
|
||||
}),
|
||||
);
|
||||
|
||||
private plansResponse$: Observable<ListResponse<PlanResponse>> = from(
|
||||
this.billingApiService.getPlans(),
|
||||
).pipe(shareReplay({ bufferSize: 1, refCount: false }));
|
||||
private organizationPlansResponse$: Observable<ListResponse<PlanResponse>> =
|
||||
this.environmentService.environment$.pipe(
|
||||
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(
|
||||
this.billingApiService.getPremiumPlan(),
|
||||
).pipe(
|
||||
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 premiumPlanResponse$: Observable<PremiumPlanResponse> =
|
||||
this.environmentService.environment$.pipe(
|
||||
take(1),
|
||||
switchMap((environment) =>
|
||||
!environment.isCloud()
|
||||
? of({ seat: undefined, storage: undefined } as unknown as PremiumPlanResponse)
|
||||
: from(this.billingApiService.getPremiumPlan()).pipe(
|
||||
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
|
||||
.getFeatureFlag$(FeatureFlag.PM26793_FetchPremiumPriceFromPricingService)
|
||||
@ -113,9 +131,9 @@ export class DefaultSubscriptionPricingService implements SubscriptionPricingSer
|
||||
fetchPremiumFromPricingService
|
||||
? this.premiumPlanResponse$.pipe(
|
||||
map((premiumPlan) => ({
|
||||
seat: premiumPlan.seat.price,
|
||||
storage: premiumPlan.storage.price,
|
||||
provided: premiumPlan.storage.provided,
|
||||
seat: premiumPlan.seat?.price,
|
||||
storage: premiumPlan.storage?.price,
|
||||
provided: premiumPlan.storage?.provided,
|
||||
})),
|
||||
)
|
||||
: of({
|
||||
@ -145,41 +163,42 @@ export class DefaultSubscriptionPricingService implements SubscriptionPricingSer
|
||||
})),
|
||||
);
|
||||
|
||||
private families$: Observable<PersonalSubscriptionPricingTier> = this.plansResponse$.pipe(
|
||||
combineLatestWith(this.configService.getFeatureFlag$(FeatureFlag.PM26462_Milestone_3)),
|
||||
map(([plans, milestone3FeatureEnabled]) => {
|
||||
const familiesPlan = plans.data.find(
|
||||
(plan) =>
|
||||
plan.type ===
|
||||
(milestone3FeatureEnabled ? PlanType.FamiliesAnnually : PlanType.FamiliesAnnually2025),
|
||||
)!;
|
||||
private families$: Observable<PersonalSubscriptionPricingTier> =
|
||||
this.organizationPlansResponse$.pipe(
|
||||
combineLatestWith(this.configService.getFeatureFlag$(FeatureFlag.PM26462_Milestone_3)),
|
||||
map(([plans, milestone3FeatureEnabled]) => {
|
||||
const familiesPlan = plans.data.find(
|
||||
(plan) =>
|
||||
plan.type ===
|
||||
(milestone3FeatureEnabled ? PlanType.FamiliesAnnually : PlanType.FamiliesAnnually2025),
|
||||
);
|
||||
|
||||
return {
|
||||
id: PersonalSubscriptionPricingTierIds.Families,
|
||||
name: this.i18nService.t("planNameFamilies"),
|
||||
description: this.i18nService.t("planDescFamiliesV2"),
|
||||
availableCadences: [SubscriptionCadenceIds.Annually],
|
||||
passwordManager: {
|
||||
type: "packaged",
|
||||
users: familiesPlan.PasswordManager.baseSeats,
|
||||
annualPrice: familiesPlan.PasswordManager.basePrice,
|
||||
annualPricePerAdditionalStorageGB:
|
||||
familiesPlan.PasswordManager.additionalStoragePricePerGb,
|
||||
providedStorageGB: familiesPlan.PasswordManager.baseStorageGb,
|
||||
features: [
|
||||
this.featureTranslations.premiumAccounts(),
|
||||
this.featureTranslations.familiesUnlimitedSharing(),
|
||||
this.featureTranslations.familiesUnlimitedCollections(),
|
||||
this.featureTranslations.familiesSharedStorage(),
|
||||
],
|
||||
},
|
||||
};
|
||||
}),
|
||||
);
|
||||
return {
|
||||
id: PersonalSubscriptionPricingTierIds.Families,
|
||||
name: this.i18nService.t("planNameFamilies"),
|
||||
description: this.i18nService.t("planDescFamiliesV2"),
|
||||
availableCadences: [SubscriptionCadenceIds.Annually],
|
||||
passwordManager: {
|
||||
type: "packaged",
|
||||
users: familiesPlan?.PasswordManager?.baseSeats,
|
||||
annualPrice: familiesPlan?.PasswordManager?.basePrice,
|
||||
annualPricePerAdditionalStorageGB:
|
||||
familiesPlan?.PasswordManager?.additionalStoragePricePerGb,
|
||||
providedStorageGB: familiesPlan?.PasswordManager?.baseStorageGb,
|
||||
features: [
|
||||
this.featureTranslations.premiumAccounts(),
|
||||
this.featureTranslations.familiesUnlimitedSharing(),
|
||||
this.featureTranslations.familiesUnlimitedCollections(),
|
||||
this.featureTranslations.familiesSharedStorage(),
|
||||
],
|
||||
},
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
private free$: Observable<BusinessSubscriptionPricingTier> = this.plansResponse$.pipe(
|
||||
private free$: Observable<BusinessSubscriptionPricingTier> = this.organizationPlansResponse$.pipe(
|
||||
map((plans): BusinessSubscriptionPricingTier => {
|
||||
const freePlan = plans.data.find((plan) => plan.type === PlanType.Free)!;
|
||||
const freePlan = plans.data.find((plan) => plan.type === PlanType.Free);
|
||||
|
||||
return {
|
||||
id: BusinessSubscriptionPricingTierIds.Free,
|
||||
@ -189,8 +208,10 @@ export class DefaultSubscriptionPricingService implements SubscriptionPricingSer
|
||||
passwordManager: {
|
||||
type: "free",
|
||||
features: [
|
||||
this.featureTranslations.limitedUsersV2(freePlan.PasswordManager.maxSeats),
|
||||
this.featureTranslations.limitedCollectionsV2(freePlan.PasswordManager.maxCollections),
|
||||
this.featureTranslations.limitedUsersV2(freePlan?.PasswordManager?.maxSeats),
|
||||
this.featureTranslations.limitedCollectionsV2(
|
||||
freePlan?.PasswordManager?.maxCollections,
|
||||
),
|
||||
this.featureTranslations.alwaysFree(),
|
||||
],
|
||||
},
|
||||
@ -198,110 +219,113 @@ export class DefaultSubscriptionPricingService implements SubscriptionPricingSer
|
||||
type: "free",
|
||||
features: [
|
||||
this.featureTranslations.twoSecretsIncluded(),
|
||||
this.featureTranslations.projectsIncludedV2(freePlan.SecretsManager.maxProjects),
|
||||
this.featureTranslations.projectsIncludedV2(freePlan?.SecretsManager?.maxProjects),
|
||||
],
|
||||
},
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
private teams$: Observable<BusinessSubscriptionPricingTier> = this.plansResponse$.pipe(
|
||||
map((plans) => {
|
||||
const annualTeamsPlan = plans.data.find((plan) => plan.type === PlanType.TeamsAnnually)!;
|
||||
private teams$: Observable<BusinessSubscriptionPricingTier> =
|
||||
this.organizationPlansResponse$.pipe(
|
||||
map((plans) => {
|
||||
const annualTeamsPlan = plans.data.find((plan) => plan.type === PlanType.TeamsAnnually);
|
||||
|
||||
return {
|
||||
id: BusinessSubscriptionPricingTierIds.Teams,
|
||||
name: this.i18nService.t("planNameTeams"),
|
||||
description: this.i18nService.t("teamsPlanUpgradeMessage"),
|
||||
availableCadences: [SubscriptionCadenceIds.Annually, SubscriptionCadenceIds.Monthly],
|
||||
passwordManager: {
|
||||
type: "scalable",
|
||||
annualPricePerUser: annualTeamsPlan.PasswordManager.seatPrice,
|
||||
annualPricePerAdditionalStorageGB:
|
||||
annualTeamsPlan.PasswordManager.additionalStoragePricePerGb,
|
||||
providedStorageGB: annualTeamsPlan.PasswordManager.baseStorageGb,
|
||||
features: [
|
||||
this.featureTranslations.secureItemSharing(),
|
||||
this.featureTranslations.eventLogMonitoring(),
|
||||
this.featureTranslations.directoryIntegration(),
|
||||
this.featureTranslations.scimSupport(),
|
||||
],
|
||||
},
|
||||
secretsManager: {
|
||||
type: "scalable",
|
||||
annualPricePerUser: annualTeamsPlan.SecretsManager.seatPrice,
|
||||
annualPricePerAdditionalServiceAccount:
|
||||
annualTeamsPlan.SecretsManager.additionalPricePerServiceAccount,
|
||||
features: [
|
||||
this.featureTranslations.unlimitedSecretsAndProjects(),
|
||||
this.featureTranslations.includedMachineAccountsV2(
|
||||
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(),
|
||||
],
|
||||
},
|
||||
return {
|
||||
id: BusinessSubscriptionPricingTierIds.Teams,
|
||||
name: this.i18nService.t("planNameTeams"),
|
||||
description: this.i18nService.t("teamsPlanUpgradeMessage"),
|
||||
availableCadences: [SubscriptionCadenceIds.Annually, SubscriptionCadenceIds.Monthly],
|
||||
passwordManager: {
|
||||
type: "scalable",
|
||||
annualPricePerUser: annualTeamsPlan?.PasswordManager?.seatPrice,
|
||||
annualPricePerAdditionalStorageGB:
|
||||
annualTeamsPlan?.PasswordManager?.additionalStoragePricePerGb,
|
||||
providedStorageGB: annualTeamsPlan?.PasswordManager?.baseStorageGb,
|
||||
features: [
|
||||
this.featureTranslations.secureItemSharing(),
|
||||
this.featureTranslations.eventLogMonitoring(),
|
||||
this.featureTranslations.directoryIntegration(),
|
||||
this.featureTranslations.scimSupport(),
|
||||
],
|
||||
},
|
||||
secretsManager: {
|
||||
type: "scalable",
|
||||
annualPricePerUser: annualTeamsPlan?.SecretsManager?.seatPrice,
|
||||
annualPricePerAdditionalServiceAccount:
|
||||
annualTeamsPlan?.SecretsManager?.additionalPricePerServiceAccount,
|
||||
features: [
|
||||
this.featureTranslations.unlimitedSecretsAndProjects(),
|
||||
this.featureTranslations.includedMachineAccountsV2(
|
||||
annualTeamsPlan?.SecretsManager?.baseServiceAccount,
|
||||
),
|
||||
],
|
||||
},
|
||||
};
|
||||
}),
|
||||
),
|
||||
);
|
||||
);
|
||||
|
||||
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 = {
|
||||
builtInAuthenticator: () => ({
|
||||
@ -340,11 +364,11 @@ export class DefaultSubscriptionPricingService implements SubscriptionPricingSer
|
||||
key: "familiesSharedStorage",
|
||||
value: this.i18nService.t("familiesSharedStorage"),
|
||||
}),
|
||||
limitedUsersV2: (users: number) => ({
|
||||
limitedUsersV2: (users?: number) => ({
|
||||
key: "limitedUsersV2",
|
||||
value: this.i18nService.t("limitedUsersV2", users),
|
||||
}),
|
||||
limitedCollectionsV2: (collections: number) => ({
|
||||
limitedCollectionsV2: (collections?: number) => ({
|
||||
key: "limitedCollectionsV2",
|
||||
value: this.i18nService.t("limitedCollectionsV2", collections),
|
||||
}),
|
||||
@ -356,7 +380,7 @@ export class DefaultSubscriptionPricingService implements SubscriptionPricingSer
|
||||
key: "twoSecretsIncluded",
|
||||
value: this.i18nService.t("twoSecretsIncluded"),
|
||||
}),
|
||||
projectsIncludedV2: (projects: number) => ({
|
||||
projectsIncludedV2: (projects?: number) => ({
|
||||
key: "projectsIncludedV2",
|
||||
value: this.i18nService.t("projectsIncludedV2", projects),
|
||||
}),
|
||||
@ -380,7 +404,7 @@ export class DefaultSubscriptionPricingService implements SubscriptionPricingSer
|
||||
key: "unlimitedSecretsAndProjects",
|
||||
value: this.i18nService.t("unlimitedSecretsAndProjects"),
|
||||
}),
|
||||
includedMachineAccountsV2: (included: number) => ({
|
||||
includedMachineAccountsV2: (included?: number) => ({
|
||||
key: "includedMachineAccountsV2",
|
||||
value: this.i18nService.t("includedMachineAccountsV2", included),
|
||||
}),
|
||||
|
||||
@ -27,26 +27,26 @@ type HasFeatures = {
|
||||
};
|
||||
|
||||
type HasAdditionalStorage = {
|
||||
annualPricePerAdditionalStorageGB: number;
|
||||
annualPricePerAdditionalStorageGB?: number;
|
||||
};
|
||||
|
||||
type HasProvidedStorage = {
|
||||
providedStorageGB: number;
|
||||
providedStorageGB?: number;
|
||||
};
|
||||
|
||||
type StandalonePasswordManager = HasFeatures &
|
||||
HasAdditionalStorage &
|
||||
HasProvidedStorage & {
|
||||
type: "standalone";
|
||||
annualPrice: number;
|
||||
annualPrice?: number;
|
||||
};
|
||||
|
||||
type PackagedPasswordManager = HasFeatures &
|
||||
HasProvidedStorage &
|
||||
HasAdditionalStorage & {
|
||||
type: "packaged";
|
||||
users: number;
|
||||
annualPrice: number;
|
||||
users?: number;
|
||||
annualPrice?: number;
|
||||
};
|
||||
|
||||
type FreePasswordManager = HasFeatures & {
|
||||
@ -61,7 +61,7 @@ type ScalablePasswordManager = HasFeatures &
|
||||
HasProvidedStorage &
|
||||
HasAdditionalStorage & {
|
||||
type: "scalable";
|
||||
annualPricePerUser: number;
|
||||
annualPricePerUser?: number;
|
||||
};
|
||||
|
||||
type FreeSecretsManager = HasFeatures & {
|
||||
@ -70,8 +70,8 @@ type FreeSecretsManager = HasFeatures & {
|
||||
|
||||
type ScalableSecretsManager = HasFeatures & {
|
||||
type: "scalable";
|
||||
annualPricePerUser: number;
|
||||
annualPricePerAdditionalServiceAccount: number;
|
||||
annualPricePerUser?: number;
|
||||
annualPricePerAdditionalServiceAccount?: number;
|
||||
};
|
||||
|
||||
export type PersonalSubscriptionPricingTier = {
|
||||
|
||||
@ -43,6 +43,7 @@ export enum FeatureFlag {
|
||||
LinuxBiometricsV2 = "pm-26340-linux-biometrics-v2",
|
||||
UnlockWithMasterPasswordUnlockData = "pm-23246-unlock-with-master-password-unlock-data",
|
||||
NoLogoutOnKdfChange = "pm-23995-no-logout-on-kdf-change",
|
||||
DataRecoveryTool = "pm-28813-data-recovery-tool",
|
||||
ConsolidatedSessionTimeoutComponent = "pm-26056-consolidated-session-timeout-component",
|
||||
|
||||
/* Tools */
|
||||
@ -64,6 +65,7 @@ export enum FeatureFlag {
|
||||
RiskInsightsForPremium = "pm-23904-risk-insights-for-premium",
|
||||
VaultLoadingSkeletons = "pm-25081-vault-skeleton-loaders",
|
||||
BrowserPremiumSpotlight = "pm-23384-browser-premium-spotlight",
|
||||
MigrateMyVaultToMyItems = "pm-20558-migrate-myvault-to-myitems",
|
||||
|
||||
/* Platform */
|
||||
IpcChannelFramework = "ipc-channel-framework",
|
||||
@ -123,6 +125,7 @@ export const DefaultFeatureFlagValue = {
|
||||
[FeatureFlag.RiskInsightsForPremium]: FALSE,
|
||||
[FeatureFlag.VaultLoadingSkeletons]: FALSE,
|
||||
[FeatureFlag.BrowserPremiumSpotlight]: FALSE,
|
||||
[FeatureFlag.MigrateMyVaultToMyItems]: FALSE,
|
||||
|
||||
/* Auth */
|
||||
[FeatureFlag.PM23801_PrefetchPasswordPrelogin]: FALSE,
|
||||
@ -147,6 +150,7 @@ export const DefaultFeatureFlagValue = {
|
||||
[FeatureFlag.LinuxBiometricsV2]: FALSE,
|
||||
[FeatureFlag.UnlockWithMasterPasswordUnlockData]: FALSE,
|
||||
[FeatureFlag.NoLogoutOnKdfChange]: FALSE,
|
||||
[FeatureFlag.DataRecoveryTool]: FALSE,
|
||||
[FeatureFlag.ConsolidatedSessionTimeoutComponent]: FALSE,
|
||||
|
||||
/* Platform */
|
||||
|
||||
@ -451,6 +451,24 @@ describe("ChipSelectComponent", () => {
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@ -100,10 +100,21 @@ export class ChipSelectComponent<T = unknown> implements ControlValueAccessor {
|
||||
/** Tree constructed from `this.options` */
|
||||
private rootTree?: ChipSelectOption<T> | null;
|
||||
|
||||
/** Store the pending value when writeValue is called before options are initialized */
|
||||
private pendingValue?: T;
|
||||
|
||||
constructor() {
|
||||
// Initialize the root tree whenever options change
|
||||
effect(() => {
|
||||
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)
|
||||
@ -255,6 +266,12 @@ export class ChipSelectComponent<T = unknown> implements ControlValueAccessor {
|
||||
|
||||
/** Implemented as part of NG_VALUE_ACCESSOR */
|
||||
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.setOrResetRenderedOptions();
|
||||
// OnPush components require manual change detection when writeValue() is called
|
||||
|
||||
@ -7,4 +7,11 @@ export abstract class UserAsymmetricKeysRegenerationService {
|
||||
* @param userId The user id.
|
||||
*/
|
||||
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>;
|
||||
}
|
||||
|
||||
@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@ -37,7 +37,7 @@ export class DefaultUserAsymmetricKeysRegenerationService
|
||||
if (privateKeyRegenerationFlag) {
|
||||
const shouldRegenerate = await this.shouldRegenerate(userId);
|
||||
if (shouldRegenerate) {
|
||||
await this.regenerateUserAsymmetricKeys(userId);
|
||||
await this.regenerateUserPublicKeyEncryptionKeyPair(userId);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
@ -125,11 +125,14 @@ export class DefaultUserAsymmetricKeysRegenerationService
|
||||
return false;
|
||||
}
|
||||
|
||||
private async regenerateUserAsymmetricKeys(userId: UserId): Promise<void> {
|
||||
async regenerateUserPublicKeyEncryptionKeyPair(userId: UserId): Promise<void> {
|
||||
const userKey = await firstValueFrom(this.keyService.userKey$(userId));
|
||||
if (userKey == null) {
|
||||
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(
|
||||
this.sdkService.client$.pipe(
|
||||
map((sdk) => {
|
||||
|
||||
@ -24,6 +24,7 @@ describe("availableAlgorithms_vNextPolicy", () => {
|
||||
overridePasswordType: override,
|
||||
},
|
||||
enabled: true,
|
||||
revisionDate: new Date().toISOString(),
|
||||
});
|
||||
|
||||
const result = availableAlgorithms([policy]);
|
||||
@ -44,6 +45,7 @@ describe("availableAlgorithms_vNextPolicy", () => {
|
||||
overridePasswordType: override,
|
||||
},
|
||||
enabled: true,
|
||||
revisionDate: new Date().toISOString(),
|
||||
});
|
||||
|
||||
const result = availableAlgorithms([policy, policy]);
|
||||
@ -64,6 +66,7 @@ describe("availableAlgorithms_vNextPolicy", () => {
|
||||
overridePasswordType: "password",
|
||||
},
|
||||
enabled: true,
|
||||
revisionDate: new Date().toISOString(),
|
||||
});
|
||||
const passphrase = new Policy({
|
||||
id: "" as PolicyId,
|
||||
@ -73,6 +76,7 @@ describe("availableAlgorithms_vNextPolicy", () => {
|
||||
overridePasswordType: "passphrase",
|
||||
},
|
||||
enabled: true,
|
||||
revisionDate: new Date().toISOString(),
|
||||
});
|
||||
|
||||
const result = availableAlgorithms([password, passphrase]);
|
||||
@ -93,6 +97,7 @@ describe("availableAlgorithms_vNextPolicy", () => {
|
||||
some: "policy",
|
||||
},
|
||||
enabled: true,
|
||||
revisionDate: new Date().toISOString(),
|
||||
});
|
||||
|
||||
const result = availableAlgorithms([policy]);
|
||||
@ -111,6 +116,7 @@ describe("availableAlgorithms_vNextPolicy", () => {
|
||||
some: "policy",
|
||||
},
|
||||
enabled: false,
|
||||
revisionDate: new Date().toISOString(),
|
||||
});
|
||||
|
||||
const result = availableAlgorithms([policy]);
|
||||
@ -129,6 +135,7 @@ describe("availableAlgorithms_vNextPolicy", () => {
|
||||
some: "policy",
|
||||
},
|
||||
enabled: true,
|
||||
revisionDate: new Date().toISOString(),
|
||||
});
|
||||
|
||||
const result = availableAlgorithms([policy]);
|
||||
|
||||
@ -17,6 +17,7 @@ function createPolicy(
|
||||
data,
|
||||
enabled,
|
||||
type,
|
||||
revisionDate: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@ -17,6 +17,7 @@ function createPolicy(
|
||||
data,
|
||||
enabled,
|
||||
type,
|
||||
revisionDate: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@ -57,6 +57,7 @@ const somePolicy = new Policy({
|
||||
id: "" as PolicyId,
|
||||
organizationId: "" as OrganizationId,
|
||||
enabled: true,
|
||||
revisionDate: new Date().toISOString(),
|
||||
});
|
||||
|
||||
const stateProvider = new FakeStateProvider(accountService);
|
||||
|
||||
@ -70,6 +70,7 @@ describe("DefaultGeneratorNavigationService", () => {
|
||||
enabled: true,
|
||||
type: PolicyType.PasswordGenerator,
|
||||
data: { overridePasswordType: "password" },
|
||||
revisionDate: new Date().toISOString(),
|
||||
}),
|
||||
]);
|
||||
},
|
||||
|
||||
@ -17,6 +17,7 @@ function createPolicy(
|
||||
data,
|
||||
enabled,
|
||||
type,
|
||||
revisionDate: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@ -6,3 +6,4 @@ export { SendItemsService } from "./services/send-items.service";
|
||||
export { SendSearchComponent } from "./send-search/send-search.component";
|
||||
export { SendListFiltersComponent } from "./send-list-filters/send-list-filters.component";
|
||||
export { SendListFiltersService } from "./services/send-list-filters.service";
|
||||
export { SendTableComponent } from "./send-table/send-table.component";
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
<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 }}
|
||||
</button>
|
||||
<bit-menu #itemOptions>
|
||||
|
||||
@ -1,7 +1,9 @@
|
||||
<bit-section [formGroup]="sendFileDetailsForm">
|
||||
<div *ngIf="config().mode === 'edit'">
|
||||
<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>
|
||||
<bit-form-field *ngIf="config().mode !== 'edit'">
|
||||
|
||||
102
libs/tools/send/send-ui/src/send-table/send-table.component.html
Normal file
102
libs/tools/send/send-ui/src/send-table/send-table.component.html
Normal 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>
|
||||
@ -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 = {};
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
59
libs/vault/src/abstractions/vault-items-transfer.service.ts
Normal file
59
libs/vault/src/abstractions/vault-items-transfer.service.ts
Normal 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>;
|
||||
}
|
||||
@ -122,7 +122,7 @@
|
||||
</bit-form-field>
|
||||
<bit-form-field *ngIf="cipher.login.totp">
|
||||
<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 }}
|
||||
<app-premium-badge [organizationId]="cipher.organizationId"></app-premium-badge>
|
||||
</div>
|
||||
|
||||
13
libs/vault/src/components/vault-items-transfer/index.ts
Normal file
13
libs/vault/src/components/vault-items-transfer/index.ts
Normal 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";
|
||||
@ -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
Loading…
x
Reference in New Issue
Block a user