mirror of
https://github.com/bitwarden/directory-connector
synced 2026-02-19 19:03:40 +00:00
Compare commits
29 Commits
context-ru
...
ac/pm-3100
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ec37b8839d | ||
|
|
762c3ac21d | ||
|
|
1fd8bf318f | ||
|
|
c472d5e199 | ||
|
|
1a42e76c79 | ||
|
|
1aad9e1cbe | ||
|
|
3059934d4c | ||
|
|
42cf13df08 | ||
|
|
1a9f0a2ca7 | ||
|
|
30b3595de3 | ||
|
|
45cc3d4c4f | ||
|
|
28f0ff4b24 | ||
|
|
14fc69c810 | ||
|
|
1ad0aea61f | ||
|
|
f41156969c | ||
|
|
39b151b1e0 | ||
|
|
483f26fa6f | ||
|
|
8849385d1b | ||
|
|
a7aff97360 | ||
|
|
7381857296 | ||
|
|
ba17d5b438 | ||
|
|
b5d31e693b | ||
|
|
2854a2eba1 | ||
|
|
4485ecab3c | ||
|
|
9e3b2d2d95 | ||
|
|
b2997358dc | ||
|
|
db258f0191 | ||
|
|
19d7884933 | ||
|
|
21ce02f431 |
23
.github/PULL_REQUEST_TEMPLATE.md
vendored
23
.github/PULL_REQUEST_TEMPLATE.md
vendored
@@ -9,26 +9,3 @@
|
|||||||
## 📸 Screenshots
|
## 📸 Screenshots
|
||||||
|
|
||||||
<!-- Required for any UI changes; delete if not applicable. Use fixed width images for better display. -->
|
<!-- Required for any UI changes; delete if not applicable. Use fixed width images for better display. -->
|
||||||
|
|
||||||
## ⏰ Reminders before review
|
|
||||||
|
|
||||||
- Contributor guidelines followed
|
|
||||||
- All formatters and local linters executed and passed
|
|
||||||
- Written new unit and / or integration tests where applicable
|
|
||||||
- Used internationalization (i18n) for all UI strings
|
|
||||||
- CI builds passed
|
|
||||||
- Communicated to DevOps any deployment requirements
|
|
||||||
- Updated any necessary documentation (Confluence, contributing docs) or informed the documentation team
|
|
||||||
|
|
||||||
## 🦮 Reviewer guidelines
|
|
||||||
|
|
||||||
<!-- Suggested interactions but feel free to use (or not) as you desire! -->
|
|
||||||
|
|
||||||
- 👍 (`:+1:`) or similar for great changes
|
|
||||||
- 📝 (`:memo:`) or ℹ️ (`:information_source:`) for notes or general info
|
|
||||||
- ❓ (`:question:`) for questions
|
|
||||||
- 🤔 (`:thinking:`) or 💭 (`:thought_balloon:`) for more open inquiry that's not quite a confirmed issue and could potentially benefit from discussion
|
|
||||||
- 🎨 (`:art:`) for suggestions / improvements
|
|
||||||
- ❌ (`:x:`) or ⚠️ (`:warning:`) for more significant problems or concerns needing attention
|
|
||||||
- 🌱 (`:seedling:`) or ♻️ (`:recycle:`) for future improvements or indications of technical debt
|
|
||||||
- ⛏ (`:pick:`) for minor or nitpick changes
|
|
||||||
|
|||||||
26
.github/workflows/build.yml
vendored
26
.github/workflows/build.yml
vendored
@@ -23,7 +23,7 @@ jobs:
|
|||||||
node_version: ${{ steps.retrieve-node-version.outputs.node_version }}
|
node_version: ${{ steps.retrieve-node-version.outputs.node_version }}
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repo
|
- name: Checkout repo
|
||||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
|
||||||
@@ -51,12 +51,12 @@ jobs:
|
|||||||
contents: read
|
contents: read
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repo
|
- name: Checkout repo
|
||||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Set up Node
|
- name: Set up Node
|
||||||
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
|
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||||
with:
|
with:
|
||||||
cache: 'npm'
|
cache: 'npm'
|
||||||
cache-dependency-path: '**/package-lock.json'
|
cache-dependency-path: '**/package-lock.json'
|
||||||
@@ -129,12 +129,12 @@ jobs:
|
|||||||
_NODE_VERSION: ${{ needs.setup.outputs.node_version }}
|
_NODE_VERSION: ${{ needs.setup.outputs.node_version }}
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repo
|
- name: Checkout repo
|
||||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Set up Node
|
- name: Set up Node
|
||||||
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
|
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||||
with:
|
with:
|
||||||
cache: 'npm'
|
cache: 'npm'
|
||||||
cache-dependency-path: '**/package-lock.json'
|
cache-dependency-path: '**/package-lock.json'
|
||||||
@@ -200,7 +200,7 @@ jobs:
|
|||||||
_NODE_VERSION: ${{ needs.setup.outputs.node_version }}
|
_NODE_VERSION: ${{ needs.setup.outputs.node_version }}
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repo
|
- name: Checkout repo
|
||||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
|
||||||
@@ -209,7 +209,7 @@ jobs:
|
|||||||
choco install checksum --no-progress
|
choco install checksum --no-progress
|
||||||
|
|
||||||
- name: Set up Node
|
- name: Set up Node
|
||||||
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
|
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||||
with:
|
with:
|
||||||
cache: 'npm'
|
cache: 'npm'
|
||||||
cache-dependency-path: '**/package-lock.json'
|
cache-dependency-path: '**/package-lock.json'
|
||||||
@@ -279,12 +279,12 @@ jobs:
|
|||||||
HUSKY: 0
|
HUSKY: 0
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repo
|
- name: Checkout repo
|
||||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Set up Node
|
- name: Set up Node
|
||||||
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
|
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||||
with:
|
with:
|
||||||
cache: 'npm'
|
cache: 'npm'
|
||||||
cache-dependency-path: '**/package-lock.json'
|
cache-dependency-path: '**/package-lock.json'
|
||||||
@@ -379,12 +379,12 @@ jobs:
|
|||||||
HUSKY: 0
|
HUSKY: 0
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repo
|
- name: Checkout repo
|
||||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Set up Node
|
- name: Set up Node
|
||||||
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
|
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||||
with:
|
with:
|
||||||
cache: 'npm'
|
cache: 'npm'
|
||||||
cache-dependency-path: '**/package-lock.json'
|
cache-dependency-path: '**/package-lock.json'
|
||||||
@@ -439,12 +439,12 @@ jobs:
|
|||||||
HUSKY: 0
|
HUSKY: 0
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repo
|
- name: Checkout repo
|
||||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Set up Node
|
- name: Set up Node
|
||||||
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
|
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||||
with:
|
with:
|
||||||
cache: 'npm'
|
cache: 'npm'
|
||||||
cache-dependency-path: '**/package-lock.json'
|
cache-dependency-path: '**/package-lock.json'
|
||||||
|
|||||||
10
.github/workflows/integration-test.yml
vendored
10
.github/workflows/integration-test.yml
vendored
@@ -40,7 +40,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Check out repo
|
- name: Check out repo
|
||||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
|
||||||
@@ -52,7 +52,7 @@ jobs:
|
|||||||
echo "node_version=$NODE_VERSION" >> "$GITHUB_OUTPUT"
|
echo "node_version=$NODE_VERSION" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
- name: Set up Node
|
- name: Set up Node
|
||||||
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
|
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||||
with:
|
with:
|
||||||
cache: 'npm'
|
cache: 'npm'
|
||||||
cache-dependency-path: '**/package-lock.json'
|
cache-dependency-path: '**/package-lock.json'
|
||||||
@@ -129,7 +129,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Report test results
|
- name: Report test results
|
||||||
id: report
|
id: report
|
||||||
uses: dorny/test-reporter@fe45e9537387dac839af0d33ba56eed8e24189e8 # v2.3.0
|
uses: dorny/test-reporter@b082adf0eced0765477756c2a610396589b8c637 # v2.5.0
|
||||||
# This will skip the job if it's a pull request from a fork, because that won't have permission to upload test results.
|
# This will skip the job if it's a pull request from a fork, because that won't have permission to upload test results.
|
||||||
# PRs from the repository and all other events are OK.
|
# PRs from the repository and all other events are OK.
|
||||||
if: (github.event_name == 'push' || github.event_name == 'workflow_dispatch' || github.event.pull_request.head.repo.full_name == github.repository) && !cancelled()
|
if: (github.event_name == 'push' || github.event_name == 'workflow_dispatch' || github.event.pull_request.head.repo.full_name == github.repository) && !cancelled()
|
||||||
@@ -143,4 +143,6 @@ jobs:
|
|||||||
uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2
|
uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2
|
||||||
|
|
||||||
- name: Upload results to codecov.io
|
- name: Upload results to codecov.io
|
||||||
uses: codecov/test-results-action@0fa95f0e1eeaafde2c782583b36b28ad0d8c77d3 # v1.2.1
|
uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2
|
||||||
|
with:
|
||||||
|
report_type: test_results
|
||||||
|
|||||||
2
.github/workflows/release.yml
vendored
2
.github/workflows/release.yml
vendored
@@ -26,7 +26,7 @@ jobs:
|
|||||||
release_version: ${{ steps.version.outputs.version }}
|
release_version: ${{ steps.version.outputs.version }}
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repo
|
- name: Checkout repo
|
||||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
|
||||||
|
|||||||
10
.github/workflows/test.yml
vendored
10
.github/workflows/test.yml
vendored
@@ -22,7 +22,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Check out repo
|
- name: Check out repo
|
||||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
|
||||||
@@ -34,7 +34,7 @@ jobs:
|
|||||||
echo "node_version=$NODE_VERSION" >> "$GITHUB_OUTPUT"
|
echo "node_version=$NODE_VERSION" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
- name: Set up Node
|
- name: Set up Node
|
||||||
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
|
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||||
with:
|
with:
|
||||||
cache: 'npm'
|
cache: 'npm'
|
||||||
cache-dependency-path: '**/package-lock.json'
|
cache-dependency-path: '**/package-lock.json'
|
||||||
@@ -53,7 +53,7 @@ jobs:
|
|||||||
run: npm run test --coverage
|
run: npm run test --coverage
|
||||||
|
|
||||||
- name: Report test results
|
- name: Report test results
|
||||||
uses: dorny/test-reporter@fe45e9537387dac839af0d33ba56eed8e24189e8 # v2.3.0
|
uses: dorny/test-reporter@b082adf0eced0765477756c2a610396589b8c637 # v2.5.0
|
||||||
# This will skip the job if it's a pull request from a fork, because that won't have permission to upload test results.
|
# This will skip the job if it's a pull request from a fork, because that won't have permission to upload test results.
|
||||||
# PRs from the repository and all other events are OK.
|
# PRs from the repository and all other events are OK.
|
||||||
if: (github.event_name == 'push' || github.event_name == 'workflow_dispatch' || github.event.pull_request.head.repo.full_name == github.repository) && !cancelled()
|
if: (github.event_name == 'push' || github.event_name == 'workflow_dispatch' || github.event.pull_request.head.repo.full_name == github.repository) && !cancelled()
|
||||||
@@ -67,4 +67,6 @@ jobs:
|
|||||||
uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2
|
uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2
|
||||||
|
|
||||||
- name: Upload results to codecov.io
|
- name: Upload results to codecov.io
|
||||||
uses: codecov/test-results-action@0fa95f0e1eeaafde2c782583b36b28ad0d8c77d3 # v1.2.1
|
uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2
|
||||||
|
with:
|
||||||
|
report_type: test_results
|
||||||
|
|||||||
2
.github/workflows/version-bump.yml
vendored
2
.github/workflows/version-bump.yml
vendored
@@ -50,7 +50,7 @@ jobs:
|
|||||||
permission-contents: write
|
permission-contents: write
|
||||||
|
|
||||||
- name: Checkout Branch
|
- name: Checkout Branch
|
||||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
with:
|
with:
|
||||||
token: ${{ steps.app-token.outputs.token }}
|
token: ${{ steps.app-token.outputs.token }}
|
||||||
persist-credentials: true
|
persist-credentials: true
|
||||||
|
|||||||
10
angular.json
10
angular.json
@@ -18,15 +18,17 @@
|
|||||||
"prefix": "app",
|
"prefix": "app",
|
||||||
"architect": {
|
"architect": {
|
||||||
"build": {
|
"build": {
|
||||||
"builder": "@angular-devkit/build-angular:browser",
|
"builder": "@angular/build:application",
|
||||||
"options": {
|
"options": {
|
||||||
"outputPath": "dist",
|
"outputPath": {
|
||||||
|
"base": "dist"
|
||||||
|
},
|
||||||
"index": "src/index.html",
|
"index": "src/index.html",
|
||||||
"main": "src/main.ts",
|
|
||||||
"tsConfig": "tsconfig.json",
|
"tsConfig": "tsconfig.json",
|
||||||
"assets": [],
|
"assets": [],
|
||||||
"styles": [],
|
"styles": [],
|
||||||
"scripts": []
|
"scripts": [],
|
||||||
|
"browser": "src/main.ts"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,75 +1,77 @@
|
|||||||
import { animate, state, style, transition, trigger } from "@angular/animations";
|
|
||||||
import { CommonModule } from "@angular/common";
|
import { CommonModule } from "@angular/common";
|
||||||
import { Component, ModuleWithProviders, NgModule } from "@angular/core";
|
import { Component, ModuleWithProviders, NgModule } from "@angular/core";
|
||||||
import {
|
import { DefaultNoComponentGlobalConfig, GlobalConfig, Toast, TOAST_CONFIG } from "ngx-toastr";
|
||||||
DefaultNoComponentGlobalConfig,
|
|
||||||
GlobalConfig,
|
|
||||||
Toast as BaseToast,
|
|
||||||
ToastPackage,
|
|
||||||
ToastrService,
|
|
||||||
TOAST_CONFIG,
|
|
||||||
} from "ngx-toastr";
|
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: "[toast-component2]",
|
selector: "[toast-component2]",
|
||||||
template: `
|
template: `
|
||||||
<button
|
@if (options().closeButton) {
|
||||||
*ngIf="options.closeButton"
|
<button (click)="remove()" type="button" class="toast-close-button" aria-label="Close">
|
||||||
(click)="remove()"
|
<span aria-hidden="true">×</span>
|
||||||
type="button"
|
</button>
|
||||||
class="toast-close-button"
|
}
|
||||||
aria-label="Close"
|
|
||||||
>
|
|
||||||
<span aria-hidden="true">×</span>
|
|
||||||
</button>
|
|
||||||
<div class="icon">
|
<div class="icon">
|
||||||
<i></i>
|
<i></i>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div *ngIf="title" [class]="options.titleClass" [attr.aria-label]="title">
|
@if (title()) {
|
||||||
{{ title }} <ng-container *ngIf="duplicatesCount">[{{ duplicatesCount + 1 }}]</ng-container>
|
<div [class]="options().titleClass" [attr.aria-label]="title()">
|
||||||
</div>
|
{{ title() }}
|
||||||
<div
|
@if (duplicatesCount) {
|
||||||
*ngIf="message && options.enableHtml"
|
[{{ duplicatesCount + 1 }}]
|
||||||
role="alertdialog"
|
}
|
||||||
aria-live="polite"
|
</div>
|
||||||
[class]="options.messageClass"
|
}
|
||||||
[innerHTML]="message"
|
@if (message() && options().enableHtml) {
|
||||||
></div>
|
<div
|
||||||
<div
|
role="alertdialog"
|
||||||
*ngIf="message && !options.enableHtml"
|
aria-live="polite"
|
||||||
role="alertdialog"
|
[class]="options().messageClass"
|
||||||
aria-live="polite"
|
[innerHTML]="message()"
|
||||||
[class]="options.messageClass"
|
></div>
|
||||||
[attr.aria-label]="message"
|
}
|
||||||
>
|
@if (message() && !options().enableHtml) {
|
||||||
{{ message }}
|
<div
|
||||||
</div>
|
role="alertdialog"
|
||||||
</div>
|
aria-live="polite"
|
||||||
<div *ngIf="options.progressBar">
|
[class]="options().messageClass"
|
||||||
<div class="toast-progress" [style.width]="width + '%'"></div>
|
[attr.aria-label]="message()"
|
||||||
|
>
|
||||||
|
{{ message() }}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
|
@if (options().progressBar) {
|
||||||
|
<div>
|
||||||
|
<div class="toast-progress" [style.width]="width + '%'"></div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
styles: `
|
||||||
|
:host {
|
||||||
|
&.toast-in {
|
||||||
|
animation: toast-animation var(--animation-duration) var(--animation-easing);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.toast-out {
|
||||||
|
animation: toast-animation var(--animation-duration) var(--animation-easing) reverse
|
||||||
|
forwards;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes toast-animation {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
`,
|
`,
|
||||||
animations: [
|
|
||||||
trigger("flyInOut", [
|
|
||||||
state("inactive", style({ opacity: 0 })),
|
|
||||||
state("active", style({ opacity: 1 })),
|
|
||||||
state("removed", style({ opacity: 0 })),
|
|
||||||
transition("inactive => active", animate("{{ easeTime }}ms {{ easing }}")),
|
|
||||||
transition("active => removed", animate("{{ easeTime }}ms {{ easing }}")),
|
|
||||||
]),
|
|
||||||
],
|
|
||||||
preserveWhitespaces: false,
|
preserveWhitespaces: false,
|
||||||
standalone: false,
|
standalone: false,
|
||||||
})
|
})
|
||||||
export class BitwardenToast extends BaseToast {
|
export class BitwardenToast extends Toast {}
|
||||||
constructor(
|
|
||||||
protected toastrService: ToastrService,
|
|
||||||
public toastPackage: ToastPackage,
|
|
||||||
) {
|
|
||||||
super(toastrService, toastPackage);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const BitwardenToastGlobalConfig: GlobalConfig = {
|
export const BitwardenToastGlobalConfig: GlobalConfig = {
|
||||||
...DefaultNoComponentGlobalConfig,
|
...DefaultNoComponentGlobalConfig,
|
||||||
|
|||||||
@@ -1,195 +0,0 @@
|
|||||||
import { Substitute, Arg } from "@fluffy-spoon/substitute";
|
|
||||||
|
|
||||||
import { CryptoService } from "@/jslib/common/src/abstractions/crypto.service";
|
|
||||||
import { EncryptionType } from "@/jslib/common/src/enums/encryptionType";
|
|
||||||
import { EncString } from "@/jslib/common/src/models/domain/encString";
|
|
||||||
import { SymmetricCryptoKey } from "@/jslib/common/src/models/domain/symmetricCryptoKey";
|
|
||||||
import { ContainerService } from "@/jslib/common/src/services/container.service";
|
|
||||||
|
|
||||||
describe("EncString", () => {
|
|
||||||
afterEach(() => {
|
|
||||||
(window as any).bitwardenContainerService = undefined;
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("Rsa2048_OaepSha256_B64", () => {
|
|
||||||
it("constructor", () => {
|
|
||||||
const encString = new EncString(EncryptionType.Rsa2048_OaepSha256_B64, "data");
|
|
||||||
|
|
||||||
expect(encString).toEqual({
|
|
||||||
data: "data",
|
|
||||||
encryptedString: "3.data",
|
|
||||||
encryptionType: 3,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("parse existing", () => {
|
|
||||||
it("valid", () => {
|
|
||||||
const encString = new EncString("3.data");
|
|
||||||
|
|
||||||
expect(encString).toEqual({
|
|
||||||
data: "data",
|
|
||||||
encryptedString: "3.data",
|
|
||||||
encryptionType: 3,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("invalid", () => {
|
|
||||||
const encString = new EncString("3.data|test");
|
|
||||||
|
|
||||||
expect(encString).toEqual({
|
|
||||||
encryptedString: "3.data|test",
|
|
||||||
encryptionType: 3,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("decrypt", () => {
|
|
||||||
const encString = new EncString(EncryptionType.Rsa2048_OaepSha256_B64, "data");
|
|
||||||
|
|
||||||
const cryptoService = Substitute.for<CryptoService>();
|
|
||||||
cryptoService.getOrgKey(null).resolves(null);
|
|
||||||
cryptoService.decryptToUtf8(encString, Arg.any()).resolves("decrypted");
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
(window as any).bitwardenContainerService = new ContainerService(cryptoService);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("decrypts correctly", async () => {
|
|
||||||
const decrypted = await encString.decrypt(null);
|
|
||||||
|
|
||||||
expect(decrypted).toBe("decrypted");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("result should be cached", async () => {
|
|
||||||
const decrypted = await encString.decrypt(null);
|
|
||||||
cryptoService.received(1).decryptToUtf8(Arg.any(), Arg.any());
|
|
||||||
|
|
||||||
expect(decrypted).toBe("decrypted");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("AesCbc256_B64", () => {
|
|
||||||
it("constructor", () => {
|
|
||||||
const encString = new EncString(EncryptionType.AesCbc256_B64, "data", "iv");
|
|
||||||
|
|
||||||
expect(encString).toEqual({
|
|
||||||
data: "data",
|
|
||||||
encryptedString: "0.iv|data",
|
|
||||||
encryptionType: 0,
|
|
||||||
iv: "iv",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("parse existing", () => {
|
|
||||||
it("valid", () => {
|
|
||||||
const encString = new EncString("0.iv|data");
|
|
||||||
|
|
||||||
expect(encString).toEqual({
|
|
||||||
data: "data",
|
|
||||||
encryptedString: "0.iv|data",
|
|
||||||
encryptionType: 0,
|
|
||||||
iv: "iv",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("invalid", () => {
|
|
||||||
const encString = new EncString("0.iv|data|mac");
|
|
||||||
|
|
||||||
expect(encString).toEqual({
|
|
||||||
encryptedString: "0.iv|data|mac",
|
|
||||||
encryptionType: 0,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("AesCbc256_HmacSha256_B64", () => {
|
|
||||||
it("constructor", () => {
|
|
||||||
const encString = new EncString(EncryptionType.AesCbc256_HmacSha256_B64, "data", "iv", "mac");
|
|
||||||
|
|
||||||
expect(encString).toEqual({
|
|
||||||
data: "data",
|
|
||||||
encryptedString: "2.iv|data|mac",
|
|
||||||
encryptionType: 2,
|
|
||||||
iv: "iv",
|
|
||||||
mac: "mac",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("valid", () => {
|
|
||||||
const encString = new EncString("2.iv|data|mac");
|
|
||||||
|
|
||||||
expect(encString).toEqual({
|
|
||||||
data: "data",
|
|
||||||
encryptedString: "2.iv|data|mac",
|
|
||||||
encryptionType: 2,
|
|
||||||
iv: "iv",
|
|
||||||
mac: "mac",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("invalid", () => {
|
|
||||||
const encString = new EncString("2.iv|data");
|
|
||||||
|
|
||||||
expect(encString).toEqual({
|
|
||||||
encryptedString: "2.iv|data",
|
|
||||||
encryptionType: 2,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("Exit early if null", () => {
|
|
||||||
const encString = new EncString(null);
|
|
||||||
|
|
||||||
expect(encString).toEqual({
|
|
||||||
encryptedString: null,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("decrypt", () => {
|
|
||||||
it("throws exception when bitwarden container not initialized", async () => {
|
|
||||||
const encString = new EncString(null);
|
|
||||||
|
|
||||||
expect.assertions(1);
|
|
||||||
try {
|
|
||||||
await encString.decrypt(null);
|
|
||||||
} catch (e) {
|
|
||||||
expect(e.message).toEqual("global bitwardenContainerService not initialized.");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("handles value it can't decrypt", async () => {
|
|
||||||
const encString = new EncString(null);
|
|
||||||
|
|
||||||
const cryptoService = Substitute.for<CryptoService>();
|
|
||||||
cryptoService.getOrgKey(null).resolves(null);
|
|
||||||
cryptoService.decryptToUtf8(encString, Arg.any()).throws("error");
|
|
||||||
|
|
||||||
(window as any).bitwardenContainerService = new ContainerService(cryptoService);
|
|
||||||
|
|
||||||
const decrypted = await encString.decrypt(null);
|
|
||||||
|
|
||||||
expect(decrypted).toBe("[error: cannot decrypt]");
|
|
||||||
|
|
||||||
expect(encString).toEqual({
|
|
||||||
decryptedValue: "[error: cannot decrypt]",
|
|
||||||
encryptedString: null,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("passes along key", async () => {
|
|
||||||
const encString = new EncString(null);
|
|
||||||
const key = Substitute.for<SymmetricCryptoKey>();
|
|
||||||
|
|
||||||
const cryptoService = Substitute.for<CryptoService>();
|
|
||||||
cryptoService.getOrgKey(null).resolves(null);
|
|
||||||
|
|
||||||
(window as any).bitwardenContainerService = new ContainerService(cryptoService);
|
|
||||||
|
|
||||||
await encString.decrypt(null, key);
|
|
||||||
|
|
||||||
cryptoService.received().decryptToUtf8(encString, key);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -9,7 +9,7 @@ describe("SymmetricCryptoKey", () => {
|
|||||||
new SymmetricCryptoKey(null);
|
new SymmetricCryptoKey(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
expect(t).toThrowError("Must provide key");
|
expect(t).toThrow("Must provide key");
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("guesses encKey from key length", () => {
|
describe("guesses encKey from key length", () => {
|
||||||
@@ -63,7 +63,7 @@ describe("SymmetricCryptoKey", () => {
|
|||||||
new SymmetricCryptoKey(makeStaticByteArray(30));
|
new SymmetricCryptoKey(makeStaticByteArray(30));
|
||||||
};
|
};
|
||||||
|
|
||||||
expect(t).toThrowError("Unable to determine encType.");
|
expect(t).toThrow("Unable to determine encType.");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,84 +0,0 @@
|
|||||||
import { Arg, Substitute, SubstituteOf } from "@fluffy-spoon/substitute";
|
|
||||||
|
|
||||||
import { StorageService } from "@/jslib/common/src/abstractions/storage.service";
|
|
||||||
import { StateVersion } from "@/jslib/common/src/enums/stateVersion";
|
|
||||||
import { StateFactory } from "@/jslib/common/src/factories/stateFactory";
|
|
||||||
import { Account } from "@/jslib/common/src/models/domain/account";
|
|
||||||
import { GlobalState } from "@/jslib/common/src/models/domain/globalState";
|
|
||||||
import { StateMigrationService } from "@/jslib/common/src/services/stateMigration.service";
|
|
||||||
|
|
||||||
const userId = "USER_ID";
|
|
||||||
|
|
||||||
describe("State Migration Service", () => {
|
|
||||||
let storageService: SubstituteOf<StorageService>;
|
|
||||||
let secureStorageService: SubstituteOf<StorageService>;
|
|
||||||
let stateFactory: SubstituteOf<StateFactory>;
|
|
||||||
|
|
||||||
let stateMigrationService: StateMigrationService;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
storageService = Substitute.for<StorageService>();
|
|
||||||
secureStorageService = Substitute.for<StorageService>();
|
|
||||||
stateFactory = Substitute.for<StateFactory>();
|
|
||||||
|
|
||||||
stateMigrationService = new StateMigrationService(
|
|
||||||
storageService,
|
|
||||||
secureStorageService,
|
|
||||||
stateFactory,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("StateVersion 3 to 4 migration", async () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
const globalVersion3: Partial<GlobalState> = {
|
|
||||||
stateVersion: StateVersion.Three,
|
|
||||||
};
|
|
||||||
|
|
||||||
storageService.get("global", Arg.any()).resolves(globalVersion3);
|
|
||||||
storageService.get("authenticatedAccounts", Arg.any()).resolves([userId]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("clears everBeenUnlocked", async () => {
|
|
||||||
const accountVersion3: Account = {
|
|
||||||
profile: {
|
|
||||||
apiKeyClientId: null,
|
|
||||||
convertAccountToKeyConnector: null,
|
|
||||||
email: "EMAIL",
|
|
||||||
emailVerified: true,
|
|
||||||
everBeenUnlocked: true,
|
|
||||||
hasPremiumPersonally: false,
|
|
||||||
kdfIterations: 100000,
|
|
||||||
kdfType: 0,
|
|
||||||
keyHash: "KEY_HASH",
|
|
||||||
lastSync: "LAST_SYNC",
|
|
||||||
userId: userId,
|
|
||||||
usesKeyConnector: false,
|
|
||||||
forcePasswordReset: false,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const expectedAccountVersion4: Account = {
|
|
||||||
profile: {
|
|
||||||
...accountVersion3.profile,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
delete expectedAccountVersion4.profile.everBeenUnlocked;
|
|
||||||
|
|
||||||
storageService.get(userId, Arg.any()).resolves(accountVersion3);
|
|
||||||
|
|
||||||
await stateMigrationService.migrate();
|
|
||||||
|
|
||||||
storageService.received(1).save(userId, expectedAccountVersion4, Arg.any());
|
|
||||||
});
|
|
||||||
|
|
||||||
it("updates StateVersion number", async () => {
|
|
||||||
await stateMigrationService.migrate();
|
|
||||||
|
|
||||||
storageService.received(1).save(
|
|
||||||
"global",
|
|
||||||
Arg.is((globals: GlobalState) => globals.stateVersion === StateVersion.Four),
|
|
||||||
Arg.any(),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,7 +1,3 @@
|
|||||||
import { Substitute, Arg } from "@fluffy-spoon/substitute";
|
|
||||||
|
|
||||||
import { EncString } from "@/jslib/common/src/models/domain/encString";
|
|
||||||
|
|
||||||
function newGuid() {
|
function newGuid() {
|
||||||
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
|
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
|
||||||
const r = (Math.random() * 16) | 0;
|
const r = (Math.random() * 16) | 0;
|
||||||
@@ -21,17 +17,10 @@ export function BuildTestObject<T, K extends keyof T = keyof T>(
|
|||||||
return Object.assign(constructor === null ? {} : new constructor(), def) as T;
|
return Object.assign(constructor === null ? {} : new constructor(), def) as T;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function mockEnc(s: string): EncString {
|
|
||||||
const mock = Substitute.for<EncString>();
|
|
||||||
mock.decrypt(Arg.any(), Arg.any()).resolves(s);
|
|
||||||
|
|
||||||
return mock;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function makeStaticByteArray(length: number, start = 0) {
|
export function makeStaticByteArray(length: number, start = 0) {
|
||||||
const arr = new Uint8Array(length);
|
const arr = new Uint8Array(length);
|
||||||
for (let i = 0; i < length; i++) {
|
for (let i = 0; i < length; i++) {
|
||||||
arr[i] = start + i;
|
arr[i] = start + i;
|
||||||
}
|
}
|
||||||
return arr;
|
return arr.buffer;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,9 +26,4 @@ export class NodeUtils {
|
|||||||
.on("error", (err) => reject(err));
|
.on("error", (err) => reject(err));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// https://stackoverflow.com/a/31394257
|
|
||||||
static bufferToArrayBuffer(buf: Buffer): ArrayBuffer {
|
|
||||||
return buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ export class Utils {
|
|||||||
Utils.global = Utils.isNode && !Utils.isBrowser ? global : window;
|
Utils.global = Utils.isNode && !Utils.isBrowser ? global : window;
|
||||||
}
|
}
|
||||||
|
|
||||||
static fromB64ToArray(str: string): Uint8Array {
|
static fromB64ToArray(str: string): Uint8Array<ArrayBuffer> {
|
||||||
if (Utils.isNode) {
|
if (Utils.isNode) {
|
||||||
return new Uint8Array(Buffer.from(str, "base64"));
|
return new Uint8Array(Buffer.from(str, "base64"));
|
||||||
} else {
|
} else {
|
||||||
@@ -49,11 +49,11 @@ export class Utils {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static fromUrlB64ToArray(str: string): Uint8Array {
|
static fromUrlB64ToArray(str: string): Uint8Array<ArrayBuffer> {
|
||||||
return Utils.fromB64ToArray(Utils.fromUrlB64ToB64(str));
|
return Utils.fromB64ToArray(Utils.fromUrlB64ToB64(str));
|
||||||
}
|
}
|
||||||
|
|
||||||
static fromHexToArray(str: string): Uint8Array {
|
static fromHexToArray(str: string): Uint8Array<ArrayBuffer> {
|
||||||
if (Utils.isNode) {
|
if (Utils.isNode) {
|
||||||
return new Uint8Array(Buffer.from(str, "hex"));
|
return new Uint8Array(Buffer.from(str, "hex"));
|
||||||
} else {
|
} else {
|
||||||
@@ -65,7 +65,7 @@ export class Utils {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static fromUtf8ToArray(str: string): Uint8Array {
|
static fromUtf8ToArray(str: string): Uint8Array<ArrayBuffer> {
|
||||||
if (Utils.isNode) {
|
if (Utils.isNode) {
|
||||||
return new Uint8Array(Buffer.from(str, "utf8"));
|
return new Uint8Array(Buffer.from(str, "utf8"));
|
||||||
} else {
|
} else {
|
||||||
@@ -78,7 +78,7 @@ export class Utils {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static fromByteStringToArray(str: string): Uint8Array {
|
static fromByteStringToArray(str: string): Uint8Array<ArrayBuffer> {
|
||||||
const arr = new Uint8Array(str.length);
|
const arr = new Uint8Array(str.length);
|
||||||
for (let i = 0; i < str.length; i++) {
|
for (let i = 0; i < str.length; i++) {
|
||||||
arr[i] = str.charCodeAt(i);
|
arr[i] = str.charCodeAt(i);
|
||||||
@@ -99,8 +99,8 @@ export class Utils {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static fromBufferToUrlB64(buffer: ArrayBuffer): string {
|
static fromBufferToUrlB64(buffer: Uint8Array<ArrayBuffer>): string {
|
||||||
return Utils.fromB64toUrlB64(Utils.fromBufferToB64(buffer));
|
return Utils.fromB64toUrlB64(Utils.fromBufferToB64(buffer.buffer));
|
||||||
}
|
}
|
||||||
|
|
||||||
static fromB64toUrlB64(b64Str: string) {
|
static fromB64toUrlB64(b64Str: string) {
|
||||||
|
|||||||
@@ -636,9 +636,9 @@ export class CryptoService implements CryptoServiceAbstraction {
|
|||||||
|
|
||||||
const encBytes = new Uint8Array(encBuf);
|
const encBytes = new Uint8Array(encBuf);
|
||||||
const encType = encBytes[0];
|
const encType = encBytes[0];
|
||||||
let ctBytes: Uint8Array = null;
|
let ctBytes: Uint8Array<ArrayBuffer> = null;
|
||||||
let ivBytes: Uint8Array = null;
|
let ivBytes: Uint8Array<ArrayBuffer> = null;
|
||||||
let macBytes: Uint8Array = null;
|
let macBytes: Uint8Array<ArrayBuffer> = null;
|
||||||
|
|
||||||
switch (encType) {
|
switch (encType) {
|
||||||
case EncryptionType.AesCbc128_HmacSha256_B64:
|
case EncryptionType.AesCbc128_HmacSha256_B64:
|
||||||
|
|||||||
@@ -127,6 +127,13 @@ export class WindowMain {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Enable SharedArrayBuffer. See https://developer.chrome.com/blog/enabling-shared-array-buffer/#cross-origin-isolation
|
||||||
|
this.win.webContents.session.webRequest.onHeadersReceived((details, callback) => {
|
||||||
|
details.responseHeaders["Cross-Origin-Opener-Policy"] = ["same-origin"];
|
||||||
|
details.responseHeaders["Cross-Origin-Embedder-Policy"] = ["require-corp"];
|
||||||
|
callback({ responseHeaders: details.responseHeaders });
|
||||||
|
});
|
||||||
|
|
||||||
if (this.windowStates[mainWindowSizeKey].isMaximized) {
|
if (this.windowStates[mainWindowSizeKey].isMaximized) {
|
||||||
this.win.maximize();
|
this.win.maximize();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -94,7 +94,7 @@ describe("NodeCrypto Function Service", () => {
|
|||||||
it("should fail with prk too small", async () => {
|
it("should fail with prk too small", async () => {
|
||||||
const cryptoFunctionService = new NodeCryptoFunctionService();
|
const cryptoFunctionService = new NodeCryptoFunctionService();
|
||||||
const f = cryptoFunctionService.hkdfExpand(
|
const f = cryptoFunctionService.hkdfExpand(
|
||||||
Utils.fromB64ToArray(prk16Byte),
|
Utils.fromB64ToArray(prk16Byte).buffer,
|
||||||
"info",
|
"info",
|
||||||
32,
|
32,
|
||||||
"sha256",
|
"sha256",
|
||||||
@@ -105,7 +105,7 @@ describe("NodeCrypto Function Service", () => {
|
|||||||
it("should fail with outputByteSize is too large", async () => {
|
it("should fail with outputByteSize is too large", async () => {
|
||||||
const cryptoFunctionService = new NodeCryptoFunctionService();
|
const cryptoFunctionService = new NodeCryptoFunctionService();
|
||||||
const f = cryptoFunctionService.hkdfExpand(
|
const f = cryptoFunctionService.hkdfExpand(
|
||||||
Utils.fromB64ToArray(prk32Byte),
|
Utils.fromB64ToArray(prk32Byte).buffer,
|
||||||
"info",
|
"info",
|
||||||
8161,
|
8161,
|
||||||
"sha256",
|
"sha256",
|
||||||
@@ -341,7 +341,7 @@ function testHkdf(
|
|||||||
utf8Key: string,
|
utf8Key: string,
|
||||||
unicodeKey: string,
|
unicodeKey: string,
|
||||||
) {
|
) {
|
||||||
const ikm = Utils.fromB64ToArray("criAmKtfzxanbgea5/kelQ==");
|
const ikm = Utils.fromB64ToArray("criAmKtfzxanbgea5/kelQ==").buffer;
|
||||||
|
|
||||||
const regularSalt = "salt";
|
const regularSalt = "salt";
|
||||||
const utf8Salt = "üser_salt";
|
const utf8Salt = "üser_salt";
|
||||||
@@ -393,7 +393,7 @@ function testHkdfExpand(
|
|||||||
it("should create valid " + algorithm + " " + outputByteSize + " byte okm", async () => {
|
it("should create valid " + algorithm + " " + outputByteSize + " byte okm", async () => {
|
||||||
const cryptoFunctionService = new NodeCryptoFunctionService();
|
const cryptoFunctionService = new NodeCryptoFunctionService();
|
||||||
const okm = await cryptoFunctionService.hkdfExpand(
|
const okm = await cryptoFunctionService.hkdfExpand(
|
||||||
Utils.fromB64ToArray(b64prk),
|
Utils.fromB64ToArray(b64prk).buffer,
|
||||||
info,
|
info,
|
||||||
outputByteSize,
|
outputByteSize,
|
||||||
algorithm,
|
algorithm,
|
||||||
|
|||||||
12069
package-lock.json
generated
12069
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
67
package.json
67
package.json
@@ -2,7 +2,7 @@
|
|||||||
"name": "@bitwarden/directory-connector",
|
"name": "@bitwarden/directory-connector",
|
||||||
"productName": "Bitwarden Directory Connector",
|
"productName": "Bitwarden Directory Connector",
|
||||||
"description": "Sync your user directory to your Bitwarden organization.",
|
"description": "Sync your user directory to your Bitwarden organization.",
|
||||||
"version": "2025.12.0",
|
"version": "2026.2.0",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"bitwarden",
|
"bitwarden",
|
||||||
"password",
|
"password",
|
||||||
@@ -73,17 +73,16 @@
|
|||||||
"test:types": "npx tsc --noEmit"
|
"test:types": "npx tsc --noEmit"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@angular-devkit/build-angular": "20.3.3",
|
"@angular-eslint/eslint-plugin-template": "21.1.0",
|
||||||
"@angular-eslint/eslint-plugin-template": "20.7.0",
|
"@angular-eslint/template-parser": "21.1.0",
|
||||||
"@angular-eslint/template-parser": "20.7.0",
|
"@angular/build": "21.1.2",
|
||||||
"@angular/compiler-cli": "20.3.15",
|
"@angular/compiler-cli": "21.1.1",
|
||||||
"@electron/notarize": "2.5.0",
|
"@electron/notarize": "2.5.0",
|
||||||
"@electron/rebuild": "4.0.1",
|
"@electron/rebuild": "4.0.1",
|
||||||
"@fluffy-spoon/substitute": "1.208.0",
|
|
||||||
"@microsoft/microsoft-graph-types": "2.43.1",
|
"@microsoft/microsoft-graph-types": "2.43.1",
|
||||||
"@ngtools/webpack": "20.3.3",
|
"@ngtools/webpack": "21.1.2",
|
||||||
"@types/inquirer": "8.2.10",
|
"@types/inquirer": "8.2.10",
|
||||||
"@types/jest": "29.5.14",
|
"@types/jest": "30.0.0",
|
||||||
"@types/lowdb": "1.0.15",
|
"@types/lowdb": "1.0.15",
|
||||||
"@types/node": "22.19.2",
|
"@types/node": "22.19.2",
|
||||||
"@types/node-fetch": "2.6.12",
|
"@types/node-fetch": "2.6.12",
|
||||||
@@ -91,10 +90,12 @@
|
|||||||
"@types/proper-lockfile": "4.1.4",
|
"@types/proper-lockfile": "4.1.4",
|
||||||
"@types/semver": "7.7.1",
|
"@types/semver": "7.7.1",
|
||||||
"@types/tldjs": "2.3.4",
|
"@types/tldjs": "2.3.4",
|
||||||
"@typescript-eslint/eslint-plugin": "8.50.0",
|
"@typescript-eslint/eslint-plugin": "8.54.0",
|
||||||
"@typescript-eslint/parser": "8.50.0",
|
"@typescript-eslint/parser": "8.54.0",
|
||||||
"@yao-pkg/pkg": "5.16.1",
|
"@yao-pkg/pkg": "5.16.1",
|
||||||
|
"babel-loader": "10.0.0",
|
||||||
"clean-webpack-plugin": "4.0.0",
|
"clean-webpack-plugin": "4.0.0",
|
||||||
|
"jest-environment-jsdom": "30.2.0",
|
||||||
"concurrently": "9.2.0",
|
"concurrently": "9.2.0",
|
||||||
"copy-webpack-plugin": "13.0.0",
|
"copy-webpack-plugin": "13.0.0",
|
||||||
"cross-env": "7.0.3",
|
"cross-env": "7.0.3",
|
||||||
@@ -105,28 +106,28 @@
|
|||||||
"electron-log": "5.4.1",
|
"electron-log": "5.4.1",
|
||||||
"electron-reload": "2.0.0-alpha.1",
|
"electron-reload": "2.0.0-alpha.1",
|
||||||
"electron-store": "8.2.0",
|
"electron-store": "8.2.0",
|
||||||
"electron-updater": "6.6.2",
|
"electron-updater": "6.7.3",
|
||||||
"eslint": "9.39.1",
|
"eslint": "9.39.1",
|
||||||
"eslint-config-prettier": "10.1.5",
|
"eslint-config-prettier": "10.1.5",
|
||||||
"eslint-import-resolver-typescript": "4.4.4",
|
"eslint-import-resolver-typescript": "4.4.4",
|
||||||
"eslint-plugin-import": "2.32.0",
|
"eslint-plugin-import": "2.32.0",
|
||||||
"eslint-plugin-rxjs-angular-x": "0.1.0",
|
"eslint-plugin-rxjs-angular-x": "0.1.0",
|
||||||
"eslint-plugin-rxjs-x": "0.8.3",
|
"eslint-plugin-rxjs-x": "0.9.1",
|
||||||
"form-data": "4.0.4",
|
"form-data": "4.0.4",
|
||||||
"glob": "13.0.0",
|
"glob": "13.0.0",
|
||||||
"html-loader": "5.1.0",
|
"html-loader": "5.1.0",
|
||||||
"html-webpack-plugin": "5.6.3",
|
"html-webpack-plugin": "5.6.3",
|
||||||
"husky": "9.1.7",
|
"husky": "9.1.7",
|
||||||
"jest": "29.7.0",
|
"jest": "30.2.0",
|
||||||
"jest-junit": "16.0.0",
|
"jest-junit": "16.0.0",
|
||||||
"jest-mock-extended": "4.0.0",
|
"jest-mock-extended": "4.0.0",
|
||||||
"jest-preset-angular": "14.6.0",
|
"jest-preset-angular": "16.0.0",
|
||||||
"lint-staged": "16.2.6",
|
"lint-staged": "16.2.6",
|
||||||
"mini-css-extract-plugin": "2.9.2",
|
"mini-css-extract-plugin": "2.10.0",
|
||||||
"minimatch": "5.1.2",
|
"minimatch": "5.1.2",
|
||||||
"node-forge": "1.3.2",
|
"node-forge": "1.3.2",
|
||||||
"node-loader": "2.1.0",
|
"node-loader": "2.1.0",
|
||||||
"prettier": "3.7.4",
|
"prettier": "3.8.1",
|
||||||
"rimraf": "6.1.0",
|
"rimraf": "6.1.0",
|
||||||
"rxjs": "7.8.2",
|
"rxjs": "7.8.2",
|
||||||
"sass": "1.97.1",
|
"sass": "1.97.1",
|
||||||
@@ -134,25 +135,25 @@
|
|||||||
"ts-jest": "29.4.1",
|
"ts-jest": "29.4.1",
|
||||||
"ts-loader": "9.5.2",
|
"ts-loader": "9.5.2",
|
||||||
"tsconfig-paths-webpack-plugin": "4.2.0",
|
"tsconfig-paths-webpack-plugin": "4.2.0",
|
||||||
"type-fest": "5.3.0",
|
"type-fest": "5.4.2",
|
||||||
"typescript": "5.8.3",
|
"typescript": "5.9.3",
|
||||||
"webpack": "5.104.1",
|
"webpack": "5.105.1",
|
||||||
"webpack-cli": "6.0.1",
|
"webpack-cli": "6.0.1",
|
||||||
"webpack-merge": "6.0.1",
|
"webpack-merge": "6.0.1",
|
||||||
"webpack-node-externals": "3.0.0",
|
"webpack-node-externals": "3.0.0",
|
||||||
"zone.js": "0.15.1"
|
"zone.js": "0.16.0"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@angular/animations": "20.3.15",
|
"@angular/animations": "21.1.1",
|
||||||
"@angular/cdk": "20.2.14",
|
"@angular/cdk": "21.1.1",
|
||||||
"@angular/cli": "20.3.3",
|
"@angular/cli": "21.1.2",
|
||||||
"@angular/common": "20.3.15",
|
"@angular/common": "21.1.1",
|
||||||
"@angular/compiler": "20.3.15",
|
"@angular/compiler": "21.1.1",
|
||||||
"@angular/core": "20.3.15",
|
"@angular/core": "21.1.1",
|
||||||
"@angular/forms": "20.3.15",
|
"@angular/forms": "21.1.1",
|
||||||
"@angular/platform-browser": "20.3.15",
|
"@angular/platform-browser": "21.1.1",
|
||||||
"@angular/platform-browser-dynamic": "20.3.15",
|
"@angular/platform-browser-dynamic": "21.1.1",
|
||||||
"@angular/router": "20.3.15",
|
"@angular/router": "21.1.1",
|
||||||
"@microsoft/microsoft-graph-client": "3.0.7",
|
"@microsoft/microsoft-graph-client": "3.0.7",
|
||||||
"big-integer": "1.6.52",
|
"big-integer": "1.6.52",
|
||||||
"bootstrap": "5.3.7",
|
"bootstrap": "5.3.7",
|
||||||
@@ -164,16 +165,16 @@
|
|||||||
"https-proxy-agent": "7.0.6",
|
"https-proxy-agent": "7.0.6",
|
||||||
"inquirer": "8.2.6",
|
"inquirer": "8.2.6",
|
||||||
"keytar": "7.9.0",
|
"keytar": "7.9.0",
|
||||||
"ldapts": "8.0.1",
|
"ldapts": "8.1.3",
|
||||||
"lowdb": "1.0.0",
|
"lowdb": "1.0.0",
|
||||||
"ngx-toastr": "19.1.0",
|
"ngx-toastr": "20.0.4",
|
||||||
"node-fetch": "2.7.0",
|
"node-fetch": "2.7.0",
|
||||||
"parse5": "8.0.0",
|
"parse5": "8.0.0",
|
||||||
"proper-lockfile": "4.1.2",
|
"proper-lockfile": "4.1.2",
|
||||||
"rxjs": "7.8.2",
|
"rxjs": "7.8.2",
|
||||||
"tldjs": "2.3.1",
|
"tldjs": "2.3.1",
|
||||||
"uuid": "11.1.0",
|
"uuid": "11.1.0",
|
||||||
"zone.js": "0.15.1"
|
"zone.js": "0.16.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "~20",
|
"node": "~20",
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { enableProdMode } from "@angular/core";
|
import { enableProdMode, provideZoneChangeDetection } from "@angular/core";
|
||||||
import { platformBrowserDynamic } from "@angular/platform-browser-dynamic";
|
import { platformBrowserDynamic } from "@angular/platform-browser-dynamic";
|
||||||
|
|
||||||
import { isDev } from "@/jslib/electron/src/utils";
|
import { isDev } from "@/jslib/electron/src/utils";
|
||||||
@@ -11,4 +11,7 @@ if (!isDev()) {
|
|||||||
enableProdMode();
|
enableProdMode();
|
||||||
}
|
}
|
||||||
|
|
||||||
platformBrowserDynamic().bootstrapModule(AppModule, { preserveWhitespaces: true });
|
platformBrowserDynamic().bootstrapModule(AppModule, {
|
||||||
|
applicationProviders: [provideZoneChangeDetection()],
|
||||||
|
preserveWhitespaces: true,
|
||||||
|
});
|
||||||
|
|||||||
@@ -3,17 +3,25 @@
|
|||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<p>
|
<p>
|
||||||
{{ "lastGroupSync" | i18n }}:
|
{{ "lastGroupSync" | i18n }}:
|
||||||
<span *ngIf="!lastGroupSync">-</span>
|
@if (!lastGroupSync) {
|
||||||
|
<span>-</span>
|
||||||
|
}
|
||||||
{{ lastGroupSync | date: "medium" }}
|
{{ lastGroupSync | date: "medium" }}
|
||||||
<br />
|
<br />
|
||||||
{{ "lastUserSync" | i18n }}:
|
{{ "lastUserSync" | i18n }}:
|
||||||
<span *ngIf="!lastUserSync">-</span>
|
@if (!lastUserSync) {
|
||||||
|
<span>-</span>
|
||||||
|
}
|
||||||
{{ lastUserSync | date: "medium" }}
|
{{ lastUserSync | date: "medium" }}
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
{{ "syncStatus" | i18n }}:
|
{{ "syncStatus" | i18n }}:
|
||||||
<strong *ngIf="syncRunning" class="text-success">{{ "running" | i18n }}</strong>
|
@if (syncRunning) {
|
||||||
<strong *ngIf="!syncRunning" class="text-danger">{{ "stopped" | i18n }}</strong>
|
<strong class="text-success">{{ "running" | i18n }}</strong>
|
||||||
|
}
|
||||||
|
@if (!syncRunning) {
|
||||||
|
<strong class="text-danger">{{ "stopped" | i18n }}</strong>
|
||||||
|
}
|
||||||
</p>
|
</p>
|
||||||
<form #startForm [appApiAction]="startPromise" class="d-inline">
|
<form #startForm [appApiAction]="startPromise" class="d-inline">
|
||||||
<button
|
<button
|
||||||
@@ -60,57 +68,85 @@
|
|||||||
/>
|
/>
|
||||||
<label class="form-check-label" for="simSinceLast">{{ "testLastSync" | i18n }}</label>
|
<label class="form-check-label" for="simSinceLast">{{ "testLastSync" | i18n }}</label>
|
||||||
</div>
|
</div>
|
||||||
<ng-container *ngIf="!simForm.loading && (simUsers || simGroups)">
|
@if (!simForm.loading && (simUsers || simGroups)) {
|
||||||
<hr />
|
<hr />
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-lg">
|
<div class="col-lg">
|
||||||
<h4>{{ "users" | i18n }}</h4>
|
<h4>{{ "users" | i18n }}</h4>
|
||||||
<ul class="bwi-ul testing-list" *ngIf="simEnabledUsers && simEnabledUsers.length">
|
@if (simEnabledUsers && simEnabledUsers.length) {
|
||||||
<li *ngFor="let u of simEnabledUsers" title="{{ u.referenceId }}">
|
<ul class="bwi-ul testing-list">
|
||||||
<i class="bwi bwi-li bwi-user"></i>
|
@for (u of simEnabledUsers; track u) {
|
||||||
{{ u.displayName }}
|
<li title="{{ u.referenceId }}">
|
||||||
</li>
|
<i class="bwi bwi-li bwi-user"></i>
|
||||||
</ul>
|
{{ u.displayName }}
|
||||||
<p *ngIf="!simEnabledUsers || !simEnabledUsers.length">
|
</li>
|
||||||
{{ "noUsers" | i18n }}
|
}
|
||||||
</p>
|
</ul>
|
||||||
|
}
|
||||||
|
@if (!simEnabledUsers || !simEnabledUsers.length) {
|
||||||
|
<p>
|
||||||
|
{{ "noUsers" | i18n }}
|
||||||
|
</p>
|
||||||
|
}
|
||||||
<h4>{{ "disabledUsers" | i18n }}</h4>
|
<h4>{{ "disabledUsers" | i18n }}</h4>
|
||||||
<ul class="bwi-ul testing-list" *ngIf="simDisabledUsers && simDisabledUsers.length">
|
@if (simDisabledUsers && simDisabledUsers.length) {
|
||||||
<li *ngFor="let u of simDisabledUsers" title="{{ u.referenceId }}">
|
<ul class="bwi-ul testing-list">
|
||||||
<i class="bwi bwi-li bwi-user"></i>
|
@for (u of simDisabledUsers; track u) {
|
||||||
{{ u.displayName }}
|
<li title="{{ u.referenceId }}">
|
||||||
</li>
|
<i class="bwi bwi-li bwi-user"></i>
|
||||||
</ul>
|
{{ u.displayName }}
|
||||||
<p *ngIf="!simDisabledUsers || !simDisabledUsers.length">
|
</li>
|
||||||
{{ "noUsers" | i18n }}
|
}
|
||||||
</p>
|
</ul>
|
||||||
|
}
|
||||||
|
@if (!simDisabledUsers || !simDisabledUsers.length) {
|
||||||
|
<p>
|
||||||
|
{{ "noUsers" | i18n }}
|
||||||
|
</p>
|
||||||
|
}
|
||||||
<h4>{{ "deletedUsers" | i18n }}</h4>
|
<h4>{{ "deletedUsers" | i18n }}</h4>
|
||||||
<ul class="bwi-ul testing-list" *ngIf="simDeletedUsers && simDeletedUsers.length">
|
@if (simDeletedUsers && simDeletedUsers.length) {
|
||||||
<li *ngFor="let u of simDeletedUsers" title="{{ u.referenceId }}">
|
<ul class="bwi-ul testing-list">
|
||||||
<i class="bwi bwi-li bwi-user"></i>
|
@for (u of simDeletedUsers; track u) {
|
||||||
{{ u.displayName }}
|
<li title="{{ u.referenceId }}">
|
||||||
</li>
|
<i class="bwi bwi-li bwi-user"></i>
|
||||||
</ul>
|
{{ u.displayName }}
|
||||||
<p *ngIf="!simDeletedUsers || !simDeletedUsers.length">
|
</li>
|
||||||
{{ "noUsers" | i18n }}
|
}
|
||||||
</p>
|
</ul>
|
||||||
|
}
|
||||||
|
@if (!simDeletedUsers || !simDeletedUsers.length) {
|
||||||
|
<p>
|
||||||
|
{{ "noUsers" | i18n }}
|
||||||
|
</p>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
<div class="col-lg">
|
<div class="col-lg">
|
||||||
<h4>{{ "groups" | i18n }}</h4>
|
<h4>{{ "groups" | i18n }}</h4>
|
||||||
<ul class="bwi-ul testing-list" *ngIf="simGroups && simGroups.length">
|
@if (simGroups && simGroups.length) {
|
||||||
<li *ngFor="let g of simGroups" title="{{ g.referenceId }}">
|
<ul class="bwi-ul testing-list">
|
||||||
<i class="bwi bwi-li bwi-sitemap"></i>
|
@for (g of simGroups; track g) {
|
||||||
{{ g.displayName }}
|
<li title="{{ g.referenceId }}">
|
||||||
<ul class="small" *ngIf="g.users && g.users.length">
|
<i class="bwi bwi-li bwi-sitemap"></i>
|
||||||
<li *ngFor="let u of g.users" title="{{ u.referenceId }}">
|
{{ g.displayName }}
|
||||||
{{ u.displayName }}
|
@if (g.users && g.users.length) {
|
||||||
|
<ul class="small">
|
||||||
|
@for (u of g.users; track u) {
|
||||||
|
<li title="{{ u.referenceId }}">
|
||||||
|
{{ u.displayName }}
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
}
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
}
|
||||||
</li>
|
</ul>
|
||||||
</ul>
|
}
|
||||||
<p *ngIf="!simGroups || !simGroups.length">{{ "noGroups" | i18n }}</p>
|
@if (!simGroups || !simGroups.length) {
|
||||||
|
<p>{{ "noGroups" | i18n }}</p>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</ng-container>
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -6,9 +6,11 @@
|
|||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label for="directory" class="form-label">{{ "type" | i18n }}</label>
|
<label for="directory" class="form-label">{{ "type" | i18n }}</label>
|
||||||
<select class="form-select" id="directory" name="Directory" [(ngModel)]="directory">
|
<select class="form-select" id="directory" name="Directory" [(ngModel)]="directory">
|
||||||
<option *ngFor="let o of directoryOptions" [ngValue]="o.value">
|
@for (o of directoryOptions; track o) {
|
||||||
{{ o.name }}
|
<option [ngValue]="o.value">
|
||||||
</option>
|
{{ o.name }}
|
||||||
|
</option>
|
||||||
|
}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div [hidden]="directory != directoryType.Ldap">
|
<div [hidden]="directory != directoryType.Ldap">
|
||||||
@@ -51,20 +53,22 @@
|
|||||||
<label class="form-check-label" for="ad">{{ "ldapAd" | i18n }}</label>
|
<label class="form-check-label" for="ad">{{ "ldapAd" | i18n }}</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-3" *ngIf="!ldap.ad">
|
@if (!ldap.ad) {
|
||||||
<div class="form-check">
|
<div class="mb-3">
|
||||||
<input
|
<div class="form-check">
|
||||||
class="form-check-input"
|
<input
|
||||||
type="checkbox"
|
class="form-check-input"
|
||||||
id="pagedSearch"
|
type="checkbox"
|
||||||
[(ngModel)]="ldap.pagedSearch"
|
id="pagedSearch"
|
||||||
name="PagedSearch"
|
[(ngModel)]="ldap.pagedSearch"
|
||||||
/>
|
name="PagedSearch"
|
||||||
<label class="form-check-label" for="pagedSearch">{{
|
/>
|
||||||
"ldapPagedResults" | i18n
|
<label class="form-check-label" for="pagedSearch">{{
|
||||||
}}</label>
|
"ldapPagedResults" | i18n
|
||||||
|
}}</label>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
}
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<div class="form-check">
|
<div class="form-check">
|
||||||
<input
|
<input
|
||||||
@@ -79,116 +83,122 @@
|
|||||||
}}</label>
|
}}</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="ms-4" *ngIf="ldap.ssl">
|
@if (ldap.ssl) {
|
||||||
<div class="mb-3">
|
<div class="ms-4">
|
||||||
<div class="form-check">
|
<div class="mb-3">
|
||||||
<input
|
<div class="form-check">
|
||||||
class="form-check-input"
|
<input
|
||||||
type="radio"
|
class="form-check-input"
|
||||||
[value]="false"
|
type="radio"
|
||||||
id="ssl"
|
[value]="false"
|
||||||
[(ngModel)]="ldap.startTls"
|
id="ssl"
|
||||||
name="SSL"
|
[(ngModel)]="ldap.startTls"
|
||||||
/>
|
name="SSL"
|
||||||
<label class="form-check-label" for="ssl">{{ "ldapSsl" | i18n }}</label>
|
/>
|
||||||
|
<label class="form-check-label" for="ssl">{{ "ldapSsl" | i18n }}</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-check">
|
||||||
|
<input
|
||||||
|
class="form-check-input"
|
||||||
|
type="radio"
|
||||||
|
[value]="true"
|
||||||
|
id="startTls"
|
||||||
|
[(ngModel)]="ldap.startTls"
|
||||||
|
name="StartTLS"
|
||||||
|
/>
|
||||||
|
<label class="form-check-label" for="startTls">{{ "ldapTls" | i18n }}</label>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-check">
|
@if (ldap.startTls) {
|
||||||
<input
|
<div class="ms-4">
|
||||||
class="form-check-input"
|
<p>{{ "ldapTlsUntrustedDesc" | i18n }}</p>
|
||||||
type="radio"
|
<div class="mb-3">
|
||||||
[value]="true"
|
<label for="tlsCaPath" class="form-label">{{ "ldapTlsCa" | i18n }}</label>
|
||||||
id="startTls"
|
<input
|
||||||
[(ngModel)]="ldap.startTls"
|
type="file"
|
||||||
name="StartTLS"
|
class="form-control mb-2"
|
||||||
/>
|
id="tlsCaPath_file"
|
||||||
<label class="form-check-label" for="startTls">{{ "ldapTls" | i18n }}</label>
|
(change)="setSslPath('tlsCaPath')"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="form-control"
|
||||||
|
id="tlsCaPath"
|
||||||
|
name="TLSCaPath"
|
||||||
|
[(ngModel)]="ldap.tlsCaPath"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
@if (!ldap.startTls) {
|
||||||
|
<div class="ms-4">
|
||||||
|
<p>{{ "ldapSslUntrustedDesc" | i18n }}</p>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="sslCertPath" class="form-label">{{ "ldapSslCert" | i18n }}</label>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
class="form-control mb-2"
|
||||||
|
id="sslCertPath_file"
|
||||||
|
(change)="setSslPath('sslCertPath')"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="form-control"
|
||||||
|
id="sslCertPath"
|
||||||
|
name="SSLCertPath"
|
||||||
|
[(ngModel)]="ldap.sslCertPath"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="sslKeyPath" class="form-label">{{ "ldapSslKey" | i18n }}</label>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
class="form-control mb-2"
|
||||||
|
id="sslKeyPath_file"
|
||||||
|
(change)="setSslPath('sslKeyPath')"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="form-control"
|
||||||
|
id="sslKeyPath"
|
||||||
|
name="SSLKeyPath"
|
||||||
|
[(ngModel)]="ldap.sslKeyPath"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="sslCaPath" class="form-label">{{ "ldapSslCa" | i18n }}</label>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
class="form-control mb-2"
|
||||||
|
id="sslCaPath_file"
|
||||||
|
(change)="setSslPath('sslCaPath')"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="form-control"
|
||||||
|
id="sslCaPath"
|
||||||
|
name="SSLCaPath"
|
||||||
|
[(ngModel)]="ldap.sslCaPath"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
<div class="mb-3">
|
||||||
|
<div class="form-check">
|
||||||
|
<input
|
||||||
|
class="form-check-input"
|
||||||
|
type="checkbox"
|
||||||
|
id="certDoNotVerify"
|
||||||
|
[(ngModel)]="ldap.sslAllowUnauthorized"
|
||||||
|
name="CertDoNoVerify"
|
||||||
|
/>
|
||||||
|
<label class="form-check-label" for="certDoNotVerify">{{
|
||||||
|
"ldapCertDoNotVerify" | i18n
|
||||||
|
}}</label>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="ms-4" *ngIf="ldap.startTls">
|
}
|
||||||
<p>{{ "ldapTlsUntrustedDesc" | i18n }}</p>
|
|
||||||
<div class="mb-3">
|
|
||||||
<label for="tlsCaPath" class="form-label">{{ "ldapTlsCa" | i18n }}</label>
|
|
||||||
<input
|
|
||||||
type="file"
|
|
||||||
class="form-control mb-2"
|
|
||||||
id="tlsCaPath_file"
|
|
||||||
(change)="setSslPath('tlsCaPath')"
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
class="form-control"
|
|
||||||
id="tlsCaPath"
|
|
||||||
name="TLSCaPath"
|
|
||||||
[(ngModel)]="ldap.tlsCaPath"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="ms-4" *ngIf="!ldap.startTls">
|
|
||||||
<p>{{ "ldapSslUntrustedDesc" | i18n }}</p>
|
|
||||||
<div class="mb-3">
|
|
||||||
<label for="sslCertPath" class="form-label">{{ "ldapSslCert" | i18n }}</label>
|
|
||||||
<input
|
|
||||||
type="file"
|
|
||||||
class="form-control mb-2"
|
|
||||||
id="sslCertPath_file"
|
|
||||||
(change)="setSslPath('sslCertPath')"
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
class="form-control"
|
|
||||||
id="sslCertPath"
|
|
||||||
name="SSLCertPath"
|
|
||||||
[(ngModel)]="ldap.sslCertPath"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="mb-3">
|
|
||||||
<label for="sslKeyPath" class="form-label">{{ "ldapSslKey" | i18n }}</label>
|
|
||||||
<input
|
|
||||||
type="file"
|
|
||||||
class="form-control mb-2"
|
|
||||||
id="sslKeyPath_file"
|
|
||||||
(change)="setSslPath('sslKeyPath')"
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
class="form-control"
|
|
||||||
id="sslKeyPath"
|
|
||||||
name="SSLKeyPath"
|
|
||||||
[(ngModel)]="ldap.sslKeyPath"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="mb-3">
|
|
||||||
<label for="sslCaPath" class="form-label">{{ "ldapSslCa" | i18n }}</label>
|
|
||||||
<input
|
|
||||||
type="file"
|
|
||||||
class="form-control mb-2"
|
|
||||||
id="sslCaPath_file"
|
|
||||||
(change)="setSslPath('sslCaPath')"
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
class="form-control"
|
|
||||||
id="sslCaPath"
|
|
||||||
name="SSLCaPath"
|
|
||||||
[(ngModel)]="ldap.sslCaPath"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="mb-3">
|
|
||||||
<div class="form-check">
|
|
||||||
<input
|
|
||||||
class="form-check-input"
|
|
||||||
type="checkbox"
|
|
||||||
id="certDoNotVerify"
|
|
||||||
[(ngModel)]="ldap.sslAllowUnauthorized"
|
|
||||||
name="CertDoNoVerify"
|
|
||||||
/>
|
|
||||||
<label class="form-check-label" for="certDoNotVerify">{{
|
|
||||||
"ldapCertDoNotVerify" | i18n
|
|
||||||
}}</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="mb-3" [hidden]="true">
|
<div class="mb-3" [hidden]="true">
|
||||||
<div class="form-check">
|
<div class="form-check">
|
||||||
<input
|
<input
|
||||||
@@ -211,10 +221,12 @@
|
|||||||
name="Username"
|
name="Username"
|
||||||
[(ngModel)]="ldap.username"
|
[(ngModel)]="ldap.username"
|
||||||
/>
|
/>
|
||||||
<div class="form-text" *ngIf="ldap.ad">{{ "ex" | i18n }} company\admin</div>
|
@if (ldap.ad) {
|
||||||
<div class="form-text" *ngIf="!ldap.ad">
|
<div class="form-text">{{ "ex" | i18n }} company\admin</div>
|
||||||
{{ "ex" | i18n }} cn=admin,dc=company,dc=com
|
}
|
||||||
</div>
|
@if (!ldap.ad) {
|
||||||
|
<div class="form-text">{{ "ex" | i18n }} cn=admin,dc=company,dc=com</div>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label for="password" class="form-label">{{ "password" | i18n }}</label>
|
<label for="password" class="form-label">{{ "password" | i18n }}</label>
|
||||||
@@ -604,18 +616,24 @@
|
|||||||
name="UserFilter"
|
name="UserFilter"
|
||||||
[(ngModel)]="sync.userFilter"
|
[(ngModel)]="sync.userFilter"
|
||||||
></textarea>
|
></textarea>
|
||||||
<div class="form-text" *ngIf="directory === directoryType.Ldap">
|
@if (directory === directoryType.Ldap) {
|
||||||
{{ "ex" | i18n }} (&(givenName=John)(|(l=Dallas)(l=Austin)))
|
<div class="form-text">
|
||||||
</div>
|
{{ "ex" | i18n }} (&(givenName=John)(|(l=Dallas)(l=Austin)))
|
||||||
<div class="form-text" *ngIf="directory === directoryType.EntraID">
|
</div>
|
||||||
{{ "ex" | i18n }} exclude:joe@company.com
|
}
|
||||||
</div>
|
@if (directory === directoryType.EntraID) {
|
||||||
<div class="form-text" *ngIf="directory === directoryType.Okta">
|
<div class="form-text">{{ "ex" | i18n }} exclude:joe@company.com</div>
|
||||||
{{ "ex" | i18n }} exclude:joe@company.com | profile.firstName eq "John"
|
}
|
||||||
</div>
|
@if (directory === directoryType.Okta) {
|
||||||
<div class="form-text" *ngIf="directory === directoryType.GSuite">
|
<div class="form-text">
|
||||||
{{ "ex" | i18n }} exclude:joe@company.com | orgUnitPath=/Engineering
|
{{ "ex" | i18n }} exclude:joe@company.com | profile.firstName eq "John"
|
||||||
</div>
|
</div>
|
||||||
|
}
|
||||||
|
@if (directory === directoryType.GSuite) {
|
||||||
|
<div class="form-text">
|
||||||
|
{{ "ex" | i18n }} exclude:joe@company.com | orgUnitPath=/Engineering
|
||||||
|
</div>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-3" [hidden]="directory != directoryType.Ldap">
|
<div class="mb-3" [hidden]="directory != directoryType.Ldap">
|
||||||
<label for="userPath" class="form-label">{{ "userPath" | i18n }}</label>
|
<label for="userPath" class="form-label">{{ "userPath" | i18n }}</label>
|
||||||
@@ -681,18 +699,20 @@
|
|||||||
name="GroupFilter"
|
name="GroupFilter"
|
||||||
[(ngModel)]="sync.groupFilter"
|
[(ngModel)]="sync.groupFilter"
|
||||||
></textarea>
|
></textarea>
|
||||||
<div class="form-text" *ngIf="directory === directoryType.Ldap">
|
@if (directory === directoryType.Ldap) {
|
||||||
{{ "ex" | i18n }} (&(objectClass=group)(!(cn=Sales*))(!(cn=IT*)))
|
<div class="form-text">
|
||||||
</div>
|
{{ "ex" | i18n }} (&(objectClass=group)(!(cn=Sales*))(!(cn=IT*)))
|
||||||
<div class="form-text" *ngIf="directory === directoryType.EntraID">
|
</div>
|
||||||
{{ "ex" | i18n }} include:Sales,IT
|
}
|
||||||
</div>
|
@if (directory === directoryType.EntraID) {
|
||||||
<div class="form-text" *ngIf="directory === directoryType.Okta">
|
<div class="form-text">{{ "ex" | i18n }} include:Sales,IT</div>
|
||||||
{{ "ex" | i18n }} include:Sales,IT | type eq "APP_GROUP"
|
}
|
||||||
</div>
|
@if (directory === directoryType.Okta) {
|
||||||
<div class="form-text" *ngIf="directory === directoryType.GSuite">
|
<div class="form-text">{{ "ex" | i18n }} include:Sales,IT | type eq "APP_GROUP"</div>
|
||||||
{{ "ex" | i18n }} include:Sales,IT
|
}
|
||||||
</div>
|
@if (directory === directoryType.GSuite) {
|
||||||
|
<div class="form-text">{{ "ex" | i18n }} include:Sales,IT</div>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-3" [hidden]="directory != directoryType.Ldap">
|
<div class="mb-3" [hidden]="directory != directoryType.Ldap">
|
||||||
<label for="groupPath" class="form-label">{{ "groupPath" | i18n }}</label>
|
<label for="groupPath" class="form-label">{{ "groupPath" | i18n }}</label>
|
||||||
@@ -703,8 +723,12 @@
|
|||||||
name="GroupPath"
|
name="GroupPath"
|
||||||
[(ngModel)]="sync.groupPath"
|
[(ngModel)]="sync.groupPath"
|
||||||
/>
|
/>
|
||||||
<div class="form-text" *ngIf="!ldap.ad">{{ "ex" | i18n }} CN=Groups</div>
|
@if (!ldap.ad) {
|
||||||
<div class="form-text" *ngIf="ldap.ad">{{ "ex" | i18n }} CN=Users</div>
|
<div class="form-text">{{ "ex" | i18n }} CN=Groups</div>
|
||||||
|
}
|
||||||
|
@if (ldap.ad) {
|
||||||
|
<div class="form-text">{{ "ex" | i18n }} CN=Users</div>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
<div [hidden]="directory != directoryType.Ldap || ldap.ad">
|
<div [hidden]="directory != directoryType.Ldap || ldap.ad">
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
|
|||||||
2
src/scss/bootstrap.scss
vendored
2
src/scss/bootstrap.scss
vendored
@@ -28,4 +28,4 @@ $danger: map_get($theme-colors, "danger");
|
|||||||
$secondary: map_get($theme-colors, "secondary");
|
$secondary: map_get($theme-colors, "secondary");
|
||||||
$secondary-alt: map_get($theme-colors, "secondary-alt");
|
$secondary-alt: map_get($theme-colors, "secondary-alt");
|
||||||
|
|
||||||
@import "~bootstrap/scss/bootstrap.scss";
|
@import "bootstrap/scss/bootstrap.scss";
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
@import "~bootstrap/scss/_variables.scss";
|
@import "bootstrap/scss/_variables.scss";
|
||||||
|
|
||||||
html.os_windows {
|
html.os_windows {
|
||||||
body {
|
body {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
@import "~bootstrap/scss/_variables.scss";
|
@import "bootstrap/scss/_variables.scss";
|
||||||
|
|
||||||
body {
|
body {
|
||||||
padding: 10px 0 20px 0;
|
padding: 10px 0 20px 0;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
@import "~ngx-toastr/toastr";
|
@import "ngx-toastr/toastr";
|
||||||
|
|
||||||
@import "~bootstrap/scss/_variables.scss";
|
@import "bootstrap/scss/_variables.scss";
|
||||||
|
|
||||||
.toast-container {
|
.toast-container {
|
||||||
.toast-close-button {
|
.toast-close-button {
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { Arg, Substitute, SubstituteOf } from "@fluffy-spoon/substitute";
|
import { mock } from "jest-mock-extended";
|
||||||
|
|
||||||
import { ApiService } from "@/jslib/common/src/abstractions/api.service";
|
import { ApiService } from "@/jslib/common/src/abstractions/api.service";
|
||||||
import { AppIdService } from "@/jslib/common/src/abstractions/appId.service";
|
import { AppIdService } from "@/jslib/common/src/abstractions/appId.service";
|
||||||
|
import { MessagingService } from "@/jslib/common/src/abstractions/messaging.service";
|
||||||
import { PlatformUtilsService } from "@/jslib/common/src/abstractions/platformUtils.service";
|
import { PlatformUtilsService } from "@/jslib/common/src/abstractions/platformUtils.service";
|
||||||
import { Utils } from "@/jslib/common/src/misc/utils";
|
import { Utils } from "@/jslib/common/src/misc/utils";
|
||||||
import {
|
import {
|
||||||
@@ -11,7 +12,6 @@ import {
|
|||||||
} from "@/jslib/common/src/models/domain/account";
|
} from "@/jslib/common/src/models/domain/account";
|
||||||
import { IdentityTokenResponse } from "@/jslib/common/src/models/response/identityTokenResponse";
|
import { IdentityTokenResponse } from "@/jslib/common/src/models/response/identityTokenResponse";
|
||||||
|
|
||||||
import { MessagingService } from "../../jslib/common/src/abstractions/messaging.service";
|
|
||||||
import { Account, DirectoryConfigurations, DirectorySettings } from "../models/account";
|
import { Account, DirectoryConfigurations, DirectorySettings } from "../models/account";
|
||||||
|
|
||||||
import { AuthService } from "./auth.service";
|
import { AuthService } from "./auth.service";
|
||||||
@@ -35,22 +35,22 @@ export function identityTokenResponseFactory() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("AuthService", () => {
|
describe("AuthService", () => {
|
||||||
let apiService: SubstituteOf<ApiService>;
|
let apiService: jest.Mocked<ApiService>;
|
||||||
let appIdService: SubstituteOf<AppIdService>;
|
let appIdService: jest.Mocked<AppIdService>;
|
||||||
let platformUtilsService: SubstituteOf<PlatformUtilsService>;
|
let platformUtilsService: jest.Mocked<PlatformUtilsService>;
|
||||||
let messagingService: SubstituteOf<MessagingService>;
|
let messagingService: jest.Mocked<MessagingService>;
|
||||||
let stateService: SubstituteOf<StateService>;
|
let stateService: jest.Mocked<StateService>;
|
||||||
|
|
||||||
let authService: AuthService;
|
let authService: AuthService;
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
apiService = Substitute.for();
|
apiService = mock<ApiService>();
|
||||||
appIdService = Substitute.for();
|
appIdService = mock<AppIdService>();
|
||||||
platformUtilsService = Substitute.for();
|
platformUtilsService = mock<PlatformUtilsService>();
|
||||||
stateService = Substitute.for();
|
stateService = mock<StateService>();
|
||||||
messagingService = Substitute.for();
|
messagingService = mock<MessagingService>();
|
||||||
|
|
||||||
appIdService.getAppId().resolves(deviceId);
|
appIdService.getAppId.mockResolvedValue(deviceId);
|
||||||
|
|
||||||
authService = new AuthService(
|
authService = new AuthService(
|
||||||
apiService,
|
apiService,
|
||||||
@@ -62,11 +62,12 @@ describe("AuthService", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("sets the local environment after a successful login", async () => {
|
it("sets the local environment after a successful login", async () => {
|
||||||
apiService.postIdentityToken(Arg.any()).resolves(identityTokenResponseFactory());
|
apiService.postIdentityToken.mockResolvedValue(identityTokenResponseFactory());
|
||||||
|
|
||||||
await authService.logIn({ clientId, clientSecret });
|
await authService.logIn({ clientId, clientSecret });
|
||||||
|
|
||||||
stateService.received(1).addAccount(
|
expect(stateService.addAccount).toHaveBeenCalledTimes(1);
|
||||||
|
expect(stateService.addAccount).toHaveBeenCalledWith(
|
||||||
new Account({
|
new Account({
|
||||||
profile: {
|
profile: {
|
||||||
...new AccountProfile(),
|
...new AccountProfile(),
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import { MessagingService } from "@/jslib/common/src/abstractions/messaging.serv
|
|||||||
import { OrganizationImportRequest } from "@/jslib/common/src/models/request/organizationImportRequest";
|
import { OrganizationImportRequest } from "@/jslib/common/src/models/request/organizationImportRequest";
|
||||||
import { ApiService } from "@/jslib/common/src/services/api.service";
|
import { ApiService } from "@/jslib/common/src/services/api.service";
|
||||||
|
|
||||||
|
import { GroupEntry } from "@/src/models/groupEntry";
|
||||||
|
|
||||||
import { getSyncConfiguration } from "../../utils/openldap/config-fixtures";
|
import { getSyncConfiguration } from "../../utils/openldap/config-fixtures";
|
||||||
import { DirectoryFactoryService } from "../abstractions/directory-factory.service";
|
import { DirectoryFactoryService } from "../abstractions/directory-factory.service";
|
||||||
import { DirectoryType } from "../enums/directoryType";
|
import { DirectoryType } from "../enums/directoryType";
|
||||||
@@ -134,4 +136,134 @@ describe("SyncService", () => {
|
|||||||
|
|
||||||
expect(apiService.postPublicImportDirectory).not.toHaveBeenCalled();
|
expect(apiService.postPublicImportDirectory).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("nested and circular group handling", () => {
|
||||||
|
function createGroup(
|
||||||
|
name: string,
|
||||||
|
userExternalIds: string[] = [],
|
||||||
|
groupMemberReferenceIds: string[] = [],
|
||||||
|
) {
|
||||||
|
return GroupEntry.fromJSON({
|
||||||
|
name,
|
||||||
|
referenceId: name,
|
||||||
|
externalId: name,
|
||||||
|
userMemberExternalIds: userExternalIds,
|
||||||
|
groupMemberReferenceIds: groupMemberReferenceIds,
|
||||||
|
users: [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupSyncWithGroups(groups: GroupEntry[]) {
|
||||||
|
const mockDirectoryService = mock<LdapDirectoryService>();
|
||||||
|
mockDirectoryService.getEntries.mockResolvedValue([groups, []]);
|
||||||
|
directoryFactory.createService.mockReturnValue(mockDirectoryService);
|
||||||
|
|
||||||
|
stateService.getSync.mockResolvedValue(getSyncConfiguration({ groups: true, users: true }));
|
||||||
|
cryptoFunctionService.hash.mockResolvedValue(new ArrayBuffer(1));
|
||||||
|
stateService.getLastSyncHash.mockResolvedValue("unique hash");
|
||||||
|
singleRequestBuilder.buildRequest.mockReturnValue([
|
||||||
|
{ members: [], groups: [], overwriteExisting: true, largeImport: false },
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
it("should handle simple circular reference (A ↔ B) without stack overflow", async () => {
|
||||||
|
const groupA = createGroup("GroupA", ["userA"], ["GroupB"]);
|
||||||
|
const groupB = createGroup("GroupB", ["userB"], ["GroupA"]);
|
||||||
|
setupSyncWithGroups([groupA, groupB]);
|
||||||
|
|
||||||
|
const [groups] = await syncService.sync(true, true);
|
||||||
|
|
||||||
|
const [a, b] = groups;
|
||||||
|
expect(a.userMemberExternalIds).toEqual(new Set(["userA", "userB"]));
|
||||||
|
expect(b.userMemberExternalIds).toEqual(new Set(["userA", "userB"]));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle longer circular chain (A → B → C → A) without stack overflow", async () => {
|
||||||
|
const groupA = createGroup("GroupA", ["userA"], ["GroupB"]);
|
||||||
|
const groupB = createGroup("GroupB", ["userB"], ["GroupC"]);
|
||||||
|
const groupC = createGroup("GroupC", ["userC"], ["GroupA"]);
|
||||||
|
setupSyncWithGroups([groupA, groupB, groupC]);
|
||||||
|
|
||||||
|
const [groups] = await syncService.sync(true, true);
|
||||||
|
|
||||||
|
const allUsers = new Set(["userA", "userB", "userC"]);
|
||||||
|
for (const group of groups) {
|
||||||
|
expect(group.userMemberExternalIds).toEqual(allUsers);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle diamond structure (A → [B, C] → D)", async () => {
|
||||||
|
const groupA = createGroup("GroupA", ["userA"], ["GroupB", "GroupC"]);
|
||||||
|
const groupB = createGroup("GroupB", ["userB"], ["GroupD"]);
|
||||||
|
const groupC = createGroup("GroupC", ["userC"], ["GroupD"]);
|
||||||
|
const groupD = createGroup("GroupD", ["userD"], []);
|
||||||
|
setupSyncWithGroups([groupA, groupB, groupC, groupD]);
|
||||||
|
|
||||||
|
const [groups] = await syncService.sync(true, true);
|
||||||
|
|
||||||
|
const [a, b, c, d] = groups;
|
||||||
|
expect(a.userMemberExternalIds).toEqual(new Set(["userA", "userB", "userC", "userD"]));
|
||||||
|
expect(b.userMemberExternalIds).toEqual(new Set(["userB", "userD"]));
|
||||||
|
expect(c.userMemberExternalIds).toEqual(new Set(["userC", "userD"]));
|
||||||
|
expect(d.userMemberExternalIds).toEqual(new Set(["userD"]));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle deep nesting with circular reference at leaf", async () => {
|
||||||
|
// Structure: A → B → C → D → B (cycle back to B)
|
||||||
|
const groupA = createGroup("GroupA", ["userA"], ["GroupB"]);
|
||||||
|
const groupB = createGroup("GroupB", ["userB"], ["GroupC"]);
|
||||||
|
const groupC = createGroup("GroupC", ["userC"], ["GroupD"]);
|
||||||
|
const groupD = createGroup("GroupD", ["userD"], ["GroupB"]);
|
||||||
|
setupSyncWithGroups([groupA, groupB, groupC, groupD]);
|
||||||
|
|
||||||
|
const [groups] = await syncService.sync(true, true);
|
||||||
|
|
||||||
|
const [a, b, c, d] = groups;
|
||||||
|
const cycleUsers = new Set(["userB", "userC", "userD"]);
|
||||||
|
expect(a.userMemberExternalIds).toEqual(new Set(["userA", ...cycleUsers]));
|
||||||
|
expect(b.userMemberExternalIds).toEqual(cycleUsers);
|
||||||
|
expect(c.userMemberExternalIds).toEqual(cycleUsers);
|
||||||
|
expect(d.userMemberExternalIds).toEqual(cycleUsers);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle complex structure with multiple cycles and shared members", async () => {
|
||||||
|
// Structure:
|
||||||
|
// A → [B, C]
|
||||||
|
// B → [D, E]
|
||||||
|
// C → [E, F]
|
||||||
|
// D → A (cycle)
|
||||||
|
// E → C (cycle)
|
||||||
|
// F → (leaf)
|
||||||
|
const groupA = createGroup("GroupA", ["userA"], ["GroupB", "GroupC"]);
|
||||||
|
const groupB = createGroup("GroupB", ["userB"], ["GroupD", "GroupE"]);
|
||||||
|
const groupC = createGroup("GroupC", ["userC"], ["GroupE", "GroupF"]);
|
||||||
|
const groupD = createGroup("GroupD", ["userD"], ["GroupA"]);
|
||||||
|
const groupE = createGroup("GroupE", ["userE"], ["GroupC"]);
|
||||||
|
const groupF = createGroup("GroupF", ["userF"], []);
|
||||||
|
setupSyncWithGroups([groupA, groupB, groupC, groupD, groupE, groupF]);
|
||||||
|
|
||||||
|
const [groups] = await syncService.sync(true, true);
|
||||||
|
|
||||||
|
const allUsers = new Set(["userA", "userB", "userC", "userD", "userE", "userF"]);
|
||||||
|
const a = groups.find((g) => g.name === "GroupA");
|
||||||
|
const b = groups.find((g) => g.name === "GroupB");
|
||||||
|
const c = groups.find((g) => g.name === "GroupC");
|
||||||
|
const d = groups.find((g) => g.name === "GroupD");
|
||||||
|
const e = groups.find((g) => g.name === "GroupE");
|
||||||
|
const f = groups.find((g) => g.name === "GroupF");
|
||||||
|
|
||||||
|
// A can reach all groups, so it gets all users
|
||||||
|
expect(a.userMemberExternalIds).toEqual(allUsers);
|
||||||
|
// B reaches D, E, and through cycles reaches everything
|
||||||
|
expect(b.userMemberExternalIds).toEqual(allUsers);
|
||||||
|
// C reaches E (which cycles back to C) and F
|
||||||
|
expect(c.userMemberExternalIds).toEqual(new Set(["userC", "userE", "userF"]));
|
||||||
|
// D cycles to A, which reaches everything
|
||||||
|
expect(d.userMemberExternalIds).toEqual(allUsers);
|
||||||
|
// E cycles to C, picking up C's descendants
|
||||||
|
expect(e.userMemberExternalIds).toEqual(new Set(["userC", "userE", "userF"]));
|
||||||
|
// F is a leaf
|
||||||
|
expect(f.userMemberExternalIds).toEqual(new Set(["userF"]));
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -196,14 +196,27 @@ export class SyncService {
|
|||||||
return users == null ? null : users.filter((u) => u.email?.length <= 256);
|
return users == null ? null : users.filter((u) => u.email?.length <= 256);
|
||||||
}
|
}
|
||||||
|
|
||||||
private flattenUsersToGroups(levelGroups: GroupEntry[], allGroups: GroupEntry[]): Set<string> {
|
private flattenUsersToGroups(
|
||||||
|
levelGroups: GroupEntry[],
|
||||||
|
allGroups: GroupEntry[],
|
||||||
|
visitedGroups?: Set<string>,
|
||||||
|
): Set<string> {
|
||||||
let allUsers = new Set<string>();
|
let allUsers = new Set<string>();
|
||||||
if (allGroups == null) {
|
if (allGroups == null) {
|
||||||
return allUsers;
|
return allUsers;
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const group of levelGroups) {
|
for (const group of levelGroups) {
|
||||||
|
const visited = visitedGroups ?? new Set<string>();
|
||||||
|
|
||||||
|
if (visited.has(group.referenceId)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
visited.add(group.referenceId);
|
||||||
|
|
||||||
const childGroups = allGroups.filter((g) => group.groupMemberReferenceIds.has(g.referenceId));
|
const childGroups = allGroups.filter((g) => group.groupMemberReferenceIds.has(g.referenceId));
|
||||||
const childUsers = this.flattenUsersToGroups(childGroups, allGroups);
|
const childUsers = this.flattenUsersToGroups(childGroups, allGroups, visited);
|
||||||
childUsers.forEach((id) => group.userMemberExternalIds.add(id));
|
childUsers.forEach((id) => group.userMemberExternalIds.add(id));
|
||||||
allUsers = new Set([...allUsers, ...group.userMemberExternalIds]);
|
allUsers = new Set([...allUsers, ...group.userMemberExternalIds]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { webcrypto } from "crypto";
|
import { webcrypto } from "crypto";
|
||||||
|
import { TextEncoder, TextDecoder } from "util";
|
||||||
|
|
||||||
import "jest-preset-angular/setup-jest";
|
Object.assign(globalThis, { TextEncoder, TextDecoder });
|
||||||
|
|
||||||
Object.defineProperty(window, "CSS", { value: null });
|
Object.defineProperty(window, "CSS", { value: null });
|
||||||
Object.defineProperty(window, "getComputedStyle", {
|
Object.defineProperty(window, "getComputedStyle", {
|
||||||
value: () => {
|
value: () => {
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
},
|
},
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"pretty": true,
|
"pretty": true,
|
||||||
"moduleResolution": "node",
|
"moduleResolution": "bundler",
|
||||||
"noImplicitAny": true,
|
"noImplicitAny": true,
|
||||||
"target": "ES2016",
|
"target": "ES2016",
|
||||||
"module": "ES2020",
|
"module": "ES2020",
|
||||||
|
|||||||
308
utils/openldap/example-ldifs/directory-circular-groups.ldif
Normal file
308
utils/openldap/example-ldifs/directory-circular-groups.ldif
Normal file
@@ -0,0 +1,308 @@
|
|||||||
|
version: 1
|
||||||
|
|
||||||
|
dn: dc=bitwarden,dc=com
|
||||||
|
dc: bitwarden
|
||||||
|
objectClass: dcObject
|
||||||
|
objectClass: organization
|
||||||
|
o: Bitwarden
|
||||||
|
|
||||||
|
# Organizational Units
|
||||||
|
dn: ou=Human Resources,dc=bitwarden,dc=com
|
||||||
|
changetype: add
|
||||||
|
ou: Human Resources
|
||||||
|
objectClass: top
|
||||||
|
objectClass: organizationalUnit
|
||||||
|
|
||||||
|
dn: ou=Engineering,dc=bitwarden,dc=com
|
||||||
|
changetype: add
|
||||||
|
ou: Engineering
|
||||||
|
objectClass: top
|
||||||
|
objectClass: organizationalUnit
|
||||||
|
|
||||||
|
dn: ou=Marketing,dc=bitwarden,dc=com
|
||||||
|
changetype: add
|
||||||
|
ou: Marketing
|
||||||
|
objectClass: top
|
||||||
|
objectClass: organizationalUnit
|
||||||
|
|
||||||
|
# Users - Human Resources
|
||||||
|
dn: cn=Roland Dyke,ou=Human Resources,dc=bitwarden,dc=com
|
||||||
|
changetype: add
|
||||||
|
objectClass: top
|
||||||
|
objectClass: person
|
||||||
|
objectClass: organizationalPerson
|
||||||
|
objectClass: inetOrgPerson
|
||||||
|
cn: Roland Dyke
|
||||||
|
sn: Dyke
|
||||||
|
description: This is Roland Dyke's description
|
||||||
|
facsimileTelephoneNumber: +1 804 674-5794
|
||||||
|
l: San Francisco
|
||||||
|
ou: Human Resources
|
||||||
|
postalAddress: Human Resources$San Francisco
|
||||||
|
telephoneNumber: +1 804 831-5121
|
||||||
|
title: Supreme Human Resources Writer
|
||||||
|
userPassword: Password1
|
||||||
|
uid: DykeR
|
||||||
|
givenName: Roland
|
||||||
|
mail: DykeR@220af87272f04218bb8dd81d50fb19f5.bitwarden.com
|
||||||
|
carLicense: 4CMGOJ
|
||||||
|
departmentNumber: 2838
|
||||||
|
employeeType: Contract
|
||||||
|
homePhone: +1 804 936-4965
|
||||||
|
initials: R. D.
|
||||||
|
mobile: +1 804 592-3734
|
||||||
|
pager: +1 804 285-2962
|
||||||
|
roomNumber: 9890
|
||||||
|
|
||||||
|
dn: cn=Teirtza Kara,ou=Human Resources,dc=bitwarden,dc=com
|
||||||
|
changetype: add
|
||||||
|
objectClass: top
|
||||||
|
objectClass: person
|
||||||
|
objectClass: organizationalPerson
|
||||||
|
objectClass: inetOrgPerson
|
||||||
|
cn: Teirtza Kara
|
||||||
|
sn: Kara
|
||||||
|
description: This is Teirtza Kara's description
|
||||||
|
facsimileTelephoneNumber: +1 206 759-2040
|
||||||
|
l: San Francisco
|
||||||
|
ou: Human Resources
|
||||||
|
postalAddress: Human Resources$San Francisco
|
||||||
|
telephoneNumber: +1 206 562-1407
|
||||||
|
title: Junior Human Resources President
|
||||||
|
userPassword: Password1
|
||||||
|
uid: KaraT
|
||||||
|
givenName: Teirtza
|
||||||
|
mail: KaraT@c2afe8b3509f4a20b2b784841685bd74.bitwarden.com
|
||||||
|
carLicense: O9GAN2
|
||||||
|
departmentNumber: 3880
|
||||||
|
employeeType: Employee
|
||||||
|
homePhone: +1 206 154-4842
|
||||||
|
initials: T. K.
|
||||||
|
mobile: +1 206 860-1835
|
||||||
|
pager: +1 206 684-1438
|
||||||
|
roomNumber: 9079
|
||||||
|
|
||||||
|
# Users - Engineering
|
||||||
|
dn: cn=Alice Chen,ou=Engineering,dc=bitwarden,dc=com
|
||||||
|
changetype: add
|
||||||
|
objectClass: top
|
||||||
|
objectClass: person
|
||||||
|
objectClass: organizationalPerson
|
||||||
|
objectClass: inetOrgPerson
|
||||||
|
cn: Alice Chen
|
||||||
|
sn: Chen
|
||||||
|
description: Senior DevOps Engineer
|
||||||
|
l: Seattle
|
||||||
|
ou: Engineering
|
||||||
|
telephoneNumber: +1 206 555-0101
|
||||||
|
title: Senior DevOps Engineer
|
||||||
|
userPassword: Password1
|
||||||
|
uid: ChenA
|
||||||
|
givenName: Alice
|
||||||
|
mail: ChenA@bitwarden.com
|
||||||
|
employeeType: Employee
|
||||||
|
|
||||||
|
dn: cn=Bob Martinez,ou=Engineering,dc=bitwarden,dc=com
|
||||||
|
changetype: add
|
||||||
|
objectClass: top
|
||||||
|
objectClass: person
|
||||||
|
objectClass: organizationalPerson
|
||||||
|
objectClass: inetOrgPerson
|
||||||
|
cn: Bob Martinez
|
||||||
|
sn: Martinez
|
||||||
|
description: Platform Engineer
|
||||||
|
l: Austin
|
||||||
|
ou: Engineering
|
||||||
|
telephoneNumber: +1 512 555-0102
|
||||||
|
title: Platform Engineer
|
||||||
|
userPassword: Password1
|
||||||
|
uid: MartinezB
|
||||||
|
givenName: Bob
|
||||||
|
mail: MartinezB@bitwarden.com
|
||||||
|
employeeType: Employee
|
||||||
|
|
||||||
|
dn: cn=Carol Williams,ou=Engineering,dc=bitwarden,dc=com
|
||||||
|
changetype: add
|
||||||
|
objectClass: top
|
||||||
|
objectClass: person
|
||||||
|
objectClass: organizationalPerson
|
||||||
|
objectClass: inetOrgPerson
|
||||||
|
cn: Carol Williams
|
||||||
|
sn: Williams
|
||||||
|
description: QA Lead
|
||||||
|
l: Denver
|
||||||
|
ou: Engineering
|
||||||
|
telephoneNumber: +1 303 555-0103
|
||||||
|
title: QA Lead
|
||||||
|
userPassword: Password1
|
||||||
|
uid: WilliamsC
|
||||||
|
givenName: Carol
|
||||||
|
mail: WilliamsC@bitwarden.com
|
||||||
|
employeeType: Employee
|
||||||
|
|
||||||
|
dn: cn=David Kim,ou=Engineering,dc=bitwarden,dc=com
|
||||||
|
changetype: add
|
||||||
|
objectClass: top
|
||||||
|
objectClass: person
|
||||||
|
objectClass: organizationalPerson
|
||||||
|
objectClass: inetOrgPerson
|
||||||
|
cn: David Kim
|
||||||
|
sn: Kim
|
||||||
|
description: QA Engineer
|
||||||
|
l: Portland
|
||||||
|
ou: Engineering
|
||||||
|
telephoneNumber: +1 503 555-0104
|
||||||
|
title: QA Engineer
|
||||||
|
userPassword: Password1
|
||||||
|
uid: KimD
|
||||||
|
givenName: David
|
||||||
|
mail: KimD@bitwarden.com
|
||||||
|
employeeType: Contractor
|
||||||
|
|
||||||
|
# Users - Marketing
|
||||||
|
dn: cn=Eva Johnson,ou=Marketing,dc=bitwarden,dc=com
|
||||||
|
changetype: add
|
||||||
|
objectClass: top
|
||||||
|
objectClass: person
|
||||||
|
objectClass: organizationalPerson
|
||||||
|
objectClass: inetOrgPerson
|
||||||
|
cn: Eva Johnson
|
||||||
|
sn: Johnson
|
||||||
|
description: Marketing Director
|
||||||
|
l: New York
|
||||||
|
ou: Marketing
|
||||||
|
telephoneNumber: +1 212 555-0105
|
||||||
|
title: Marketing Director
|
||||||
|
userPassword: Password1
|
||||||
|
uid: JohnsonE
|
||||||
|
givenName: Eva
|
||||||
|
mail: JohnsonE@bitwarden.com
|
||||||
|
employeeType: Employee
|
||||||
|
|
||||||
|
dn: cn=Frank Lee,ou=Marketing,dc=bitwarden,dc=com
|
||||||
|
changetype: add
|
||||||
|
objectClass: top
|
||||||
|
objectClass: person
|
||||||
|
objectClass: organizationalPerson
|
||||||
|
objectClass: inetOrgPerson
|
||||||
|
cn: Frank Lee
|
||||||
|
sn: Lee
|
||||||
|
description: Content Strategist
|
||||||
|
l: Chicago
|
||||||
|
ou: Marketing
|
||||||
|
telephoneNumber: +1 312 555-0106
|
||||||
|
title: Content Strategist
|
||||||
|
userPassword: Password1
|
||||||
|
uid: LeeF
|
||||||
|
givenName: Frank
|
||||||
|
mail: LeeF@bitwarden.com
|
||||||
|
employeeType: Employee
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# GROUP HIERARCHY
|
||||||
|
# ============================================================
|
||||||
|
# Structure (arrows show "contains" relationship):
|
||||||
|
#
|
||||||
|
# AllStaff
|
||||||
|
# ├── Engineering ◄────────────────┐ (CYCLE from Platform)
|
||||||
|
# │ ├── DevOps │
|
||||||
|
# │ │ └── Platform ────────┘
|
||||||
|
# │ └── QA
|
||||||
|
# ├── Marketing
|
||||||
|
# └── HR
|
||||||
|
#
|
||||||
|
# Contractors ─── DevOps (diamond: second path to Platform)
|
||||||
|
#
|
||||||
|
# TestNestA ◄──► TestNestB (simple bidirectional cycle)
|
||||||
|
#
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
# Leaf group - Platform team (CYCLES BACK to Engineering)
|
||||||
|
dn: cn=Platform,dc=bitwarden,dc=com
|
||||||
|
changetype: add
|
||||||
|
cn: Platform
|
||||||
|
member: cn=Bob Martinez,ou=Engineering,dc=bitwarden,dc=com
|
||||||
|
member: cn=Engineering,dc=bitwarden,dc=com
|
||||||
|
objectclass: groupOfNames
|
||||||
|
objectclass: top
|
||||||
|
|
||||||
|
# DevOps group - contains Platform subgroup
|
||||||
|
dn: cn=DevOps,dc=bitwarden,dc=com
|
||||||
|
changetype: add
|
||||||
|
cn: DevOps
|
||||||
|
member: cn=Alice Chen,ou=Engineering,dc=bitwarden,dc=com
|
||||||
|
member: cn=Platform,dc=bitwarden,dc=com
|
||||||
|
objectclass: groupOfNames
|
||||||
|
objectclass: top
|
||||||
|
|
||||||
|
# QA group
|
||||||
|
dn: cn=QA,dc=bitwarden,dc=com
|
||||||
|
changetype: add
|
||||||
|
cn: QA
|
||||||
|
member: cn=Carol Williams,ou=Engineering,dc=bitwarden,dc=com
|
||||||
|
member: cn=David Kim,ou=Engineering,dc=bitwarden,dc=com
|
||||||
|
objectclass: groupOfNames
|
||||||
|
objectclass: top
|
||||||
|
|
||||||
|
# Engineering group - contains DevOps and QA subgroups
|
||||||
|
dn: cn=Engineering,dc=bitwarden,dc=com
|
||||||
|
changetype: add
|
||||||
|
cn: Engineering
|
||||||
|
member: cn=DevOps,dc=bitwarden,dc=com
|
||||||
|
member: cn=QA,dc=bitwarden,dc=com
|
||||||
|
objectclass: groupOfNames
|
||||||
|
objectclass: top
|
||||||
|
|
||||||
|
# Marketing group
|
||||||
|
dn: cn=Marketing,dc=bitwarden,dc=com
|
||||||
|
changetype: add
|
||||||
|
cn: Marketing
|
||||||
|
member: cn=Eva Johnson,ou=Marketing,dc=bitwarden,dc=com
|
||||||
|
member: cn=Frank Lee,ou=Marketing,dc=bitwarden,dc=com
|
||||||
|
objectclass: groupOfNames
|
||||||
|
objectclass: top
|
||||||
|
|
||||||
|
# HR group
|
||||||
|
dn: cn=HR,dc=bitwarden,dc=com
|
||||||
|
changetype: add
|
||||||
|
cn: HR
|
||||||
|
member: cn=Roland Dyke,ou=Human Resources,dc=bitwarden,dc=com
|
||||||
|
member: cn=Teirtza Kara,ou=Human Resources,dc=bitwarden,dc=com
|
||||||
|
objectclass: groupOfNames
|
||||||
|
objectclass: top
|
||||||
|
|
||||||
|
# AllStaff - top-level group containing all departments
|
||||||
|
dn: cn=AllStaff,dc=bitwarden,dc=com
|
||||||
|
changetype: add
|
||||||
|
cn: AllStaff
|
||||||
|
member: cn=Engineering,dc=bitwarden,dc=com
|
||||||
|
member: cn=Marketing,dc=bitwarden,dc=com
|
||||||
|
member: cn=HR,dc=bitwarden,dc=com
|
||||||
|
objectclass: groupOfNames
|
||||||
|
objectclass: top
|
||||||
|
|
||||||
|
# Contractors group - creates diamond pattern (second path to Platform via DevOps)
|
||||||
|
dn: cn=Contractors,dc=bitwarden,dc=com
|
||||||
|
changetype: add
|
||||||
|
cn: Contractors
|
||||||
|
member: cn=DevOps,dc=bitwarden,dc=com
|
||||||
|
member: cn=David Kim,ou=Engineering,dc=bitwarden,dc=com
|
||||||
|
objectclass: groupOfNames
|
||||||
|
objectclass: top
|
||||||
|
|
||||||
|
# Simple bidirectional cycle test groups (preserved from original)
|
||||||
|
dn: cn=TestNestA,dc=bitwarden,dc=com
|
||||||
|
changetype: add
|
||||||
|
cn: TestNestA
|
||||||
|
member: cn=TestNestB,dc=bitwarden,dc=com
|
||||||
|
member: cn=Roland Dyke,ou=Human Resources,dc=bitwarden,dc=com
|
||||||
|
objectclass: groupOfNames
|
||||||
|
objectclass: top
|
||||||
|
|
||||||
|
dn: cn=TestNestB,dc=bitwarden,dc=com
|
||||||
|
changetype: add
|
||||||
|
cn: TestNestB
|
||||||
|
member: cn=TestNestA,dc=bitwarden,dc=com
|
||||||
|
member: cn=Teirtza Kara,ou=Human Resources,dc=bitwarden,dc=com
|
||||||
|
objectclass: groupOfNames
|
||||||
|
objectclass: top
|
||||||
Reference in New Issue
Block a user