1
0
mirror of https://github.com/bitwarden/directory-connector synced 2026-02-05 19:23:34 +00:00

Compare commits

...

17 Commits

Author SHA1 Message Date
Sven
45cc3d4c4f Fix circular groups 2026-02-05 11:16:37 -06:00
renovate[bot]
14fc69c810 [deps]: Update ngx-toastr to v20 (#989)
* [deps]: Update ngx-toastr to v20

* Adjust to toastr v20

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Sven <svernyi@bitwarden.com>
2026-02-04 13:44:40 -06:00
renovate[bot]
1ad0aea61f [deps]: Update prettier to v3.8.1 (#985)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-04 13:27:17 -05:00
renovate[bot]
f41156969c [deps]: Update angular monorepo (#981)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Jared <TheWolfBadger@gmail.com>
2026-02-04 13:20:42 -05:00
renovate[bot]
39b151b1e0 [deps]: Update mini-css-extract-plugin to v2.10.0 (#984)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-04 13:05:34 -05:00
renovate[bot]
483f26fa6f [deps]: Update type-fest to v5.4.2 (#986)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-03 11:39:06 -05:00
renovate[bot]
8849385d1b [deps]: Update @angular/cdk to v21.1.1 (#980)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-03 09:41:55 -05:00
renovate[bot]
a7aff97360 [deps]: Lock file maintenance (#978)
* [deps]: Lock file maintenance

* add COEP and COOP headers to enabled SharedArrayBuffer

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Brandon <btreston@bitwarden.com>
2026-02-02 11:49:42 -05:00
renovate[bot]
7381857296 [deps]: Update gh minor (#973)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-29 14:19:11 -05:00
renovate[bot]
ba17d5b438 [deps]: Update electron-updater to v6.7.3 (#974)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-26 11:06:25 -06:00
Daniel James Smith
b5d31e693b Replace deprecated codecov/test-results-action with codecov/codecov-action with report_type set to test_results (#979)
https://github.com/codecov/test-results-action?tab=readme-ov-file#%EF%B8%8F-deprecation-warning-%EF%B8%8F

Co-authored-by: Daniel James Smith <djsmith85@users.noreply.github.com>
2026-01-23 08:06:18 -06:00
Brandon Treston
2854a2eba1 Update Angular to v21 (#972)
* update jest to v.30.2.0

* ng update 21 wip

* update @angular/cdk@21

* NG 21 WIP

* @ngtools-webpack@21 and jest-preset-angular@16

* updated jest, add babel & jest-enveironment-jsdom

* add missing polyfils for TextEncoder & TextDecoder

* cleanup lock file

* tsconfig cleanup

* fix import

* cleanup

* clean up
2026-01-21 08:37:52 +10:00
renovate[bot]
4485ecab3c [deps]: Update ldapts to v8.1.3 (#975)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-20 11:44:49 +10:00
renovate[bot]
9e3b2d2d95 [deps]: Update jest-mock-extended to v4 (#977)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-19 09:42:05 -05:00
renovate[bot]
b2997358dc [deps]: Lock file maintenance (#834)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-14 08:07:48 +10:00
renovate[bot]
db258f0191 [deps]: Update @angular/compiler to v20.3.16 [SECURITY] (#967)
* [deps]: Update @angular/compiler to v20.3.16 [SECURITY]

* Upgrade all Angular packages

* Downgrade jest-mock-extended to support Jest 29

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Thomas Rittson <trittson@bitwarden.com>
2026-01-14 07:36:46 +10:00
Vincent Salucci
19d7884933 chore: bump version to 2026.1.0 (#969) 2026-01-12 11:32:43 -06:00
23 changed files with 5866 additions and 7418 deletions

View File

@@ -23,7 +23,7 @@ jobs:
node_version: ${{ steps.retrieve-node-version.outputs.node_version }}
steps:
- name: Checkout repo
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
@@ -51,12 +51,12 @@ jobs:
contents: read
steps:
- name: Checkout repo
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Set up Node
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with:
cache: 'npm'
cache-dependency-path: '**/package-lock.json'
@@ -129,12 +129,12 @@ jobs:
_NODE_VERSION: ${{ needs.setup.outputs.node_version }}
steps:
- name: Checkout repo
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Set up Node
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with:
cache: 'npm'
cache-dependency-path: '**/package-lock.json'
@@ -200,7 +200,7 @@ jobs:
_NODE_VERSION: ${{ needs.setup.outputs.node_version }}
steps:
- name: Checkout repo
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
@@ -209,7 +209,7 @@ jobs:
choco install checksum --no-progress
- name: Set up Node
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with:
cache: 'npm'
cache-dependency-path: '**/package-lock.json'
@@ -279,12 +279,12 @@ jobs:
HUSKY: 0
steps:
- name: Checkout repo
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Set up Node
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with:
cache: 'npm'
cache-dependency-path: '**/package-lock.json'
@@ -379,12 +379,12 @@ jobs:
HUSKY: 0
steps:
- name: Checkout repo
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Set up Node
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with:
cache: 'npm'
cache-dependency-path: '**/package-lock.json'
@@ -439,12 +439,12 @@ jobs:
HUSKY: 0
steps:
- name: Checkout repo
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Set up Node
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with:
cache: 'npm'
cache-dependency-path: '**/package-lock.json'

View File

@@ -40,7 +40,7 @@ jobs:
steps:
- name: Check out repo
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
@@ -52,7 +52,7 @@ jobs:
echo "node_version=$NODE_VERSION" >> "$GITHUB_OUTPUT"
- name: Set up Node
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with:
cache: 'npm'
cache-dependency-path: '**/package-lock.json'
@@ -129,7 +129,7 @@ jobs:
- name: Report test results
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.
# 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()
@@ -143,4 +143,6 @@ jobs:
uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2
- 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

View File

@@ -26,7 +26,7 @@ jobs:
release_version: ${{ steps.version.outputs.version }}
steps:
- name: Checkout repo
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false

View File

@@ -22,7 +22,7 @@ jobs:
steps:
- name: Check out repo
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
@@ -34,7 +34,7 @@ jobs:
echo "node_version=$NODE_VERSION" >> "$GITHUB_OUTPUT"
- name: Set up Node
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with:
cache: 'npm'
cache-dependency-path: '**/package-lock.json'
@@ -53,7 +53,7 @@ jobs:
run: npm run test --coverage
- 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.
# 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()
@@ -67,4 +67,6 @@ jobs:
uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2
- 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

View File

@@ -50,7 +50,7 @@ jobs:
permission-contents: write
- name: Checkout Branch
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
token: ${{ steps.app-token.outputs.token }}
persist-credentials: true

View File

@@ -18,15 +18,17 @@
"prefix": "app",
"architect": {
"build": {
"builder": "@angular-devkit/build-angular:browser",
"builder": "@angular/build:application",
"options": {
"outputPath": "dist",
"outputPath": {
"base": "dist"
},
"index": "src/index.html",
"main": "src/main.ts",
"tsConfig": "tsconfig.json",
"assets": [],
"styles": [],
"scripts": []
"scripts": [],
"browser": "src/main.ts"
}
}
}

View File

@@ -1,75 +1,77 @@
import { animate, state, style, transition, trigger } from "@angular/animations";
import { CommonModule } from "@angular/common";
import { Component, ModuleWithProviders, NgModule } from "@angular/core";
import {
DefaultNoComponentGlobalConfig,
GlobalConfig,
Toast as BaseToast,
ToastPackage,
ToastrService,
TOAST_CONFIG,
} from "ngx-toastr";
import { DefaultNoComponentGlobalConfig, GlobalConfig, Toast, TOAST_CONFIG } from "ngx-toastr";
@Component({
selector: "[toast-component2]",
template: `
<button
*ngIf="options.closeButton"
(click)="remove()"
type="button"
class="toast-close-button"
aria-label="Close"
>
<span aria-hidden="true">&times;</span>
</button>
@if (options().closeButton) {
<button (click)="remove()" type="button" class="toast-close-button" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
}
<div class="icon">
<i></i>
</div>
<div>
<div *ngIf="title" [class]="options.titleClass" [attr.aria-label]="title">
{{ title }} <ng-container *ngIf="duplicatesCount">[{{ duplicatesCount + 1 }}]</ng-container>
</div>
<div
*ngIf="message && options.enableHtml"
role="alertdialog"
aria-live="polite"
[class]="options.messageClass"
[innerHTML]="message"
></div>
<div
*ngIf="message && !options.enableHtml"
role="alertdialog"
aria-live="polite"
[class]="options.messageClass"
[attr.aria-label]="message"
>
{{ message }}
</div>
</div>
<div *ngIf="options.progressBar">
<div class="toast-progress" [style.width]="width + '%'"></div>
@if (title()) {
<div [class]="options().titleClass" [attr.aria-label]="title()">
{{ title() }}
@if (duplicatesCount) {
[{{ duplicatesCount + 1 }}]
}
</div>
}
@if (message() && options().enableHtml) {
<div
role="alertdialog"
aria-live="polite"
[class]="options().messageClass"
[innerHTML]="message()"
></div>
}
@if (message() && !options().enableHtml) {
<div
role="alertdialog"
aria-live="polite"
[class]="options().messageClass"
[attr.aria-label]="message()"
>
{{ message() }}
</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,
standalone: false,
})
export class BitwardenToast extends BaseToast {
constructor(
protected toastrService: ToastrService,
public toastPackage: ToastPackage,
) {
super(toastrService, toastPackage);
}
}
export class BitwardenToast extends Toast {}
export const BitwardenToastGlobalConfig: GlobalConfig = {
...DefaultNoComponentGlobalConfig,

View File

@@ -9,7 +9,7 @@ describe("SymmetricCryptoKey", () => {
new SymmetricCryptoKey(null);
};
expect(t).toThrowError("Must provide key");
expect(t).toThrow("Must provide key");
});
describe("guesses encKey from key length", () => {
@@ -63,7 +63,7 @@ describe("SymmetricCryptoKey", () => {
new SymmetricCryptoKey(makeStaticByteArray(30));
};
expect(t).toThrowError("Unable to determine encType.");
expect(t).toThrow("Unable to determine encType.");
});
});
});

View File

@@ -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) {
this.win.maximize();
}

12043
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -2,7 +2,7 @@
"name": "@bitwarden/directory-connector",
"productName": "Bitwarden Directory Connector",
"description": "Sync your user directory to your Bitwarden organization.",
"version": "2025.12.0",
"version": "2026.1.0",
"keywords": [
"bitwarden",
"password",
@@ -73,17 +73,17 @@
"test:types": "npx tsc --noEmit"
},
"devDependencies": {
"@angular-devkit/build-angular": "20.3.3",
"@angular-eslint/eslint-plugin-template": "20.7.0",
"@angular-eslint/template-parser": "20.7.0",
"@angular/compiler-cli": "20.3.15",
"@angular-eslint/eslint-plugin-template": "21.1.0",
"@angular-eslint/template-parser": "21.1.0",
"@angular/build": "21.0.5",
"@angular/compiler-cli": "21.1.1",
"@electron/notarize": "2.5.0",
"@electron/rebuild": "4.0.1",
"@fluffy-spoon/substitute": "1.208.0",
"@microsoft/microsoft-graph-types": "2.43.1",
"@ngtools/webpack": "20.3.3",
"@ngtools/webpack": "21.0.5",
"@types/inquirer": "8.2.10",
"@types/jest": "29.5.14",
"@types/jest": "30.0.0",
"@types/lowdb": "1.0.15",
"@types/node": "22.19.2",
"@types/node-fetch": "2.6.12",
@@ -94,7 +94,9 @@
"@typescript-eslint/eslint-plugin": "8.50.0",
"@typescript-eslint/parser": "8.50.0",
"@yao-pkg/pkg": "5.16.1",
"babel-loader": "9.2.1",
"clean-webpack-plugin": "4.0.0",
"jest-environment-jsdom": "30.2.0",
"concurrently": "9.2.0",
"copy-webpack-plugin": "13.0.0",
"cross-env": "7.0.3",
@@ -105,7 +107,7 @@
"electron-log": "5.4.1",
"electron-reload": "2.0.0-alpha.1",
"electron-store": "8.2.0",
"electron-updater": "6.6.2",
"electron-updater": "6.7.3",
"eslint": "9.39.1",
"eslint-config-prettier": "10.1.5",
"eslint-import-resolver-typescript": "4.4.4",
@@ -117,16 +119,16 @@
"html-loader": "5.1.0",
"html-webpack-plugin": "5.6.3",
"husky": "9.1.7",
"jest": "29.7.0",
"jest": "30.2.0",
"jest-junit": "16.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",
"mini-css-extract-plugin": "2.9.2",
"mini-css-extract-plugin": "2.10.0",
"minimatch": "5.1.2",
"node-forge": "1.3.2",
"node-loader": "2.1.0",
"prettier": "3.7.4",
"prettier": "3.8.1",
"rimraf": "6.1.0",
"rxjs": "7.8.2",
"sass": "1.97.1",
@@ -134,25 +136,25 @@
"ts-jest": "29.4.1",
"ts-loader": "9.5.2",
"tsconfig-paths-webpack-plugin": "4.2.0",
"type-fest": "5.3.0",
"type-fest": "5.4.2",
"typescript": "5.9.3",
"webpack": "5.104.1",
"webpack-cli": "6.0.1",
"webpack-merge": "6.0.1",
"webpack-node-externals": "3.0.0",
"zone.js": "0.15.1"
"zone.js": "0.16.0"
},
"dependencies": {
"@angular/animations": "20.3.15",
"@angular/cdk": "20.2.14",
"@angular/cli": "20.3.3",
"@angular/common": "20.3.15",
"@angular/compiler": "20.3.15",
"@angular/core": "20.3.15",
"@angular/forms": "20.3.15",
"@angular/platform-browser": "20.3.15",
"@angular/platform-browser-dynamic": "20.3.15",
"@angular/router": "20.3.15",
"@angular/animations": "21.1.1",
"@angular/cdk": "21.1.1",
"@angular/cli": "21.0.5",
"@angular/common": "21.1.1",
"@angular/compiler": "21.1.1",
"@angular/core": "21.1.1",
"@angular/forms": "21.1.1",
"@angular/platform-browser": "21.1.1",
"@angular/platform-browser-dynamic": "21.1.1",
"@angular/router": "21.1.1",
"@microsoft/microsoft-graph-client": "3.0.7",
"big-integer": "1.6.52",
"bootstrap": "5.3.7",
@@ -164,16 +166,16 @@
"https-proxy-agent": "7.0.6",
"inquirer": "8.2.6",
"keytar": "7.9.0",
"ldapts": "8.0.1",
"ldapts": "8.1.3",
"lowdb": "1.0.0",
"ngx-toastr": "19.1.0",
"ngx-toastr": "20.0.4",
"node-fetch": "2.7.0",
"parse5": "8.0.0",
"proper-lockfile": "4.1.2",
"rxjs": "7.8.2",
"tldjs": "2.3.1",
"uuid": "11.1.0",
"zone.js": "0.15.1"
"zone.js": "0.16.0"
},
"engines": {
"node": "~20",

View File

@@ -1,4 +1,4 @@
import { enableProdMode } from "@angular/core";
import { enableProdMode, provideZoneChangeDetection } from "@angular/core";
import { platformBrowserDynamic } from "@angular/platform-browser-dynamic";
import { isDev } from "@/jslib/electron/src/utils";
@@ -11,4 +11,7 @@ if (!isDev()) {
enableProdMode();
}
platformBrowserDynamic().bootstrapModule(AppModule, { preserveWhitespaces: true });
platformBrowserDynamic().bootstrapModule(AppModule, {
applicationProviders: [provideZoneChangeDetection()],
preserveWhitespaces: true,
});

View File

@@ -3,17 +3,25 @@
<div class="card-body">
<p>
{{ "lastGroupSync" | i18n }}:
<span *ngIf="!lastGroupSync">-</span>
@if (!lastGroupSync) {
<span>-</span>
}
{{ lastGroupSync | date: "medium" }}
<br />
{{ "lastUserSync" | i18n }}:
<span *ngIf="!lastUserSync">-</span>
@if (!lastUserSync) {
<span>-</span>
}
{{ lastUserSync | date: "medium" }}
</p>
<p>
{{ "syncStatus" | i18n }}:
<strong *ngIf="syncRunning" class="text-success">{{ "running" | i18n }}</strong>
<strong *ngIf="!syncRunning" class="text-danger">{{ "stopped" | i18n }}</strong>
@if (syncRunning) {
<strong class="text-success">{{ "running" | i18n }}</strong>
}
@if (!syncRunning) {
<strong class="text-danger">{{ "stopped" | i18n }}</strong>
}
</p>
<form #startForm [appApiAction]="startPromise" class="d-inline">
<button
@@ -60,57 +68,85 @@
/>
<label class="form-check-label" for="simSinceLast">{{ "testLastSync" | i18n }}</label>
</div>
<ng-container *ngIf="!simForm.loading && (simUsers || simGroups)">
@if (!simForm.loading && (simUsers || simGroups)) {
<hr />
<div class="row">
<div class="col-lg">
<h4>{{ "users" | i18n }}</h4>
<ul class="bwi-ul testing-list" *ngIf="simEnabledUsers && simEnabledUsers.length">
<li *ngFor="let u of simEnabledUsers" title="{{ u.referenceId }}">
<i class="bwi bwi-li bwi-user"></i>
{{ u.displayName }}
</li>
</ul>
<p *ngIf="!simEnabledUsers || !simEnabledUsers.length">
{{ "noUsers" | i18n }}
</p>
@if (simEnabledUsers && simEnabledUsers.length) {
<ul class="bwi-ul testing-list">
@for (u of simEnabledUsers; track u) {
<li title="{{ u.referenceId }}">
<i class="bwi bwi-li bwi-user"></i>
{{ u.displayName }}
</li>
}
</ul>
}
@if (!simEnabledUsers || !simEnabledUsers.length) {
<p>
{{ "noUsers" | i18n }}
</p>
}
<h4>{{ "disabledUsers" | i18n }}</h4>
<ul class="bwi-ul testing-list" *ngIf="simDisabledUsers && simDisabledUsers.length">
<li *ngFor="let u of simDisabledUsers" title="{{ u.referenceId }}">
<i class="bwi bwi-li bwi-user"></i>
{{ u.displayName }}
</li>
</ul>
<p *ngIf="!simDisabledUsers || !simDisabledUsers.length">
{{ "noUsers" | i18n }}
</p>
@if (simDisabledUsers && simDisabledUsers.length) {
<ul class="bwi-ul testing-list">
@for (u of simDisabledUsers; track u) {
<li title="{{ u.referenceId }}">
<i class="bwi bwi-li bwi-user"></i>
{{ u.displayName }}
</li>
}
</ul>
}
@if (!simDisabledUsers || !simDisabledUsers.length) {
<p>
{{ "noUsers" | i18n }}
</p>
}
<h4>{{ "deletedUsers" | i18n }}</h4>
<ul class="bwi-ul testing-list" *ngIf="simDeletedUsers && simDeletedUsers.length">
<li *ngFor="let u of simDeletedUsers" title="{{ u.referenceId }}">
<i class="bwi bwi-li bwi-user"></i>
{{ u.displayName }}
</li>
</ul>
<p *ngIf="!simDeletedUsers || !simDeletedUsers.length">
{{ "noUsers" | i18n }}
</p>
@if (simDeletedUsers && simDeletedUsers.length) {
<ul class="bwi-ul testing-list">
@for (u of simDeletedUsers; track u) {
<li title="{{ u.referenceId }}">
<i class="bwi bwi-li bwi-user"></i>
{{ u.displayName }}
</li>
}
</ul>
}
@if (!simDeletedUsers || !simDeletedUsers.length) {
<p>
{{ "noUsers" | i18n }}
</p>
}
</div>
<div class="col-lg">
<h4>{{ "groups" | i18n }}</h4>
<ul class="bwi-ul testing-list" *ngIf="simGroups && simGroups.length">
<li *ngFor="let g of simGroups" title="{{ g.referenceId }}">
<i class="bwi bwi-li bwi-sitemap"></i>
{{ g.displayName }}
<ul class="small" *ngIf="g.users && g.users.length">
<li *ngFor="let u of g.users" title="{{ u.referenceId }}">
{{ u.displayName }}
@if (simGroups && simGroups.length) {
<ul class="bwi-ul testing-list">
@for (g of simGroups; track g) {
<li title="{{ g.referenceId }}">
<i class="bwi bwi-li bwi-sitemap"></i>
{{ g.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>
</ul>
</li>
</ul>
<p *ngIf="!simGroups || !simGroups.length">{{ "noGroups" | i18n }}</p>
}
</ul>
}
@if (!simGroups || !simGroups.length) {
<p>{{ "noGroups" | i18n }}</p>
}
</div>
</div>
</ng-container>
}
</div>
</div>

View File

@@ -6,9 +6,11 @@
<div class="mb-3">
<label for="directory" class="form-label">{{ "type" | i18n }}</label>
<select class="form-select" id="directory" name="Directory" [(ngModel)]="directory">
<option *ngFor="let o of directoryOptions" [ngValue]="o.value">
{{ o.name }}
</option>
@for (o of directoryOptions; track o) {
<option [ngValue]="o.value">
{{ o.name }}
</option>
}
</select>
</div>
<div [hidden]="directory != directoryType.Ldap">
@@ -51,20 +53,22 @@
<label class="form-check-label" for="ad">{{ "ldapAd" | i18n }}</label>
</div>
</div>
<div class="mb-3" *ngIf="!ldap.ad">
<div class="form-check">
<input
class="form-check-input"
type="checkbox"
id="pagedSearch"
[(ngModel)]="ldap.pagedSearch"
name="PagedSearch"
/>
<label class="form-check-label" for="pagedSearch">{{
"ldapPagedResults" | i18n
}}</label>
@if (!ldap.ad) {
<div class="mb-3">
<div class="form-check">
<input
class="form-check-input"
type="checkbox"
id="pagedSearch"
[(ngModel)]="ldap.pagedSearch"
name="PagedSearch"
/>
<label class="form-check-label" for="pagedSearch">{{
"ldapPagedResults" | i18n
}}</label>
</div>
</div>
</div>
}
<div class="mb-3">
<div class="form-check">
<input
@@ -79,116 +83,122 @@
}}</label>
</div>
</div>
<div class="ms-4" *ngIf="ldap.ssl">
<div class="mb-3">
<div class="form-check">
<input
class="form-check-input"
type="radio"
[value]="false"
id="ssl"
[(ngModel)]="ldap.startTls"
name="SSL"
/>
<label class="form-check-label" for="ssl">{{ "ldapSsl" | i18n }}</label>
@if (ldap.ssl) {
<div class="ms-4">
<div class="mb-3">
<div class="form-check">
<input
class="form-check-input"
type="radio"
[value]="false"
id="ssl"
[(ngModel)]="ldap.startTls"
name="SSL"
/>
<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 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>
@if (ldap.startTls) {
<div class="ms-4">
<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>
}
@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 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="form-check">
<input
@@ -211,10 +221,12 @@
name="Username"
[(ngModel)]="ldap.username"
/>
<div class="form-text" *ngIf="ldap.ad">{{ "ex" | i18n }} company\admin</div>
<div class="form-text" *ngIf="!ldap.ad">
{{ "ex" | i18n }} cn=admin,dc=company,dc=com
</div>
@if (ldap.ad) {
<div class="form-text">{{ "ex" | i18n }} company\admin</div>
}
@if (!ldap.ad) {
<div class="form-text">{{ "ex" | i18n }} cn=admin,dc=company,dc=com</div>
}
</div>
<div class="mb-3">
<label for="password" class="form-label">{{ "password" | i18n }}</label>
@@ -604,18 +616,24 @@
name="UserFilter"
[(ngModel)]="sync.userFilter"
></textarea>
<div class="form-text" *ngIf="directory === directoryType.Ldap">
{{ "ex" | i18n }} (&amp;(givenName=John)(|(l=Dallas)(l=Austin)))
</div>
<div class="form-text" *ngIf="directory === directoryType.EntraID">
{{ "ex" | i18n }} exclude:joe&#64;company.com
</div>
<div class="form-text" *ngIf="directory === directoryType.Okta">
{{ "ex" | i18n }} exclude:joe&#64;company.com | profile.firstName eq "John"
</div>
<div class="form-text" *ngIf="directory === directoryType.GSuite">
{{ "ex" | i18n }} exclude:joe&#64;company.com | orgUnitPath=/Engineering
</div>
@if (directory === directoryType.Ldap) {
<div class="form-text">
{{ "ex" | i18n }} (&amp;(givenName=John)(|(l=Dallas)(l=Austin)))
</div>
}
@if (directory === directoryType.EntraID) {
<div class="form-text">{{ "ex" | i18n }} exclude:joe&#64;company.com</div>
}
@if (directory === directoryType.Okta) {
<div class="form-text">
{{ "ex" | i18n }} exclude:joe&#64;company.com | profile.firstName eq "John"
</div>
}
@if (directory === directoryType.GSuite) {
<div class="form-text">
{{ "ex" | i18n }} exclude:joe&#64;company.com | orgUnitPath=/Engineering
</div>
}
</div>
<div class="mb-3" [hidden]="directory != directoryType.Ldap">
<label for="userPath" class="form-label">{{ "userPath" | i18n }}</label>
@@ -681,18 +699,20 @@
name="GroupFilter"
[(ngModel)]="sync.groupFilter"
></textarea>
<div class="form-text" *ngIf="directory === directoryType.Ldap">
{{ "ex" | i18n }} (&amp;(objectClass=group)(!(cn=Sales*))(!(cn=IT*)))
</div>
<div class="form-text" *ngIf="directory === directoryType.EntraID">
{{ "ex" | i18n }} include:Sales,IT
</div>
<div class="form-text" *ngIf="directory === directoryType.Okta">
{{ "ex" | i18n }} include:Sales,IT | type eq "APP_GROUP"
</div>
<div class="form-text" *ngIf="directory === directoryType.GSuite">
{{ "ex" | i18n }} include:Sales,IT
</div>
@if (directory === directoryType.Ldap) {
<div class="form-text">
{{ "ex" | i18n }} (&amp;(objectClass=group)(!(cn=Sales*))(!(cn=IT*)))
</div>
}
@if (directory === directoryType.EntraID) {
<div class="form-text">{{ "ex" | i18n }} include:Sales,IT</div>
}
@if (directory === directoryType.Okta) {
<div class="form-text">{{ "ex" | i18n }} include:Sales,IT | type eq "APP_GROUP"</div>
}
@if (directory === directoryType.GSuite) {
<div class="form-text">{{ "ex" | i18n }} include:Sales,IT</div>
}
</div>
<div class="mb-3" [hidden]="directory != directoryType.Ldap">
<label for="groupPath" class="form-label">{{ "groupPath" | i18n }}</label>
@@ -703,8 +723,12 @@
name="GroupPath"
[(ngModel)]="sync.groupPath"
/>
<div class="form-text" *ngIf="!ldap.ad">{{ "ex" | i18n }} CN=Groups</div>
<div class="form-text" *ngIf="ldap.ad">{{ "ex" | i18n }} CN=Users</div>
@if (!ldap.ad) {
<div class="form-text">{{ "ex" | i18n }} CN=Groups</div>
}
@if (ldap.ad) {
<div class="form-text">{{ "ex" | i18n }} CN=Users</div>
}
</div>
<div [hidden]="directory != directoryType.Ldap || ldap.ad">
<div class="mb-3">

View File

@@ -28,4 +28,4 @@ $danger: map_get($theme-colors, "danger");
$secondary: map_get($theme-colors, "secondary");
$secondary-alt: map_get($theme-colors, "secondary-alt");
@import "~bootstrap/scss/bootstrap.scss";
@import "bootstrap/scss/bootstrap.scss";

View File

@@ -1,4 +1,4 @@
@import "~bootstrap/scss/_variables.scss";
@import "bootstrap/scss/_variables.scss";
html.os_windows {
body {

View File

@@ -1,4 +1,4 @@
@import "~bootstrap/scss/_variables.scss";
@import "bootstrap/scss/_variables.scss";
body {
padding: 10px 0 20px 0;

View File

@@ -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-close-button {

View File

@@ -6,6 +6,8 @@ import { MessagingService } from "@/jslib/common/src/abstractions/messaging.serv
import { OrganizationImportRequest } from "@/jslib/common/src/models/request/organizationImportRequest";
import { ApiService } from "@/jslib/common/src/services/api.service";
import { GroupEntry } from "@/src/models/groupEntry";
import { getSyncConfiguration } from "../../utils/openldap/config-fixtures";
import { DirectoryFactoryService } from "../abstractions/directory-factory.service";
import { DirectoryType } from "../enums/directoryType";
@@ -134,4 +136,198 @@ describe("SyncService", () => {
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: [],
});
}
it("should handle simple circular reference (A ↔ B) without stack overflow", async () => {
const groupA = createGroup("GroupA", ["userA"], ["GroupB"]);
const groupB = createGroup("GroupB", ["userB"], ["GroupA"]);
const circularGroups = [groupA, groupB];
const mockDirectoryService = mock<LdapDirectoryService>();
mockDirectoryService.getEntries.mockResolvedValue([circularGroups, []]);
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 },
]);
const [groups] = await syncService.sync(true, true);
// Both groups should have both users after flattening
expect(groups[0].userMemberExternalIds).toContain("userA");
expect(groups[0].userMemberExternalIds).toContain("userB");
expect(groups[1].userMemberExternalIds).toContain("userA");
expect(groups[1].userMemberExternalIds).toContain("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"]);
const circularGroups = [groupA, groupB, groupC];
const mockDirectoryService = mock<LdapDirectoryService>();
mockDirectoryService.getEntries.mockResolvedValue([circularGroups, []]);
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 },
]);
const [groups] = await syncService.sync(true, true);
// All groups should have all users after flattening
for (const group of groups) {
expect(group.userMemberExternalIds).toContain("userA");
expect(group.userMemberExternalIds).toContain("userB");
expect(group.userMemberExternalIds).toContain("userC");
}
});
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"], []);
const diamondGroups = [groupA, groupB, groupC, groupD];
const mockDirectoryService = mock<LdapDirectoryService>();
mockDirectoryService.getEntries.mockResolvedValue([diamondGroups, []]);
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 },
]);
const [groups] = await syncService.sync(true, true);
const [a, b, c, d] = groups;
// A should have all users (through B and C, both containing D)
expect(a.userMemberExternalIds).toContain("userA");
expect(a.userMemberExternalIds).toContain("userB");
expect(a.userMemberExternalIds).toContain("userC");
expect(a.userMemberExternalIds).toContain("userD");
// B should have its own user plus D's user
expect(b.userMemberExternalIds).toContain("userB");
expect(b.userMemberExternalIds).toContain("userD");
// C should have its own user plus D's user
expect(c.userMemberExternalIds).toContain("userC");
expect(c.userMemberExternalIds).toContain("userD");
// D should only have its own user
expect(d.userMemberExternalIds).toContain("userD");
expect(d.userMemberExternalIds.size).toBe(1);
});
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"]); // cycles back to B
const deepGroups = [groupA, groupB, groupC, groupD];
const mockDirectoryService = mock<LdapDirectoryService>();
mockDirectoryService.getEntries.mockResolvedValue([deepGroups, []]);
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 },
]);
const [groups] = await syncService.sync(true, true);
const [a, b, c, d] = groups;
// A should have all users
expect(a.userMemberExternalIds.size).toBe(4);
// B, C, D form a cycle, so they should all have each other's users
expect(b.userMemberExternalIds).toContain("userB");
expect(b.userMemberExternalIds).toContain("userC");
expect(b.userMemberExternalIds).toContain("userD");
expect(c.userMemberExternalIds).toContain("userB");
expect(c.userMemberExternalIds).toContain("userC");
expect(c.userMemberExternalIds).toContain("userD");
expect(d.userMemberExternalIds).toContain("userB");
expect(d.userMemberExternalIds).toContain("userC");
expect(d.userMemberExternalIds).toContain("userD");
});
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"]); // cycle to A
const groupE = createGroup("GroupE", ["userE"], ["GroupC"]); // cycle to C
const groupF = createGroup("GroupF", ["userF"], []);
const complexGroups = [groupA, groupB, groupC, groupD, groupE, groupF];
const mockDirectoryService = mock<LdapDirectoryService>();
mockDirectoryService.getEntries.mockResolvedValue([complexGroups, []]);
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 },
]);
// Should complete without stack overflow
const [groups] = await syncService.sync(true, true);
expect(groups).toHaveLength(6);
// Verify A gets users from its descendants
const a = groups.find((g) => g.name === "GroupA");
expect(a.userMemberExternalIds).toContain("userA");
expect(a.userMemberExternalIds).toContain("userB");
expect(a.userMemberExternalIds).toContain("userC");
// F should only have its own user (it's a leaf)
const f = groups.find((g) => g.name === "GroupF");
expect(f.userMemberExternalIds).toContain("userF");
expect(f.userMemberExternalIds.size).toBe(1);
});
});
});

View File

@@ -196,14 +196,27 @@ export class SyncService {
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>();
if (allGroups == null) {
return allUsers;
}
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 childUsers = this.flattenUsersToGroups(childGroups, allGroups);
const childUsers = this.flattenUsersToGroups(childGroups, allGroups, visited);
childUsers.forEach((id) => group.userMemberExternalIds.add(id));
allUsers = new Set([...allUsers, ...group.userMemberExternalIds]);
}

View File

@@ -1,7 +1,7 @@
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, "getComputedStyle", {
value: () => {

View File

@@ -5,7 +5,7 @@
},
"compilerOptions": {
"pretty": true,
"moduleResolution": "node",
"moduleResolution": "bundler",
"noImplicitAny": true,
"target": "ES2016",
"module": "ES2020",

View 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