mirror of
https://github.com/bitwarden/directory-connector
synced 2026-02-12 06:23:42 +00:00
Compare commits
89 Commits
v2025.9.0
...
dev-clarit
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
623382f9e1 | ||
|
|
1aad9e1cbe | ||
|
|
3059934d4c | ||
|
|
42cf13df08 | ||
|
|
1a9f0a2ca7 | ||
|
|
30b3595de3 | ||
|
|
28f0ff4b24 | ||
|
|
14fc69c810 | ||
|
|
1ad0aea61f | ||
|
|
f41156969c | ||
|
|
39b151b1e0 | ||
|
|
483f26fa6f | ||
|
|
8849385d1b | ||
|
|
a7aff97360 | ||
|
|
7381857296 | ||
|
|
ba17d5b438 | ||
|
|
b5d31e693b | ||
|
|
2854a2eba1 | ||
|
|
4485ecab3c | ||
|
|
9e3b2d2d95 | ||
|
|
b2997358dc | ||
|
|
db258f0191 | ||
|
|
19d7884933 | ||
|
|
21ce02f431 | ||
|
|
1af8fc1067 | ||
|
|
6c2f54bad5 | ||
|
|
bb9a6a61ee | ||
|
|
f0a19b6267 | ||
|
|
220d6c02c7 | ||
|
|
321db6e771 | ||
|
|
554e14d7a8 | ||
|
|
f195e27938 | ||
|
|
d1ac1e667e | ||
|
|
b9867b131f | ||
|
|
bb165441ee | ||
|
|
b8964aa382 | ||
|
|
db5268ccd1 | ||
|
|
9a719c9e4e | ||
|
|
2f49f4d5f1 | ||
|
|
5426f251a7 | ||
|
|
a0c30350d4 | ||
|
|
6f3d8f73e1 | ||
|
|
d7be5486c7 | ||
|
|
ce43f651ab | ||
|
|
eda713bcc9 | ||
|
|
b53e145e62 | ||
|
|
2ad35be82e | ||
|
|
bdfc8ae5eb | ||
|
|
7d218eac2f | ||
|
|
ccbb24d504 | ||
|
|
dd1f36e3d6 | ||
|
|
0780f9a931 | ||
|
|
62c8a64298 | ||
|
|
0d3bbc1db8 | ||
|
|
99655a0abf | ||
|
|
2883ff6068 | ||
|
|
f5abaf114a | ||
|
|
5792578946 | ||
|
|
6b3b29a1a0 | ||
|
|
02809be178 | ||
|
|
6abfdd8a88 | ||
|
|
b95f57c4e7 | ||
|
|
9ecfc29ae4 | ||
|
|
e32f29b8e7 | ||
|
|
e333db372d | ||
|
|
a44eb28be8 | ||
|
|
ab436551de | ||
|
|
10e17adfb2 | ||
|
|
c7db8376ec | ||
|
|
bc996d680f | ||
|
|
fe01b49df1 | ||
|
|
daeb96713f | ||
|
|
f6791dabef | ||
|
|
a3a5ed8531 | ||
|
|
d3d62c30aa | ||
|
|
f81155b6b3 | ||
|
|
57a3ef04cc | ||
|
|
4e21b28276 | ||
|
|
1c2a0c677b | ||
|
|
5666f09e89 | ||
|
|
b13895bdd6 | ||
|
|
29fc4ad61e | ||
|
|
f722196149 | ||
|
|
a4ec6df118 | ||
|
|
01e60bf090 | ||
|
|
7c27202dab | ||
|
|
77ea7a395d | ||
|
|
a259de8b26 | ||
|
|
06dbc14136 |
619
.claude/CLAUDE.md
Normal file
619
.claude/CLAUDE.md
Normal file
@@ -0,0 +1,619 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
# Bitwarden Directory Connector
|
||||
|
||||
## Project Overview
|
||||
|
||||
Directory Connector is a TypeScript application that synchronizes users and groups from directory services to Bitwarden organizations. It provides both a desktop GUI (built with Angular and Electron) and a CLI tool (bwdc).
|
||||
|
||||
**Supported Directory Services:**
|
||||
|
||||
- LDAP (Lightweight Directory Access Protocol) - includes Active Directory and general LDAP servers
|
||||
- Microsoft Entra ID (formerly Azure Active Directory)
|
||||
- Google Workspace
|
||||
- Okta
|
||||
- OneLogin
|
||||
|
||||
**Technologies:**
|
||||
|
||||
- TypeScript
|
||||
- Angular (GUI)
|
||||
- Electron (Desktop wrapper)
|
||||
- Node
|
||||
- Jest for testing
|
||||
|
||||
### Current Project Status
|
||||
|
||||
**Mission Critical but Deprioritized:** Directory Connector is used to sync customer directory services with their Bitwarden organization. While SCIM is the more modern cloud-hosted solution, not all directory services support SCIM, and SCIM is only available on Enterprise plans. Therefore, DC remains mission-critical infrastructure for many paying customers, but it's deprioritized in the codebase due to infrequent changes.
|
||||
|
||||
**Isolated Repository:** Unlike other Bitwarden client applications that live in a monorepo with shared core libraries, Directory Connector was kept separate when other TypeScript clients moved to the monorepo. It got its own copy of the jslib repo to avoid unnecessary regressions from apparently unrelated code changes in other clients. This severed it from the rest of the codebase, causing:
|
||||
|
||||
- Outdated dependencies that can't be updated (ES modules vs CommonJS conflicts)
|
||||
- File/folder structure that doesn't match modern Bitwarden client patterns
|
||||
- Accumulated technical debt requiring significant investment to pay down
|
||||
- jslib contains unused code from all clients, but cannot be deleted due to monolithic/tightly coupled architecture
|
||||
|
||||
**Critical Issues (Current Status):**
|
||||
|
||||
- ✅ ~~Electron, Node, and Angular are on unmaintained versions~~ **RESOLVED** - All updated (Electron 39, Node 20, Angular 21, TypeScript 5.9)
|
||||
- ❌ `keytar` is archived (Dec 2022) and incompatible with Node v22, **blocking Node upgrades beyond v20** - **PRIMARY BLOCKER**
|
||||
- ❌ No ESM support blocks dependency upgrades: googleapis, lowdb, chalk, inquirer, node-fetch, electron-store
|
||||
- ⚠️ 70 dev dependencies + 31 runtime dependencies = excessive maintenance burden (count increased with Angular 21 tooling)
|
||||
- ❌ StateService is a large pre-StateProvider monolith containing every getter/setter for all clients (PM-31159 In Progress)
|
||||
- ✅ ~~Angular CLI not used~~ **RESOLVED** - Angular CLI 21.1.2 now integrated with angular.json configuration
|
||||
|
||||
**Development Approach:** When working on this codebase, prioritize sustainability and maintainability over adding new features. Consider how changes will affect long-term maintenance burden.
|
||||
|
||||
## Tech Debt Roadmap
|
||||
|
||||
### Progress Summary
|
||||
|
||||
**Completed:**
|
||||
|
||||
- ✅ Phase 0 (Immediate Priority): All major dependencies upgraded (Node 20, Angular 21, TypeScript 5.9, Electron 39)
|
||||
- ✅ Phase 6: Angular CLI integration complete
|
||||
|
||||
**In Progress:**
|
||||
|
||||
- 🔄 Phase 1: StateService rewrite (PM-31159)
|
||||
|
||||
**Blocked/Todo:**
|
||||
|
||||
- ❌ Phase 2: Remove remaining jslib code (blocked by Phase 1)
|
||||
- ❌ Phase 3: Repository restructure (should be done before Phase 5)
|
||||
- ⚠️ Phase 4: Replace Keytar **[CRITICAL BLOCKER]** - blocking Node v22+ upgrades
|
||||
- ❌ Phase 5: ESM Support (blocked by Phase 3, needed for googleapis, lowdb, chalk, inquirer, etc.)
|
||||
|
||||
**Primary Blocker:** Keytar removal (Phase 4) is the most critical task as it blocks Node upgrades beyond v20.
|
||||
|
||||
---
|
||||
|
||||
### ✅ Immediate Priority: Unsupported Dependencies (COMPLETED)
|
||||
|
||||
**Upgrade Path (July 2025 release) - STATUS: COMPLETE**
|
||||
|
||||
All major version upgrades have been completed and exceeded targets:
|
||||
|
||||
1. ✅ Node 18.20.8 → 20.18 → **COMPLETE** (engines: `~20`, .nvmrc: `v20`)
|
||||
2. ✅ Angular 17 → 18.2.x → **EXCEEDED** (now at **21.1.1**)
|
||||
3. ✅ TypeScript 5.4.5 → 5.6.0 → **EXCEEDED** (now at **5.9.3**)
|
||||
4. ✅ Electron 34 → 36 → **EXCEEDED** (now at **39.2.1**)
|
||||
5. ✅ Angular matches clients monorepo version (21.x)
|
||||
|
||||
**Current Versions:**
|
||||
|
||||
- Node: v20 (project target), blocked from v22+ by keytar
|
||||
- TypeScript: 5.9.3
|
||||
- Angular: 21.1.1 (all packages)
|
||||
- Electron: 39.2.1 (well beyond EOL target of 36)
|
||||
- @yao-pkg/pkg: 5.16.1 (community fork replacing archived pkg)
|
||||
|
||||
**Note:** Further Node upgrades to v22+ are **blocked by keytar** (see Phase 4). Electron 36 was EOL October 2028, but we're already on 39.2.1.
|
||||
|
||||
### Phase 1: StateService Rewrite (PM-31159, In Progress)
|
||||
|
||||
**Problem:** StateService is a post-account-switching, pre-StateProvider monolith containing every getter/setter for all clients. This prevents deletion of unused data models and code. Never very stable, and more complex than DC needs (DC doesn't need account switching).
|
||||
|
||||
**Current Status:** 🔄 **Active PR** - [#990](https://github.com/bitwarden/directory-connector/pull/990) (Open, Author: @BTreston)
|
||||
|
||||
- PR created: Feb 2, 2026
|
||||
- Last updated: Feb 5, 2026
|
||||
- Files changed: 17 files (+1,512, -41 lines)
|
||||
- Commits: 4 (scaffold, add tests, fix type issues, fix integration test)
|
||||
|
||||
**Implementation Details:**
|
||||
|
||||
**New Architecture:**
|
||||
|
||||
- Created `StateServiceVNext` interface (`src/abstractions/state-vNext.service.ts`)
|
||||
- New implementation: `StateServiceVNextImplementation` (`src/services/state-service/state-vNext.service.ts`)
|
||||
- New state model with flat key-value structure (`src/models/state.model.ts`)
|
||||
- Comprehensive test suite: `state-vNext.service.spec.ts` (488 lines of tests)
|
||||
|
||||
**Storage Key Structure:**
|
||||
|
||||
```typescript
|
||||
// vNext Storage Keys (Flat key-value structure)
|
||||
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",
|
||||
};
|
||||
|
||||
// Secure storage keys for sensitive data
|
||||
SecureStorageKeysVNext = {
|
||||
ldap: "secret_ldap",
|
||||
gsuite: "secret_gsuite",
|
||||
azure: "secret_azure", // Backwards compatible with old name
|
||||
entra: "secret_entra",
|
||||
okta: "secret_okta",
|
||||
oneLogin: "secret_oneLogin",
|
||||
userDelta: "userDeltaToken",
|
||||
groupDelta: "groupDeltaToken",
|
||||
lastUserSync: "lastUserSync",
|
||||
lastGroupSync: "lastGroupSync",
|
||||
lastSyncHash: "lastSyncHash",
|
||||
};
|
||||
```
|
||||
|
||||
**Migration Strategy:**
|
||||
|
||||
- State version bumped to `StateVersion.Five` (`jslib/common/src/enums/stateVersion.ts`)
|
||||
- Enhanced `StateMigrationService` to handle migration from old account-based structure to new flat structure
|
||||
- Migration keys defined for backwards compatibility (`MigrationKeys`, `SecureStorageKeysMigration`)
|
||||
- Temporary keys used during migration (`TempKeys`) to preserve data during transition
|
||||
|
||||
**File Organization:**
|
||||
|
||||
- State-related files moved to `src/services/state-service/` subdirectory:
|
||||
- `state-vNext.service.ts` (new implementation)
|
||||
- `state-vNext.service.spec.ts` (488 lines of tests)
|
||||
- `state.service.ts` (legacy, moved from `src/services/`)
|
||||
- `stateMigration.service.ts` (enhanced for v5 migration)
|
||||
- New abstraction: `src/abstractions/state-vNext.service.ts`
|
||||
- New model: `src/models/state.model.ts` (defines all storage keys)
|
||||
|
||||
**Integration:**
|
||||
|
||||
- Both old `StateService` and new `StateServiceVNext` injected in parallel during migration phase
|
||||
- `DirectoryFactoryService` updated to accept both services
|
||||
- Services module provides both implementations
|
||||
- CLI (`bwdc.ts`) and GUI (`main.ts`) both instantiate new service alongside old one
|
||||
|
||||
**Chosen Approach Benefits:**
|
||||
|
||||
- Clean break with old StateService - high degree of certainty
|
||||
- Simple and focused on DC's needs (no account switching, no rxjs)
|
||||
- Flat key-value structure easier to maintain
|
||||
- Versioning and migration capabilities included
|
||||
- Keeps existing data.json around during transition
|
||||
- All getters/setters in one place (acceptable for small application)
|
||||
|
||||
**Rejected Approaches:**
|
||||
|
||||
- Copy StateProvider from clients: Too complex (supports account switching, rxjs, syncing background/foreground contexts)
|
||||
- Rewrite simplified StateService keeping current data structure: Commits us to previous decisions, keeps monolithic account objects
|
||||
|
||||
**Next Steps:**
|
||||
|
||||
- Complete PR review and merge
|
||||
- Monitor for regressions during initial rollout
|
||||
- After several releases, can remove old StateService and migration code
|
||||
- Begin Phase 2: Remove remaining jslib code that was only needed by old StateService
|
||||
|
||||
### Phase 2: Remove Remaining jslib Code
|
||||
|
||||
After StateService is removed, review and delete old models and remaining services that referenced each other. jslib contains unused code from all clients that DC doesn't need.
|
||||
|
||||
### Phase 3: Restructure Repository (PM-31852, To Do)
|
||||
|
||||
**Current Structure:**
|
||||
|
||||
```
|
||||
src/ # Both Electron and CLI app code
|
||||
src-cli/ # package.json entry point for CLI only, no code
|
||||
jslib/
|
||||
├── common/ # Shared common code
|
||||
├── node/ # Node specific code used in CLI
|
||||
└── electron/ # Electron specific code used in GUI
|
||||
```
|
||||
|
||||
**Target Structure:**
|
||||
|
||||
```
|
||||
src-gui/ # Electron specific code only (combining src (partial) + jslib/electron)
|
||||
src-cli/ # Node and CLI specific code only (combining src (partial) + jslib/node)
|
||||
libs/ # Shared app-independent DC code, e.g. sync services (combining src (partial) + jslib/common)
|
||||
```
|
||||
|
||||
**Why:** Makes subsequent changes (code reorganizing, ESM support) much easier. This should be done early in the modernization process.
|
||||
|
||||
### Phase 4: Replace Keytar (PM-12436, To Do) ⚠️ **CRITICAL BLOCKER**
|
||||
|
||||
**Problem:** `keytar` (OS secure storage for secrets) was archived December 2022 and is incompatible with Node v22, **actively blocking Node upgrades beyond v20**.
|
||||
|
||||
**Current Status:**
|
||||
|
||||
- `keytar`: **7.9.0** (still present in dependencies)
|
||||
- **This is the #1 blocker preventing Node v22+ upgrades**
|
||||
- All "Immediate Priority" dependencies have been upgraded, but further progress requires removing keytar
|
||||
|
||||
**Solution:** Migrate to Bitwarden's Rust implementation in `desktop_native` (same as clients monorepo did)
|
||||
|
||||
1. Implement Rust <-> NAPI integration (like `desktop_native/napi`) from Electron app to Rust code
|
||||
2. Copy, rename, and expose necessary functions
|
||||
3. Point to `desktop_native` crate using git link from DC repo (no need for SDK yet):
|
||||
```rust
|
||||
desktop_core = { git = "https://github.com/bitwarden/clients", rev = "00cf24972d944638bbd1adc00a0ae3eeabb6eb9a" }
|
||||
```
|
||||
|
||||
**Important:** `keytar` uses wrong encoding on Windows (UTF-8 instead of UTF-16). Bitwarden uses UTF-16. Code should contain a migration - ensure old values are migrated correctly during testing.
|
||||
|
||||
**Priority:** This should be prioritized as it's blocking the Node upgrade path and has been archived for over 2 years.
|
||||
|
||||
### Phase 5: Add ESM Support (PM-31850, To Do)
|
||||
|
||||
**Problem:** No ESM module support prevents upgrading key dependencies.
|
||||
|
||||
**Blocked Dependencies (Current Status):**
|
||||
|
||||
- ❌ `googleapis`: **149.0.0** → current (major dependency, disabled in renovate.json5)
|
||||
- ❌ `lowdb`: **1.0.0** → v7
|
||||
- ❌ `@types/lowdb`: **1.0.15** (can be deleted once inquirer is upgraded)
|
||||
- ❌ `@electron/notarize`: **2.5.0** → v3.0.1
|
||||
- ❌ `chalk`: **4.1.2** → v5.3.0
|
||||
- ❌ `inquirer`: **8.2.6** → v12.1.0
|
||||
- ❌ `@types/inquirer`: **8.2.10** (should be deleted when inquirer upgraded)
|
||||
- ❌ `node-fetch`: **2.7.0** → v3.3.2 (should use native Node fetch API when on Node >=21)
|
||||
- ❌ `electron-store`: **8.2.0** → v10.1.0
|
||||
|
||||
**Status:** These dependencies remain blocked as expected. They will stay on old versions until:
|
||||
|
||||
1. Phase 3 (Repository Restructure) is complete
|
||||
2. ESM support is implemented
|
||||
3. Note: These ESM dependencies are primarily used in CLI build, so restructuring first (Phase 3) will limit the impact of ESM migration.
|
||||
|
||||
**Implementation:**
|
||||
|
||||
1. Update tsconfig.json and package.json configurations
|
||||
2. Update import/export syntax to no longer use `require` statements
|
||||
3. Upgrade dependencies to move away from CommonJS (ESM can import CommonJS, but not vice versa)
|
||||
4. Trial and error
|
||||
|
||||
**Reference:** [Pure ESM package guide](https://gist.github.com/sindresorhus/a39789f98801d908bbc7ff3ecc99d99c)
|
||||
|
||||
### Phase 6: Add Angular CLI (PM-31849, In Progress / Possibly Complete?)
|
||||
|
||||
**Problem:** Angular CLI provides great DX and makes it easier to manage Angular changes (e.g. auto-migrations). DC didn't use it.
|
||||
|
||||
**Current Status:**
|
||||
|
||||
- ✅ `@angular/cli`: **21.1.2** is now present in **runtime dependencies**
|
||||
- ✅ `@angular/build`: **21.1.2** is present in dev dependencies
|
||||
- ✅ All Angular tooling has been updated to v21.x
|
||||
|
||||
**Status:** ✅ **COMPLETE** - Angular CLI has been successfully integrated:
|
||||
|
||||
- `angular.json` configuration file exists
|
||||
- `.angular/` cache directory present
|
||||
- `@angular/cli` 21.1.2 in runtime dependencies
|
||||
- `@angular/build` 21.1.2 in dev dependencies
|
||||
- All Angular packages updated to v21.x
|
||||
|
||||
This migration provides improved DX and access to Angular's auto-migration tools for future updates.
|
||||
|
||||
### Additional Considerations
|
||||
|
||||
**Reduce Dependency Count:** Current state is 70 dev dependencies + 31 runtime dependencies (101 total). The dev dependency count increased from the original 66 due to Angular 21 upgrade adding additional tooling. After removing old code, review dependency list:
|
||||
|
||||
- Can we remove some after code cleanup?
|
||||
- Could we reintegrate with monorepo to leverage Component Library and shared platform dependencies?
|
||||
- **Risk:** Becomes tightly coupled with monorepo code → regression risk, move slower due to coupling
|
||||
|
||||
**GitHub Workflows:** Need review and modernization:
|
||||
|
||||
- PM-20478: Add check-run workflow for CI on community PRs
|
||||
- PM-18290: Add linting workflow
|
||||
- PM-18289: Update build workflow
|
||||
- `pkg` and `pkg-fetch` for packaging Node runtime in CLI release are archived (fork exists but untrusted; clients vets all changes manually)
|
||||
- Options: Make our own fork, or use Node's single executable binary support (investigate)
|
||||
|
||||
## Common Development Commands
|
||||
|
||||
### Desktop App (Electron + Angular)
|
||||
|
||||
**Initial Setup:**
|
||||
|
||||
```bash
|
||||
npm install # Install dependencies (runs git submodule init automatically)
|
||||
npm run rebuild # Rebuild native modules for Electron
|
||||
```
|
||||
|
||||
**Development:**
|
||||
|
||||
```bash
|
||||
npm run electron # Build and run desktop app with hot reload and debugging
|
||||
npm run electron:ignore # Same as above but ignores certificate errors
|
||||
```
|
||||
|
||||
**Building:**
|
||||
|
||||
```bash
|
||||
npm run build # Build both main and renderer processes
|
||||
npm run build:main # Build Electron main process only
|
||||
npm run build:renderer # Build Angular renderer process only
|
||||
npm run build:renderer:watch # Build renderer with file watching
|
||||
```
|
||||
|
||||
**Distribution:**
|
||||
|
||||
```bash
|
||||
npm run dist:mac # Create macOS distributable
|
||||
npm run dist:win # Create Windows distributable
|
||||
npm run dist:lin # Create Linux distributable
|
||||
```
|
||||
|
||||
### CLI (bwdc)
|
||||
|
||||
**Development:**
|
||||
|
||||
```bash
|
||||
npm run build:cli:watch # Build CLI with file watching
|
||||
node ./build-cli/bwdc.js --help # Run the CLI from build output
|
||||
```
|
||||
|
||||
**Production Build:**
|
||||
|
||||
```bash
|
||||
npm run build:cli:prod # Build CLI for production
|
||||
npm run dist:cli # Create platform-specific CLI executables (all platforms)
|
||||
npm run dist:cli:mac # Create macOS CLI executable only
|
||||
npm run dist:cli:win # Create Windows CLI executable only
|
||||
npm run dist:cli:lin # Create Linux CLI executable only
|
||||
```
|
||||
|
||||
### Testing
|
||||
|
||||
**Unit Tests:**
|
||||
|
||||
```bash
|
||||
npm test # Run unit tests (excludes integration tests)
|
||||
npm run test:watch # Run unit tests in watch mode
|
||||
npm run test:watch:all # Run unit tests in watch mode (all files)
|
||||
npm run test:types # Run TypeScript type checking without emitting files
|
||||
```
|
||||
|
||||
**Integration Tests:**
|
||||
|
||||
```bash
|
||||
npm run test:integration:setup # Set up Docker containers for LDAP testing
|
||||
npm run test:integration # Run integration tests
|
||||
npm run test:integration:watch # Run integration tests in watch mode
|
||||
```
|
||||
|
||||
Integration tests require Docker and test against live directory services. The setup command creates OpenLDAP containers using docker-compose.yml.
|
||||
|
||||
### Linting & Formatting
|
||||
|
||||
```bash
|
||||
npm run lint # Run ESLint and Prettier checks
|
||||
npm run lint:fix # Auto-fix ESLint issues
|
||||
npm run prettier # Format all files with Prettier
|
||||
```
|
||||
|
||||
### Submodule Management
|
||||
|
||||
The `jslib` folder is a git submodule containing shared Bitwarden libraries:
|
||||
|
||||
```bash
|
||||
npm run sub:update # Update submodule to latest remote version
|
||||
npm run sub:pull # Pull latest changes in submodule
|
||||
npm run sub:commit # Pull and commit submodule update
|
||||
```
|
||||
|
||||
### Utility Commands
|
||||
|
||||
```bash
|
||||
npm run reset # Remove keytar modules and reinstall (use when switching between CLI/desktop)
|
||||
npm run clean:dist # Clean desktop distribution files
|
||||
npm run clean:dist:cli # Clean CLI distribution files
|
||||
```
|
||||
|
||||
**Important:** When switching between developing the desktop app and CLI, run `npm run reset` to avoid native module conflicts.
|
||||
|
||||
## Code Architecture & Structure
|
||||
|
||||
### Directory Organization
|
||||
|
||||
```
|
||||
src/
|
||||
├── abstractions/ # Interface definitions (e.g., IDirectoryService)
|
||||
├── services/ # Business logic implementations for directory services, sync, auth
|
||||
├── models/ # Data models (UserEntry, GroupEntry, etc.)
|
||||
├── commands/ # CLI command implementations
|
||||
├── app/ # Angular GUI components
|
||||
└── utils/ # Test utilities and fixtures
|
||||
|
||||
src-cli/ # CLI-specific code (imports common code from src/)
|
||||
|
||||
jslib/ # Legacy folder structure (mix of deprecated/unused and current code - new code should not be added here)
|
||||
```
|
||||
|
||||
### Key Architectural Patterns
|
||||
|
||||
1. **Abstractions = Interfaces**: All interfaces are defined in `/abstractions`
|
||||
2. **Services = Business Logic**: Implementations live in `/services`
|
||||
3. **Directory Service Pattern**: Each directory provider implements `IDirectoryService` interface
|
||||
4. **Separation of Concerns**: GUI (Angular app) and CLI (commands) share the same service layer
|
||||
|
||||
### Core Synchronization Flow
|
||||
|
||||
The sync process follows this pattern:
|
||||
|
||||
1. **DirectoryFactoryService** (`src/services/directory-factory.service.ts`) - Creates the appropriate directory service based on DirectoryType configuration
|
||||
2. **IDirectoryService** implementation (`src/services/directory-services/*.service.ts`) - Each provider (LDAP, Entra ID, Google, Okta, OneLogin) implements:
|
||||
- `getEntries(force, test)` - Returns `[GroupEntry[], UserEntry[]]`
|
||||
- Provider-specific authentication and API calls
|
||||
3. **SyncService** (`src/services/sync.service.ts`) - Orchestrates the sync:
|
||||
- Calls directory service to get entries
|
||||
- Filters and deduplicates users/groups
|
||||
- Uses BatchRequestBuilder or SingleRequestBuilder to format API requests
|
||||
- Generates hash to detect changes and avoid redundant syncs
|
||||
- Sends data to Bitwarden API via ApiService
|
||||
4. **Request Builders** (`src/services/*-request-builder.ts`) - Transform directory entries into Bitwarden API format
|
||||
|
||||
### Shared Library (jslib)
|
||||
|
||||
The `jslib` folder is a git submodule containing shared Bitwarden code:
|
||||
|
||||
- Common services (API, Crypto, Storage, Auth)
|
||||
- Platform utilities
|
||||
- Shared models and abstractions
|
||||
|
||||
**Important:** This is legacy structure - do not add new code to jslib. New code should go in `src/`.
|
||||
|
||||
## Development Conventions
|
||||
|
||||
### Code Organization
|
||||
|
||||
**File Naming:**
|
||||
|
||||
- kebab-case for files: `ldap-directory.service.ts`
|
||||
- Descriptive names that reflect purpose
|
||||
|
||||
**Class/Function Naming:**
|
||||
|
||||
- PascalCase for classes and interfaces
|
||||
- camelCase for functions and variables
|
||||
- Descriptive names that indicate purpose
|
||||
|
||||
**File Structure:**
|
||||
|
||||
- Keep files focused on single responsibility
|
||||
- Create new service files for distinct directory integrations
|
||||
- Separate models into individual files when complex
|
||||
|
||||
### TypeScript Conventions
|
||||
|
||||
**Import Patterns:**
|
||||
|
||||
- Use path aliases (`@/`) for project imports
|
||||
- `@/` - project root
|
||||
- `@/jslib/` - jslib folder
|
||||
- ESLint enforces alphabetized import ordering with newlines between groups
|
||||
|
||||
**Type Safety:**
|
||||
|
||||
- Avoid `any` types - use proper typing or `unknown` with type guards
|
||||
- Prefer interfaces for contracts, types for unions/intersections
|
||||
- Use strict null checks - handle `null` and `undefined` explicitly
|
||||
- Leverage TypeScript's type inference where appropriate
|
||||
|
||||
**Configuration:**
|
||||
|
||||
- Use configuration files or environment variables
|
||||
- Never hardcode URLs or configuration values
|
||||
|
||||
## Security Best Practices
|
||||
|
||||
**Credential Handling:**
|
||||
|
||||
- Never log directory service credentials, API keys, or tokens
|
||||
- Use secure storage mechanisms for sensitive data
|
||||
- Credentials should never be hardcoded
|
||||
- Store credentials encrypted, never in plain text
|
||||
|
||||
**Sensitive Data:**
|
||||
|
||||
- User and group data from directories should be handled securely
|
||||
- Avoid exposing sensitive information in error messages
|
||||
- Sanitize data before logging
|
||||
- Be cautious with data persistence
|
||||
|
||||
**Input Validation:**
|
||||
|
||||
- Validate and sanitize data from external directory services
|
||||
- Check for injection vulnerabilities (LDAP injection, etc.)
|
||||
- Validate configuration inputs from users
|
||||
|
||||
**API Security:**
|
||||
|
||||
- Ensure authentication flows are implemented correctly
|
||||
- Verify SSL/TLS is used for all external connections
|
||||
- Check for secure token storage and refresh mechanisms
|
||||
|
||||
## Error Handling
|
||||
|
||||
**Best Practices:**
|
||||
|
||||
1. **Try-catch for async operations** - Always wrap external API calls
|
||||
2. **Meaningful error messages** - Provide context for debugging
|
||||
3. **Error propagation** - Don't swallow errors silently
|
||||
4. **User-facing errors** - Separate user messages from developer logs
|
||||
|
||||
## Performance Best Practices
|
||||
|
||||
**Large Dataset Handling:**
|
||||
|
||||
- Use pagination for large user/group lists
|
||||
- Avoid loading entire datasets into memory at once
|
||||
- Consider streaming or batch processing for large operations
|
||||
|
||||
**API Rate Limiting:**
|
||||
|
||||
- Respect rate limits for Microsoft Graph API, Google Admin SDK, etc.
|
||||
- Consider batching large API calls where necessary
|
||||
|
||||
**Memory Management:**
|
||||
|
||||
- Close connections and clean up resources
|
||||
- Remove event listeners when components are destroyed
|
||||
- Be cautious with caching large datasets
|
||||
|
||||
## Testing
|
||||
|
||||
**Framework:**
|
||||
|
||||
- Jest with jest-preset-angular
|
||||
- jest-mock-extended for type-safe mocks with `mock<Type>()`
|
||||
|
||||
**Test Organization:**
|
||||
|
||||
- Tests colocated with source files
|
||||
- `*.spec.ts` - Unit tests for individual components/services
|
||||
- `*.integration.spec.ts` - Integration tests against live directory services
|
||||
- Test helpers located in `utils/` directory
|
||||
|
||||
**Test Naming:**
|
||||
|
||||
- Descriptive, human-readable test names
|
||||
- Example: `'should return empty array when no users exist in directory'`
|
||||
|
||||
**Test Coverage:**
|
||||
|
||||
- New features must include tests
|
||||
- Bug fixes should include regression tests
|
||||
- Changes to core sync logic or directory specific logic require integration tests
|
||||
|
||||
**Testing Approach:**
|
||||
|
||||
- **Unit tests**: Mock external API calls using jest-mock-extended
|
||||
- **Integration tests**: Use live directory services (Docker containers or configured cloud services)
|
||||
- Focus on critical paths (authentication, sync, data transformation)
|
||||
- Test error scenarios and edge cases (empty results, malformed data, connection failures), not just happy paths
|
||||
|
||||
## Directory Service Patterns
|
||||
|
||||
### IDirectoryService Interface
|
||||
|
||||
All directory services implement this core interface with methods:
|
||||
|
||||
- `getUsers()` - Retrieve users from directory and transform them into standard objects
|
||||
- `getGroups()` - Retrieve groups from directory and transform them into standard objects
|
||||
- Connection and authentication handling
|
||||
|
||||
### Service-Specific Implementations
|
||||
|
||||
Each directory service has unique authentication and query patterns:
|
||||
|
||||
- **LDAP**: Direct LDAP queries, bind authentication
|
||||
- **Microsoft Entra ID**: Microsoft Graph API, OAuth tokens
|
||||
- **Google Workspace**: Google Admin SDK, service account credentials
|
||||
- **Okta/OneLogin**: REST APIs with API tokens
|
||||
|
||||
## References
|
||||
|
||||
- [Architectural Decision Records (ADRs)](https://contributing.bitwarden.com/architecture/adr/)
|
||||
- [Contributing Guidelines](https://contributing.bitwarden.com/contributing/)
|
||||
- [Code Style](https://contributing.bitwarden.com/contributing/code-style/)
|
||||
- [Security Whitepaper](https://bitwarden.com/help/bitwarden-security-white-paper/)
|
||||
- [Security Definitions](https://contributing.bitwarden.com/architecture/security/definitions)
|
||||
@@ -1,10 +0,0 @@
|
||||
dist
|
||||
build
|
||||
build-cli
|
||||
webpack.cli.js
|
||||
webpack.main.js
|
||||
webpack.renderer.js
|
||||
|
||||
**/node_modules
|
||||
|
||||
**/jest.config.js
|
||||
@@ -1,95 +0,0 @@
|
||||
{
|
||||
"root": true,
|
||||
"env": {
|
||||
"browser": true,
|
||||
"node": true
|
||||
},
|
||||
"overrides": [
|
||||
{
|
||||
"files": ["*.ts", "*.js"],
|
||||
"plugins": ["@typescript-eslint", "rxjs", "rxjs-angular", "import"],
|
||||
"parser": "@typescript-eslint/parser",
|
||||
"parserOptions": {
|
||||
"project": ["./tsconfig.eslint.json"],
|
||||
"sourceType": "module",
|
||||
"ecmaVersion": 2020
|
||||
},
|
||||
"extends": [
|
||||
"eslint:recommended",
|
||||
"plugin:@typescript-eslint/recommended",
|
||||
"plugin:import/recommended",
|
||||
"plugin:import/typescript",
|
||||
"prettier",
|
||||
"plugin:rxjs/recommended"
|
||||
],
|
||||
"settings": {
|
||||
"import/parsers": {
|
||||
"@typescript-eslint/parser": [".ts"]
|
||||
},
|
||||
"import/resolver": {
|
||||
"typescript": {
|
||||
"alwaysTryTypes": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"rules": {
|
||||
"@typescript-eslint/explicit-member-accessibility": [
|
||||
"error",
|
||||
{ "accessibility": "no-public" }
|
||||
],
|
||||
"@typescript-eslint/no-explicit-any": "off", // TODO: This should be re-enabled
|
||||
"@typescript-eslint/no-misused-promises": ["error", { "checksVoidReturn": false }],
|
||||
"@typescript-eslint/no-this-alias": ["error", { "allowedNames": ["self"] }],
|
||||
"@typescript-eslint/no-unused-vars": ["error", { "args": "none" }],
|
||||
"no-console": "error",
|
||||
"import/no-unresolved": "off", // TODO: Look into turning off once each package is an actual package.
|
||||
"import/order": [
|
||||
"error",
|
||||
{
|
||||
"alphabetize": {
|
||||
"order": "asc"
|
||||
},
|
||||
"newlines-between": "always",
|
||||
"pathGroups": [
|
||||
{
|
||||
"pattern": "@/jslib/**/*",
|
||||
"group": "external",
|
||||
"position": "after"
|
||||
},
|
||||
{
|
||||
"pattern": "@/src/**/*",
|
||||
"group": "parent",
|
||||
"position": "before"
|
||||
}
|
||||
],
|
||||
"pathGroupsExcludedImportTypes": ["builtin"]
|
||||
}
|
||||
],
|
||||
"rxjs-angular/prefer-takeuntil": "error",
|
||||
"rxjs/no-exposed-subjects": ["error", { "allowProtected": true }],
|
||||
"no-restricted-syntax": [
|
||||
"error",
|
||||
{
|
||||
"message": "Calling `svgIcon` directly is not allowed",
|
||||
"selector": "CallExpression[callee.name='svgIcon']"
|
||||
},
|
||||
{
|
||||
"message": "Accessing FormGroup using `get` is not allowed, use `.value` instead",
|
||||
"selector": "ChainExpression[expression.object.callee.property.name='get'][expression.property.name='value']"
|
||||
}
|
||||
],
|
||||
"curly": ["error", "all"],
|
||||
"import/namespace": ["off"], // This doesn't resolve namespace imports correctly, but TS will throw for this anyway
|
||||
"no-restricted-imports": ["error", { "patterns": ["src/**/*"] }]
|
||||
}
|
||||
},
|
||||
{
|
||||
"files": ["*.html"],
|
||||
"parser": "@angular-eslint/template-parser",
|
||||
"plugins": ["@angular-eslint/template"],
|
||||
"rules": {
|
||||
"@angular-eslint/template/button-has-type": "error"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
11
.github/CODEOWNERS
vendored
11
.github/CODEOWNERS
vendored
@@ -6,3 +6,14 @@
|
||||
|
||||
# Default file owners.
|
||||
* @bitwarden/team-admin-console-dev
|
||||
|
||||
# Docker-related files
|
||||
**/Dockerfile @bitwarden/team-appsec @bitwarden/dept-bre
|
||||
**/*.dockerignore @bitwarden/team-appsec @bitwarden/dept-bre
|
||||
**/entrypoint.sh @bitwarden/team-appsec @bitwarden/dept-bre
|
||||
**/docker-compose.yml @bitwarden/team-appsec @bitwarden/dept-bre
|
||||
|
||||
# Claude related files
|
||||
.claude/ @bitwarden/team-ai-sme
|
||||
.github/workflows/respond.yml @bitwarden/team-ai-sme
|
||||
.github/workflows/review-code.yml @bitwarden/team-ai-sme
|
||||
|
||||
14
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
14
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
@@ -0,0 +1,14 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: Feature Requests
|
||||
url: https://community.bitwarden.com/c/feature-requests/
|
||||
about: Request new features using the Community Forums. Please search existing feature requests before making a new one.
|
||||
- name: Bitwarden Community Forums
|
||||
url: https://community.bitwarden.com
|
||||
about: Please visit the community forums for general community discussion, support and the development roadmap.
|
||||
- name: Customer Support
|
||||
url: https://bitwarden.com/contact/
|
||||
about: Please contact our customer support for account issues and general customer support.
|
||||
- name: Security Issues
|
||||
url: https://hackerone.com/bitwarden
|
||||
about: We use HackerOne to manage security disclosures.
|
||||
111
.github/ISSUE_TEMPLATE/issue.yml
vendored
Normal file
111
.github/ISSUE_TEMPLATE/issue.yml
vendored
Normal file
@@ -0,0 +1,111 @@
|
||||
name: Directory Connector Bug Report
|
||||
description: File a bug report
|
||||
title: "[DC] "
|
||||
labels: ["bug"]
|
||||
type: bug
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for taking the time to fill out this bug report!
|
||||
|
||||
Please do not submit feature requests. The [Community Forums](https://community.bitwarden.com) has a section for submitting, voting for, and discussing product feature requests.
|
||||
- type: textarea
|
||||
id: reproduce
|
||||
attributes:
|
||||
label: Steps To Reproduce
|
||||
description: How can we reproduce the behavior.
|
||||
value: |
|
||||
1. Go to '...'
|
||||
2. Click on '....'
|
||||
3. Scroll down to '....'
|
||||
4. Click on '...'
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: expected
|
||||
attributes:
|
||||
label: Expected Result
|
||||
description: A clear and concise description of what you expected to happen.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: actual
|
||||
attributes:
|
||||
label: Actual Result
|
||||
description: A clear and concise description of what is happening.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: screenshots
|
||||
attributes:
|
||||
label: Screenshots or Videos
|
||||
description: If applicable, add screenshots and/or a short video to help explain your problem.
|
||||
- type: textarea
|
||||
id: additional-context
|
||||
attributes:
|
||||
label: Additional Context
|
||||
description: Add any other context about the problem here.
|
||||
- type: dropdown
|
||||
id: os
|
||||
attributes:
|
||||
label: Operating System
|
||||
description: What operating system(s) are you seeing the problem on?
|
||||
multiple: true
|
||||
options:
|
||||
- Windows
|
||||
- macOS
|
||||
- Linux
|
||||
- Other operating system (please specify in "Additional Context" section)
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
id: os-version
|
||||
attributes:
|
||||
label: Operating System Version
|
||||
description: What version of the operating system(s) are you seeing the problem on?
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
id: directories
|
||||
attributes:
|
||||
label: Directory Service
|
||||
description: What directory service(s) are you seeing the problem on?
|
||||
multiple: true
|
||||
options:
|
||||
- LDAP - Active Directory
|
||||
- Another LDAP implementation (please specify in "Additional Context" section)
|
||||
- Microsoft Entra ID
|
||||
- Google Workspace
|
||||
- Okta Universal Directory
|
||||
- OneLogin
|
||||
- Other directory service (please specify in "Additional Context" section)
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
id: application-type
|
||||
attributes:
|
||||
label: Application Type
|
||||
description: Which Directory Connector application(s) are you seeing the problem on?
|
||||
multiple: true
|
||||
options:
|
||||
- GUI (the desktop application)
|
||||
- CLI (the bwdc command line application)
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
id: version
|
||||
attributes:
|
||||
label: Build Version
|
||||
description: What version of our software are you running?
|
||||
validations:
|
||||
required: true
|
||||
- type: checkboxes
|
||||
id: issue-tracking-info
|
||||
attributes:
|
||||
label: Issue Tracking Info
|
||||
description: |
|
||||
Make sure to acknowledge the following before submitting your report!
|
||||
options:
|
||||
- label: I understand that work is tracked outside of Github. A PR will be linked to this issue should one be opened to address it, but Bitwarden doesn't use fields like "assigned", "milestone", or "project" to track progress.
|
||||
required: true
|
||||
23
.github/PULL_REQUEST_TEMPLATE.md
vendored
23
.github/PULL_REQUEST_TEMPLATE.md
vendored
@@ -9,26 +9,3 @@
|
||||
## 📸 Screenshots
|
||||
|
||||
<!-- Required for any UI changes; delete if not applicable. Use fixed width images for better display. -->
|
||||
|
||||
## ⏰ Reminders before review
|
||||
|
||||
- Contributor guidelines followed
|
||||
- All formatters and local linters executed and passed
|
||||
- Written new unit and / or integration tests where applicable
|
||||
- Used internationalization (i18n) for all UI strings
|
||||
- CI builds passed
|
||||
- Communicated to DevOps any deployment requirements
|
||||
- Updated any necessary documentation (Confluence, contributing docs) or informed the documentation team
|
||||
|
||||
## 🦮 Reviewer guidelines
|
||||
|
||||
<!-- Suggested interactions but feel free to use (or not) as you desire! -->
|
||||
|
||||
- 👍 (`:+1:`) or similar for great changes
|
||||
- 📝 (`:memo:`) or ℹ️ (`:information_source:`) for notes or general info
|
||||
- ❓ (`:question:`) for questions
|
||||
- 🤔 (`:thinking:`) or 💭 (`:thought_balloon:`) for more open inquiry that's not quite a confirmed issue and could potentially benefit from discussion
|
||||
- 🎨 (`:art:`) for suggestions / improvements
|
||||
- ❌ (`:x:`) or ⚠️ (`:warning:`) for more significant problems or concerns needing attention
|
||||
- 🌱 (`:seedling:`) or ♻️ (`:recycle:`) for future improvements or indications of technical debt
|
||||
- ⛏ (`:pick:`) for minor or nitpick changes
|
||||
|
||||
11
.github/renovate.json5
vendored
11
.github/renovate.json5
vendored
@@ -8,12 +8,6 @@
|
||||
matchManagers: ["github-actions"],
|
||||
matchUpdateTypes: ["minor", "patch"],
|
||||
},
|
||||
{
|
||||
groupName: "Google Libraries",
|
||||
matchPackagePatterns: ["google-auth-library", "googleapis"],
|
||||
matchManagers: ["npm"],
|
||||
groupSlug: "google-libraries",
|
||||
},
|
||||
],
|
||||
ignoreDeps: [
|
||||
// yao-pkg is used to create a single executable application bundle for the CLI.
|
||||
@@ -21,5 +15,10 @@
|
||||
// This must be manually vetted by our appsec team before upgrading.
|
||||
// It is excluded from renovate to avoid accidentally upgrading to a non-vetted version.
|
||||
"@yao-pkg/pkg",
|
||||
// googleapis uses ESM after 149.0.0 so we are not upgrading it until we have ESM support.
|
||||
// They release new versions every couple of weeks so ignoring it at the dependency dashboard
|
||||
// level is not sufficient.
|
||||
// FIXME: remove and upgrade when we have ESM support.
|
||||
"googleapis",
|
||||
],
|
||||
}
|
||||
|
||||
126
.github/workflows/build.yml
vendored
126
.github/workflows/build.yml
vendored
@@ -23,20 +23,22 @@ jobs:
|
||||
node_version: ${{ steps.retrieve-node-version.outputs.node_version }}
|
||||
steps:
|
||||
- name: Checkout repo
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Get Package Version
|
||||
id: retrieve-version
|
||||
run: |
|
||||
PKG_VERSION=$(jq -r .version package.json)
|
||||
echo "package_version=$PKG_VERSION" >> $GITHUB_OUTPUT
|
||||
echo "package_version=$PKG_VERSION" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Get Node Version
|
||||
id: retrieve-node-version
|
||||
run: |
|
||||
NODE_NVMRC=$(cat .nvmrc)
|
||||
NODE_VERSION=${NODE_NVMRC/v/''}
|
||||
echo "node_version=$NODE_VERSION" >> $GITHUB_OUTPUT
|
||||
echo "node_version=$NODE_VERSION" >> "$GITHUB_OUTPUT"
|
||||
|
||||
linux-cli:
|
||||
name: Build Linux CLI
|
||||
@@ -49,10 +51,12 @@ jobs:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout repo
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
|
||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||
with:
|
||||
cache: 'npm'
|
||||
cache-dependency-path: '**/package-lock.json'
|
||||
@@ -61,7 +65,7 @@ jobs:
|
||||
- name: Update NPM
|
||||
run: |
|
||||
npm install -g node-gyp
|
||||
node-gyp install $(node -v)
|
||||
node-gyp install "$(node -v)"
|
||||
|
||||
- name: Keytar
|
||||
run: |
|
||||
@@ -72,8 +76,8 @@ jobs:
|
||||
keytarUrl="https://github.com/atom/node-keytar/releases/download/v$keytarVersion/$keytarTarGz"
|
||||
|
||||
mkdir -p ./keytar/linux
|
||||
wget $keytarUrl -O ./keytar/linux/$keytarTarGz
|
||||
tar -xvf ./keytar/linux/$keytarTarGz -C ./keytar/linux
|
||||
wget "$keytarUrl" -O "./keytar/linux/$keytarTarGz"
|
||||
tar -xvf "./keytar/linux/$keytarTarGz" -C ./keytar/linux
|
||||
|
||||
- name: Install
|
||||
run: npm install
|
||||
@@ -82,19 +86,19 @@ jobs:
|
||||
run: npm run dist:cli:lin
|
||||
|
||||
- name: Zip
|
||||
run: zip -j dist-cli/bwdc-linux-$_PACKAGE_VERSION.zip dist-cli/linux/bwdc keytar/linux/build/Release/keytar.node
|
||||
run: zip -j "dist-cli/bwdc-linux-$_PACKAGE_VERSION.zip" "dist-cli/linux/bwdc" "keytar/linux/build/Release/keytar.node"
|
||||
|
||||
- name: Version Test
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt install libsecret-1-0 dbus-x11 gnome-keyring
|
||||
eval $(dbus-launch --sh-syntax)
|
||||
eval "$(dbus-launch --sh-syntax)"
|
||||
|
||||
eval $(echo -n "" | /usr/bin/gnome-keyring-daemon --login)
|
||||
eval $(/usr/bin/gnome-keyring-daemon --components=secrets --start)
|
||||
eval "$(echo -n "" | /usr/bin/gnome-keyring-daemon --login)"
|
||||
eval "$(/usr/bin/gnome-keyring-daemon --components=secrets --start)"
|
||||
|
||||
mkdir -p test/linux
|
||||
unzip ./dist-cli/bwdc-linux-$_PACKAGE_VERSION.zip -d ./test/linux
|
||||
unzip "./dist-cli/bwdc-linux-$_PACKAGE_VERSION.zip" -d ./test/linux
|
||||
|
||||
testVersion=$(./test/linux/bwdc -v)
|
||||
|
||||
@@ -107,7 +111,7 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Upload Linux Zip to GitHub
|
||||
uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
with:
|
||||
name: bwdc-linux-${{ env._PACKAGE_VERSION }}.zip
|
||||
path: ./dist-cli/bwdc-linux-${{ env._PACKAGE_VERSION }}.zip
|
||||
@@ -116,7 +120,7 @@ jobs:
|
||||
|
||||
macos-cli:
|
||||
name: Build Mac CLI
|
||||
runs-on: macos-13
|
||||
runs-on: macos-15-intel
|
||||
needs: setup
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -125,10 +129,12 @@ jobs:
|
||||
_NODE_VERSION: ${{ needs.setup.outputs.node_version }}
|
||||
steps:
|
||||
- name: Checkout repo
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
|
||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||
with:
|
||||
cache: 'npm'
|
||||
cache-dependency-path: '**/package-lock.json'
|
||||
@@ -137,7 +143,7 @@ jobs:
|
||||
- name: Update NPM
|
||||
run: |
|
||||
npm install -g node-gyp
|
||||
node-gyp install $(node -v)
|
||||
node-gyp install "$(node -v)"
|
||||
|
||||
- name: Keytar
|
||||
run: |
|
||||
@@ -148,8 +154,8 @@ jobs:
|
||||
keytarUrl="https://github.com/atom/node-keytar/releases/download/v$keytarVersion/$keytarTarGz"
|
||||
|
||||
mkdir -p ./keytar/macos
|
||||
wget $keytarUrl -O ./keytar/macos/$keytarTarGz
|
||||
tar -xvf ./keytar/macos/$keytarTarGz -C ./keytar/macos
|
||||
wget "$keytarUrl" -O "./keytar/macos/$keytarTarGz"
|
||||
tar -xvf "./keytar/macos/$keytarTarGz" -C ./keytar/macos
|
||||
|
||||
- name: Install
|
||||
run: npm install
|
||||
@@ -158,12 +164,12 @@ jobs:
|
||||
run: npm run dist:cli:mac
|
||||
|
||||
- name: Zip
|
||||
run: zip -j dist-cli/bwdc-macos-$_PACKAGE_VERSION.zip dist-cli/macos/bwdc keytar/macos/build/Release/keytar.node
|
||||
run: zip -j "dist-cli/bwdc-macos-$_PACKAGE_VERSION.zip" "dist-cli/macos/bwdc" "keytar/macos/build/Release/keytar.node"
|
||||
|
||||
- name: Version Test
|
||||
run: |
|
||||
mkdir -p test/macos
|
||||
unzip ./dist-cli/bwdc-macos-$_PACKAGE_VERSION.zip -d ./test/macos
|
||||
unzip "./dist-cli/bwdc-macos-$_PACKAGE_VERSION.zip" -d ./test/macos
|
||||
|
||||
testVersion=$(./test/macos/bwdc -v)
|
||||
|
||||
@@ -176,7 +182,7 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Upload Mac Zip to GitHub
|
||||
uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
with:
|
||||
name: bwdc-macos-${{ env._PACKAGE_VERSION }}.zip
|
||||
path: ./dist-cli/bwdc-macos-${{ env._PACKAGE_VERSION }}.zip
|
||||
@@ -194,14 +200,16 @@ jobs:
|
||||
_NODE_VERSION: ${{ needs.setup.outputs.node_version }}
|
||||
steps:
|
||||
- name: Checkout repo
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Windows builder
|
||||
run: |
|
||||
choco install checksum --no-progress
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
|
||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||
with:
|
||||
cache: 'npm'
|
||||
cache-dependency-path: '**/package-lock.json'
|
||||
@@ -241,7 +249,7 @@ jobs:
|
||||
- name: Version Test
|
||||
shell: pwsh
|
||||
run: |
|
||||
Expand-Archive -Path "dist-cli\bwdc-windows-${{ env._PACKAGE_VERSION }}.zip" -DestinationPath "test\windows"
|
||||
Expand-Archive -Path "dist-cli\bwdc-windows-$env:_PACKAGE_VERSION.zip" -DestinationPath "test\windows"
|
||||
$testVersion = Invoke-Expression '& .\test\windows\bwdc.exe -v'
|
||||
echo "version: ${env:_PACKAGE_VERSION}"
|
||||
echo "testVersion: $testVersion"
|
||||
@@ -250,7 +258,7 @@ jobs:
|
||||
}
|
||||
|
||||
- name: Upload Windows Zip to GitHub
|
||||
uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
with:
|
||||
name: bwdc-windows-${{ env._PACKAGE_VERSION }}.zip
|
||||
path: ./dist-cli/bwdc-windows-${{ env._PACKAGE_VERSION }}.zip
|
||||
@@ -271,10 +279,12 @@ jobs:
|
||||
HUSKY: 0
|
||||
steps:
|
||||
- name: Checkout repo
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
|
||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||
with:
|
||||
cache: 'npm'
|
||||
cache-dependency-path: '**/package-lock.json'
|
||||
@@ -328,28 +338,28 @@ jobs:
|
||||
SIGNING_CERT_NAME: ${{ steps.retrieve-secrets.outputs.code-signing-cert-name }}
|
||||
|
||||
- name: Upload Portable Executable to GitHub
|
||||
uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
with:
|
||||
name: Bitwarden-Connector-Portable-${{ env._PACKAGE_VERSION }}.exe
|
||||
path: ./dist/Bitwarden-Connector-Portable-${{ env._PACKAGE_VERSION }}.exe
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Upload Installer Executable to GitHub
|
||||
uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
with:
|
||||
name: Bitwarden-Connector-Installer-${{ env._PACKAGE_VERSION }}.exe
|
||||
path: ./dist/Bitwarden-Connector-Installer-${{ env._PACKAGE_VERSION }}.exe
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Upload Installer Executable Blockmap to GitHub
|
||||
uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
with:
|
||||
name: Bitwarden-Connector-Installer-${{ env._PACKAGE_VERSION }}.exe.blockmap
|
||||
path: ./dist/Bitwarden-Connector-Installer-${{ env._PACKAGE_VERSION }}.exe.blockmap
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Upload latest auto-update artifact
|
||||
uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
with:
|
||||
name: latest.yml
|
||||
path: ./dist/latest.yml
|
||||
@@ -369,10 +379,12 @@ jobs:
|
||||
HUSKY: 0
|
||||
steps:
|
||||
- name: Checkout repo
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
|
||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||
with:
|
||||
cache: 'npm'
|
||||
cache-dependency-path: '**/package-lock.json'
|
||||
@@ -381,7 +393,7 @@ jobs:
|
||||
- name: Update NPM
|
||||
run: |
|
||||
npm install -g node-gyp
|
||||
node-gyp install $(node -v)
|
||||
node-gyp install "$(node -v)"
|
||||
|
||||
- name: Set up environment
|
||||
run: |
|
||||
@@ -399,14 +411,14 @@ jobs:
|
||||
run: npm run dist:lin
|
||||
|
||||
- name: Upload AppImage
|
||||
uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
with:
|
||||
name: Bitwarden-Connector-${{ env._PACKAGE_VERSION }}-x86_64.AppImage
|
||||
path: ./dist/Bitwarden-Connector-${{ env._PACKAGE_VERSION }}-x86_64.AppImage
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Upload latest auto-update artifact
|
||||
uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
with:
|
||||
name: latest-linux.yml
|
||||
path: ./dist/latest-linux.yml
|
||||
@@ -415,7 +427,7 @@ jobs:
|
||||
|
||||
macos-gui:
|
||||
name: Build MacOS GUI
|
||||
runs-on: macos-13
|
||||
runs-on: macos-15-intel
|
||||
needs: setup
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -427,10 +439,12 @@ jobs:
|
||||
HUSKY: 0
|
||||
steps:
|
||||
- name: Checkout repo
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
|
||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||
with:
|
||||
cache: 'npm'
|
||||
cache-dependency-path: '**/package-lock.json'
|
||||
@@ -439,7 +453,7 @@ jobs:
|
||||
- name: Update NPM
|
||||
run: |
|
||||
npm install -g node-gyp
|
||||
node-gyp install $(node -v)
|
||||
node-gyp install "$(node -v)"
|
||||
|
||||
- name: Print environment
|
||||
run: |
|
||||
@@ -464,16 +478,16 @@ jobs:
|
||||
|
||||
- name: Get certificates
|
||||
run: |
|
||||
mkdir -p $HOME/certificates
|
||||
mkdir -p "$HOME/certificates"
|
||||
|
||||
az keyvault secret show --id https://bitwarden-ci.vault.azure.net/certificates/devid-app-cert |
|
||||
jq -r .value | base64 -d > $HOME/certificates/devid-app-cert.p12
|
||||
jq -r .value | base64 -d > "$HOME/certificates/devid-app-cert.p12"
|
||||
|
||||
az keyvault secret show --id https://bitwarden-ci.vault.azure.net/certificates/devid-installer-cert |
|
||||
jq -r .value | base64 -d > $HOME/certificates/devid-installer-cert.p12
|
||||
jq -r .value | base64 -d > "$HOME/certificates/devid-installer-cert.p12"
|
||||
|
||||
az keyvault secret show --id https://bitwarden-ci.vault.azure.net/certificates/macdev-cert |
|
||||
jq -r .value | base64 -d > $HOME/certificates/macdev-cert.p12
|
||||
jq -r .value | base64 -d > "$HOME/certificates/macdev-cert.p12"
|
||||
|
||||
- name: Log out from Azure
|
||||
uses: bitwarden/gh-actions/azure-logout@main
|
||||
@@ -482,9 +496,9 @@ jobs:
|
||||
env:
|
||||
KEYCHAIN_PASSWORD: ${{ steps.get-kv-secrets.outputs.KEYCHAIN-PASSWORD }}
|
||||
run: |
|
||||
security create-keychain -p $KEYCHAIN_PASSWORD build.keychain
|
||||
security create-keychain -p "$KEYCHAIN_PASSWORD" build.keychain
|
||||
security default-keychain -s build.keychain
|
||||
security unlock-keychain -p $KEYCHAIN_PASSWORD build.keychain
|
||||
security unlock-keychain -p "$KEYCHAIN_PASSWORD" build.keychain
|
||||
security set-keychain-settings -lut 1200 build.keychain
|
||||
|
||||
security import "$HOME/certificates/devid-app-cert.p12" -k build.keychain -P "" \
|
||||
@@ -496,12 +510,12 @@ jobs:
|
||||
security import "$HOME/certificates/macdev-cert.p12" -k build.keychain -P "" \
|
||||
-T /usr/bin/codesign -T /usr/bin/security -T /usr/bin/productbuild
|
||||
|
||||
security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k $KEYCHAIN_PASSWORD build.keychain
|
||||
security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$KEYCHAIN_PASSWORD" build.keychain
|
||||
|
||||
- name: Load package version
|
||||
run: |
|
||||
$rootPath = $env:GITHUB_WORKSPACE;
|
||||
$packageVersion = (Get-Content -Raw -Path $rootPath\package.json | ConvertFrom-Json).version;
|
||||
$packageVersion = (Get-Content -Raw -Path "$rootPath\package.json" | ConvertFrom-Json).version;
|
||||
|
||||
Write-Output "Setting package version to $packageVersion";
|
||||
Write-Output "PACKAGE_VERSION=$packageVersion" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append;
|
||||
@@ -511,10 +525,12 @@ jobs:
|
||||
run: npm install
|
||||
|
||||
- name: Set up private auth key
|
||||
env:
|
||||
_APP_STORE_CONNECT_AUTH_KEY: ${{ steps.get-kv-secrets.outputs.APP-STORE-CONNECT-AUTH-KEY }}
|
||||
run: |
|
||||
mkdir ~/private_keys
|
||||
cat << EOF > ~/private_keys/AuthKey_UFD296548T.p8
|
||||
${{ steps.get-kv-secrets.outputs.APP-STORE-CONNECT-AUTH-KEY }}
|
||||
${_APP_STORE_CONNECT_AUTH_KEY}
|
||||
EOF
|
||||
|
||||
- name: Build application
|
||||
@@ -526,28 +542,28 @@ jobs:
|
||||
CSC_FOR_PULL_REQUEST: true
|
||||
|
||||
- name: Upload .zip artifact
|
||||
uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
with:
|
||||
name: Bitwarden-Connector-${{ env._PACKAGE_VERSION }}-mac.zip
|
||||
path: ./dist/Bitwarden-Connector-${{ env._PACKAGE_VERSION }}-mac.zip
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Upload .dmg artifact
|
||||
uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
with:
|
||||
name: Bitwarden-Connector-${{ env._PACKAGE_VERSION }}.dmg
|
||||
path: ./dist/Bitwarden-Connector-${{ env._PACKAGE_VERSION }}.dmg
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Upload .dmg Blockmap artifact
|
||||
uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
with:
|
||||
name: Bitwarden-Connector-${{ env._PACKAGE_VERSION }}.dmg.blockmap
|
||||
path: ./dist/Bitwarden-Connector-${{ env._PACKAGE_VERSION }}.dmg.blockmap
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Upload latest auto-update artifact
|
||||
uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
with:
|
||||
name: latest-mac.yml
|
||||
path: ./dist/latest-mac.yml
|
||||
|
||||
117
.github/workflows/integration-test.yml
vendored
117
.github/workflows/integration-test.yml
vendored
@@ -2,25 +2,36 @@ name: Integration Testing
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
# Integration tests are slow, so only run them if relevant files have changed.
|
||||
# This is done at the workflow level and at the job level.
|
||||
# Make sure these triggers stay consistent with the 'changed-files' job.
|
||||
push:
|
||||
branches:
|
||||
- "main"
|
||||
- 'main'
|
||||
- 'rc'
|
||||
paths:
|
||||
- ".github/workflows/integration-test.yml" # this file
|
||||
- "src/services/ldap-directory.service*" # we only have integration for LDAP testing at the moment
|
||||
- "./openldap/**/*" # any change to test fixtures
|
||||
- "./docker-compose.yml" # any change to Docker configuration
|
||||
- "./package.json" # dependencies
|
||||
- "docker-compose.yml" # any change to Docker configuration
|
||||
- "package.json" # dependencies
|
||||
- "utils/**" # any change to test fixtures
|
||||
- "src/services/sync.service.ts" # core sync service used by all directory services
|
||||
- "src/services/directory-services/ldap-directory.service*" # LDAP directory service
|
||||
- "src/services/directory-services/gsuite-directory.service*" # Google Workspace directory service
|
||||
# Add directory services here as we add test coverage
|
||||
pull_request:
|
||||
paths:
|
||||
- ".github/workflows/integration-test.yml" # this file
|
||||
- "src/services/ldap-directory.service*" # we only have integration for LDAP testing at the moment
|
||||
- "./openldap/**/*" # any change to test fixtures
|
||||
- "./docker-compose.yml" # any change to Docker configuration
|
||||
- "./package.json" # dependencies
|
||||
- "docker-compose.yml" # any change to Docker configuration
|
||||
- "package.json" # dependencies
|
||||
- "utils/**" # any change to test fixtures
|
||||
- "src/services/sync.service.ts" # core sync service used by all directory services
|
||||
- "src/services/directory-services/ldap-directory.service*" # LDAP directory service
|
||||
- "src/services/directory-services/gsuite-directory.service*" # Google Workspace directory service
|
||||
# Add directory services here as we add test coverage
|
||||
permissions:
|
||||
contents: read
|
||||
checks: write # required by dorny/test-reporter to upload its results
|
||||
id-token: write # required to use OIDC to login to Azure Key Vault
|
||||
jobs:
|
||||
testing:
|
||||
name: Run tests
|
||||
@@ -29,17 +40,19 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Get Node version
|
||||
id: retrieve-node-version
|
||||
run: |
|
||||
NODE_NVMRC=$(cat .nvmrc)
|
||||
NODE_VERSION=${NODE_NVMRC/v/''}
|
||||
echo "node_version=$NODE_VERSION" >> $GITHUB_OUTPUT
|
||||
echo "node_version=$NODE_VERSION" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
|
||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||
with:
|
||||
cache: 'npm'
|
||||
cache-dependency-path: '**/package-lock.json'
|
||||
@@ -48,28 +61,88 @@ jobs:
|
||||
- name: Install Node dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Install mkcert
|
||||
# Get secrets from Azure Key Vault
|
||||
- name: Azure Login
|
||||
uses: bitwarden/gh-actions/azure-login@main
|
||||
with:
|
||||
subscription_id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
|
||||
tenant_id: ${{ secrets.AZURE_TENANT_ID }}
|
||||
client_id: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
|
||||
- name: Get KV Secrets
|
||||
id: get-kv-secrets
|
||||
uses: bitwarden/gh-actions/get-keyvault-secrets@main
|
||||
with:
|
||||
keyvault: gh-directory-connector
|
||||
secrets: "GOOGLE-ADMIN-USER,GOOGLE-CLIENT-EMAIL,GOOGLE-DOMAIN,GOOGLE-PRIVATE-KEY"
|
||||
|
||||
- name: Azure Logout
|
||||
uses: bitwarden/gh-actions/azure-logout@main
|
||||
|
||||
# Only run relevant tests depending on what files have changed.
|
||||
# This should be kept consistent with the workflow level triggers.
|
||||
# Note: docker-compose.yml is only used for ldap for now
|
||||
- name: Get changed files
|
||||
id: changed-files
|
||||
uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
|
||||
with:
|
||||
list-files: shell
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
# Add directory services here as we add test coverage
|
||||
filters: |
|
||||
common:
|
||||
- '.github/workflows/integration-test.yml'
|
||||
- 'utils/**'
|
||||
- 'package.json'
|
||||
- 'src/services/sync.service.ts'
|
||||
ldap:
|
||||
- 'docker-compose.yml'
|
||||
- 'src/services/directory-services/ldap-directory.service*'
|
||||
google:
|
||||
- 'src/services/directory-services/gsuite-directory.service*'
|
||||
|
||||
# LDAP
|
||||
- name: Setup LDAP integration tests
|
||||
if: steps.changed-files.outputs.common == 'true' || steps.changed-files.outputs.ldap == 'true'
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get -y install mkcert
|
||||
npm run test:integration:setup
|
||||
|
||||
- name: Setup integration tests
|
||||
run: npm run test:integration:setup
|
||||
- name: Run LDAP integration tests
|
||||
if: steps.changed-files.outputs.common == 'true' || steps.changed-files.outputs.ldap == 'true'
|
||||
env:
|
||||
JEST_JUNIT_UNIQUE_OUTPUT_NAME: "true" # avoids junit outputs from clashing
|
||||
run: npx jest ldap-directory.service.integration.spec.ts --coverage --coverageDirectory=coverage-ldap
|
||||
|
||||
- name: Run integration tests
|
||||
run: npm run test:integration --coverage
|
||||
# Google Workspace
|
||||
- name: Run Google Workspace integration tests
|
||||
if: steps.changed-files.outputs.common == 'true' || steps.changed-files.outputs.google == 'true'
|
||||
env:
|
||||
GOOGLE_DOMAIN: ${{ steps.get-kv-secrets.outputs.GOOGLE-DOMAIN }}
|
||||
GOOGLE_ADMIN_USER: ${{ steps.get-kv-secrets.outputs.GOOGLE-ADMIN-USER }}
|
||||
GOOGLE_CLIENT_EMAIL: ${{ steps.get-kv-secrets.outputs.GOOGLE-CLIENT-EMAIL }}
|
||||
GOOGLE_PRIVATE_KEY: ${{ steps.get-kv-secrets.outputs.GOOGLE-PRIVATE-KEY }}
|
||||
JEST_JUNIT_UNIQUE_OUTPUT_NAME: "true" # avoids junit outputs from clashing
|
||||
run: |
|
||||
npx jest gsuite-directory.service.integration.spec.ts --coverage --coverageDirectory=coverage-google
|
||||
|
||||
- name: Report test results
|
||||
uses: dorny/test-reporter@dc3a92680fcc15842eef52e8c4606ea7ce6bd3f3 # v2.1.1
|
||||
if: ${{ github.event.pull_request.head.repo.full_name == github.repository && !cancelled() }}
|
||||
id: report
|
||||
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()
|
||||
with:
|
||||
name: Test Results
|
||||
path: "junit.xml"
|
||||
path: "junit.xml*"
|
||||
reporter: jest-junit
|
||||
fail-on-error: true
|
||||
|
||||
- name: Upload coverage to codecov.io
|
||||
uses: codecov/codecov-action@5a605bd92782ce0810fa3b8acc235c921b497052 # v5.2.0
|
||||
uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2
|
||||
|
||||
- name: Upload results to codecov.io
|
||||
uses: codecov/test-results-action@4e79e65778be1cecd5df25e14af1eafb6df80ea9 # v1.0.2
|
||||
uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2
|
||||
with:
|
||||
report_type: test_results
|
||||
|
||||
6
.github/workflows/release.yml
vendored
6
.github/workflows/release.yml
vendored
@@ -26,7 +26,9 @@ jobs:
|
||||
release_version: ${{ steps.version.outputs.version }}
|
||||
steps:
|
||||
- name: Checkout repo
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Branch check
|
||||
if: ${{ inputs.release_type != 'Dry Run' }}
|
||||
@@ -73,7 +75,7 @@ jobs:
|
||||
|
||||
- name: Create release
|
||||
if: ${{ inputs.release_type != 'Dry Run' }}
|
||||
uses: ncipollo/release-action@cdcc88a9acf3ca41c16c37bb7d21b9ad48560d87 # v1.15.0
|
||||
uses: ncipollo/release-action@b7eabc95ff50cbeeedec83973935c8f306dfcd0b # v1.20.0
|
||||
env:
|
||||
PKG_VERSION: ${{ needs.setup.outputs.release_version }}
|
||||
with:
|
||||
|
||||
28
.github/workflows/respond.yml
vendored
Normal file
28
.github/workflows/respond.yml
vendored
Normal file
@@ -0,0 +1,28 @@
|
||||
name: Respond
|
||||
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created]
|
||||
pull_request_review_comment:
|
||||
types: [created]
|
||||
issues:
|
||||
types: [opened, assigned]
|
||||
pull_request_review:
|
||||
types: [submitted]
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
respond:
|
||||
name: Respond
|
||||
uses: bitwarden/gh-actions/.github/workflows/_respond.yml@main
|
||||
secrets:
|
||||
AZURE_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
|
||||
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
|
||||
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
permissions:
|
||||
actions: read
|
||||
contents: write
|
||||
id-token: write
|
||||
issues: write
|
||||
pull-requests: write
|
||||
21
.github/workflows/review-code.yml
vendored
Normal file
21
.github/workflows/review-code.yml
vendored
Normal file
@@ -0,0 +1,21 @@
|
||||
name: Code Review
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened]
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
review:
|
||||
name: Review
|
||||
uses: bitwarden/gh-actions/.github/workflows/_review-code.yml@main
|
||||
secrets:
|
||||
AZURE_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
|
||||
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
|
||||
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
id-token: write
|
||||
pull-requests: write
|
||||
20
.github/workflows/test.yml
vendored
20
.github/workflows/test.yml
vendored
@@ -22,17 +22,19 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Get Node version
|
||||
id: retrieve-node-version
|
||||
run: |
|
||||
NODE_NVMRC=$(cat .nvmrc)
|
||||
NODE_VERSION=${NODE_NVMRC/v/''}
|
||||
echo "node_version=$NODE_VERSION" >> $GITHUB_OUTPUT
|
||||
echo "node_version=$NODE_VERSION" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
|
||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||
with:
|
||||
cache: 'npm'
|
||||
cache-dependency-path: '**/package-lock.json'
|
||||
@@ -51,8 +53,10 @@ jobs:
|
||||
run: npm run test --coverage
|
||||
|
||||
- name: Report test results
|
||||
uses: dorny/test-reporter@dc3a92680fcc15842eef52e8c4606ea7ce6bd3f3 # v2.1.1
|
||||
if: ${{ github.event.pull_request.head.repo.full_name == github.repository && !cancelled() }}
|
||||
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()
|
||||
with:
|
||||
name: Test Results
|
||||
path: "junit.xml"
|
||||
@@ -60,7 +64,9 @@ jobs:
|
||||
fail-on-error: true
|
||||
|
||||
- name: Upload coverage to codecov.io
|
||||
uses: codecov/codecov-action@5a605bd92782ce0810fa3b8acc235c921b497052 # v5.2.0
|
||||
uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2
|
||||
|
||||
- name: Upload results to codecov.io
|
||||
uses: codecov/test-results-action@4e79e65778be1cecd5df25e14af1eafb6df80ea9 # v1.0.2
|
||||
uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2
|
||||
with:
|
||||
report_type: test_results
|
||||
|
||||
33
.github/workflows/version-bump.yml
vendored
33
.github/workflows/version-bump.yml
vendored
@@ -42,16 +42,18 @@ jobs:
|
||||
uses: bitwarden/gh-actions/azure-logout@main
|
||||
|
||||
- name: Generate GH App token
|
||||
uses: actions/create-github-app-token@df432ceedc7162793a195dd1713ff69aefc7379e # v2.0.6
|
||||
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1
|
||||
id: app-token
|
||||
with:
|
||||
app-id: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-ID }}
|
||||
private-key: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-KEY }}
|
||||
permission-contents: write
|
||||
|
||||
- name: Checkout Branch
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
token: ${{ steps.app-token.outputs.token }}
|
||||
persist-credentials: true
|
||||
|
||||
- name: Setup git
|
||||
run: |
|
||||
@@ -62,7 +64,7 @@ jobs:
|
||||
id: current-version
|
||||
run: |
|
||||
CURRENT_VERSION=$(cat package.json | jq -r '.version')
|
||||
echo "version=$CURRENT_VERSION" >> $GITHUB_OUTPUT
|
||||
echo "version=$CURRENT_VERSION" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Verify input version
|
||||
if: ${{ inputs.version_number_override != '' }}
|
||||
@@ -77,8 +79,7 @@ jobs:
|
||||
fi
|
||||
|
||||
# Check if version is newer.
|
||||
printf '%s\n' "${CURRENT_VERSION}" "${NEW_VERSION}" | sort -C -V
|
||||
if [ $? -eq 0 ]; then
|
||||
if printf '%s\n' "${CURRENT_VERSION}" "${NEW_VERSION}" | sort -C -V; then
|
||||
echo "Version check successful."
|
||||
else
|
||||
echo "Version check failed."
|
||||
@@ -110,26 +111,34 @@ jobs:
|
||||
|
||||
- name: Set final version output
|
||||
id: set-final-version-output
|
||||
env:
|
||||
_BUMP_VERSION_OVERRIDE_OUTCOME: ${{ steps.bump-version-override.outcome }}
|
||||
_INPUT_VERSION_NUMBER_OVERRIDE: ${{ inputs.version_number_override }}
|
||||
_BUMP_VERSION_AUTOMATIC_OUTCOME: ${{ steps.bump-version-automatic.outcome }}
|
||||
_CALCULATE_NEXT_VERSION: ${{ steps.calculate-next-version.outputs.version }}
|
||||
|
||||
run: |
|
||||
if [[ "${{ steps.bump-version-override.outcome }}" == "success" ]]; then
|
||||
echo "version=${{ inputs.version_number_override }}" >> $GITHUB_OUTPUT
|
||||
elif [[ "${{ steps.bump-version-automatic.outcome }}" == "success" ]]; then
|
||||
echo "version=${{ steps.calculate-next-version.outputs.version }}" >> $GITHUB_OUTPUT
|
||||
if [[ "$_BUMP_VERSION_OVERRIDE_OUTCOME" == "success" ]]; then
|
||||
echo "version=$_INPUT_VERSION_NUMBER_OVERRIDE" >> "$GITHUB_OUTPUT"
|
||||
elif [[ "$_BUMP_VERSION_AUTOMATIC_OUTCOME" == "success" ]]; then
|
||||
echo "version=$_CALCULATE_NEXT_VERSION" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Check if version changed
|
||||
id: version-changed
|
||||
run: |
|
||||
if [ -n "$(git status --porcelain)" ]; then
|
||||
echo "changes_to_commit=TRUE" >> $GITHUB_OUTPUT
|
||||
echo "changes_to_commit=TRUE" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "changes_to_commit=FALSE" >> $GITHUB_OUTPUT
|
||||
echo "changes_to_commit=FALSE" >> "$GITHUB_OUTPUT"
|
||||
echo "No changes to commit!";
|
||||
fi
|
||||
|
||||
- name: Commit files
|
||||
if: ${{ steps.version-changed.outputs.changes_to_commit == 'TRUE' }}
|
||||
run: git commit -m "Bumped version to ${{ steps.set-final-version-output.outputs.version }}" -a
|
||||
env:
|
||||
_VERSION: ${{ steps.set-final-version-output.outputs.version }}
|
||||
run: git commit -m "Bumped version to $_VERSION" -a
|
||||
|
||||
- name: Push changes
|
||||
if: ${{ steps.version-changed.outputs.changes_to_commit == 'TRUE' }}
|
||||
|
||||
7
.gitignore
vendored
7
.gitignore
vendored
@@ -2,6 +2,9 @@
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Environment variables used for tests
|
||||
.env
|
||||
|
||||
# IDEs and editors
|
||||
.idea/
|
||||
.project
|
||||
@@ -30,8 +33,8 @@ build-cli
|
||||
.angular/cache
|
||||
|
||||
# Testing
|
||||
coverage
|
||||
junit.xml
|
||||
coverage*
|
||||
junit.xml*
|
||||
|
||||
# Misc
|
||||
*.crx
|
||||
|
||||
10
angular.json
10
angular.json
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
services:
|
||||
open-ldap:
|
||||
image: bitnami/openldap:latest
|
||||
image: bitnamilegacy/openldap:latest
|
||||
hostname: openldap
|
||||
environment:
|
||||
- LDAP_ADMIN_USERNAME=admin
|
||||
@@ -11,8 +11,8 @@ services:
|
||||
- LDAP_TLS_KEY_FILE=/certs/openldap-key.pem
|
||||
- LDAP_TLS_CA_FILE=/certs/rootCA.pem
|
||||
volumes:
|
||||
- "./openldap/ldifs:/ldifs"
|
||||
- "./openldap/certs:/certs"
|
||||
- "./utils/openldap/ldifs:/ldifs"
|
||||
- "./utils/openldap/certs:/certs"
|
||||
ports:
|
||||
- "1389:1389"
|
||||
- "1636:1636"
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
},
|
||||
"productName": "Bitwarden Directory Connector",
|
||||
"appId": "com.bitwarden.directory-connector",
|
||||
"copyright": "Copyright © 2015-2022 Bitwarden Inc.",
|
||||
"copyright": "Copyright © 2015-2026 Bitwarden Inc.",
|
||||
"directories": {
|
||||
"buildResources": "resources",
|
||||
"output": "dist",
|
||||
|
||||
149
eslint.config.mjs
Normal file
149
eslint.config.mjs
Normal file
@@ -0,0 +1,149 @@
|
||||
// @ts-check
|
||||
import eslint from "@eslint/js";
|
||||
import tsParser from "@typescript-eslint/parser";
|
||||
import tsPlugin from "@typescript-eslint/eslint-plugin";
|
||||
import prettierConfig from "eslint-config-prettier";
|
||||
import importPlugin from "eslint-plugin-import";
|
||||
import rxjsX from "eslint-plugin-rxjs-x";
|
||||
import rxjsAngularX from "eslint-plugin-rxjs-angular-x";
|
||||
import angularEslint from "@angular-eslint/eslint-plugin-template";
|
||||
import angularParser from "@angular-eslint/template-parser";
|
||||
import globals from "globals";
|
||||
|
||||
export default [
|
||||
// Global ignores (replaces .eslintignore)
|
||||
{
|
||||
ignores: [
|
||||
"dist/**",
|
||||
"dist-cli/**",
|
||||
"build/**",
|
||||
"build-cli/**",
|
||||
"coverage/**",
|
||||
"**/*.cjs",
|
||||
"eslint.config.mjs",
|
||||
"scripts/**/*.js",
|
||||
"**/node_modules/**",
|
||||
],
|
||||
},
|
||||
|
||||
// Base config for all JavaScript/TypeScript files
|
||||
{
|
||||
files: ["**/*.ts", "**/*.js"],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
sourceType: "module",
|
||||
parser: tsParser,
|
||||
parserOptions: {
|
||||
project: ["./tsconfig.eslint.json"],
|
||||
},
|
||||
globals: {
|
||||
...globals.browser,
|
||||
...globals.node,
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
"@typescript-eslint": tsPlugin,
|
||||
import: importPlugin,
|
||||
"rxjs-x": rxjsX,
|
||||
"rxjs-angular-x": rxjsAngularX,
|
||||
},
|
||||
settings: {
|
||||
"import/parsers": {
|
||||
"@typescript-eslint/parser": [".ts"],
|
||||
},
|
||||
"import/resolver": {
|
||||
typescript: {
|
||||
alwaysTryTypes: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
// ESLint recommended rules
|
||||
...eslint.configs.recommended.rules,
|
||||
|
||||
// TypeScript ESLint recommended rules
|
||||
...tsPlugin.configs.recommended.rules,
|
||||
|
||||
// Import plugin recommended rules
|
||||
...importPlugin.flatConfigs.recommended.rules,
|
||||
|
||||
// RxJS recommended rules
|
||||
...rxjsX.configs.recommended.rules,
|
||||
|
||||
// Custom project rules
|
||||
"@typescript-eslint/explicit-member-accessibility": ["error", { accessibility: "no-public" }],
|
||||
"@typescript-eslint/no-explicit-any": "off", // TODO: This should be re-enabled
|
||||
"@typescript-eslint/no-misused-promises": ["error", { checksVoidReturn: false }],
|
||||
"@typescript-eslint/no-this-alias": ["error", { allowedNames: ["self"] }],
|
||||
"@typescript-eslint/no-unused-vars": ["error", { args: "none" }],
|
||||
"no-console": "error",
|
||||
"import/no-unresolved": "off", // TODO: Look into turning on once each package is an actual package.
|
||||
"import/order": [
|
||||
"error",
|
||||
{
|
||||
alphabetize: {
|
||||
order: "asc",
|
||||
},
|
||||
"newlines-between": "always",
|
||||
pathGroups: [
|
||||
{
|
||||
pattern: "@/jslib/**/*",
|
||||
group: "external",
|
||||
position: "after",
|
||||
},
|
||||
{
|
||||
pattern: "@/src/**/*",
|
||||
group: "parent",
|
||||
position: "before",
|
||||
},
|
||||
],
|
||||
pathGroupsExcludedImportTypes: ["builtin"],
|
||||
},
|
||||
],
|
||||
"rxjs-angular-x/prefer-takeuntil": "error",
|
||||
"rxjs-x/no-exposed-subjects": ["error", { allowProtected: true }],
|
||||
"no-restricted-syntax": [
|
||||
"error",
|
||||
{
|
||||
message: "Calling `svgIcon` directly is not allowed",
|
||||
selector: "CallExpression[callee.name='svgIcon']",
|
||||
},
|
||||
{
|
||||
message: "Accessing FormGroup using `get` is not allowed, use `.value` instead",
|
||||
selector:
|
||||
"ChainExpression[expression.object.callee.property.name='get'][expression.property.name='value']",
|
||||
},
|
||||
],
|
||||
curly: ["error", "all"],
|
||||
"import/namespace": ["off"], // This doesn't resolve namespace imports correctly, but TS will throw for this anyway
|
||||
"no-restricted-imports": ["error", { patterns: ["src/**/*"] }],
|
||||
},
|
||||
},
|
||||
|
||||
// Jest test files (includes any test-related files)
|
||||
{
|
||||
files: ["**/*.spec.ts", "**/test.setup.ts", "**/spec/**/*.ts", "**/utils/**/*fixtures*.ts"],
|
||||
languageOptions: {
|
||||
globals: {
|
||||
...globals.jest,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
// Angular HTML templates
|
||||
{
|
||||
files: ["**/*.html"],
|
||||
languageOptions: {
|
||||
parser: angularParser,
|
||||
},
|
||||
plugins: {
|
||||
"@angular-eslint/template": angularEslint,
|
||||
},
|
||||
rules: {
|
||||
"@angular-eslint/template/button-has-type": "error",
|
||||
},
|
||||
},
|
||||
|
||||
// Prettier config (must be last to override other configs)
|
||||
prettierConfig,
|
||||
];
|
||||
@@ -26,7 +26,6 @@ module.exports = {
|
||||
modulePaths: [compilerOptions.baseUrl],
|
||||
moduleNameMapper: pathsToModuleNameMapper(compilerOptions.paths, { prefix: "<rootDir>/" }),
|
||||
setupFilesAfterEnv: ["<rootDir>/test.setup.ts"],
|
||||
|
||||
// Workaround for a memory leak that crashes tests in CI:
|
||||
// https://github.com/facebook/jest/issues/9430#issuecomment-1149882002
|
||||
// Also anecdotally improves performance when run locally
|
||||
@@ -1,4 +1,4 @@
|
||||
import { InjectFlags, InjectOptions, Injector, ProviderToken } from "@angular/core";
|
||||
import { InjectOptions, Injector, ProviderToken } from "@angular/core";
|
||||
|
||||
export class ModalInjector implements Injector {
|
||||
constructor(
|
||||
@@ -12,8 +12,7 @@ export class ModalInjector implements Injector {
|
||||
options: InjectOptions & { optional?: false },
|
||||
): T;
|
||||
get<T>(token: ProviderToken<T>, notFoundValue: null, options: InjectOptions): T;
|
||||
get<T>(token: ProviderToken<T>, notFoundValue?: T, options?: InjectOptions | InjectFlags): T;
|
||||
get<T>(token: ProviderToken<T>, notFoundValue?: T, flags?: InjectFlags): T;
|
||||
get<T>(token: ProviderToken<T>, notFoundValue?: T, options?: InjectOptions): T;
|
||||
get(token: any, notFoundValue?: any): any;
|
||||
get(token: any, notFoundValue?: any, flags?: any): any {
|
||||
return this._additionalTokens.get(token) ?? this._parentInjector.get<any>(token, notFoundValue);
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Observable, Subject } from "rxjs";
|
||||
import { first } from "rxjs/operators";
|
||||
import { lastValueFrom, Observable, Subject } from "rxjs";
|
||||
|
||||
export class ModalRef {
|
||||
onCreated: Observable<HTMLElement>; // Modal added to the DOM.
|
||||
@@ -45,6 +44,6 @@ export class ModalRef {
|
||||
}
|
||||
|
||||
onClosedPromise(): Promise<any> {
|
||||
return this.onClosed.pipe(first()).toPromise();
|
||||
return lastValueFrom(this.onClosed);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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">×</span>
|
||||
</button>
|
||||
@if (options().closeButton) {
|
||||
<button (click)="remove()" type="button" class="toast-close-button" aria-label="Close">
|
||||
<span aria-hidden="true">×</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,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Directive, ElementRef, Input, NgZone } from "@angular/core";
|
||||
import { take } from "rxjs/operators";
|
||||
import { take } from "rxjs";
|
||||
|
||||
import { Utils } from "@/jslib/common/src/misc/utils";
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
Type,
|
||||
ViewContainerRef,
|
||||
} from "@angular/core";
|
||||
import { first } from "rxjs/operators";
|
||||
import { first, firstValueFrom } from "rxjs";
|
||||
|
||||
import { DynamicModalComponent } from "../components/modal/dynamic-modal.component";
|
||||
import { ModalInjector } from "../components/modal/modal-injector";
|
||||
@@ -58,7 +58,7 @@ export class ModalService {
|
||||
|
||||
viewContainerRef.insert(modalComponentRef.hostView);
|
||||
|
||||
await modalRef.onCreated.pipe(first()).toPromise();
|
||||
await firstValueFrom(modalRef.onCreated);
|
||||
|
||||
return [modalRef, modalComponentRef.instance.componentRef.instance];
|
||||
}
|
||||
|
||||
@@ -1,195 +0,0 @@
|
||||
import { Substitute, Arg } from "@fluffy-spoon/substitute";
|
||||
|
||||
import { CryptoService } from "@/jslib/common/src/abstractions/crypto.service";
|
||||
import { EncryptionType } from "@/jslib/common/src/enums/encryptionType";
|
||||
import { EncString } from "@/jslib/common/src/models/domain/encString";
|
||||
import { SymmetricCryptoKey } from "@/jslib/common/src/models/domain/symmetricCryptoKey";
|
||||
import { ContainerService } from "@/jslib/common/src/services/container.service";
|
||||
|
||||
describe("EncString", () => {
|
||||
afterEach(() => {
|
||||
(window as any).bitwardenContainerService = undefined;
|
||||
});
|
||||
|
||||
describe("Rsa2048_OaepSha256_B64", () => {
|
||||
it("constructor", () => {
|
||||
const encString = new EncString(EncryptionType.Rsa2048_OaepSha256_B64, "data");
|
||||
|
||||
expect(encString).toEqual({
|
||||
data: "data",
|
||||
encryptedString: "3.data",
|
||||
encryptionType: 3,
|
||||
});
|
||||
});
|
||||
|
||||
describe("parse existing", () => {
|
||||
it("valid", () => {
|
||||
const encString = new EncString("3.data");
|
||||
|
||||
expect(encString).toEqual({
|
||||
data: "data",
|
||||
encryptedString: "3.data",
|
||||
encryptionType: 3,
|
||||
});
|
||||
});
|
||||
|
||||
it("invalid", () => {
|
||||
const encString = new EncString("3.data|test");
|
||||
|
||||
expect(encString).toEqual({
|
||||
encryptedString: "3.data|test",
|
||||
encryptionType: 3,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("decrypt", () => {
|
||||
const encString = new EncString(EncryptionType.Rsa2048_OaepSha256_B64, "data");
|
||||
|
||||
const cryptoService = Substitute.for<CryptoService>();
|
||||
cryptoService.getOrgKey(null).resolves(null);
|
||||
cryptoService.decryptToUtf8(encString, Arg.any()).resolves("decrypted");
|
||||
|
||||
beforeEach(() => {
|
||||
(window as any).bitwardenContainerService = new ContainerService(cryptoService);
|
||||
});
|
||||
|
||||
it("decrypts correctly", async () => {
|
||||
const decrypted = await encString.decrypt(null);
|
||||
|
||||
expect(decrypted).toBe("decrypted");
|
||||
});
|
||||
|
||||
it("result should be cached", async () => {
|
||||
const decrypted = await encString.decrypt(null);
|
||||
cryptoService.received(1).decryptToUtf8(Arg.any(), Arg.any());
|
||||
|
||||
expect(decrypted).toBe("decrypted");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("AesCbc256_B64", () => {
|
||||
it("constructor", () => {
|
||||
const encString = new EncString(EncryptionType.AesCbc256_B64, "data", "iv");
|
||||
|
||||
expect(encString).toEqual({
|
||||
data: "data",
|
||||
encryptedString: "0.iv|data",
|
||||
encryptionType: 0,
|
||||
iv: "iv",
|
||||
});
|
||||
});
|
||||
|
||||
describe("parse existing", () => {
|
||||
it("valid", () => {
|
||||
const encString = new EncString("0.iv|data");
|
||||
|
||||
expect(encString).toEqual({
|
||||
data: "data",
|
||||
encryptedString: "0.iv|data",
|
||||
encryptionType: 0,
|
||||
iv: "iv",
|
||||
});
|
||||
});
|
||||
|
||||
it("invalid", () => {
|
||||
const encString = new EncString("0.iv|data|mac");
|
||||
|
||||
expect(encString).toEqual({
|
||||
encryptedString: "0.iv|data|mac",
|
||||
encryptionType: 0,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("AesCbc256_HmacSha256_B64", () => {
|
||||
it("constructor", () => {
|
||||
const encString = new EncString(EncryptionType.AesCbc256_HmacSha256_B64, "data", "iv", "mac");
|
||||
|
||||
expect(encString).toEqual({
|
||||
data: "data",
|
||||
encryptedString: "2.iv|data|mac",
|
||||
encryptionType: 2,
|
||||
iv: "iv",
|
||||
mac: "mac",
|
||||
});
|
||||
});
|
||||
|
||||
it("valid", () => {
|
||||
const encString = new EncString("2.iv|data|mac");
|
||||
|
||||
expect(encString).toEqual({
|
||||
data: "data",
|
||||
encryptedString: "2.iv|data|mac",
|
||||
encryptionType: 2,
|
||||
iv: "iv",
|
||||
mac: "mac",
|
||||
});
|
||||
});
|
||||
|
||||
it("invalid", () => {
|
||||
const encString = new EncString("2.iv|data");
|
||||
|
||||
expect(encString).toEqual({
|
||||
encryptedString: "2.iv|data",
|
||||
encryptionType: 2,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("Exit early if null", () => {
|
||||
const encString = new EncString(null);
|
||||
|
||||
expect(encString).toEqual({
|
||||
encryptedString: null,
|
||||
});
|
||||
});
|
||||
|
||||
describe("decrypt", () => {
|
||||
it("throws exception when bitwarden container not initialized", async () => {
|
||||
const encString = new EncString(null);
|
||||
|
||||
expect.assertions(1);
|
||||
try {
|
||||
await encString.decrypt(null);
|
||||
} catch (e) {
|
||||
expect(e.message).toEqual("global bitwardenContainerService not initialized.");
|
||||
}
|
||||
});
|
||||
|
||||
it("handles value it can't decrypt", async () => {
|
||||
const encString = new EncString(null);
|
||||
|
||||
const cryptoService = Substitute.for<CryptoService>();
|
||||
cryptoService.getOrgKey(null).resolves(null);
|
||||
cryptoService.decryptToUtf8(encString, Arg.any()).throws("error");
|
||||
|
||||
(window as any).bitwardenContainerService = new ContainerService(cryptoService);
|
||||
|
||||
const decrypted = await encString.decrypt(null);
|
||||
|
||||
expect(decrypted).toBe("[error: cannot decrypt]");
|
||||
|
||||
expect(encString).toEqual({
|
||||
decryptedValue: "[error: cannot decrypt]",
|
||||
encryptedString: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("passes along key", async () => {
|
||||
const encString = new EncString(null);
|
||||
const key = Substitute.for<SymmetricCryptoKey>();
|
||||
|
||||
const cryptoService = Substitute.for<CryptoService>();
|
||||
cryptoService.getOrgKey(null).resolves(null);
|
||||
|
||||
(window as any).bitwardenContainerService = new ContainerService(cryptoService);
|
||||
|
||||
await encString.decrypt(null, key);
|
||||
|
||||
cryptoService.received().decryptToUtf8(encString, key);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -9,7 +9,7 @@ describe("SymmetricCryptoKey", () => {
|
||||
new SymmetricCryptoKey(null);
|
||||
};
|
||||
|
||||
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.");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,15 +8,12 @@ declare let console: any;
|
||||
export function interceptConsole(interceptions: any): object {
|
||||
console = {
|
||||
log: function () {
|
||||
// eslint-disable-next-line
|
||||
interceptions.log = arguments;
|
||||
},
|
||||
warn: function () {
|
||||
// eslint-disable-next-line
|
||||
interceptions.warn = arguments;
|
||||
},
|
||||
error: function () {
|
||||
// eslint-disable-next-line
|
||||
interceptions.error = arguments;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,84 +0,0 @@
|
||||
import { Arg, Substitute, SubstituteOf } from "@fluffy-spoon/substitute";
|
||||
|
||||
import { StorageService } from "@/jslib/common/src/abstractions/storage.service";
|
||||
import { StateVersion } from "@/jslib/common/src/enums/stateVersion";
|
||||
import { StateFactory } from "@/jslib/common/src/factories/stateFactory";
|
||||
import { Account } from "@/jslib/common/src/models/domain/account";
|
||||
import { GlobalState } from "@/jslib/common/src/models/domain/globalState";
|
||||
import { StateMigrationService } from "@/jslib/common/src/services/stateMigration.service";
|
||||
|
||||
const userId = "USER_ID";
|
||||
|
||||
describe("State Migration Service", () => {
|
||||
let storageService: SubstituteOf<StorageService>;
|
||||
let secureStorageService: SubstituteOf<StorageService>;
|
||||
let stateFactory: SubstituteOf<StateFactory>;
|
||||
|
||||
let stateMigrationService: StateMigrationService;
|
||||
|
||||
beforeEach(() => {
|
||||
storageService = Substitute.for<StorageService>();
|
||||
secureStorageService = Substitute.for<StorageService>();
|
||||
stateFactory = Substitute.for<StateFactory>();
|
||||
|
||||
stateMigrationService = new StateMigrationService(
|
||||
storageService,
|
||||
secureStorageService,
|
||||
stateFactory,
|
||||
);
|
||||
});
|
||||
|
||||
describe("StateVersion 3 to 4 migration", async () => {
|
||||
beforeEach(() => {
|
||||
const globalVersion3: Partial<GlobalState> = {
|
||||
stateVersion: StateVersion.Three,
|
||||
};
|
||||
|
||||
storageService.get("global", Arg.any()).resolves(globalVersion3);
|
||||
storageService.get("authenticatedAccounts", Arg.any()).resolves([userId]);
|
||||
});
|
||||
|
||||
it("clears everBeenUnlocked", async () => {
|
||||
const accountVersion3: Account = {
|
||||
profile: {
|
||||
apiKeyClientId: null,
|
||||
convertAccountToKeyConnector: null,
|
||||
email: "EMAIL",
|
||||
emailVerified: true,
|
||||
everBeenUnlocked: true,
|
||||
hasPremiumPersonally: false,
|
||||
kdfIterations: 100000,
|
||||
kdfType: 0,
|
||||
keyHash: "KEY_HASH",
|
||||
lastSync: "LAST_SYNC",
|
||||
userId: userId,
|
||||
usesKeyConnector: false,
|
||||
forcePasswordReset: false,
|
||||
},
|
||||
};
|
||||
|
||||
const expectedAccountVersion4: Account = {
|
||||
profile: {
|
||||
...accountVersion3.profile,
|
||||
},
|
||||
};
|
||||
delete expectedAccountVersion4.profile.everBeenUnlocked;
|
||||
|
||||
storageService.get(userId, Arg.any()).resolves(accountVersion3);
|
||||
|
||||
await stateMigrationService.migrate();
|
||||
|
||||
storageService.received(1).save(userId, expectedAccountVersion4, Arg.any());
|
||||
});
|
||||
|
||||
it("updates StateVersion number", async () => {
|
||||
await stateMigrationService.migrate();
|
||||
|
||||
storageService.received(1).save(
|
||||
"global",
|
||||
Arg.is((globals: GlobalState) => globals.stateVersion === StateVersion.Four),
|
||||
Arg.any(),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,3 @@
|
||||
import { Substitute, Arg } from "@fluffy-spoon/substitute";
|
||||
|
||||
import { EncString } from "@/jslib/common/src/models/domain/encString";
|
||||
|
||||
function newGuid() {
|
||||
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
|
||||
const r = (Math.random() * 16) | 0;
|
||||
@@ -21,17 +17,10 @@ export function BuildTestObject<T, K extends keyof T = keyof T>(
|
||||
return Object.assign(constructor === null ? {} : new constructor(), def) as T;
|
||||
}
|
||||
|
||||
export function mockEnc(s: string): EncString {
|
||||
const mock = Substitute.for<EncString>();
|
||||
mock.decrypt(Arg.any(), Arg.any()).resolves(s);
|
||||
|
||||
return mock;
|
||||
}
|
||||
|
||||
export function makeStaticByteArray(length: number, start = 0) {
|
||||
const arr = new Uint8Array(length);
|
||||
for (let i = 0; i < length; i++) {
|
||||
arr[i] = start + i;
|
||||
}
|
||||
return arr;
|
||||
return arr.buffer;
|
||||
}
|
||||
|
||||
@@ -26,9 +26,4 @@ export class NodeUtils {
|
||||
.on("error", (err) => reject(err));
|
||||
});
|
||||
}
|
||||
|
||||
// https://stackoverflow.com/a/31394257
|
||||
static bufferToArrayBuffer(buf: Buffer): ArrayBuffer {
|
||||
return buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
/* eslint-disable no-useless-escape */
|
||||
import * as url from "url";
|
||||
|
||||
import { I18nService } from "../abstractions/i18n.service";
|
||||
|
||||
import * as tldjs from "tldjs";
|
||||
|
||||
const nodeURL = typeof window === "undefined" ? require("url") : null;
|
||||
const nodeURL = typeof window === "undefined" ? url : null;
|
||||
|
||||
export class Utils {
|
||||
static inited = false;
|
||||
@@ -34,7 +36,7 @@ export class Utils {
|
||||
Utils.global = Utils.isNode && !Utils.isBrowser ? global : window;
|
||||
}
|
||||
|
||||
static fromB64ToArray(str: string): Uint8Array {
|
||||
static fromB64ToArray(str: string): Uint8Array<ArrayBuffer> {
|
||||
if (Utils.isNode) {
|
||||
return new Uint8Array(Buffer.from(str, "base64"));
|
||||
} else {
|
||||
@@ -47,11 +49,11 @@ export class Utils {
|
||||
}
|
||||
}
|
||||
|
||||
static fromUrlB64ToArray(str: string): Uint8Array {
|
||||
static fromUrlB64ToArray(str: string): Uint8Array<ArrayBuffer> {
|
||||
return Utils.fromB64ToArray(Utils.fromUrlB64ToB64(str));
|
||||
}
|
||||
|
||||
static fromHexToArray(str: string): Uint8Array {
|
||||
static fromHexToArray(str: string): Uint8Array<ArrayBuffer> {
|
||||
if (Utils.isNode) {
|
||||
return new Uint8Array(Buffer.from(str, "hex"));
|
||||
} else {
|
||||
@@ -63,7 +65,7 @@ export class Utils {
|
||||
}
|
||||
}
|
||||
|
||||
static fromUtf8ToArray(str: string): Uint8Array {
|
||||
static fromUtf8ToArray(str: string): Uint8Array<ArrayBuffer> {
|
||||
if (Utils.isNode) {
|
||||
return new Uint8Array(Buffer.from(str, "utf8"));
|
||||
} else {
|
||||
@@ -76,7 +78,7 @@ export class Utils {
|
||||
}
|
||||
}
|
||||
|
||||
static fromByteStringToArray(str: string): Uint8Array {
|
||||
static fromByteStringToArray(str: string): Uint8Array<ArrayBuffer> {
|
||||
const arr = new Uint8Array(str.length);
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
arr[i] = str.charCodeAt(i);
|
||||
@@ -97,8 +99,8 @@ export class Utils {
|
||||
}
|
||||
}
|
||||
|
||||
static fromBufferToUrlB64(buffer: ArrayBuffer): string {
|
||||
return Utils.fromB64toUrlB64(Utils.fromBufferToB64(buffer));
|
||||
static fromBufferToUrlB64(buffer: Uint8Array<ArrayBuffer>): string {
|
||||
return Utils.fromB64toUrlB64(Utils.fromBufferToB64(buffer.buffer));
|
||||
}
|
||||
|
||||
static fromB64toUrlB64(b64Str: string) {
|
||||
@@ -247,7 +249,7 @@ export class Utils {
|
||||
const urlDomain =
|
||||
tldjs != null && tldjs.getDomain != null ? tldjs.getDomain(url.hostname) : null;
|
||||
return urlDomain != null ? urlDomain : url.hostname;
|
||||
} catch (e) {
|
||||
} catch {
|
||||
// Invalid domain, try another approach below.
|
||||
}
|
||||
}
|
||||
@@ -395,7 +397,7 @@ export class Utils {
|
||||
anchor.href = uriString;
|
||||
return anchor as any;
|
||||
}
|
||||
} catch (e) {
|
||||
} catch {
|
||||
// Ignore error
|
||||
}
|
||||
|
||||
|
||||
@@ -53,7 +53,7 @@ export class EncString {
|
||||
try {
|
||||
this.encryptionType = parseInt(headerPieces[0], null);
|
||||
encPieces = headerPieces[1].split("|");
|
||||
} catch (e) {
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
@@ -114,7 +114,7 @@ export class EncString {
|
||||
key = await cryptoService.getOrgKey(orgId);
|
||||
}
|
||||
this.decryptedValue = await cryptoService.decryptToUtf8(this, key);
|
||||
} catch (e) {
|
||||
} catch {
|
||||
this.decryptedValue = "[error: cannot decrypt]";
|
||||
}
|
||||
return this.decryptedValue;
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { ClientType } from "../../../enums/clientType";
|
||||
import { Utils } from "../../../misc/utils";
|
||||
import { CaptchaProtectedRequest } from "../captchaProtectedRequest";
|
||||
import { DeviceRequest } from "../deviceRequest";
|
||||
|
||||
@@ -30,5 +29,4 @@ export class PasswordTokenRequest extends TokenRequest implements CaptchaProtect
|
||||
|
||||
return obj;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -12,7 +12,6 @@ export abstract class TokenRequest {
|
||||
this.device = device != null ? device : null;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line
|
||||
alterIdentityTokenHeaders(headers: Headers) {
|
||||
// Implemented in subclass if required
|
||||
}
|
||||
|
||||
@@ -335,9 +335,11 @@ export class CryptoService implements CryptoServiceAbstraction {
|
||||
}
|
||||
|
||||
async clearStoredKey(keySuffix: KeySuffixOptions) {
|
||||
keySuffix === KeySuffixOptions.Auto
|
||||
? await this.stateService.setCryptoMasterKeyAuto(null)
|
||||
: await this.stateService.setCryptoMasterKeyBiometric(null);
|
||||
if (keySuffix === KeySuffixOptions.Auto) {
|
||||
await this.stateService.setCryptoMasterKeyAuto(null);
|
||||
} else {
|
||||
await this.stateService.setCryptoMasterKeyBiometric(null);
|
||||
}
|
||||
}
|
||||
|
||||
async clearKeyHash(userId?: string): Promise<any> {
|
||||
@@ -634,9 +636,9 @@ export class CryptoService implements CryptoServiceAbstraction {
|
||||
|
||||
const encBytes = new Uint8Array(encBuf);
|
||||
const encType = encBytes[0];
|
||||
let ctBytes: Uint8Array = null;
|
||||
let ivBytes: Uint8Array = null;
|
||||
let macBytes: Uint8Array = null;
|
||||
let ctBytes: Uint8Array<ArrayBuffer> = null;
|
||||
let ivBytes: Uint8Array<ArrayBuffer> = null;
|
||||
let macBytes: Uint8Array<ArrayBuffer> = null;
|
||||
|
||||
switch (encType) {
|
||||
case EncryptionType.AesCbc128_HmacSha256_B64:
|
||||
@@ -717,7 +719,7 @@ export class CryptoService implements CryptoServiceAbstraction {
|
||||
|
||||
const privateKey = await this.decryptToBytes(new EncString(encPrivateKey), encKey);
|
||||
await this.cryptoFunctionService.rsaExtractPublicKey(privateKey);
|
||||
} catch (e) {
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@@ -38,8 +38,7 @@ const partialKeys = {
|
||||
export class StateService<
|
||||
TGlobalState extends GlobalState = GlobalState,
|
||||
TAccount extends Account = Account,
|
||||
> implements StateServiceAbstraction<TAccount>
|
||||
{
|
||||
> implements StateServiceAbstraction<TAccount> {
|
||||
protected accountsSubject = new BehaviorSubject<{ [userId: string]: TAccount }>({});
|
||||
accounts$ = this.accountsSubject.asObservable();
|
||||
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
import * as path from "path";
|
||||
|
||||
import { app, BrowserWindow, Menu, MenuItemConstructorOptions, nativeImage, Tray } from "electron";
|
||||
import {
|
||||
app,
|
||||
BrowserWindow,
|
||||
Menu,
|
||||
MenuItemConstructorOptions,
|
||||
NativeImage,
|
||||
nativeImage,
|
||||
Tray,
|
||||
} from "electron";
|
||||
|
||||
import { I18nService } from "@/jslib/common/src/abstractions/i18n.service";
|
||||
import { StateService } from "@/jslib/common/src/abstractions/state.service";
|
||||
@@ -12,8 +20,8 @@ export class TrayMain {
|
||||
|
||||
private appName: string;
|
||||
private tray: Tray;
|
||||
private icon: string | Electron.NativeImage;
|
||||
private pressedIcon: Electron.NativeImage;
|
||||
private icon: string | NativeImage;
|
||||
private pressedIcon: NativeImage;
|
||||
|
||||
constructor(
|
||||
private windowMain: WindowMain,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import * as path from "path";
|
||||
import * as url from "url";
|
||||
|
||||
import { app, BrowserWindow, screen } from "electron";
|
||||
import { app, BrowserWindow, Rectangle, screen } from "electron";
|
||||
|
||||
import { LogService } from "@/jslib/common/src/abstractions/log.service";
|
||||
import { StateService } from "@/jslib/common/src/abstractions/state.service";
|
||||
@@ -14,7 +14,7 @@ export class WindowMain {
|
||||
win: BrowserWindow;
|
||||
isQuitting = false;
|
||||
|
||||
private windowStateChangeTimer: NodeJS.Timeout;
|
||||
private windowStateChangeTimer: ReturnType<typeof setTimeout>;
|
||||
private windowStates: { [key: string]: any } = {};
|
||||
private enableAlwaysOnTop = false;
|
||||
|
||||
@@ -37,7 +37,6 @@ export class WindowMain {
|
||||
app.quit();
|
||||
return;
|
||||
} else {
|
||||
// eslint-disable-next-line
|
||||
app.on("second-instance", (event, argv, workingDirectory) => {
|
||||
// Someone tried to run a second instance, we should focus our window.
|
||||
if (this.win != null) {
|
||||
@@ -128,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();
|
||||
}
|
||||
@@ -241,7 +247,7 @@ export class WindowMain {
|
||||
const state = await this.stateService.getWindow();
|
||||
|
||||
const isValid = state != null && (this.stateHasBounds(state) || state.isMaximized);
|
||||
let displayBounds: Electron.Rectangle = null;
|
||||
let displayBounds: Rectangle = null;
|
||||
if (!isValid) {
|
||||
state.width = defaultWidth;
|
||||
state.height = defaultHeight;
|
||||
|
||||
@@ -94,7 +94,7 @@ describe("NodeCrypto Function Service", () => {
|
||||
it("should fail with prk too small", async () => {
|
||||
const cryptoFunctionService = new NodeCryptoFunctionService();
|
||||
const f = cryptoFunctionService.hkdfExpand(
|
||||
Utils.fromB64ToArray(prk16Byte),
|
||||
Utils.fromB64ToArray(prk16Byte).buffer,
|
||||
"info",
|
||||
32,
|
||||
"sha256",
|
||||
@@ -105,7 +105,7 @@ describe("NodeCrypto Function Service", () => {
|
||||
it("should fail with outputByteSize is too large", async () => {
|
||||
const cryptoFunctionService = new NodeCryptoFunctionService();
|
||||
const f = cryptoFunctionService.hkdfExpand(
|
||||
Utils.fromB64ToArray(prk32Byte),
|
||||
Utils.fromB64ToArray(prk32Byte).buffer,
|
||||
"info",
|
||||
8161,
|
||||
"sha256",
|
||||
@@ -341,7 +341,7 @@ function testHkdf(
|
||||
utf8Key: string,
|
||||
unicodeKey: string,
|
||||
) {
|
||||
const ikm = Utils.fromB64ToArray("criAmKtfzxanbgea5/kelQ==");
|
||||
const ikm = Utils.fromB64ToArray("criAmKtfzxanbgea5/kelQ==").buffer;
|
||||
|
||||
const regularSalt = "salt";
|
||||
const utf8Salt = "üser_salt";
|
||||
@@ -393,7 +393,7 @@ function testHkdfExpand(
|
||||
it("should create valid " + algorithm + " " + outputByteSize + " byte okm", async () => {
|
||||
const cryptoFunctionService = new NodeCryptoFunctionService();
|
||||
const okm = await cryptoFunctionService.hkdfExpand(
|
||||
Utils.fromB64ToArray(b64prk),
|
||||
Utils.fromB64ToArray(b64prk).buffer,
|
||||
info,
|
||||
outputByteSize,
|
||||
algorithm,
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
if ! [ -x "$(command -v mkcert)" ]; then
|
||||
echo 'Error: mkcert is not installed. Install mkcert first and then re-run this script.'
|
||||
echo 'e.g. brew install mkcert'
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mkcert -install
|
||||
mkdir -p ./openldap/certs
|
||||
cp "$(mkcert -CAROOT)/rootCA.pem" ./openldap/certs/rootCA.pem
|
||||
mkcert -key-file ./openldap/certs/openldap-key.pem -cert-file ./openldap/certs/openldap.pem localhost openldap
|
||||
17527
package-lock.json
generated
17527
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
112
package.json
112
package.json
@@ -2,7 +2,7 @@
|
||||
"name": "@bitwarden/directory-connector",
|
||||
"productName": "Bitwarden Directory Connector",
|
||||
"description": "Sync your user directory to your Bitwarden organization.",
|
||||
"version": "2025.9.0",
|
||||
"version": "2026.2.0",
|
||||
"keywords": [
|
||||
"bitwarden",
|
||||
"password",
|
||||
@@ -31,14 +31,14 @@
|
||||
"lint": "eslint . && prettier --check .",
|
||||
"lint:fix": "eslint . --fix",
|
||||
"build": "concurrently -n Main,Rend -c yellow,cyan \"npm run build:main\" \"npm run build:renderer\"",
|
||||
"build:main": "webpack --config webpack.main.js",
|
||||
"build:renderer": "webpack --config webpack.renderer.js",
|
||||
"build:renderer:watch": "webpack --config webpack.renderer.js --watch",
|
||||
"build:main": "webpack --config webpack.main.cjs",
|
||||
"build:renderer": "webpack --config webpack.renderer.cjs",
|
||||
"build:renderer:watch": "webpack --config webpack.renderer.cjs --watch",
|
||||
"build:dist": "npm run reset && npm run rebuild && npm run build",
|
||||
"build:cli": "webpack --config webpack.cli.js",
|
||||
"build:cli:watch": "webpack --config webpack.cli.js --watch",
|
||||
"build:cli:prod": "cross-env NODE_ENV=production webpack --config webpack.cli.js",
|
||||
"build:cli:prod:watch": "cross-env NODE_ENV=production webpack --config webpack.cli.js --watch",
|
||||
"build:cli": "webpack --config webpack.cli.cjs",
|
||||
"build:cli:watch": "webpack --config webpack.cli.cjs --watch",
|
||||
"build:cli:prod": "cross-env NODE_ENV=production webpack --config webpack.cli.cjs",
|
||||
"build:cli:prod:watch": "cross-env NODE_ENV=production webpack --config webpack.cli.cjs --watch",
|
||||
"electron": "npm run build:main && concurrently -k -n Main,Rend -c yellow,cyan \"electron --inspect=5858 ./build --watch\" \"npm run build:renderer:watch\"",
|
||||
"electron:ignore": "npm run build:main && concurrently -k -n Main,Rend -c yellow,cyan \"electron --inspect=5858 --ignore-certificate-errors ./build --watch\" \"npm run build:renderer:watch\"",
|
||||
"clean:dist": "rimraf --glob ./dist/*",
|
||||
@@ -69,112 +69,112 @@
|
||||
"test:watch:all": "jest --watchAll --testPathIgnorePatterns=.integration.spec.ts",
|
||||
"test:integration": "jest .integration.spec.ts",
|
||||
"test:integration:watch": "jest .integration.spec.ts --watch",
|
||||
"test:integration:setup": "sh ./openldap/mkcert.sh && docker compose up -d",
|
||||
"test:integration:setup": "sh ./utils/openldap/mkcert.sh && docker compose up -d",
|
||||
"test:types": "npx tsc --noEmit"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@angular-devkit/build-angular": "19.2.15",
|
||||
"@angular-eslint/eslint-plugin-template": "19.8.0",
|
||||
"@angular-eslint/template-parser": "19.8.0",
|
||||
"@angular/compiler-cli": "19.2.14",
|
||||
"@angular-eslint/eslint-plugin-template": "21.1.0",
|
||||
"@angular-eslint/template-parser": "21.1.0",
|
||||
"@angular/build": "21.1.2",
|
||||
"@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.40.0",
|
||||
"@ngtools/webpack": "19.2.14",
|
||||
"@microsoft/microsoft-graph-types": "2.43.1",
|
||||
"@ngtools/webpack": "21.1.2",
|
||||
"@types/inquirer": "8.2.10",
|
||||
"@types/jest": "29.5.14",
|
||||
"@types/jest": "30.0.0",
|
||||
"@types/lowdb": "1.0.15",
|
||||
"@types/node": "22.18.1",
|
||||
"@types/node": "22.19.2",
|
||||
"@types/node-fetch": "2.6.12",
|
||||
"@types/node-forge": "1.3.11",
|
||||
"@types/proper-lockfile": "4.1.4",
|
||||
"@types/semver": "7.7.1",
|
||||
"@types/tldjs": "2.3.4",
|
||||
"@typescript-eslint/eslint-plugin": "8.43.0",
|
||||
"@typescript-eslint/parser": "8.43.0",
|
||||
"@typescript-eslint/eslint-plugin": "8.54.0",
|
||||
"@typescript-eslint/parser": "8.54.0",
|
||||
"@yao-pkg/pkg": "5.16.1",
|
||||
"babel-loader": "10.0.0",
|
||||
"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",
|
||||
"css-loader": "7.1.2",
|
||||
"dotenv": "17.2.0",
|
||||
"electron": "38.1.0",
|
||||
"electron": "39.2.1",
|
||||
"electron-builder": "24.13.3",
|
||||
"electron-log": "5.4.1",
|
||||
"electron-reload": "2.0.0-alpha.1",
|
||||
"electron-store": "8.2.0",
|
||||
"electron-updater": "6.6.2",
|
||||
"eslint": "8.57.1",
|
||||
"electron-updater": "6.7.3",
|
||||
"eslint": "9.39.1",
|
||||
"eslint-config-prettier": "10.1.5",
|
||||
"eslint-import-resolver-typescript": "4.4.4",
|
||||
"eslint-plugin-import": "2.32.0",
|
||||
"eslint-plugin-rxjs": "5.0.3",
|
||||
"eslint-plugin-rxjs-angular": "2.0.1",
|
||||
"eslint-plugin-rxjs-angular-x": "0.1.0",
|
||||
"eslint-plugin-rxjs-x": "0.8.3",
|
||||
"form-data": "4.0.4",
|
||||
"glob": "13.0.0",
|
||||
"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": "3.0.7",
|
||||
"jest-preset-angular": "14.6.0",
|
||||
"lint-staged": "16.1.2",
|
||||
"mini-css-extract-plugin": "2.9.2",
|
||||
"node-abi": "3.77.0",
|
||||
"node-forge": "1.3.1",
|
||||
"jest-mock-extended": "4.0.0",
|
||||
"jest-preset-angular": "16.0.0",
|
||||
"lint-staged": "16.2.6",
|
||||
"mini-css-extract-plugin": "2.10.0",
|
||||
"minimatch": "5.1.2",
|
||||
"node-forge": "1.3.2",
|
||||
"node-loader": "2.1.0",
|
||||
"prettier": "3.6.2",
|
||||
"rimraf": "6.0.1",
|
||||
"prettier": "3.8.1",
|
||||
"rimraf": "6.1.0",
|
||||
"rxjs": "7.8.2",
|
||||
"sass": "1.92.1",
|
||||
"sass": "1.97.1",
|
||||
"sass-loader": "16.0.5",
|
||||
"ts-jest": "29.4.1",
|
||||
"ts-loader": "9.5.2",
|
||||
"tsconfig-paths-webpack-plugin": "4.2.0",
|
||||
"type-fest": "4.41.0",
|
||||
"typescript": "5.8.3",
|
||||
"webpack": "5.101.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": "19.2.14",
|
||||
"@angular/cdk": "19.2.14",
|
||||
"@angular/cli": "19.2.14",
|
||||
"@angular/common": "19.2.14",
|
||||
"@angular/compiler": "19.2.14",
|
||||
"@angular/core": "19.2.14",
|
||||
"@angular/forms": "19.2.14",
|
||||
"@angular/platform-browser": "19.2.14",
|
||||
"@angular/platform-browser-dynamic": "19.2.14",
|
||||
"@angular/router": "19.2.14",
|
||||
"@angular/animations": "21.1.1",
|
||||
"@angular/cdk": "21.1.1",
|
||||
"@angular/cli": "21.1.2",
|
||||
"@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",
|
||||
"browser-hrtime": "1.1.8",
|
||||
"chalk": "4.1.2",
|
||||
"commander": "14.0.0",
|
||||
"core-js": "3.44.0",
|
||||
"form-data": "4.0.4",
|
||||
"google-auth-library": "10.3.0",
|
||||
"googleapis": "153.0.0",
|
||||
"googleapis-common": "8.0.0",
|
||||
"googleapis": "149.0.0",
|
||||
"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.0.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",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { DirectoryType } from "@/src/enums/directoryType";
|
||||
import { IDirectoryService } from "@/src/services/directory.service";
|
||||
import { IDirectoryService } from "@/src/services/directory-services/directory.service";
|
||||
|
||||
export abstract class DirectoryFactoryService {
|
||||
abstract createService(type: DirectoryType): IDirectoryService;
|
||||
|
||||
@@ -23,7 +23,7 @@ import { EnvironmentComponent } from "./environment.component";
|
||||
// The only subscription in this component is closed from a child component, confusing eslint.
|
||||
// https://github.com/cartant/eslint-plugin-rxjs-angular/blob/main/docs/rules/prefer-takeuntil.md
|
||||
//
|
||||
// eslint-disable-next-line rxjs-angular/prefer-takeuntil
|
||||
// eslint-disable-next-line rxjs-angular-x/prefer-takeuntil
|
||||
export class ApiKeyComponent {
|
||||
@ViewChild("environment", { read: ViewContainerRef, static: true })
|
||||
environmentModal: ViewContainerRef;
|
||||
@@ -100,7 +100,7 @@ export class ApiKeyComponent {
|
||||
this.environmentModal,
|
||||
);
|
||||
|
||||
// eslint-disable-next-line rxjs-angular/prefer-takeuntil
|
||||
// eslint-disable-next-line rxjs-angular-x/prefer-takeuntil
|
||||
childComponent.onSaved.pipe(takeUntil(modalRef.onClosed)).subscribe(() => {
|
||||
modalRef.close();
|
||||
});
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
// core-js is required for bwdc cli which appears to require these pollyfills for dynamic imports
|
||||
// see https://github.com/bitwarden/directory-connector/issues/878
|
||||
import "core-js/stable";
|
||||
import "zone.js";
|
||||
|
||||
import { NgModule } from "@angular/core";
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
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";
|
||||
|
||||
// tslint:disable-next-line
|
||||
require("../scss/styles.scss");
|
||||
import "../scss/styles.scss";
|
||||
|
||||
import { AppModule } from "./app.module";
|
||||
|
||||
@@ -12,4 +11,7 @@ if (!isDev()) {
|
||||
enableProdMode();
|
||||
}
|
||||
|
||||
platformBrowserDynamic().bootstrapModule(AppModule, { preserveWhitespaces: true });
|
||||
platformBrowserDynamic().bootstrapModule(AppModule, {
|
||||
applicationProviders: [provideZoneChangeDetection()],
|
||||
preserveWhitespaces: true,
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 }} (&(givenName=John)(|(l=Dallas)(l=Austin)))
|
||||
</div>
|
||||
<div class="form-text" *ngIf="directory === directoryType.EntraID">
|
||||
{{ "ex" | i18n }} exclude:joe@company.com
|
||||
</div>
|
||||
<div class="form-text" *ngIf="directory === directoryType.Okta">
|
||||
{{ "ex" | i18n }} exclude:joe@company.com | profile.firstName eq "John"
|
||||
</div>
|
||||
<div class="form-text" *ngIf="directory === directoryType.GSuite">
|
||||
{{ "ex" | i18n }} exclude:joe@company.com | orgUnitPath=/Engineering
|
||||
</div>
|
||||
@if (directory === directoryType.Ldap) {
|
||||
<div class="form-text">
|
||||
{{ "ex" | i18n }} (&(givenName=John)(|(l=Dallas)(l=Austin)))
|
||||
</div>
|
||||
}
|
||||
@if (directory === directoryType.EntraID) {
|
||||
<div class="form-text">{{ "ex" | i18n }} exclude:joe@company.com</div>
|
||||
}
|
||||
@if (directory === directoryType.Okta) {
|
||||
<div class="form-text">
|
||||
{{ "ex" | i18n }} exclude:joe@company.com | profile.firstName eq "John"
|
||||
</div>
|
||||
}
|
||||
@if (directory === directoryType.GSuite) {
|
||||
<div class="form-text">
|
||||
{{ "ex" | i18n }} exclude:joe@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 }} (&(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 }} (&(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">
|
||||
|
||||
@@ -768,5 +768,8 @@
|
||||
},
|
||||
"launchWebVault": {
|
||||
"message": "Launch Web Vault"
|
||||
},
|
||||
"authenticationFailed": {
|
||||
"message": "Authentication failed"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import { MenuMain } from "./menu.main";
|
||||
const SyncCheckInterval = 60 * 1000; // 1 minute
|
||||
|
||||
export class MessagingMain {
|
||||
private syncTimeout: NodeJS.Timeout;
|
||||
private syncTimeout: ReturnType<typeof setTimeout>;
|
||||
|
||||
constructor(
|
||||
private windowMain: WindowMain,
|
||||
|
||||
2
src/scss/bootstrap.scss
vendored
2
src/scss/bootstrap.scss
vendored
@@ -28,4 +28,4 @@ $danger: map_get($theme-colors, "danger");
|
||||
$secondary: map_get($theme-colors, "secondary");
|
||||
$secondary-alt: map_get($theme-colors, "secondary-alt");
|
||||
|
||||
@import "~bootstrap/scss/bootstrap.scss";
|
||||
@import "bootstrap/scss/bootstrap.scss";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@import "~bootstrap/scss/_variables.scss";
|
||||
@import "bootstrap/scss/_variables.scss";
|
||||
|
||||
html.os_windows {
|
||||
body {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@import "~bootstrap/scss/_variables.scss";
|
||||
@import "bootstrap/scss/_variables.scss";
|
||||
|
||||
body {
|
||||
padding: 10px 0 20px 0;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
@import "~ngx-toastr/toastr";
|
||||
@import "ngx-toastr/toastr";
|
||||
|
||||
@import "~bootstrap/scss/_variables.scss";
|
||||
@import "bootstrap/scss/_variables.scss";
|
||||
|
||||
.toast-container {
|
||||
.toast-close-button {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { Arg, Substitute, SubstituteOf } from "@fluffy-spoon/substitute";
|
||||
import { mock } from "jest-mock-extended";
|
||||
|
||||
import { ApiService } from "@/jslib/common/src/abstractions/api.service";
|
||||
import { AppIdService } from "@/jslib/common/src/abstractions/appId.service";
|
||||
import { MessagingService } from "@/jslib/common/src/abstractions/messaging.service";
|
||||
import { PlatformUtilsService } from "@/jslib/common/src/abstractions/platformUtils.service";
|
||||
import { Utils } from "@/jslib/common/src/misc/utils";
|
||||
import {
|
||||
@@ -11,7 +12,6 @@ import {
|
||||
} from "@/jslib/common/src/models/domain/account";
|
||||
import { IdentityTokenResponse } from "@/jslib/common/src/models/response/identityTokenResponse";
|
||||
|
||||
import { MessagingService } from "../../jslib/common/src/abstractions/messaging.service";
|
||||
import { Account, DirectoryConfigurations, DirectorySettings } from "../models/account";
|
||||
|
||||
import { AuthService } from "./auth.service";
|
||||
@@ -35,22 +35,22 @@ export function identityTokenResponseFactory() {
|
||||
}
|
||||
|
||||
describe("AuthService", () => {
|
||||
let apiService: SubstituteOf<ApiService>;
|
||||
let appIdService: SubstituteOf<AppIdService>;
|
||||
let platformUtilsService: SubstituteOf<PlatformUtilsService>;
|
||||
let messagingService: SubstituteOf<MessagingService>;
|
||||
let stateService: SubstituteOf<StateService>;
|
||||
let apiService: jest.Mocked<ApiService>;
|
||||
let appIdService: jest.Mocked<AppIdService>;
|
||||
let platformUtilsService: jest.Mocked<PlatformUtilsService>;
|
||||
let messagingService: jest.Mocked<MessagingService>;
|
||||
let stateService: jest.Mocked<StateService>;
|
||||
|
||||
let authService: AuthService;
|
||||
|
||||
beforeEach(async () => {
|
||||
apiService = Substitute.for();
|
||||
appIdService = Substitute.for();
|
||||
platformUtilsService = Substitute.for();
|
||||
stateService = Substitute.for();
|
||||
messagingService = Substitute.for();
|
||||
apiService = mock<ApiService>();
|
||||
appIdService = mock<AppIdService>();
|
||||
platformUtilsService = mock<PlatformUtilsService>();
|
||||
stateService = mock<StateService>();
|
||||
messagingService = mock<MessagingService>();
|
||||
|
||||
appIdService.getAppId().resolves(deviceId);
|
||||
appIdService.getAppId.mockResolvedValue(deviceId);
|
||||
|
||||
authService = new AuthService(
|
||||
apiService,
|
||||
@@ -62,11 +62,12 @@ describe("AuthService", () => {
|
||||
});
|
||||
|
||||
it("sets the local environment after a successful login", async () => {
|
||||
apiService.postIdentityToken(Arg.any()).resolves(identityTokenResponseFactory());
|
||||
apiService.postIdentityToken.mockResolvedValue(identityTokenResponseFactory());
|
||||
|
||||
await authService.logIn({ clientId, clientSecret });
|
||||
|
||||
stateService.received(1).addAccount(
|
||||
expect(stateService.addAccount).toHaveBeenCalledTimes(1);
|
||||
expect(stateService.addAccount).toHaveBeenCalledWith(
|
||||
new Account({
|
||||
profile: {
|
||||
...new AccountProfile(),
|
||||
|
||||
@@ -2,8 +2,8 @@ import { GetUniqueString } from "@/jslib/common/spec/utils";
|
||||
|
||||
import { UserEntry } from "@/src/models/userEntry";
|
||||
|
||||
import { groupSimulator, userSimulator } from "../../utils/request-builder-helper";
|
||||
import { RequestBuilderOptions } from "../abstractions/request-builder.service";
|
||||
import { groupSimulator, userSimulator } from "../utils/request-builder-helper";
|
||||
|
||||
import { BatchRequestBuilder } from "./batch-request-builder";
|
||||
|
||||
|
||||
@@ -5,11 +5,11 @@ import { DirectoryFactoryService } from "../abstractions/directory-factory.servi
|
||||
import { StateService } from "../abstractions/state.service";
|
||||
import { DirectoryType } from "../enums/directoryType";
|
||||
|
||||
import { EntraIdDirectoryService } from "./entra-id-directory.service";
|
||||
import { GSuiteDirectoryService } from "./gsuite-directory.service";
|
||||
import { LdapDirectoryService } from "./ldap-directory.service";
|
||||
import { OktaDirectoryService } from "./okta-directory.service";
|
||||
import { OneLoginDirectoryService } from "./onelogin-directory.service";
|
||||
import { EntraIdDirectoryService } from "./directory-services/entra-id-directory.service";
|
||||
import { GSuiteDirectoryService } from "./directory-services/gsuite-directory.service";
|
||||
import { LdapDirectoryService } from "./directory-services/ldap-directory.service";
|
||||
import { OktaDirectoryService } from "./directory-services/okta-directory.service";
|
||||
import { OneLoginDirectoryService } from "./directory-services/onelogin-directory.service";
|
||||
|
||||
export class DefaultDirectoryFactoryService implements DirectoryFactoryService {
|
||||
constructor(
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { GroupEntry } from "../models/groupEntry";
|
||||
import { UserEntry } from "../models/userEntry";
|
||||
import { GroupEntry } from "../../models/groupEntry";
|
||||
import { UserEntry } from "../../models/userEntry";
|
||||
|
||||
export interface IDirectoryService {
|
||||
getEntries(force: boolean, test: boolean): Promise<[GroupEntry[], UserEntry[]]>;
|
||||
@@ -7,14 +7,14 @@ import * as graphType from "@microsoft/microsoft-graph-types";
|
||||
import { I18nService } from "@/jslib/common/src/abstractions/i18n.service";
|
||||
import { LogService } from "@/jslib/common/src/abstractions/log.service";
|
||||
|
||||
import { StateService } from "../abstractions/state.service";
|
||||
import { DirectoryType } from "../enums/directoryType";
|
||||
import { EntraIdConfiguration } from "../models/entraIdConfiguration";
|
||||
import { GroupEntry } from "../models/groupEntry";
|
||||
import { SyncConfiguration } from "../models/syncConfiguration";
|
||||
import { UserEntry } from "../models/userEntry";
|
||||
import { StateService } from "../../abstractions/state.service";
|
||||
import { DirectoryType } from "../../enums/directoryType";
|
||||
import { EntraIdConfiguration } from "../../models/entraIdConfiguration";
|
||||
import { GroupEntry } from "../../models/groupEntry";
|
||||
import { SyncConfiguration } from "../../models/syncConfiguration";
|
||||
import { UserEntry } from "../../models/userEntry";
|
||||
import { BaseDirectoryService } from "../baseDirectory.service";
|
||||
|
||||
import { BaseDirectoryService } from "./baseDirectory.service";
|
||||
import { IDirectoryService } from "./directory.service";
|
||||
|
||||
const EntraIdPublicIdentityAuthority = "login.microsoftonline.com";
|
||||
@@ -132,7 +132,7 @@ export class EntraIdDirectoryService extends BaseDirectoryService implements IDi
|
||||
}
|
||||
|
||||
const setFilter = this.createCustomUserSet(this.syncConfig.userFilter);
|
||||
// eslint-disable-next-line
|
||||
|
||||
while (true) {
|
||||
const users: graphType.User[] = res.value;
|
||||
if (users != null) {
|
||||
@@ -211,7 +211,7 @@ export class EntraIdDirectoryService extends BaseDirectoryService implements IDi
|
||||
let auMembers = await this.client
|
||||
.api(`${this.getGraphApiEndpoint()}/v1.0/directory/administrativeUnits/${p}/members`)
|
||||
.get();
|
||||
// eslint-disable-next-line
|
||||
|
||||
while (true) {
|
||||
for (const auMember of auMembers.value) {
|
||||
const groupId = auMember.id;
|
||||
@@ -328,7 +328,7 @@ export class EntraIdDirectoryService extends BaseDirectoryService implements IDi
|
||||
const entries: GroupEntry[] = [];
|
||||
const groupsReq = this.client.api("/groups");
|
||||
let res = await groupsReq.get();
|
||||
// eslint-disable-next-line
|
||||
|
||||
while (true) {
|
||||
const groups: graphType.Group[] = res.value;
|
||||
if (groups != null) {
|
||||
@@ -421,7 +421,7 @@ export class EntraIdDirectoryService extends BaseDirectoryService implements IDi
|
||||
|
||||
const memReq = this.client.api("/groups/" + group.id + "/members");
|
||||
let memRes = await memReq.get();
|
||||
// eslint-disable-next-line
|
||||
|
||||
while (true) {
|
||||
const members: any = memRes.value;
|
||||
if (members != null) {
|
||||
@@ -0,0 +1,85 @@
|
||||
import { config as dotenvConfig } from "dotenv";
|
||||
import { mock, MockProxy } from "jest-mock-extended";
|
||||
|
||||
import { I18nService } from "../../../jslib/common/src/abstractions/i18n.service";
|
||||
import { LogService } from "../../../jslib/common/src/abstractions/log.service";
|
||||
import {
|
||||
getGSuiteConfiguration,
|
||||
getSyncConfiguration,
|
||||
} from "../../../utils/google-workspace/config-fixtures";
|
||||
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 { GSuiteDirectoryService } from "./gsuite-directory.service";
|
||||
|
||||
// These tests integrate with a test Google Workspace instance.
|
||||
// Credentials are located in the shared Bitwarden collection for Directory Connector testing.
|
||||
// Place the .env file attachment in the utils folder.
|
||||
|
||||
// Load .env variables
|
||||
dotenvConfig({ path: "utils/.env" });
|
||||
|
||||
// These filters target integration test data.
|
||||
// These should return data that matches the user and group fixtures exactly.
|
||||
// There may be additional data present if not used.
|
||||
const INTEGRATION_USER_FILTER = "|orgUnitPath='/Integration testing'";
|
||||
const INTEGRATION_GROUP_FILTER = "|name:Integration*";
|
||||
|
||||
// These tests are slow!
|
||||
// Increase the default timeout from 5s to 15s
|
||||
jest.setTimeout(15000);
|
||||
|
||||
describe("gsuiteDirectoryService", () => {
|
||||
let logService: MockProxy<LogService>;
|
||||
let i18nService: MockProxy<I18nService>;
|
||||
let stateService: MockProxy<StateService>;
|
||||
|
||||
let directoryService: GSuiteDirectoryService;
|
||||
|
||||
beforeEach(() => {
|
||||
logService = mock();
|
||||
i18nService = mock();
|
||||
stateService = mock();
|
||||
|
||||
stateService.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);
|
||||
});
|
||||
|
||||
it("syncs without using filters (includes test data)", async () => {
|
||||
const directoryConfig = getGSuiteConfiguration();
|
||||
stateService.getDirectory.calledWith(DirectoryType.GSuite).mockResolvedValue(directoryConfig);
|
||||
|
||||
const syncConfig = getSyncConfiguration({
|
||||
groups: true,
|
||||
users: true,
|
||||
});
|
||||
stateService.getSync.mockResolvedValue(syncConfig);
|
||||
|
||||
const result = await directoryService.getEntries(true, true);
|
||||
|
||||
expect(result[0]).toEqual(expect.arrayContaining(groupFixtures));
|
||||
expect(result[1]).toEqual(expect.arrayContaining(userFixtures));
|
||||
});
|
||||
|
||||
it("syncs using user and group filters (exact match for test data)", async () => {
|
||||
const directoryConfig = getGSuiteConfiguration();
|
||||
stateService.getDirectory.calledWith(DirectoryType.GSuite).mockResolvedValue(directoryConfig);
|
||||
|
||||
const syncConfig = getSyncConfiguration({
|
||||
groups: true,
|
||||
users: true,
|
||||
userFilter: INTEGRATION_USER_FILTER,
|
||||
groupFilter: INTEGRATION_GROUP_FILTER,
|
||||
});
|
||||
stateService.getSync.mockResolvedValue(syncConfig);
|
||||
|
||||
const result = await directoryService.getEntries(true, true);
|
||||
|
||||
expect(result).toEqual([groupFixtures, userFixtures]);
|
||||
});
|
||||
});
|
||||
@@ -4,14 +4,14 @@ 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 { StateService } from "../abstractions/state.service";
|
||||
import { DirectoryType } from "../enums/directoryType";
|
||||
import { GroupEntry } from "../models/groupEntry";
|
||||
import { GSuiteConfiguration } from "../models/gsuiteConfiguration";
|
||||
import { SyncConfiguration } from "../models/syncConfiguration";
|
||||
import { UserEntry } from "../models/userEntry";
|
||||
import { StateService } from "../../abstractions/state.service";
|
||||
import { DirectoryType } from "../../enums/directoryType";
|
||||
import { GroupEntry } from "../../models/groupEntry";
|
||||
import { GSuiteConfiguration } from "../../models/gsuiteConfiguration";
|
||||
import { SyncConfiguration } from "../../models/syncConfiguration";
|
||||
import { UserEntry } from "../../models/userEntry";
|
||||
import { BaseDirectoryService } from "../baseDirectory.service";
|
||||
|
||||
import { BaseDirectoryService } from "./baseDirectory.service";
|
||||
import { IDirectoryService } from "./directory.service";
|
||||
|
||||
export class GSuiteDirectoryService extends BaseDirectoryService implements IDirectoryService {
|
||||
@@ -71,7 +71,7 @@ export class GSuiteDirectoryService extends BaseDirectoryService implements IDir
|
||||
let nextPageToken: string = null;
|
||||
|
||||
const filter = this.createCustomSet(this.syncConfig.userFilter);
|
||||
// eslint-disable-next-line
|
||||
|
||||
while (true) {
|
||||
this.logService.info("Querying users - nextPageToken:" + nextPageToken);
|
||||
const p = Object.assign({ query: query, pageToken: nextPageToken }, this.authParams);
|
||||
@@ -99,7 +99,7 @@ export class GSuiteDirectoryService extends BaseDirectoryService implements IDir
|
||||
}
|
||||
|
||||
nextPageToken = null;
|
||||
// eslint-disable-next-line
|
||||
|
||||
while (true) {
|
||||
this.logService.info("Querying deleted users - nextPageToken:" + nextPageToken);
|
||||
const p = Object.assign(
|
||||
@@ -154,7 +154,6 @@ export class GSuiteDirectoryService extends BaseDirectoryService implements IDir
|
||||
const query = this.createDirectoryQuery(this.syncConfig.groupFilter);
|
||||
let nextPageToken: string = null;
|
||||
|
||||
// eslint-disable-next-line
|
||||
while (true) {
|
||||
this.logService.info("Querying groups - nextPageToken:" + nextPageToken);
|
||||
let p = null;
|
||||
@@ -194,7 +193,6 @@ export class GSuiteDirectoryService extends BaseDirectoryService implements IDir
|
||||
entry.externalId = group.id;
|
||||
entry.name = group.name;
|
||||
|
||||
// eslint-disable-next-line
|
||||
while (true) {
|
||||
const p = Object.assign({ groupKey: group.id, pageToken: nextPageToken }, this.authParams);
|
||||
const memRes = await this.service.members.list(p);
|
||||
@@ -253,7 +251,15 @@ export class GSuiteDirectoryService extends BaseDirectoryService implements IDir
|
||||
],
|
||||
});
|
||||
|
||||
await this.client.authorize();
|
||||
try {
|
||||
await this.client.authorize();
|
||||
} catch (error) {
|
||||
// Catch and rethrow this to sanitize any sensitive info (e.g. private key) in the error message
|
||||
this.logService.error(
|
||||
`Google Workspace authentication failed: ${error?.name || "Unknown error"}`,
|
||||
);
|
||||
throw new Error(this.i18nService.t("authenticationFailed"));
|
||||
}
|
||||
|
||||
this.authParams = {
|
||||
auth: this.client,
|
||||
@@ -1,14 +1,17 @@
|
||||
import { mock, MockProxy } from "jest-mock-extended";
|
||||
|
||||
import { I18nService } from "../../jslib/common/src/abstractions/i18n.service";
|
||||
import { LogService } from "../../jslib/common/src/abstractions/log.service";
|
||||
import { groupFixtures } from "../../openldap/group-fixtures";
|
||||
import { userFixtures } from "../../openldap/user-fixtures";
|
||||
import { DirectoryType } from "../enums/directoryType";
|
||||
import { getLdapConfiguration, getSyncConfiguration } from "../utils/test-fixtures";
|
||||
import { I18nService } from "../../../jslib/common/src/abstractions/i18n.service";
|
||||
import { LogService } from "../../../jslib/common/src/abstractions/log.service";
|
||||
import {
|
||||
getLdapConfiguration,
|
||||
getSyncConfiguration,
|
||||
} from "../../../utils/openldap/config-fixtures";
|
||||
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 { LdapDirectoryService } from "./ldap-directory.service";
|
||||
import { StateService } from "./state.service";
|
||||
|
||||
// These tests integrate with the OpenLDAP docker image and seed data located in the openldap folder.
|
||||
// To run theses tests:
|
||||
@@ -52,7 +55,7 @@ describe("ldapDirectoryService", () => {
|
||||
getLdapConfiguration({
|
||||
ssl: true,
|
||||
startTls: true,
|
||||
tlsCaPath: "./openldap/certs/rootCA.pem",
|
||||
tlsCaPath: "./utils/openldap/certs/rootCA.pem",
|
||||
}),
|
||||
);
|
||||
stateService.getSync.mockResolvedValue(getSyncConfiguration({ groups: true, users: true }));
|
||||
@@ -67,7 +70,7 @@ describe("ldapDirectoryService", () => {
|
||||
getLdapConfiguration({
|
||||
port: 1636,
|
||||
ssl: true,
|
||||
sslCaPath: "./openldap/certs/rootCA.pem",
|
||||
sslCaPath: "./utils/openldap/certs/rootCA.pem",
|
||||
}),
|
||||
);
|
||||
stateService.getSync.mockResolvedValue(getSyncConfiguration({ groups: true, users: true }));
|
||||
@@ -7,12 +7,12 @@ import { I18nService } from "@/jslib/common/src/abstractions/i18n.service";
|
||||
import { LogService } from "@/jslib/common/src/abstractions/log.service";
|
||||
import { Utils } from "@/jslib/common/src/misc/utils";
|
||||
|
||||
import { StateService } from "../abstractions/state.service";
|
||||
import { DirectoryType } from "../enums/directoryType";
|
||||
import { GroupEntry } from "../models/groupEntry";
|
||||
import { LdapConfiguration } from "../models/ldapConfiguration";
|
||||
import { SyncConfiguration } from "../models/syncConfiguration";
|
||||
import { UserEntry } from "../models/userEntry";
|
||||
import { StateService } from "../../abstractions/state.service";
|
||||
import { DirectoryType } from "../../enums/directoryType";
|
||||
import { GroupEntry } from "../../models/groupEntry";
|
||||
import { LdapConfiguration } from "../../models/ldapConfiguration";
|
||||
import { SyncConfiguration } from "../../models/syncConfiguration";
|
||||
import { UserEntry } from "../../models/userEntry";
|
||||
|
||||
import { IDirectoryService } from "./directory.service";
|
||||
|
||||
@@ -118,7 +118,7 @@ export class LdapDirectoryService implements IDirectoryService {
|
||||
[delControl],
|
||||
);
|
||||
return regularUsers.concat(deletedUsers);
|
||||
} catch (e) {
|
||||
} catch {
|
||||
this.logService.warning("Cannot query deleted users.");
|
||||
return regularUsers;
|
||||
}
|
||||
@@ -192,14 +192,21 @@ export class LdapDirectoryService implements IDirectoryService {
|
||||
this.syncConfig.userFilter,
|
||||
);
|
||||
const userPath = this.makeSearchPath(this.syncConfig.userPath);
|
||||
const userIdMap = new Map<string, string>();
|
||||
const userDnMap = new Map<string, string>();
|
||||
const userUidMap = new Map<string, string>();
|
||||
await this.search<string>(userPath, userFilter, (se: any) => {
|
||||
userIdMap.set(this.getReferenceId(se), this.getExternalId(se, this.getReferenceId(se)));
|
||||
const dn = this.getReferenceId(se);
|
||||
const uid = this.getAttr<string>(se, "uid");
|
||||
const externalId = this.getExternalId(se, dn);
|
||||
userDnMap.set(dn, externalId);
|
||||
if (uid != null) {
|
||||
userUidMap.set(uid.toLowerCase(), externalId);
|
||||
}
|
||||
return se;
|
||||
});
|
||||
|
||||
for (const se of groupSearchEntries) {
|
||||
const group = this.buildGroup(se, userIdMap);
|
||||
const group = this.buildGroup(se, userDnMap, userUidMap);
|
||||
if (group != null) {
|
||||
entries.push(group);
|
||||
}
|
||||
@@ -208,7 +215,20 @@ export class LdapDirectoryService implements IDirectoryService {
|
||||
return entries;
|
||||
}
|
||||
|
||||
private buildGroup(searchEntry: any, userMap: Map<string, string>) {
|
||||
/**
|
||||
* Builds a GroupEntry from LDAP search results, including membership.
|
||||
* Supports user membership by DN or UID and nested group membership by DN.
|
||||
*
|
||||
* @param searchEntry - The LDAP search entry containing group data
|
||||
* @param userDnMap - Map of user DNs to their external IDs
|
||||
* @param userUidMap - Map of user UIDs to their external IDs
|
||||
* @returns A populated GroupEntry object, or null if the group lacks required properties
|
||||
*/
|
||||
private buildGroup(
|
||||
searchEntry: any,
|
||||
userDnMap: Map<string, string>,
|
||||
userUidMap: Map<string, string>,
|
||||
) {
|
||||
const group = new GroupEntry();
|
||||
group.referenceId = this.getReferenceId(searchEntry);
|
||||
if (group.referenceId == null) {
|
||||
@@ -228,11 +248,34 @@ export class LdapDirectoryService implements IDirectoryService {
|
||||
|
||||
const members = this.getAttrVals<string>(searchEntry, this.syncConfig.memberAttribute);
|
||||
if (members != null) {
|
||||
for (const memDn of members) {
|
||||
if (userMap.has(memDn) && !group.userMemberExternalIds.has(userMap.get(memDn))) {
|
||||
group.userMemberExternalIds.add(userMap.get(memDn));
|
||||
} else if (!group.groupMemberReferenceIds.has(memDn)) {
|
||||
group.groupMemberReferenceIds.add(memDn);
|
||||
// Parses a group member attribute and identifies it as a member DN, member Uid, or a group Dn
|
||||
const getMemberAttributeType = (member: string): "memberDn" | "memberUid" | "groupDn" => {
|
||||
const isDnLike = member.includes("=") && member.includes(",");
|
||||
if (isDnLike) {
|
||||
return userDnMap.has(member) ? "memberDn" : "groupDn";
|
||||
}
|
||||
return "memberUid";
|
||||
};
|
||||
|
||||
for (const member of members) {
|
||||
switch (getMemberAttributeType(member)) {
|
||||
case "memberDn": {
|
||||
const externalId = userDnMap.get(member);
|
||||
if (externalId != null) {
|
||||
group.userMemberExternalIds.add(externalId);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "memberUid": {
|
||||
const externalId = userUidMap.get(member.toLowerCase());
|
||||
if (externalId != null) {
|
||||
group.userMemberExternalIds.add(externalId);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "groupDn":
|
||||
group.groupMemberReferenceIds.add(member);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,14 +3,14 @@ import * as https from "https";
|
||||
import { I18nService } from "@/jslib/common/src/abstractions/i18n.service";
|
||||
import { LogService } from "@/jslib/common/src/abstractions/log.service";
|
||||
|
||||
import { StateService } from "../abstractions/state.service";
|
||||
import { DirectoryType } from "../enums/directoryType";
|
||||
import { GroupEntry } from "../models/groupEntry";
|
||||
import { OktaConfiguration } from "../models/oktaConfiguration";
|
||||
import { SyncConfiguration } from "../models/syncConfiguration";
|
||||
import { UserEntry } from "../models/userEntry";
|
||||
import { StateService } from "../../abstractions/state.service";
|
||||
import { DirectoryType } from "../../enums/directoryType";
|
||||
import { GroupEntry } from "../../models/groupEntry";
|
||||
import { OktaConfiguration } from "../../models/oktaConfiguration";
|
||||
import { SyncConfiguration } from "../../models/syncConfiguration";
|
||||
import { UserEntry } from "../../models/userEntry";
|
||||
import { BaseDirectoryService } from "../baseDirectory.service";
|
||||
|
||||
import { BaseDirectoryService } from "./baseDirectory.service";
|
||||
import { IDirectoryService } from "./directory.service";
|
||||
|
||||
const DelayBetweenBuildGroupCallsInMilliseconds = 500;
|
||||
@@ -1,14 +1,14 @@
|
||||
import { I18nService } from "@/jslib/common/src/abstractions/i18n.service";
|
||||
import { LogService } from "@/jslib/common/src/abstractions/log.service";
|
||||
|
||||
import { StateService } from "../abstractions/state.service";
|
||||
import { DirectoryType } from "../enums/directoryType";
|
||||
import { GroupEntry } from "../models/groupEntry";
|
||||
import { OneLoginConfiguration } from "../models/oneLoginConfiguration";
|
||||
import { SyncConfiguration } from "../models/syncConfiguration";
|
||||
import { UserEntry } from "../models/userEntry";
|
||||
import { StateService } from "../../abstractions/state.service";
|
||||
import { DirectoryType } from "../../enums/directoryType";
|
||||
import { GroupEntry } from "../../models/groupEntry";
|
||||
import { OneLoginConfiguration } from "../../models/oneLoginConfiguration";
|
||||
import { SyncConfiguration } from "../../models/syncConfiguration";
|
||||
import { UserEntry } from "../../models/userEntry";
|
||||
import { BaseDirectoryService } from "../baseDirectory.service";
|
||||
|
||||
import { BaseDirectoryService } from "./baseDirectory.service";
|
||||
import { IDirectoryService } from "./directory.service";
|
||||
|
||||
// Basic email validation: something@something.something
|
||||
@@ -2,8 +2,8 @@ import { GetUniqueString } from "@/jslib/common/spec/utils";
|
||||
|
||||
import { UserEntry } from "@/src/models/userEntry";
|
||||
|
||||
import { groupSimulator, userSimulator } from "../../utils/request-builder-helper";
|
||||
import { RequestBuilderOptions } from "../abstractions/request-builder.service";
|
||||
import { groupSimulator, userSimulator } from "../utils/request-builder-helper";
|
||||
|
||||
import { SingleRequestBuilder } from "./single-request-builder";
|
||||
|
||||
|
||||
@@ -7,19 +7,20 @@ import { EnvironmentService } from "@/jslib/common/src/services/environment.serv
|
||||
|
||||
import { I18nService } from "../../jslib/common/src/abstractions/i18n.service";
|
||||
import { LogService } from "../../jslib/common/src/abstractions/log.service";
|
||||
import { groupFixtures } from "../../openldap/group-fixtures";
|
||||
import { userFixtures } from "../../openldap/user-fixtures";
|
||||
import { getLdapConfiguration, getSyncConfiguration } from "../../utils/openldap/config-fixtures";
|
||||
import { DirectoryFactoryService } from "../abstractions/directory-factory.service";
|
||||
import { DirectoryType } from "../enums/directoryType";
|
||||
import { getLdapConfiguration, getSyncConfiguration } from "../utils/test-fixtures";
|
||||
|
||||
import { BatchRequestBuilder } from "./batch-request-builder";
|
||||
import { LdapDirectoryService } from "./ldap-directory.service";
|
||||
import { LdapDirectoryService } from "./directory-services/ldap-directory.service";
|
||||
import { SingleRequestBuilder } from "./single-request-builder";
|
||||
import { StateService } from "./state.service";
|
||||
import { SyncService } from "./sync.service";
|
||||
import * as constants from "./sync.service";
|
||||
|
||||
import { groupFixtures } from "@/utils/openldap/group-fixtures";
|
||||
import { userFixtures } from "@/utils/openldap/user-fixtures";
|
||||
|
||||
describe("SyncService", () => {
|
||||
let logService: MockProxy<LogService>;
|
||||
let i18nService: MockProxy<I18nService>;
|
||||
@@ -115,6 +116,7 @@ describe("SyncService", () => {
|
||||
stateService.getLastSyncHash.mockResolvedValue("unique hash");
|
||||
|
||||
// @ts-expect-error This is a workaround to make the batchsize smaller to trigger the batching logic since its a const.
|
||||
// eslint-disable-next-line no-import-assign
|
||||
constants.batchSize = 4;
|
||||
|
||||
const syncResult = await syncService.sync(false, false);
|
||||
@@ -123,9 +125,13 @@ describe("SyncService", () => {
|
||||
expect(apiService.postPublicImportDirectory).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ overwriteExisting: false }),
|
||||
);
|
||||
expect(apiService.postPublicImportDirectory).toHaveBeenCalledTimes(6);
|
||||
|
||||
// The expected number of calls may change if more data is added to the ldif
|
||||
// Make sure it equals (number of users / 4) + (number of groups / 4)
|
||||
expect(apiService.postPublicImportDirectory).toHaveBeenCalledTimes(7);
|
||||
|
||||
// @ts-expect-error Reset batch size to original state.
|
||||
// eslint-disable-next-line no-import-assign
|
||||
constants.batchSize = originalBatchSize;
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,20 +6,20 @@ 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 { getSyncConfiguration } from "../../utils/openldap/config-fixtures";
|
||||
import { DirectoryFactoryService } from "../abstractions/directory-factory.service";
|
||||
import { DirectoryType } from "../enums/directoryType";
|
||||
import { getSyncConfiguration } from "../utils/test-fixtures";
|
||||
|
||||
import { BatchRequestBuilder } from "./batch-request-builder";
|
||||
import { LdapDirectoryService } from "./directory-services/ldap-directory.service";
|
||||
import { I18nService } from "./i18n.service";
|
||||
import { LdapDirectoryService } from "./ldap-directory.service";
|
||||
import { SingleRequestBuilder } from "./single-request-builder";
|
||||
import { StateService } from "./state.service";
|
||||
import { SyncService } from "./sync.service";
|
||||
import * as constants from "./sync.service";
|
||||
|
||||
import { groupFixtures } from "@/openldap/group-fixtures";
|
||||
import { userFixtures } from "@/openldap/user-fixtures";
|
||||
import { groupFixtures } from "@/utils/openldap/group-fixtures";
|
||||
import { userFixtures } from "@/utils/openldap/user-fixtures";
|
||||
|
||||
describe("SyncService", () => {
|
||||
let cryptoFunctionService: MockProxy<CryptoFunctionService>;
|
||||
@@ -97,6 +97,7 @@ describe("SyncService", () => {
|
||||
stateService.getLastSyncHash.mockResolvedValue("unique hash");
|
||||
|
||||
// @ts-expect-error This is a workaround to make the batchsize smaller to trigger the batching logic since its a const.
|
||||
// eslint-disable-next-line no-import-assign
|
||||
constants.batchSize = 4;
|
||||
|
||||
const mockRequests = new Array(6).fill({
|
||||
@@ -119,6 +120,7 @@ describe("SyncService", () => {
|
||||
expect(apiService.postPublicImportDirectory).toHaveBeenCalledWith(mockRequests[5]);
|
||||
|
||||
// @ts-expect-error Reset batch size back to original value.
|
||||
// eslint-disable-next-line no-import-assign
|
||||
constants.batchSize = originalBatchSize;
|
||||
});
|
||||
|
||||
|
||||
@@ -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: () => {
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
},
|
||||
"compilerOptions": {
|
||||
"pretty": true,
|
||||
"moduleResolution": "node",
|
||||
"moduleResolution": "bundler",
|
||||
"noImplicitAny": true,
|
||||
"target": "ES2016",
|
||||
"module": "ES2020",
|
||||
|
||||
4
utils/.env.example
Normal file
4
utils/.env.example
Normal file
@@ -0,0 +1,4 @@
|
||||
GOOGLE_DOMAIN=
|
||||
GOOGLE_ADMIN_USER=
|
||||
GOOGLE_CLIENT_EMAIL=
|
||||
GOOGLE_PRIVATE_KEY=
|
||||
56
utils/google-workspace/config-fixtures.ts
Normal file
56
utils/google-workspace/config-fixtures.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { GSuiteConfiguration } from "../../src/models/gsuiteConfiguration";
|
||||
import { SyncConfiguration } from "../../src/models/syncConfiguration";
|
||||
|
||||
/**
|
||||
* @returns a basic GSuite configuration. Can be overridden by passing in a partial configuration.
|
||||
*/
|
||||
export const getGSuiteConfiguration = (
|
||||
config?: Partial<GSuiteConfiguration>,
|
||||
): GSuiteConfiguration => {
|
||||
const adminUser = process.env.GOOGLE_ADMIN_USER;
|
||||
const clientEmail = process.env.GOOGLE_CLIENT_EMAIL;
|
||||
const privateKey = process.env.GOOGLE_PRIVATE_KEY;
|
||||
const domain = process.env.GOOGLE_DOMAIN;
|
||||
|
||||
if (!adminUser || !clientEmail || !privateKey || !domain) {
|
||||
throw new Error("Google Workspace integration test credentials not configured.");
|
||||
}
|
||||
|
||||
return {
|
||||
// TODO
|
||||
adminUser,
|
||||
clientEmail,
|
||||
privateKey,
|
||||
domain: domain,
|
||||
customer: "",
|
||||
...(config ?? {}),
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* @returns a basic Google Workspace sync configuration. Can be overridden by passing in a partial configuration.
|
||||
*/
|
||||
export const getSyncConfiguration = (config?: Partial<SyncConfiguration>): SyncConfiguration => ({
|
||||
users: false,
|
||||
groups: false,
|
||||
interval: 5,
|
||||
userFilter: "",
|
||||
groupFilter: "",
|
||||
removeDisabled: false,
|
||||
overwriteExisting: false,
|
||||
largeImport: false,
|
||||
// Ldap properties - not optional for some reason
|
||||
groupObjectClass: "",
|
||||
userObjectClass: "",
|
||||
groupPath: null,
|
||||
userPath: null,
|
||||
groupNameAttribute: "",
|
||||
userEmailAttribute: "",
|
||||
memberAttribute: "",
|
||||
useEmailPrefixSuffix: false,
|
||||
emailPrefixAttribute: "",
|
||||
emailSuffix: null,
|
||||
creationDateAttribute: "",
|
||||
revisionDateAttribute: "",
|
||||
...(config ?? {}),
|
||||
});
|
||||
26
utils/google-workspace/group-fixtures.ts
Normal file
26
utils/google-workspace/group-fixtures.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { Jsonify } from "type-fest";
|
||||
|
||||
import { GroupEntry } from "../../src/models/groupEntry";
|
||||
|
||||
// These must match the Google Workspace seed data
|
||||
|
||||
const data: Jsonify<GroupEntry>[] = [
|
||||
{
|
||||
externalId: "0319y80a3anpxhj",
|
||||
groupMemberReferenceIds: [],
|
||||
name: "Integration Test Group A",
|
||||
referenceId: "0319y80a3anpxhj",
|
||||
userMemberExternalIds: ["111605910541641314041", "111147009830456099026"],
|
||||
users: [],
|
||||
},
|
||||
{
|
||||
externalId: "02afmg28317uyub",
|
||||
groupMemberReferenceIds: [],
|
||||
name: "Integration Test Group B",
|
||||
referenceId: "02afmg28317uyub",
|
||||
userMemberExternalIds: ["111147009830456099026", "100150970267699397306"],
|
||||
users: [],
|
||||
},
|
||||
];
|
||||
|
||||
export const groupFixtures = data.map((g) => GroupEntry.fromJSON(g));
|
||||
50
utils/google-workspace/user-fixtures.ts
Normal file
50
utils/google-workspace/user-fixtures.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { Jsonify } from "type-fest";
|
||||
|
||||
import { UserEntry } from "../../src/models/userEntry";
|
||||
|
||||
// These must match the Google Workspace seed data
|
||||
|
||||
const data: Jsonify<UserEntry>[] = [
|
||||
// In Group A
|
||||
{
|
||||
deleted: false,
|
||||
disabled: false,
|
||||
email: "testuser1@bwrox.dev",
|
||||
externalId: "111605910541641314041",
|
||||
referenceId: "111605910541641314041",
|
||||
},
|
||||
// In Groups A + B
|
||||
{
|
||||
deleted: false,
|
||||
disabled: false,
|
||||
email: "testuser2@bwrox.dev",
|
||||
externalId: "111147009830456099026",
|
||||
referenceId: "111147009830456099026",
|
||||
},
|
||||
// In Group B
|
||||
{
|
||||
deleted: false,
|
||||
disabled: false,
|
||||
email: "testuser3@bwrox.dev",
|
||||
externalId: "100150970267699397306",
|
||||
referenceId: "100150970267699397306",
|
||||
},
|
||||
// Not in a group
|
||||
{
|
||||
deleted: false,
|
||||
disabled: false,
|
||||
email: "testuser4@bwrox.dev",
|
||||
externalId: "113764752650306721470",
|
||||
referenceId: "113764752650306721470",
|
||||
},
|
||||
// Disabled user
|
||||
{
|
||||
deleted: false,
|
||||
disabled: true,
|
||||
email: "testuser5@bwrox.dev",
|
||||
externalId: "110381976819725658200",
|
||||
referenceId: "110381976819725658200",
|
||||
},
|
||||
];
|
||||
|
||||
export const userFixtures = data.map((g) => UserEntry.fromJSON(g));
|
||||
@@ -1,5 +1,5 @@
|
||||
import { LdapConfiguration } from "../models/ldapConfiguration";
|
||||
import { SyncConfiguration } from "../models/syncConfiguration";
|
||||
import { LdapConfiguration } from "../../src/models/ldapConfiguration";
|
||||
import { SyncConfiguration } from "../../src/models/syncConfiguration";
|
||||
|
||||
/**
|
||||
* @returns a basic ldap configuration without TLS/SSL enabled. Can be overridden by passing in a partial configuration.
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Jsonify } from "type-fest";
|
||||
|
||||
import { GroupEntry } from "../src/models/groupEntry";
|
||||
import { GroupEntry } from "@/src/models/groupEntry";
|
||||
|
||||
// These must match the ldap server seed data in directory.ldif
|
||||
const data: Jsonify<GroupEntry>[] = [
|
||||
@@ -35,6 +35,29 @@ const data: Jsonify<GroupEntry>[] = [
|
||||
externalId: "cn=Cleaners,ou=Janitorial,dc=bitwarden,dc=com",
|
||||
name: "Cleaners",
|
||||
},
|
||||
{
|
||||
userMemberExternalIds: [
|
||||
"cn=Painterson Miki,ou=Product Development,dc=bitwarden,dc=com",
|
||||
"cn=Virgina Pichocki,ou=Product Development,dc=bitwarden,dc=com",
|
||||
"cn=Steffen Carsten,ou=Product Development,dc=bitwarden,dc=com",
|
||||
],
|
||||
groupMemberReferenceIds: [],
|
||||
users: [],
|
||||
referenceId: "cn=DevOps Team,dc=bitwarden,dc=com",
|
||||
externalId: "cn=DevOps Team,dc=bitwarden,dc=com",
|
||||
name: "DevOps Team",
|
||||
},
|
||||
{
|
||||
userMemberExternalIds: [
|
||||
"cn=Angus Merizzi,ou=Management,dc=bitwarden,dc=com",
|
||||
"cn=Grissel Currer,ou=Management,dc=bitwarden,dc=com",
|
||||
],
|
||||
groupMemberReferenceIds: [],
|
||||
users: [],
|
||||
referenceId: "cn=Security Team,dc=bitwarden,dc=com",
|
||||
externalId: "cn=Security Team,dc=bitwarden,dc=com",
|
||||
name: "Security Team",
|
||||
},
|
||||
];
|
||||
|
||||
export const groupFixtures = data.map((g) => GroupEntry.fromJSON(g));
|
||||
@@ -688,4 +688,27 @@ mobile: +1 804 319-5569
|
||||
pager: +1 804 815-3661
|
||||
roomNumber: 9273
|
||||
manager: cn=Inga Schnirer,ou=Product Testing,dc=bitwarden, dc=com
|
||||
secretary: cn=Keven Gilleland,ou=Administrative,dc=bitwarden, dc=com
|
||||
secretary: cn=Keven Gilleland,ou=Administrative,dc=bitwarden, dc=com
|
||||
|
||||
# DevOps Team and Security Team identify their members by the member uid attribute,
|
||||
# instead of the member Dn attribute.
|
||||
# These test that group membership by uid works correctly.
|
||||
|
||||
dn: cn=DevOps Team,dc=bitwarden,dc=com
|
||||
changetype: add
|
||||
cn: DevOps Team
|
||||
gidnumber: 800
|
||||
memberuid: mikip
|
||||
memberuid: pichockv
|
||||
memberuid: carstens
|
||||
objectclass: posixGroup
|
||||
objectclass: top
|
||||
|
||||
dn: cn=Security Team,dc=bitwarden,dc=com
|
||||
changetype: add
|
||||
cn: Security Team
|
||||
gidnumber: 900
|
||||
memberuid: merizzia
|
||||
memberuid: currerg
|
||||
objectclass: posixGroup
|
||||
objectclass: top
|
||||
10
utils/openldap/mkcert.sh
Executable file
10
utils/openldap/mkcert.sh
Executable file
@@ -0,0 +1,10 @@
|
||||
if ! [ -x "$(command -v mkcert)" ]; then
|
||||
echo 'Error: mkcert is not installed. Install mkcert first and then re-run this script.'
|
||||
echo 'e.g. brew install mkcert'
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mkcert -install
|
||||
mkdir -p ./utils/openldap/certs
|
||||
cp "$(mkcert -CAROOT)/rootCA.pem" ./utils/openldap/certs/rootCA.pem
|
||||
mkcert -key-file ./utils/openldap/certs/openldap-key.pem -cert-file ./utils/openldap/certs/openldap.pem localhost openldap
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Jsonify } from "type-fest";
|
||||
|
||||
import { UserEntry } from "../src/models/userEntry";
|
||||
import { UserEntry } from "@/src/models/userEntry";
|
||||
|
||||
// These must match the ldap server seed data in directory.ldif
|
||||
const data: Jsonify<UserEntry>[] = [
|
||||
@@ -1,7 +1,7 @@
|
||||
import { GetUniqueString } from "@/jslib/common/spec/utils";
|
||||
|
||||
import { GroupEntry } from "../models/groupEntry";
|
||||
import { UserEntry } from "../models/userEntry";
|
||||
import { GroupEntry } from "../src/models/groupEntry";
|
||||
import { UserEntry } from "../src/models/userEntry";
|
||||
|
||||
export function userSimulator(userCount: number): UserEntry[] {
|
||||
const users: UserEntry[] = [];
|
||||
Reference in New Issue
Block a user