1
0
mirror of https://github.com/bitwarden/directory-connector synced 2026-02-14 15:33:37 +00:00

Compare commits

...

12 Commits

Author SHA1 Message Date
Brandon
a0e74948bd fix integration test 2026-02-05 12:04:41 -05:00
Brandon
9f8018e8f8 fix type issues 2026-02-05 11:53:24 -05:00
Brandon
0bff38c459 add tests 2026-02-04 16:00:01 -05:00
Brandon
94ff20f69f scaffold new state service, add migration, initial commit 2026-01-29 14:36:38 -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
36 changed files with 6067 additions and 7100 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

@@ -13,42 +13,47 @@ import {
@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>
}
`,
animations: [
trigger("flyInOut", [

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

@@ -3,5 +3,6 @@ export enum StateVersion {
Two = 2, // Move to a typed State object
Three = 3, // Fix migration of users' premium status
Four = 4, // Fix 'Never Lock' option by removing stale data
Latest = Four,
Five = 5, // New state service implementation
Latest = Five,
}

11135
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -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.0.8",
"@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,10 +119,10 @@
"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",
"minimatch": "5.1.2",
@@ -143,16 +145,16 @@
"zone.js": "0.15.1"
},
"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.0.8",
"@angular/cdk": "21.0.6",
"@angular/cli": "21.0.5",
"@angular/common": "21.0.8",
"@angular/compiler": "21.0.8",
"@angular/core": "21.0.8",
"@angular/forms": "21.0.8",
"@angular/platform-browser": "21.0.8",
"@angular/platform-browser-dynamic": "21.0.8",
"@angular/router": "21.0.8",
"@microsoft/microsoft-graph-client": "3.0.7",
"big-integer": "1.6.52",
"bootstrap": "5.3.7",
@@ -164,7 +166,7 @@
"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",
"node-fetch": "2.7.0",

View File

@@ -0,0 +1,54 @@
import { StorageOptions } from "@/jslib/common/src/models/domain/storageOptions";
import { DirectoryType } from "@/src/enums/directoryType";
import { EntraIdConfiguration } from "@/src/models/entraIdConfiguration";
import { GSuiteConfiguration } from "@/src/models/gsuiteConfiguration";
import { LdapConfiguration } from "@/src/models/ldapConfiguration";
import { OktaConfiguration } from "@/src/models/oktaConfiguration";
import { OneLoginConfiguration } from "@/src/models/oneLoginConfiguration";
import { SyncConfiguration } from "@/src/models/syncConfiguration";
export abstract class StateServiceVNext {
getDirectory: <IConfiguration>(type: DirectoryType) => Promise<IConfiguration>;
setDirectory: (
type: DirectoryType,
config:
| LdapConfiguration
| GSuiteConfiguration
| EntraIdConfiguration
| OktaConfiguration
| OneLoginConfiguration,
) => Promise<any>;
getLdapConfiguration: (options?: StorageOptions) => Promise<LdapConfiguration>;
setLdapConfiguration: (value: LdapConfiguration, options?: StorageOptions) => Promise<void>;
getGsuiteConfiguration: (options?: StorageOptions) => Promise<GSuiteConfiguration>;
setGsuiteConfiguration: (value: GSuiteConfiguration, options?: StorageOptions) => Promise<void>;
getEntraConfiguration: (options?: StorageOptions) => Promise<EntraIdConfiguration>;
setEntraConfiguration: (value: EntraIdConfiguration, options?: StorageOptions) => Promise<void>;
getOktaConfiguration: (options?: StorageOptions) => Promise<OktaConfiguration>;
setOktaConfiguration: (value: OktaConfiguration, options?: StorageOptions) => Promise<void>;
getOneLoginConfiguration: (options?: StorageOptions) => Promise<OneLoginConfiguration>;
setOneLoginConfiguration: (
value: OneLoginConfiguration,
options?: StorageOptions,
) => Promise<void>;
getOrganizationId: (options?: StorageOptions) => Promise<string>;
setOrganizationId: (value: string, options?: StorageOptions) => Promise<void>;
getSync: (options?: StorageOptions) => Promise<SyncConfiguration>;
setSync: (value: SyncConfiguration, options?: StorageOptions) => Promise<void>;
getDirectoryType: (options?: StorageOptions) => Promise<DirectoryType>;
setDirectoryType: (value: DirectoryType, options?: StorageOptions) => Promise<void>;
getUserDelta: (options?: StorageOptions) => Promise<string>;
setUserDelta: (value: string, options?: StorageOptions) => Promise<void>;
getLastUserSync: (options?: StorageOptions) => Promise<Date>;
setLastUserSync: (value: Date, options?: StorageOptions) => Promise<void>;
getLastGroupSync: (options?: StorageOptions) => Promise<Date>;
setLastGroupSync: (value: Date, options?: StorageOptions) => Promise<void>;
getGroupDelta: (options?: StorageOptions) => Promise<string>;
setGroupDelta: (value: string, options?: StorageOptions) => Promise<void>;
getLastSyncHash: (options?: StorageOptions) => Promise<string>;
setLastSyncHash: (value: string, options?: StorageOptions) => Promise<void>;
getSyncingDir: (options?: StorageOptions) => Promise<boolean>;
setSyncingDir: (value: boolean, options?: StorageOptions) => Promise<void>;
clearSyncSettings: (syncHashToo: boolean) => Promise<void>;
}

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

@@ -31,12 +31,14 @@ import { DefaultDirectoryFactoryService } from "@/src/services/directory-factory
import { SingleRequestBuilder } from "@/src/services/single-request-builder";
import { AuthService as AuthServiceAbstraction } from "../../abstractions/auth.service";
import { StateServiceVNext } from "../../abstractions/state-vNext.service";
import { StateService as StateServiceAbstraction } from "../../abstractions/state.service";
import { Account } from "../../models/account";
import { AuthService } from "../../services/auth.service";
import { I18nService } from "../../services/i18n.service";
import { StateService } from "../../services/state.service";
import { StateMigrationService } from "../../services/stateMigration.service";
import { StateServiceVNextImplementation } from "../../services/state-service/state-vNext.service";
import { StateService } from "../../services/state-service/state.service";
import { StateMigrationService } from "../../services/state-service/stateMigration.service";
import { SyncService } from "../../services/sync.service";
import { AuthGuardService } from "./auth-guard.service";
@@ -222,6 +224,29 @@ export function initFactory(
StateMigrationServiceAbstraction,
],
}),
// Use new StateServiceVNext with flat key-value structure (new interface)
safeProvider({
provide: StateServiceVNext,
useFactory: (
storageService: StorageServiceAbstraction,
secureStorageService: StorageServiceAbstraction,
logService: LogServiceAbstraction,
stateMigrationService: StateMigrationServiceAbstraction,
) =>
new StateServiceVNextImplementation(
storageService,
secureStorageService,
logService,
stateMigrationService,
true,
),
deps: [
StorageServiceAbstraction,
SECURE_STORAGE,
LogServiceAbstraction,
StateMigrationServiceAbstraction,
],
}),
safeProvider({
provide: SingleRequestBuilder,
deps: [],
@@ -233,7 +258,12 @@ export function initFactory(
safeProvider({
provide: DirectoryFactoryService,
useClass: DefaultDirectoryFactoryService,
deps: [LogServiceAbstraction, I18nServiceAbstraction, StateServiceAbstraction],
deps: [
LogServiceAbstraction,
I18nServiceAbstraction,
StateServiceAbstraction,
StateServiceVNext,
],
}),
] satisfies SafeProvider[],
})

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

@@ -18,6 +18,7 @@ import { NodeApiService } from "@/jslib/node/src/services/nodeApi.service";
import { NodeCryptoFunctionService } from "@/jslib/node/src/services/nodeCryptoFunction.service";
import { DirectoryFactoryService } from "./abstractions/directory-factory.service";
import { StateServiceVNext } from "./abstractions/state-vNext.service";
import { Account } from "./models/account";
import { Program } from "./program";
import { AuthService } from "./services/auth.service";
@@ -27,8 +28,9 @@ import { I18nService } from "./services/i18n.service";
import { KeytarSecureStorageService } from "./services/keytarSecureStorage.service";
import { LowdbStorageService } from "./services/lowdbStorage.service";
import { SingleRequestBuilder } from "./services/single-request-builder";
import { StateService } from "./services/state.service";
import { StateMigrationService } from "./services/stateMigration.service";
import { StateServiceVNextImplementation } from "./services/state-service/state-vNext.service";
import { StateService } from "./services/state-service/state.service";
import { StateMigrationService } from "./services/state-service/stateMigration.service";
import { SyncService } from "./services/sync.service";
// eslint-disable-next-line
@@ -53,6 +55,7 @@ export class Main {
cryptoFunctionService: NodeCryptoFunctionService;
authService: AuthService;
syncService: SyncService;
stateServiceVNext: StateServiceVNext;
stateService: StateService;
stateMigrationService: StateMigrationService;
directoryFactoryService: DirectoryFactoryService;
@@ -116,6 +119,14 @@ export class Main {
process.env.BITWARDENCLI_CONNECTOR_PLAINTEXT_SECRETS !== "true",
new StateFactory(GlobalState, Account),
);
// Use new StateServiceVNext with flat key-value structure
this.stateServiceVNext = new StateServiceVNextImplementation(
this.storageService,
this.secureStorageService,
this.logService,
this.stateMigrationService,
process.env.BITWARDENCLI_CONNECTOR_PLAINTEXT_SECRETS !== "true",
);
this.cryptoService = new CryptoService(
this.cryptoFunctionService,
@@ -157,6 +168,7 @@ export class Main {
this.logService,
this.i18nService,
this.stateService,
this.stateServiceVNext,
);
this.batchRequestBuilder = new BatchRequestBuilder();

View File

@@ -11,12 +11,14 @@ import { TrayMain } from "@/jslib/electron/src/tray.main";
import { UpdaterMain } from "@/jslib/electron/src/updater.main";
import { WindowMain } from "@/jslib/electron/src/window.main";
import { StateServiceVNext } from "./abstractions/state-vNext.service";
import { DCCredentialStorageListener } from "./main/credential-storage-listener";
import { MenuMain } from "./main/menu.main";
import { MessagingMain } from "./main/messaging.main";
import { Account } from "./models/account";
import { I18nService } from "./services/i18n.service";
import { StateService } from "./services/state.service";
import { StateServiceVNextImplementation } from "./services/state-service/state-vNext.service";
import { StateService } from "./services/state-service/state.service";
export class Main {
logService: ElectronLogService;
@@ -24,6 +26,7 @@ export class Main {
storageService: ElectronStorageService;
messagingService: ElectronMainMessagingService;
credentialStorageListener: DCCredentialStorageListener;
stateServiceVNext: StateServiceVNext;
stateService: StateService;
windowMain: WindowMain;
@@ -66,6 +69,14 @@ export class Main {
true,
new StateFactory(GlobalState, Account),
);
// Use new StateServiceVNext with flat key-value structure
this.stateServiceVNext = new StateServiceVNextImplementation(
this.storageService,
null,
this.logService,
null,
true,
);
this.windowMain = new WindowMain(
this.stateService,

108
src/models/state.model.ts Normal file
View File

@@ -0,0 +1,108 @@
// ===================================================================
// vNext Storage Keys (Flat key-value structure)
// ===================================================================
export const StorageKeysVNext = {
stateVersion: "stateVersion",
directoryType: "directoryType",
organizationId: "organizationId",
directory_ldap: "directory_ldap",
directory_gsuite: "directory_gsuite",
directory_entra: "directory_entra",
directory_okta: "directory_okta",
directory_onelogin: "directory_onelogin",
sync: "sync",
syncingDir: "syncingDir",
};
export const SecureStorageKeysVNext: { [key: string]: any } = {
ldap: "secret_ldap",
gsuite: "secret_gsuite",
// Azure Active Directory was renamed to Entra ID, but we've kept the old property name
// to be backwards compatible with existing configurations.
azure: "secret_azure",
entra: "secret_entra",
okta: "secret_okta",
oneLogin: "secret_oneLogin",
userDelta: "userDeltaToken",
groupDelta: "groupDeltaToken",
lastUserSync: "lastUserSync",
lastGroupSync: "lastGroupSync",
lastSyncHash: "lastSyncHash",
};
// ===================================================================
// Legacy Storage Keys (Account-based hierarchy)
// ===================================================================
export const SecureStorageKeysLegacy = {
ldap: "ldapPassword",
gsuite: "gsuitePrivateKey",
// Azure Active Directory was renamed to Entra ID, but we've kept the old property name
// to be backwards compatible with existing configurations.
azure: "azureKey",
entra: "entraKey",
okta: "oktaToken",
oneLogin: "oneLoginClientSecret",
userDelta: "userDeltaToken",
groupDelta: "groupDeltaToken",
lastUserSync: "lastUserSync",
lastGroupSync: "lastGroupSync",
lastSyncHash: "lastSyncHash",
};
export const TempKeys = {
tempAccountSettings: "tempAccountSettings",
tempDirectoryConfigs: "tempDirectoryConfigs",
tempDirectorySettings: "tempDirectorySettings",
};
// ===================================================================
// Migration Storage Keys
// ===================================================================
export const SecureStorageKeysMigration: { [key: string]: any } = {
ldap: "ldapPassword",
gsuite: "gsuitePrivateKey",
azure: "azureKey",
entra: "entraIdKey",
okta: "oktaToken",
oneLogin: "oneLoginClientSecret",
directoryConfigPrefix: "directoryConfig_",
sync: "syncConfig",
directoryType: "directoryType",
organizationId: "organizationId",
};
export const MigrationKeys: { [key: string]: any } = {
entityId: "entityId",
directoryType: "directoryType",
organizationId: "organizationId",
lastUserSync: "lastUserSync",
lastGroupSync: "lastGroupSync",
lastSyncHash: "lastSyncHash",
syncingDir: "syncingDir",
syncConfig: "syncConfig",
userDelta: "userDeltaToken",
groupDelta: "groupDeltaToken",
tempDirectoryConfigs: "tempDirectoryConfigs",
tempDirectorySettings: "tempDirectorySettings",
};
export const MigrationStateKeys = {
global: "global",
authenticatedAccounts: "authenticatedAccounts",
};
export const MigrationClientKeys: { [key: string]: any } = {
clientIdOld: "clientId",
clientId: "apikey_clientId",
clientSecretOld: "clientSecret",
clientSecret: "apikey_clientSecret",
};
// ===================================================================
// Shared Constants
// ===================================================================
export const StoredSecurely = "[STORED SECURELY]";

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

@@ -15,7 +15,7 @@ import { MessagingService } from "../../jslib/common/src/abstractions/messaging.
import { Account, DirectoryConfigurations, DirectorySettings } from "../models/account";
import { AuthService } from "./auth.service";
import { StateService } from "./state.service";
import { StateService } from "./state-service/state.service";
const clientId = "organization.CLIENT_ID";
const clientSecret = "CLIENT_SECRET";

View File

@@ -2,6 +2,7 @@ import { I18nService } from "@/jslib/common/src/abstractions/i18n.service";
import { LogService } from "@/jslib/common/src/abstractions/log.service";
import { DirectoryFactoryService } from "../abstractions/directory-factory.service";
import { StateServiceVNext } from "../abstractions/state-vNext.service";
import { StateService } from "../abstractions/state.service";
import { DirectoryType } from "../enums/directoryType";
@@ -16,12 +17,18 @@ export class DefaultDirectoryFactoryService implements DirectoryFactoryService {
private logService: LogService,
private i18nService: I18nService,
private stateService: StateService,
private stateServiceVNext: StateServiceVNext,
) {}
createService(directoryType: DirectoryType) {
switch (directoryType) {
case DirectoryType.GSuite:
return new GSuiteDirectoryService(this.logService, this.i18nService, this.stateService);
return new GSuiteDirectoryService(
this.logService,
this.i18nService,
this.stateService,
this.stateServiceVNext,
);
case DirectoryType.EntraID:
return new EntraIdDirectoryService(this.logService, this.i18nService, this.stateService);
case DirectoryType.Ldap:

View File

@@ -1,6 +1,8 @@
import { config as dotenvConfig } from "dotenv";
import { mock, MockProxy } from "jest-mock-extended";
import { StateServiceVNext } from "@/src/abstractions/state-vNext.service";
import { I18nService } from "../../../jslib/common/src/abstractions/i18n.service";
import { LogService } from "../../../jslib/common/src/abstractions/log.service";
import {
@@ -10,7 +12,7 @@ import {
import { groupFixtures } from "../../../utils/google-workspace/group-fixtures";
import { userFixtures } from "../../../utils/google-workspace/user-fixtures";
import { DirectoryType } from "../../enums/directoryType";
import { StateService } from "../state.service";
import { StateService } from "../state-service/state.service";
import { GSuiteDirectoryService } from "./gsuite-directory.service";
@@ -35,6 +37,7 @@ describe("gsuiteDirectoryService", () => {
let logService: MockProxy<LogService>;
let i18nService: MockProxy<I18nService>;
let stateService: MockProxy<StateService>;
let stateServiceVNext: MockProxy<StateServiceVNext>;
let directoryService: GSuiteDirectoryService;
@@ -42,23 +45,31 @@ describe("gsuiteDirectoryService", () => {
logService = mock();
i18nService = mock();
stateService = mock();
stateServiceVNext = mock();
stateService.getDirectoryType.mockResolvedValue(DirectoryType.GSuite);
stateServiceVNext.getDirectoryType.mockResolvedValue(DirectoryType.GSuite);
stateService.getLastUserSync.mockResolvedValue(null); // do not filter results by last modified date
i18nService.t.mockImplementation((id) => id); // passthrough implementation for any error messages
directoryService = new GSuiteDirectoryService(logService, i18nService, stateService);
directoryService = new GSuiteDirectoryService(
logService,
i18nService,
stateService,
stateServiceVNext,
);
});
it("syncs without using filters (includes test data)", async () => {
const directoryConfig = getGSuiteConfiguration();
stateService.getDirectory.calledWith(DirectoryType.GSuite).mockResolvedValue(directoryConfig);
stateServiceVNext.getDirectory
.calledWith(DirectoryType.GSuite)
.mockResolvedValue(directoryConfig);
const syncConfig = getSyncConfiguration({
groups: true,
users: true,
});
stateService.getSync.mockResolvedValue(syncConfig);
stateServiceVNext.getSync.mockResolvedValue(syncConfig);
const result = await directoryService.getEntries(true, true);
@@ -68,7 +79,9 @@ describe("gsuiteDirectoryService", () => {
it("syncs using user and group filters (exact match for test data)", async () => {
const directoryConfig = getGSuiteConfiguration();
stateService.getDirectory.calledWith(DirectoryType.GSuite).mockResolvedValue(directoryConfig);
stateServiceVNext.getDirectory
.calledWith(DirectoryType.GSuite)
.mockResolvedValue(directoryConfig);
const syncConfig = getSyncConfiguration({
groups: true,
@@ -76,7 +89,7 @@ describe("gsuiteDirectoryService", () => {
userFilter: INTEGRATION_USER_FILTER,
groupFilter: INTEGRATION_GROUP_FILTER,
});
stateService.getSync.mockResolvedValue(syncConfig);
stateServiceVNext.getSync.mockResolvedValue(syncConfig);
const result = await directoryService.getEntries(true, true);

View File

@@ -4,6 +4,8 @@ import { admin_directory_v1, google } from "googleapis";
import { I18nService } from "@/jslib/common/src/abstractions/i18n.service";
import { LogService } from "@/jslib/common/src/abstractions/log.service";
import { StateServiceVNext } from "@/src/abstractions/state-vNext.service";
import { StateService } from "../../abstractions/state.service";
import { DirectoryType } from "../../enums/directoryType";
import { GroupEntry } from "../../models/groupEntry";
@@ -25,25 +27,26 @@ export class GSuiteDirectoryService extends BaseDirectoryService implements IDir
private logService: LogService,
private i18nService: I18nService,
private stateService: StateService,
private stateServiceVNext: StateServiceVNext,
) {
super();
this.service = google.admin("directory_v1");
}
async getEntries(force: boolean, test: boolean): Promise<[GroupEntry[], UserEntry[]]> {
const type = await this.stateService.getDirectoryType();
const type = await this.stateServiceVNext.getDirectoryType();
if (type !== DirectoryType.GSuite) {
return;
}
this.dirConfig = await this.stateService.getDirectory<GSuiteConfiguration>(
this.dirConfig = await this.stateServiceVNext.getDirectory<GSuiteConfiguration>(
DirectoryType.GSuite,
);
if (this.dirConfig == null) {
return;
}
this.syncConfig = await this.stateService.getSync();
this.syncConfig = await this.stateServiceVNext.getSync();
if (this.syncConfig == null) {
return;
}

View File

@@ -9,7 +9,7 @@ import {
import { groupFixtures } from "../../../utils/openldap/group-fixtures";
import { userFixtures } from "../../../utils/openldap/user-fixtures";
import { DirectoryType } from "../../enums/directoryType";
import { StateService } from "../state.service";
import { StateService } from "../state-service/state.service";
import { LdapDirectoryService } from "./ldap-directory.service";

View File

@@ -0,0 +1,488 @@
import { mock, MockProxy } from "jest-mock-extended";
import { LogService } from "@/jslib/common/src/abstractions/log.service";
import { StateMigrationService } from "@/jslib/common/src/abstractions/stateMigration.service";
import { StorageService } from "@/jslib/common/src/abstractions/storage.service";
import { DirectoryType } from "@/src/enums/directoryType";
import { EntraIdConfiguration } from "@/src/models/entraIdConfiguration";
import { GSuiteConfiguration } from "@/src/models/gsuiteConfiguration";
import { LdapConfiguration } from "@/src/models/ldapConfiguration";
import { OktaConfiguration } from "@/src/models/oktaConfiguration";
import { OneLoginConfiguration } from "@/src/models/oneLoginConfiguration";
import { StorageKeysVNext as StorageKeys, StoredSecurely } from "@/src/models/state.model";
import { SyncConfiguration } from "@/src/models/syncConfiguration";
import { StateServiceVNextImplementation } from "./state-vNext.service";
describe("StateServiceVNextImplementation", () => {
let storageService: MockProxy<StorageService>;
let secureStorageService: MockProxy<StorageService>;
let logService: MockProxy<LogService>;
let stateMigrationService: MockProxy<StateMigrationService>;
let stateService: StateServiceVNextImplementation;
beforeEach(() => {
storageService = mock<StorageService>();
secureStorageService = mock<StorageService>();
logService = mock<LogService>();
stateMigrationService = mock<StateMigrationService>();
stateService = new StateServiceVNextImplementation(
storageService,
secureStorageService,
logService,
stateMigrationService,
true, // useSecureStorageForSecrets
);
});
describe("init", () => {
it("should run migration if needed", async () => {
stateMigrationService.needsMigration.mockResolvedValue(true);
await stateService.init();
expect(stateMigrationService.needsMigration).toHaveBeenCalled();
expect(stateMigrationService.migrate).toHaveBeenCalled();
});
it("should not run migration if not needed", async () => {
stateMigrationService.needsMigration.mockResolvedValue(false);
await stateService.init();
expect(stateMigrationService.needsMigration).toHaveBeenCalled();
expect(stateMigrationService.migrate).not.toHaveBeenCalled();
});
});
describe("clean", () => {
it("should clear all directory settings and configurations", async () => {
await stateService.clean();
// Verify all directory types are cleared
expect(storageService.save).toHaveBeenCalledWith(StorageKeys.directoryType, null);
expect(storageService.save).toHaveBeenCalledWith(StorageKeys.organizationId, null);
expect(storageService.save).toHaveBeenCalledWith(StorageKeys.sync, null);
});
});
describe("Directory Type", () => {
it("should store and retrieve directory type", async () => {
storageService.get.mockResolvedValue(DirectoryType.Ldap);
await stateService.setDirectoryType(DirectoryType.Ldap);
const result = await stateService.getDirectoryType();
expect(storageService.save).toHaveBeenCalledWith(
StorageKeys.directoryType,
DirectoryType.Ldap,
);
expect(result).toBe(DirectoryType.Ldap);
});
it("should return null when directory type is not set", async () => {
storageService.get.mockResolvedValue(null);
const result = await stateService.getDirectoryType();
expect(result).toBeNull();
});
});
describe("Organization Id", () => {
it("should store and retrieve organization ID", async () => {
const orgId = "test-org-123";
storageService.get.mockResolvedValue(orgId);
await stateService.setOrganizationId(orgId);
const result = await stateService.getOrganizationId();
expect(storageService.save).toHaveBeenCalledWith(StorageKeys.organizationId, orgId);
expect(result).toBe(orgId);
});
});
describe("LDAP Configuration", () => {
it("should store and retrieve LDAP configuration with secrets in secure storage", async () => {
const config: LdapConfiguration = {
ssl: true,
startTls: false,
tlsCaPath: null,
sslAllowUnauthorized: false,
sslCertPath: null,
sslKeyPath: null,
sslCaPath: null,
hostname: "ldap.example.com",
port: 636,
domain: null,
rootPath: null,
ad: true,
username: "admin",
password: "secret-password",
currentUser: false,
pagedSearch: true,
};
secureStorageService.get.mockResolvedValue("secret-password");
storageService.get.mockResolvedValue({
...config,
password: StoredSecurely,
});
await stateService.setDirectory(DirectoryType.Ldap, config);
const result = await stateService.getDirectory<LdapConfiguration>(DirectoryType.Ldap);
// Verify password is stored in secure storage
expect(secureStorageService.save).toHaveBeenCalled();
// Verify configuration is stored
expect(storageService.save).toHaveBeenCalled();
// Verify retrieved config has real password from secure storage
expect(result?.password).toBe("secret-password");
});
it("should return null when LDAP configuration is not set", async () => {
storageService.get.mockResolvedValue(null);
const result = await stateService.getLdapConfiguration();
expect(result).toBeNull();
});
it("should handle null password in LDAP configuration", async () => {
const config: LdapConfiguration = {
ssl: true,
startTls: false,
tlsCaPath: null,
sslAllowUnauthorized: false,
sslCertPath: null,
sslKeyPath: null,
sslCaPath: null,
hostname: "ldap.example.com",
port: 636,
domain: null,
rootPath: null,
ad: true,
username: "admin",
password: null,
currentUser: false,
pagedSearch: true,
};
await stateService.setDirectory(DirectoryType.Ldap, config);
// Null passwords should call remove on the secure storage secret key
expect(secureStorageService.remove).toHaveBeenCalled();
});
});
describe("GSuite Configuration", () => {
it("should store and retrieve GSuite configuration with privateKey in secure storage", async () => {
const config: GSuiteConfiguration = {
domain: "example.com",
clientEmail: "service@example.com",
adminUser: "admin@example.com",
privateKey: "private-key-content",
customer: "customer-id",
};
secureStorageService.get.mockResolvedValue("private-key-content");
storageService.get.mockResolvedValue({
...config,
privateKey: StoredSecurely,
});
await stateService.setDirectory(DirectoryType.GSuite, config);
const result = await stateService.getDirectory<GSuiteConfiguration>(DirectoryType.GSuite);
expect(secureStorageService.save).toHaveBeenCalled();
expect(result?.privateKey).toBe("private-key-content");
});
it("should handle null privateKey in GSuite configuration", async () => {
const config: GSuiteConfiguration = {
domain: "example.com",
clientEmail: "service@example.com",
adminUser: "admin@example.com",
privateKey: null,
customer: "customer-id",
};
await stateService.setDirectory(DirectoryType.GSuite, config);
// Null privateKey should call remove on the secure storage secret key
expect(secureStorageService.remove).toHaveBeenCalled();
});
});
describe("Entra ID Configuration", () => {
it("should store and retrieve Entra ID configuration with key in secure storage", async () => {
const config: EntraIdConfiguration = {
identityAuthority: "https://login.microsoftonline.com",
tenant: "tenant-id",
applicationId: "app-id",
key: "secret-key",
};
secureStorageService.get.mockResolvedValue("secret-key");
storageService.get.mockResolvedValue({
...config,
key: StoredSecurely,
});
await stateService.setDirectory(DirectoryType.EntraID, config);
const result = await stateService.getDirectory<EntraIdConfiguration>(DirectoryType.EntraID);
expect(secureStorageService.save).toHaveBeenCalled();
expect(result?.key).toBe("secret-key");
});
it("should maintain backwards compatibility with Azure key storage", async () => {
const config: EntraIdConfiguration = {
identityAuthority: "https://login.microsoftonline.com",
tenant: "tenant-id",
applicationId: "app-id",
key: StoredSecurely,
};
storageService.get.mockResolvedValue(config);
secureStorageService.get.mockResolvedValueOnce(null); // entra key not found
secureStorageService.get.mockResolvedValueOnce("azure-secret-key"); // fallback to azure key
const result = await stateService.getDirectory<EntraIdConfiguration>(DirectoryType.EntraID);
expect(secureStorageService.get).toHaveBeenCalled();
expect(result?.key).toBe("azure-secret-key");
});
});
describe("Okta Configuration", () => {
it("should store and retrieve Okta configuration with token in secure storage", async () => {
const config: OktaConfiguration = {
orgUrl: "https://example.okta.com",
token: "okta-token",
};
secureStorageService.get.mockResolvedValue("okta-token");
storageService.get.mockResolvedValue({
...config,
token: StoredSecurely,
});
await stateService.setDirectory(DirectoryType.Okta, config);
const result = await stateService.getDirectory<OktaConfiguration>(DirectoryType.Okta);
expect(secureStorageService.save).toHaveBeenCalled();
expect(result?.token).toBe("okta-token");
});
});
describe("OneLogin Configuration", () => {
it("should store and retrieve OneLogin configuration with clientSecret in secure storage", async () => {
const config: OneLoginConfiguration = {
region: "us",
clientId: "client-id",
clientSecret: "client-secret",
};
secureStorageService.get.mockResolvedValue("client-secret");
storageService.get.mockResolvedValue({
...config,
clientSecret: StoredSecurely,
});
await stateService.setDirectory(DirectoryType.OneLogin, config);
const result = await stateService.getDirectory<OneLoginConfiguration>(DirectoryType.OneLogin);
expect(secureStorageService.save).toHaveBeenCalled();
expect(result?.clientSecret).toBe("client-secret");
});
});
describe("Sync Configuration", () => {
it("should store and retrieve sync configuration", async () => {
const syncConfig: SyncConfiguration = {
users: true,
groups: true,
interval: 5,
userFilter: null,
groupFilter: null,
removeDisabled: true,
overwriteExisting: false,
largeImport: false,
groupObjectClass: null,
userObjectClass: null,
groupPath: null,
userPath: null,
groupNameAttribute: null,
userEmailAttribute: null,
memberAttribute: "member",
creationDateAttribute: "whenCreated",
revisionDateAttribute: "whenChanged",
useEmailPrefixSuffix: false,
emailPrefixAttribute: null,
emailSuffix: null,
};
storageService.get.mockResolvedValue(syncConfig);
await stateService.setSync(syncConfig);
const result = await stateService.getSync();
expect(storageService.save).toHaveBeenCalledWith(StorageKeys.sync, syncConfig);
expect(result).toEqual(syncConfig);
});
});
describe("Sync Settings", () => {
it("should clear sync settings when clearSyncSettings is called", async () => {
await stateService.clearSyncSettings(false);
// Should set delta and sync values to null
expect(storageService.save).toHaveBeenCalled();
});
it("should clear lastSyncHash when hashToo is true", async () => {
await stateService.clearSyncSettings(true);
// Should set all values including lastSyncHash to null
expect(storageService.save).toHaveBeenCalled();
});
it("should not clear lastSyncHash when hashToo is false", async () => {
await stateService.clearSyncSettings(false);
// Should set delta and sync values but not lastSyncHash
expect(storageService.save).toHaveBeenCalled();
});
});
describe("Last Sync Hash", () => {
it("should store and retrieve last sync hash", async () => {
const hash = "hash";
storageService.get.mockResolvedValue(hash);
await stateService.setLastSyncHash(hash);
const result = await stateService.getLastSyncHash();
expect(storageService.save).toHaveBeenCalled();
expect(result).toBe(hash);
});
});
describe("Delta Tokens", () => {
it("should store and retrieve user delta token", async () => {
const token = "user-delta-token";
storageService.get.mockResolvedValue(token);
await stateService.setUserDelta(token);
const result = await stateService.getUserDelta();
expect(storageService.save).toHaveBeenCalled();
expect(result).toBe(token);
});
it("should store and retrieve group delta token", async () => {
const token = "group-delta-token";
storageService.get.mockResolvedValue(token);
await stateService.setGroupDelta(token);
const result = await stateService.getGroupDelta();
expect(storageService.save).toHaveBeenCalled();
expect(result).toBe(token);
});
});
describe("Last Sync Timestamps", () => {
it("should store and retrieve last user sync timestamp", async () => {
const timestamp = new Date("2024-01-01T00:00:00Z");
storageService.get.mockResolvedValue(timestamp.toISOString());
await stateService.setLastUserSync(timestamp);
const result = await stateService.getLastUserSync();
expect(storageService.save).toHaveBeenCalled();
expect(result?.toISOString()).toBe(timestamp.toISOString());
});
it("should store and retrieve last group sync timestamp", async () => {
const timestamp = new Date("2024-01-01T00:00:00Z");
storageService.get.mockResolvedValue(timestamp.toISOString());
await stateService.setLastGroupSync(timestamp);
const result = await stateService.getLastGroupSync();
expect(storageService.save).toHaveBeenCalled();
expect(result?.toISOString()).toBe(timestamp.toISOString());
});
it("should return null when last user sync timestamp is not set", async () => {
storageService.get.mockResolvedValue(null);
const result = await stateService.getLastUserSync();
expect(result).toBeNull();
});
it("should return null when last group sync timestamp is not set", async () => {
storageService.get.mockResolvedValue(null);
const result = await stateService.getLastGroupSync();
expect(result).toBeNull();
});
});
describe("Secure Storage Flag", () => {
it("should not separate secrets when useSecureStorageForSecrets is false", async () => {
const insecureStateService = new StateServiceVNextImplementation(
storageService,
secureStorageService,
logService,
stateMigrationService,
false, // useSecureStorageForSecrets = false
);
const config: LdapConfiguration = {
ssl: true,
startTls: false,
tlsCaPath: null,
sslAllowUnauthorized: false,
sslCertPath: null,
sslKeyPath: null,
sslCaPath: null,
hostname: "ldap.example.com",
port: 636,
domain: null,
rootPath: null,
ad: true,
username: "admin",
password: "secret-password",
currentUser: false,
pagedSearch: true,
};
storageService.get.mockResolvedValue(config);
// When useSecureStorageForSecrets is false, setDirectory doesn't process secrets
await insecureStateService.setDirectory(DirectoryType.Ldap, config);
// Retrieve config - should return password as-is from storage (not from secure storage)
const result = await insecureStateService.getDirectory<LdapConfiguration>(DirectoryType.Ldap);
// Password should be retrieved directly from storage, not secure storage
expect(result?.password).toBe("secret-password");
expect(secureStorageService.get).not.toHaveBeenCalled();
});
});
});

View File

@@ -0,0 +1,409 @@
import { LogService } from "@/jslib/common/src/abstractions/log.service";
import { StateMigrationService } from "@/jslib/common/src/abstractions/stateMigration.service";
import { StorageService } from "@/jslib/common/src/abstractions/storage.service";
import { EnvironmentUrls } from "@/jslib/common/src/models/domain/environmentUrls";
import { StorageOptions } from "@/jslib/common/src/models/domain/storageOptions";
import { StateServiceVNext as StateServiceVNextAbstraction } from "@/src/abstractions/state-vNext.service";
import { DirectoryType } from "@/src/enums/directoryType";
import { IConfiguration } from "@/src/models/IConfiguration";
import { EntraIdConfiguration } from "@/src/models/entraIdConfiguration";
import { GSuiteConfiguration } from "@/src/models/gsuiteConfiguration";
import { LdapConfiguration } from "@/src/models/ldapConfiguration";
import { OktaConfiguration } from "@/src/models/oktaConfiguration";
import { OneLoginConfiguration } from "@/src/models/oneLoginConfiguration";
import {
SecureStorageKeysVNext as SecureStorageKeys,
StorageKeysVNext as StorageKeys,
StoredSecurely,
} from "@/src/models/state.model";
import { SyncConfiguration } from "@/src/models/syncConfiguration";
export class StateServiceVNextImplementation implements StateServiceVNextAbstraction {
constructor(
protected storageService: StorageService,
protected secureStorageService: StorageService,
protected logService: LogService,
protected stateMigrationService: StateMigrationService,
private useSecureStorageForSecrets = true,
) {}
async init(): Promise<void> {
if (await this.stateMigrationService.needsMigration()) {
await this.stateMigrationService.migrate();
}
}
async clean(options?: StorageOptions): Promise<void> {
// Clear all directory settings and configurations
// but preserve version and environment settings
await this.setDirectoryType(null);
await this.setOrganizationId(null);
await this.setSync(null);
await this.setLdapConfiguration(null);
await this.setGsuiteConfiguration(null);
await this.setEntraConfiguration(null);
await this.setOktaConfiguration(null);
await this.setOneLoginConfiguration(null);
await this.clearSyncSettings(true);
}
// ===================================================================
// Directory Configuration Methods
// ===================================================================
async getDirectory<T extends IConfiguration>(type: DirectoryType): Promise<T> {
const config = await this.getConfiguration(type);
if (config == null) {
return config as T;
}
if (this.useSecureStorageForSecrets) {
// Create a copy to avoid modifying the cached config
const configWithSecrets = Object.assign({}, config);
switch (type) {
case DirectoryType.Ldap:
(configWithSecrets as any).password = await this.getLdapSecret();
break;
case DirectoryType.EntraID:
(configWithSecrets as any).key = await this.getEntraSecret();
break;
case DirectoryType.Okta:
(configWithSecrets as any).token = await this.getOktaSecret();
break;
case DirectoryType.GSuite:
(configWithSecrets as any).privateKey = await this.getGsuiteSecret();
break;
case DirectoryType.OneLogin:
(configWithSecrets as any).clientSecret = await this.getOneLoginSecret();
break;
}
return configWithSecrets as T;
}
return config as T;
}
async setDirectory(
type: DirectoryType,
config:
| LdapConfiguration
| GSuiteConfiguration
| EntraIdConfiguration
| OktaConfiguration
| OneLoginConfiguration,
): Promise<any> {
if (this.useSecureStorageForSecrets) {
switch (type) {
case DirectoryType.Ldap: {
const ldapConfig = config as LdapConfiguration;
await this.setLdapSecret(ldapConfig.password);
ldapConfig.password = StoredSecurely;
await this.setLdapConfiguration(ldapConfig);
break;
}
case DirectoryType.EntraID: {
const entraConfig = config as EntraIdConfiguration;
await this.setEntraSecret(entraConfig.key);
entraConfig.key = StoredSecurely;
await this.setEntraConfiguration(entraConfig);
break;
}
case DirectoryType.Okta: {
const oktaConfig = config as OktaConfiguration;
await this.setOktaSecret(oktaConfig.token);
oktaConfig.token = StoredSecurely;
await this.setOktaConfiguration(oktaConfig);
break;
}
case DirectoryType.GSuite: {
const gsuiteConfig = config as GSuiteConfiguration;
if (gsuiteConfig.privateKey == null) {
await this.setGsuiteSecret(null);
} else {
const normalizedPrivateKey = gsuiteConfig.privateKey.replace(/\\n/g, "\n");
await this.setGsuiteSecret(normalizedPrivateKey);
gsuiteConfig.privateKey = StoredSecurely;
}
await this.setGsuiteConfiguration(gsuiteConfig);
break;
}
case DirectoryType.OneLogin: {
const oneLoginConfig = config as OneLoginConfiguration;
await this.setOneLoginSecret(oneLoginConfig.clientSecret);
oneLoginConfig.clientSecret = StoredSecurely;
await this.setOneLoginConfiguration(oneLoginConfig);
break;
}
}
}
}
async getConfiguration(type: DirectoryType): Promise<IConfiguration> {
switch (type) {
case DirectoryType.Ldap:
return await this.getLdapConfiguration();
case DirectoryType.GSuite:
return await this.getGsuiteConfiguration();
case DirectoryType.EntraID:
return await this.getEntraConfiguration();
case DirectoryType.Okta:
return await this.getOktaConfiguration();
case DirectoryType.OneLogin:
return await this.getOneLoginConfiguration();
}
}
// ===================================================================
// Secret Storage Methods (Secure Storage)
// ===================================================================
private async getLdapSecret(): Promise<string> {
return await this.secureStorageService.get<string>(SecureStorageKeys.ldap);
}
private async setLdapSecret(value: string): Promise<void> {
if (value == null) {
await this.secureStorageService.remove(SecureStorageKeys.ldap);
} else {
await this.secureStorageService.save(SecureStorageKeys.ldap, value);
}
}
private async getGsuiteSecret(): Promise<string> {
return await this.secureStorageService.get<string>(SecureStorageKeys.gsuite);
}
private async setGsuiteSecret(value: string): Promise<void> {
if (value == null) {
await this.secureStorageService.remove(SecureStorageKeys.gsuite);
} else {
await this.secureStorageService.save(SecureStorageKeys.gsuite, value);
}
}
private async getEntraSecret(): Promise<string> {
// Try new key first, fall back to old azure key for backwards compatibility
const entraKey = await this.secureStorageService.get<string>(SecureStorageKeys.entra);
if (entraKey != null) {
return entraKey;
}
return await this.secureStorageService.get<string>(SecureStorageKeys.azure);
}
private async setEntraSecret(value: string): Promise<void> {
if (value == null) {
await this.secureStorageService.remove(SecureStorageKeys.entra);
await this.secureStorageService.remove(SecureStorageKeys.azure);
} else {
await this.secureStorageService.save(SecureStorageKeys.entra, value);
}
}
private async getOktaSecret(): Promise<string> {
return await this.secureStorageService.get<string>(SecureStorageKeys.okta);
}
private async setOktaSecret(value: string): Promise<void> {
if (value == null) {
await this.secureStorageService.remove(SecureStorageKeys.okta);
} else {
await this.secureStorageService.save(SecureStorageKeys.okta, value);
}
}
private async getOneLoginSecret(): Promise<string> {
return await this.secureStorageService.get<string>(SecureStorageKeys.oneLogin);
}
private async setOneLoginSecret(value: string): Promise<void> {
if (value == null) {
await this.secureStorageService.remove(SecureStorageKeys.oneLogin);
} else {
await this.secureStorageService.save(SecureStorageKeys.oneLogin, value);
}
}
// ===================================================================
// Directory-Specific Configuration Methods
// ===================================================================
async getLdapConfiguration(options?: StorageOptions): Promise<LdapConfiguration> {
return await this.storageService.get<LdapConfiguration>(StorageKeys.directory_ldap);
}
async setLdapConfiguration(value: LdapConfiguration, options?: StorageOptions): Promise<void> {
await this.storageService.save(StorageKeys.directory_ldap, value);
}
async getGsuiteConfiguration(options?: StorageOptions): Promise<GSuiteConfiguration> {
return await this.storageService.get<GSuiteConfiguration>(StorageKeys.directory_gsuite);
}
async setGsuiteConfiguration(
value: GSuiteConfiguration,
options?: StorageOptions,
): Promise<void> {
await this.storageService.save(StorageKeys.directory_gsuite, value);
}
async getEntraConfiguration(options?: StorageOptions): Promise<EntraIdConfiguration> {
return await this.storageService.get<EntraIdConfiguration>(StorageKeys.directory_entra);
}
async setEntraConfiguration(
value: EntraIdConfiguration,
options?: StorageOptions,
): Promise<void> {
await this.storageService.save(StorageKeys.directory_entra, value);
}
async getOktaConfiguration(options?: StorageOptions): Promise<OktaConfiguration> {
return await this.storageService.get<OktaConfiguration>(StorageKeys.directory_okta);
}
async setOktaConfiguration(value: OktaConfiguration, options?: StorageOptions): Promise<void> {
await this.storageService.save(StorageKeys.directory_okta, value);
}
async getOneLoginConfiguration(options?: StorageOptions): Promise<OneLoginConfiguration> {
return await this.storageService.get<OneLoginConfiguration>(StorageKeys.directory_onelogin);
}
async setOneLoginConfiguration(
value: OneLoginConfiguration,
options?: StorageOptions,
): Promise<void> {
await this.storageService.save(StorageKeys.directory_onelogin, value);
}
// ===================================================================
// Directory Settings Methods
// ===================================================================
async getOrganizationId(options?: StorageOptions): Promise<string> {
return await this.storageService.get<string>(StorageKeys.organizationId);
}
async setOrganizationId(value: string, options?: StorageOptions): Promise<void> {
const currentId = await this.getOrganizationId();
if (currentId !== value) {
await this.clearSyncSettings();
}
await this.storageService.save(StorageKeys.organizationId, value);
}
async getSync(options?: StorageOptions): Promise<SyncConfiguration> {
return await this.storageService.get<SyncConfiguration>(StorageKeys.sync);
}
async setSync(value: SyncConfiguration, options?: StorageOptions): Promise<void> {
await this.storageService.save(StorageKeys.sync, value);
}
async getDirectoryType(options?: StorageOptions): Promise<DirectoryType> {
return await this.storageService.get<DirectoryType>(StorageKeys.directoryType);
}
async setDirectoryType(value: DirectoryType, options?: StorageOptions): Promise<void> {
const currentType = await this.getDirectoryType();
if (value !== currentType) {
await this.clearSyncSettings();
}
await this.storageService.save(StorageKeys.directoryType, value);
}
async getLastUserSync(options?: StorageOptions): Promise<Date> {
const dateString = await this.storageService.get<string>(SecureStorageKeys.lastUserSync);
return dateString ? new Date(dateString) : null;
}
async setLastUserSync(value: Date, options?: StorageOptions): Promise<void> {
await this.storageService.save(SecureStorageKeys.lastUserSync, value);
}
async getLastGroupSync(options?: StorageOptions): Promise<Date> {
const dateString = await this.storageService.get<string>(SecureStorageKeys.lastGroupSync);
return dateString ? new Date(dateString) : null;
}
async setLastGroupSync(value: Date, options?: StorageOptions): Promise<void> {
await this.storageService.save(SecureStorageKeys.lastGroupSync, value);
}
async getLastSyncHash(options?: StorageOptions): Promise<string> {
return await this.storageService.get<string>(SecureStorageKeys.lastSyncHash);
}
async setLastSyncHash(value: string, options?: StorageOptions): Promise<void> {
await this.storageService.save(SecureStorageKeys.lastSyncHash, value);
}
async getSyncingDir(options?: StorageOptions): Promise<boolean> {
return await this.storageService.get<boolean>(StorageKeys.syncingDir);
}
async setSyncingDir(value: boolean, options?: StorageOptions): Promise<void> {
await this.storageService.save(StorageKeys.syncingDir, value);
}
async getUserDelta(options?: StorageOptions): Promise<string> {
return await this.storageService.get<string>(SecureStorageKeys.userDelta);
}
async setUserDelta(value: string, options?: StorageOptions): Promise<void> {
await this.storageService.save(SecureStorageKeys.userDelta, value);
}
async getGroupDelta(options?: StorageOptions): Promise<string> {
return await this.storageService.get<string>(SecureStorageKeys.groupDelta);
}
async setGroupDelta(value: string, options?: StorageOptions): Promise<void> {
await this.storageService.save(SecureStorageKeys.groupDelta, value);
}
async clearSyncSettings(hashToo = false): Promise<void> {
await this.setUserDelta(null);
await this.setGroupDelta(null);
await this.setLastGroupSync(null);
await this.setLastUserSync(null);
if (hashToo) {
await this.setLastSyncHash(null);
}
}
// ===================================================================
// Environment URLs (inherited from base, simplified implementation)
// ===================================================================
async getEnvironmentUrls(options?: StorageOptions): Promise<EnvironmentUrls> {
return await this.storageService.get<EnvironmentUrls>("environmentUrls");
}
async setEnvironmentUrls(value: EnvironmentUrls): Promise<void> {
await this.storageService.save("environmentUrls", value);
}
// ===================================================================
// Additional State Methods
// ===================================================================
async getLocale(options?: StorageOptions): Promise<string> {
return await this.storageService.get<string>("locale");
}
async setLocale(value: string, options?: StorageOptions): Promise<void> {
await this.storageService.save("locale", value);
}
async getInstalledVersion(options?: StorageOptions): Promise<string> {
return await this.storageService.get<string>("installedVersion");
}
async setInstalledVersion(value: string, options?: StorageOptions): Promise<void> {
await this.storageService.save("installedVersion", value);
}
}

View File

@@ -16,32 +16,13 @@ import { GSuiteConfiguration } from "@/src/models/gsuiteConfiguration";
import { LdapConfiguration } from "@/src/models/ldapConfiguration";
import { OktaConfiguration } from "@/src/models/oktaConfiguration";
import { OneLoginConfiguration } from "@/src/models/oneLoginConfiguration";
import {
SecureStorageKeysLegacy as SecureStorageKeys,
StoredSecurely,
TempKeys as keys,
} from "@/src/models/state.model";
import { SyncConfiguration } from "@/src/models/syncConfiguration";
const SecureStorageKeys = {
ldap: "ldapPassword",
gsuite: "gsuitePrivateKey",
// Azure Active Directory was renamed to Entra ID, but we've kept the old property name
// to be backwards compatible with existing configurations.
azure: "azureKey",
entra: "entraKey",
okta: "oktaToken",
oneLogin: "oneLoginClientSecret",
userDelta: "userDeltaToken",
groupDelta: "groupDeltaToken",
lastUserSync: "lastUserSync",
lastGroupSync: "lastGroupSync",
lastSyncHash: "lastSyncHash",
};
const keys = {
tempAccountSettings: "tempAccountSettings",
tempDirectoryConfigs: "tempDirectoryConfigs",
tempDirectorySettings: "tempDirectorySettings",
};
const StoredSecurely = "[STORED SECURELY]";
export class StateService
extends BaseStateService<GlobalState, Account>
implements StateServiceAbstraction

View File

@@ -8,48 +8,14 @@ import { GSuiteConfiguration } from "@/src/models/gsuiteConfiguration";
import { LdapConfiguration } from "@/src/models/ldapConfiguration";
import { OktaConfiguration } from "@/src/models/oktaConfiguration";
import { OneLoginConfiguration } from "@/src/models/oneLoginConfiguration";
import {
MigrationClientKeys as ClientKeys,
MigrationKeys as Keys,
MigrationStateKeys as StateKeys,
SecureStorageKeysMigration as SecureStorageKeys,
} from "@/src/models/state.model";
import { SyncConfiguration } from "@/src/models/syncConfiguration";
const SecureStorageKeys: { [key: string]: any } = {
ldap: "ldapPassword",
gsuite: "gsuitePrivateKey",
azure: "azureKey",
entra: "entraIdKey",
okta: "oktaToken",
oneLogin: "oneLoginClientSecret",
directoryConfigPrefix: "directoryConfig_",
sync: "syncConfig",
directoryType: "directoryType",
organizationId: "organizationId",
};
const Keys: { [key: string]: any } = {
entityId: "entityId",
directoryType: "directoryType",
organizationId: "organizationId",
lastUserSync: "lastUserSync",
lastGroupSync: "lastGroupSync",
lastSyncHash: "lastSyncHash",
syncingDir: "syncingDir",
syncConfig: "syncConfig",
userDelta: "userDeltaToken",
groupDelta: "groupDeltaToken",
tempDirectoryConfigs: "tempDirectoryConfigs",
tempDirectorySettings: "tempDirectorySettings",
};
const StateKeys = {
global: "global",
authenticatedAccounts: "authenticatedAccounts",
};
const ClientKeys: { [key: string]: any } = {
clientIdOld: "clientId",
clientId: "apikey_clientId",
clientSecretOld: "clientSecret",
clientSecret: "apikey_clientSecret",
};
export class StateMigrationService extends BaseStateMigrationService {
async migrate(): Promise<void> {
let currentStateVersion = await this.getCurrentStateVersion();
@@ -61,6 +27,13 @@ export class StateMigrationService extends BaseStateMigrationService {
break;
case StateVersion.Two:
await this.migrateStateFrom2To3();
break;
case StateVersion.Three:
await this.migrateStateFrom3To4();
break;
case StateVersion.Four:
await this.migrateStateFrom4To5();
break;
}
currentStateVersion += 1;
}
@@ -198,4 +171,124 @@ export class StateMigrationService extends BaseStateMigrationService {
globals.stateVersion = StateVersion.Three;
await this.set(StateKeys.global, globals);
}
/**
* Migrate from State v4 (Account-based hierarchy) to v5 (flat key-value structure)
*
* This is a clean break from the Account-based structure. Data is extracted from
* the account and saved into flat keys for simpler access.
*
* Old structure: authenticatedAccounts -> userId -> account.directorySettings/directoryConfigurations
* New structure: flat keys like "directoryType", "organizationId", "directory_ldap", etc.
*
* Secrets migrate from: {userId}_{secretKey} -> secret_{secretKey}
*/
protected async migrateStateFrom4To5(useSecureStorageForSecrets = true): Promise<void> {
// Get the authenticated user IDs from v3 structure
const authenticatedUserIds = await this.get<string[]>(StateKeys.authenticatedAccounts);
if (
!authenticatedUserIds ||
!Array.isArray(authenticatedUserIds) ||
authenticatedUserIds.length === 0
) {
// No accounts to migrate, just update version
const globals = await this.getGlobals();
globals.stateVersion = StateVersion.Five;
await this.set(StateKeys.global, globals);
return;
}
// DC is single-user, so we take the first (and likely only) account
const userId = authenticatedUserIds[0];
const account = await this.get<Account>(userId);
if (!account) {
// No account data found, just update version
const globals = await this.getGlobals();
globals.stateVersion = StateVersion.Five;
await this.set(StateKeys.global, globals);
return;
}
// Migrate directory configurations to flat structure
if (account.directoryConfigurations) {
if (account.directoryConfigurations.ldap) {
await this.set("directory_ldap", account.directoryConfigurations.ldap);
}
if (account.directoryConfigurations.gsuite) {
await this.set("directory_gsuite", account.directoryConfigurations.gsuite);
}
if (account.directoryConfigurations.entra) {
await this.set("directory_entra", account.directoryConfigurations.entra);
} else if (account.directoryConfigurations.azure) {
// Backwards compatibility: migrate azure to entra
await this.set("directory_entra", account.directoryConfigurations.azure);
}
if (account.directoryConfigurations.okta) {
await this.set("directory_okta", account.directoryConfigurations.okta);
}
if (account.directoryConfigurations.oneLogin) {
await this.set("directory_onelogin", account.directoryConfigurations.oneLogin);
}
}
// Migrate directory settings to flat structure
if (account.directorySettings) {
if (account.directorySettings.organizationId) {
await this.set("organizationId", account.directorySettings.organizationId);
}
if (account.directorySettings.directoryType != null) {
await this.set("directoryType", account.directorySettings.directoryType);
}
if (account.directorySettings.sync) {
await this.set("sync", account.directorySettings.sync);
}
if (account.directorySettings.lastUserSync) {
await this.set("lastUserSync", account.directorySettings.lastUserSync);
}
if (account.directorySettings.lastGroupSync) {
await this.set("lastGroupSync", account.directorySettings.lastGroupSync);
}
if (account.directorySettings.lastSyncHash) {
await this.set("lastSyncHash", account.directorySettings.lastSyncHash);
}
if (account.directorySettings.userDelta) {
await this.set("userDelta", account.directorySettings.userDelta);
}
if (account.directorySettings.groupDelta) {
await this.set("groupDelta", account.directorySettings.groupDelta);
}
if (account.directorySettings.syncingDir != null) {
await this.set("syncingDir", account.directorySettings.syncingDir);
}
}
// Migrate secrets from {userId}_* to secret_* pattern
if (useSecureStorageForSecrets) {
const oldSecretKeys = [
{ old: `${userId}_${SecureStorageKeys.ldap}`, new: "secret_ldap" },
{ old: `${userId}_${SecureStorageKeys.gsuite}`, new: "secret_gsuite" },
{ old: `${userId}_${SecureStorageKeys.azure}`, new: "secret_azure" },
{ old: `${userId}_${SecureStorageKeys.entra}`, new: "secret_entra" },
{ old: `${userId}_${SecureStorageKeys.okta}`, new: "secret_okta" },
{ old: `${userId}_${SecureStorageKeys.oneLogin}`, new: "secret_onelogin" },
];
for (const { old: oldKey, new: newKey } of oldSecretKeys) {
if (await this.secureStorageService.has(oldKey)) {
const value = await this.secureStorageService.get(oldKey);
if (value) {
await this.secureStorageService.save(newKey, value);
}
// @TODO Keep old key for now - will remove in future release
// await this.secureStorageService.remove(oldKey);
}
}
}
const globals = await this.getGlobals();
globals.stateVersion = StateVersion.Five;
await this.set(StateKeys.global, globals);
}
}

View File

@@ -14,7 +14,7 @@ import { DirectoryType } from "../enums/directoryType";
import { BatchRequestBuilder } from "./batch-request-builder";
import { LdapDirectoryService } from "./directory-services/ldap-directory.service";
import { SingleRequestBuilder } from "./single-request-builder";
import { StateService } from "./state.service";
import { StateService } from "./state-service/state.service";
import { SyncService } from "./sync.service";
import * as constants from "./sync.service";

View File

@@ -14,7 +14,7 @@ import { BatchRequestBuilder } from "./batch-request-builder";
import { LdapDirectoryService } from "./directory-services/ldap-directory.service";
import { I18nService } from "./i18n.service";
import { SingleRequestBuilder } from "./single-request-builder";
import { StateService } from "./state.service";
import { StateService } from "./state-service/state.service";
import { SyncService } from "./sync.service";
import * as constants from "./sync.service";

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",