mirror of
https://github.com/bitwarden/directory-connector
synced 2026-01-16 23:43:15 +00:00
Compare commits
2 Commits
update-ang
...
gsuite-doc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5761a391f7 | ||
|
|
8cd2850e8d |
10
angular.json
10
angular.json
@@ -18,17 +18,15 @@
|
|||||||
"prefix": "app",
|
"prefix": "app",
|
||||||
"architect": {
|
"architect": {
|
||||||
"build": {
|
"build": {
|
||||||
"builder": "@angular/build:application",
|
"builder": "@angular-devkit/build-angular:browser",
|
||||||
"options": {
|
"options": {
|
||||||
"outputPath": {
|
"outputPath": "dist",
|
||||||
"base": "dist"
|
|
||||||
},
|
|
||||||
"index": "src/index.html",
|
"index": "src/index.html",
|
||||||
|
"main": "src/main.ts",
|
||||||
"tsConfig": "tsconfig.json",
|
"tsConfig": "tsconfig.json",
|
||||||
"assets": [],
|
"assets": [],
|
||||||
"styles": [],
|
"styles": [],
|
||||||
"scripts": [],
|
"scripts": []
|
||||||
"browser": "src/main.ts"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
300
docs/google-workspace.md
Normal file
300
docs/google-workspace.md
Normal file
@@ -0,0 +1,300 @@
|
|||||||
|
# Google Workspace Directory Integration
|
||||||
|
|
||||||
|
This document provides technical documentation for the Google Workspace (formerly G Suite) directory integration in Bitwarden Directory Connector.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The Google Workspace integration synchronizes users and groups from Google Workspace to Bitwarden organizations using the Google Admin SDK Directory API. The service uses a service account with domain-wide delegation to authenticate and access directory data.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Service Location
|
||||||
|
|
||||||
|
- **Implementation**: `src/services/directory-services/gsuite-directory.service.ts`
|
||||||
|
- **Configuration Model**: `src/models/gsuiteConfiguration.ts`
|
||||||
|
- **Integration Tests**: `src/services/directory-services/gsuite-directory.service.integration.spec.ts`
|
||||||
|
|
||||||
|
### Authentication Flow
|
||||||
|
|
||||||
|
The Google Workspace integration uses **OAuth 2.0 with Service Accounts** and domain-wide delegation:
|
||||||
|
|
||||||
|
1. A service account is created in Google Cloud Console
|
||||||
|
2. The service account is granted domain-wide delegation authority
|
||||||
|
3. The service account is authorized for specific OAuth scopes in Google Workspace Admin Console
|
||||||
|
4. The Directory Connector uses the service account's private key to generate JWT tokens
|
||||||
|
5. JWT tokens are exchanged for access tokens to call the Admin SDK APIs
|
||||||
|
|
||||||
|
### Required OAuth Scopes
|
||||||
|
|
||||||
|
The service account must be granted the following OAuth 2.0 scopes:
|
||||||
|
|
||||||
|
```
|
||||||
|
https://www.googleapis.com/auth/admin.directory.user.readonly
|
||||||
|
https://www.googleapis.com/auth/admin.directory.group.readonly
|
||||||
|
https://www.googleapis.com/auth/admin.directory.group.member.readonly
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
### Required Fields
|
||||||
|
|
||||||
|
| Field | Description |
|
||||||
|
| ------------- | --------------------------------------------------------------------------------------- |
|
||||||
|
| `clientEmail` | Service account email address (e.g., `service-account@project.iam.gserviceaccount.com`) |
|
||||||
|
| `privateKey` | Service account private key in PEM format |
|
||||||
|
| `adminUser` | Admin user email to impersonate for domain-wide delegation |
|
||||||
|
| `domain` | Primary domain of the Google Workspace organization |
|
||||||
|
|
||||||
|
### Optional Fields
|
||||||
|
|
||||||
|
| Field | Description |
|
||||||
|
| ---------- | ---------------------------------------------------------- |
|
||||||
|
| `customer` | Customer ID for multi-domain organizations (rarely needed) |
|
||||||
|
|
||||||
|
### Example Configuration
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
clientEmail: "directory-connector@my-project.iam.gserviceaccount.com",
|
||||||
|
privateKey: "-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----\n",
|
||||||
|
adminUser: "admin@example.com",
|
||||||
|
domain: "example.com",
|
||||||
|
customer: "" // Usually not required
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Setup Instructions
|
||||||
|
|
||||||
|
### 1. Create a Service Account
|
||||||
|
|
||||||
|
1. Go to [Google Cloud Console](https://console.cloud.google.com)
|
||||||
|
2. Create or select a project
|
||||||
|
3. Navigate to **IAM & Admin** > **Service Accounts**
|
||||||
|
4. Click **Create Service Account**
|
||||||
|
5. Enter a name and description
|
||||||
|
6. Click **Create and Continue**
|
||||||
|
7. Skip granting roles (not needed for this use case)
|
||||||
|
8. Click **Done**
|
||||||
|
|
||||||
|
### 2. Generate Service Account Key
|
||||||
|
|
||||||
|
1. Click on the newly created service account
|
||||||
|
2. Navigate to the **Keys** tab
|
||||||
|
3. Click **Add Key** > **Create new key**
|
||||||
|
4. Select **JSON** format
|
||||||
|
5. Click **Create** and download the key file
|
||||||
|
6. Extract `client_email` and `private_key` from the JSON file
|
||||||
|
|
||||||
|
### 3. Enable Domain-Wide Delegation
|
||||||
|
|
||||||
|
1. In the service account details, click **Show Advanced Settings**
|
||||||
|
2. Under **Domain-wide delegation**, click **Enable Google Workspace Domain-wide Delegation**
|
||||||
|
3. Note the **Client ID** (numeric ID)
|
||||||
|
|
||||||
|
### 4. Authorize the Service Account in Google Workspace
|
||||||
|
|
||||||
|
1. Go to [Google Workspace Admin Console](https://admin.google.com)
|
||||||
|
2. Navigate to **Security** > **API Controls** > **Domain-wide Delegation**
|
||||||
|
3. Click **Add new**
|
||||||
|
4. Enter the **Client ID** from step 3
|
||||||
|
5. Enter the following OAuth scopes (comma-separated):
|
||||||
|
```
|
||||||
|
https://www.googleapis.com/auth/admin.directory.user.readonly,
|
||||||
|
https://www.googleapis.com/auth/admin.directory.group.readonly,
|
||||||
|
https://www.googleapis.com/auth/admin.directory.group.member.readonly
|
||||||
|
```
|
||||||
|
6. Click **Authorize**
|
||||||
|
|
||||||
|
### 5. Configure Directory Connector
|
||||||
|
|
||||||
|
Use the extracted values to configure the Directory Connector:
|
||||||
|
|
||||||
|
- **Client Email**: From `client_email` in the JSON key file
|
||||||
|
- **Private Key**: From `private_key` in the JSON key file (keep the `\n` line breaks)
|
||||||
|
- **Admin User**: Email of a super admin user in your Google Workspace domain
|
||||||
|
- **Domain**: Your primary Google Workspace domain
|
||||||
|
|
||||||
|
## Sync Behavior
|
||||||
|
|
||||||
|
### User Synchronization
|
||||||
|
|
||||||
|
The service synchronizes the following user attributes:
|
||||||
|
|
||||||
|
| Google Workspace Field | Bitwarden Field | Notes |
|
||||||
|
| ------------------------- | --------------------------- | ----------------------------------------- |
|
||||||
|
| `id` | `referenceId`, `externalId` | User's unique Google ID |
|
||||||
|
| `primaryEmail` | `email` | Normalized to lowercase |
|
||||||
|
| `suspended` OR `archived` | `disabled` | User is disabled if suspended or archived |
|
||||||
|
| Deleted status | `deleted` | Set to true for deleted users |
|
||||||
|
|
||||||
|
**Special Behavior:**
|
||||||
|
|
||||||
|
- The service queries both **active users** and **deleted users** separately
|
||||||
|
- Suspended and archived users are included but marked as disabled
|
||||||
|
- Deleted users are included with the `deleted` flag set to true
|
||||||
|
|
||||||
|
### Group Synchronization
|
||||||
|
|
||||||
|
The service synchronizes the following group attributes:
|
||||||
|
|
||||||
|
| Google Workspace Field | Bitwarden Field | Notes |
|
||||||
|
| ----------------------- | --------------------------- | ------------------------ |
|
||||||
|
| `id` | `referenceId`, `externalId` | Group's unique Google ID |
|
||||||
|
| `name` | `name` | Group display name |
|
||||||
|
| Members (type=USER) | `userMemberExternalIds` | Individual user members |
|
||||||
|
| Members (type=GROUP) | `groupMemberReferenceIds` | Nested group members |
|
||||||
|
| Members (type=CUSTOMER) | `userMemberExternalIds` | All domain users |
|
||||||
|
|
||||||
|
**Member Types:**
|
||||||
|
|
||||||
|
- **USER**: Individual user accounts (only ACTIVE status users are synced)
|
||||||
|
- **GROUP**: Nested groups (allows group hierarchy)
|
||||||
|
- **CUSTOMER**: Special member type that includes all users in the domain
|
||||||
|
|
||||||
|
### Filtering
|
||||||
|
|
||||||
|
#### User Filter Examples
|
||||||
|
|
||||||
|
```
|
||||||
|
exclude:testuser1@bwrox.dev | testuser1@bwrox.dev # Exclude multiple users
|
||||||
|
|orgUnitPath='/Integration testing' # Users in Integration testing Organizational unit (OU)
|
||||||
|
exclude:testuser1@bwrox.dev | orgUnitPath='/Integration testing' # Combined filter: get users in OU excluding provided user
|
||||||
|
|email:testuser* # Users with email starting with "testuser"
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Group Filter Examples
|
||||||
|
|
||||||
|
An important note for group filters is that it implicitly only syncs users that are in groups. For example, in the case of
|
||||||
|
the integration test data, `admin@bwrox.dev` is not a member of any group. Therefore, the first example filter below will
|
||||||
|
also implicitly exclude `admin@bwrox.dev`, who is not in any group. This is important because when it is paired with an
|
||||||
|
empty user filter, this query may semantically be understood as "sync everyone not in Integration Test Group A," while in
|
||||||
|
practice it means "Only sync members of groups not in integration Test Groups A."
|
||||||
|
|
||||||
|
```
|
||||||
|
exclude:Integration Test Group A # Get all users in groups excluding the provided group.
|
||||||
|
```
|
||||||
|
|
||||||
|
### User AND Group Filter Examples
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
**Filter Syntax:**
|
||||||
|
|
||||||
|
- Prefix with `|` for custom filters
|
||||||
|
- Use `:` for pattern matching (supports `*` wildcard)
|
||||||
|
- Combine multiple conditions with spaces (AND logic)
|
||||||
|
|
||||||
|
### Pagination
|
||||||
|
|
||||||
|
The service automatically handles pagination for all API calls:
|
||||||
|
|
||||||
|
- Users API (active and deleted)
|
||||||
|
- Groups API
|
||||||
|
- Group Members API
|
||||||
|
|
||||||
|
Each API call processes all pages using the `nextPageToken` mechanism until no more results are available.
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
### Common Errors
|
||||||
|
|
||||||
|
| Error | Cause | Resolution |
|
||||||
|
| ---------------------- | ------------------------------------- | ---------------------------------------------------------- |
|
||||||
|
| "dirConfigIncomplete" | Missing required configuration fields | Verify all required fields are provided |
|
||||||
|
| "authenticationFailed" | Invalid credentials or unauthorized | Check service account key and domain-wide delegation setup |
|
||||||
|
| API returns 401/403 | Missing OAuth scopes | Verify scopes are authorized in Admin Console |
|
||||||
|
| API returns 404 | Invalid domain or customer ID | Check domain configuration |
|
||||||
|
|
||||||
|
### Security Considerations
|
||||||
|
|
||||||
|
The service implements the following security measures:
|
||||||
|
|
||||||
|
1. **Credential sanitization**: Error messages do not expose private keys or sensitive credentials
|
||||||
|
2. **Secure authentication**: Uses OAuth 2.0 with JWT tokens, not API keys
|
||||||
|
3. **Read-only access**: Only requires read-only scopes for directory data
|
||||||
|
4. **No credential logging**: Service account credentials are not logged
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
### Integration Tests
|
||||||
|
|
||||||
|
Integration tests are located in `src/services/directory-services/gsuite-directory.service.integration.spec.ts`.
|
||||||
|
|
||||||
|
**Test Coverage:**
|
||||||
|
|
||||||
|
- Basic sync (users and groups)
|
||||||
|
- Sync with filters
|
||||||
|
- Users-only sync
|
||||||
|
- Groups-only sync
|
||||||
|
- User filtering scenarios
|
||||||
|
- Group filtering scenarios
|
||||||
|
- Disabled users handling
|
||||||
|
- Group membership scenarios
|
||||||
|
- Error handling
|
||||||
|
|
||||||
|
**Running Integration Tests:**
|
||||||
|
|
||||||
|
Integration tests require live Google Workspace credentials:
|
||||||
|
|
||||||
|
1. Create a `.env` file in the `utils/` folder with:
|
||||||
|
```
|
||||||
|
GOOGLE_ADMIN_USER=admin@example.com
|
||||||
|
GOOGLE_CLIENT_EMAIL=service-account@project.iam.gserviceaccount.com
|
||||||
|
GOOGLE_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----\n"
|
||||||
|
GOOGLE_DOMAIN=example.com
|
||||||
|
```
|
||||||
|
2. Run tests:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run all integration tests (includes LDAP, Google Workspace, etc.)
|
||||||
|
npm run test:integration
|
||||||
|
|
||||||
|
# Run only Google Workspace integration tests
|
||||||
|
npx jest gsuite-directory.service.integration.spec.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
**Test Data:**
|
||||||
|
|
||||||
|
The integration tests expect specific test data in Google Workspace:
|
||||||
|
|
||||||
|
- **Users**: 5 test users in organizational unit `/Integration testing`
|
||||||
|
- testuser1@bwrox.dev (in Group A)
|
||||||
|
- testuser2@bwrox.dev (in Groups A & B)
|
||||||
|
- testuser3@bwrox.dev (in Group B)
|
||||||
|
- testuser4@bwrox.dev (no groups)
|
||||||
|
- testuser5@bwrox.dev (disabled)
|
||||||
|
|
||||||
|
- **Groups**: 2 test groups with name pattern `Integration*`
|
||||||
|
- Integration Test Group A
|
||||||
|
- Integration Test Group B
|
||||||
|
|
||||||
|
## API Reference
|
||||||
|
|
||||||
|
### Google Admin SDK APIs Used
|
||||||
|
|
||||||
|
- **Users API**: `admin.users.list()`
|
||||||
|
- [Documentation](https://developers.google.com/admin-sdk/directory/reference/rest/v1/users/list)
|
||||||
|
|
||||||
|
- **Groups API**: `admin.groups.list()`
|
||||||
|
- [Documentation](https://developers.google.com/admin-sdk/directory/reference/rest/v1/groups/list)
|
||||||
|
|
||||||
|
- **Members API**: `admin.members.list()`
|
||||||
|
- [Documentation](https://developers.google.com/admin-sdk/directory/reference/rest/v1/members/list)
|
||||||
|
|
||||||
|
### Rate Limits
|
||||||
|
|
||||||
|
Google Workspace Directory API rate limits:
|
||||||
|
|
||||||
|
- Default: 2,400 queries per minute per user, per Google Cloud Project
|
||||||
|
|
||||||
|
The service does not implement rate limiting logic; it relies on API error responses.
|
||||||
|
|
||||||
|
## Resources
|
||||||
|
|
||||||
|
- [Google Admin SDK Directory API Guide](https://developers.google.com/admin-sdk/directory/v1/guides)
|
||||||
|
- [Service Account Authentication](https://developers.google.com/identity/protocols/oauth2/service-account)
|
||||||
|
- [Domain-wide Delegation](https://support.google.com/a/answer/162106)
|
||||||
|
- [Google Workspace Admin Console](https://admin.google.com)
|
||||||
|
- [Bitwarden Directory Connector Documentation](https://bitwarden.com/help/directory-sync/)
|
||||||
@@ -13,47 +13,42 @@ import {
|
|||||||
@Component({
|
@Component({
|
||||||
selector: "[toast-component2]",
|
selector: "[toast-component2]",
|
||||||
template: `
|
template: `
|
||||||
@if (options.closeButton) {
|
<button
|
||||||
<button (click)="remove()" type="button" class="toast-close-button" aria-label="Close">
|
*ngIf="options.closeButton"
|
||||||
<span aria-hidden="true">×</span>
|
(click)="remove()"
|
||||||
</button>
|
type="button"
|
||||||
}
|
class="toast-close-button"
|
||||||
|
aria-label="Close"
|
||||||
|
>
|
||||||
|
<span aria-hidden="true">×</span>
|
||||||
|
</button>
|
||||||
<div class="icon">
|
<div class="icon">
|
||||||
<i></i>
|
<i></i>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@if (title) {
|
<div *ngIf="title" [class]="options.titleClass" [attr.aria-label]="title">
|
||||||
<div [class]="options.titleClass" [attr.aria-label]="title">
|
{{ title }} <ng-container *ngIf="duplicatesCount">[{{ duplicatesCount + 1 }}]</ng-container>
|
||||||
{{ 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>
|
</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>
|
||||||
|
</div>
|
||||||
`,
|
`,
|
||||||
animations: [
|
animations: [
|
||||||
trigger("flyInOut", [
|
trigger("flyInOut", [
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ describe("SymmetricCryptoKey", () => {
|
|||||||
new SymmetricCryptoKey(null);
|
new SymmetricCryptoKey(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
expect(t).toThrow("Must provide key");
|
expect(t).toThrowError("Must provide key");
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("guesses encKey from key length", () => {
|
describe("guesses encKey from key length", () => {
|
||||||
@@ -63,7 +63,7 @@ describe("SymmetricCryptoKey", () => {
|
|||||||
new SymmetricCryptoKey(makeStaticByteArray(30));
|
new SymmetricCryptoKey(makeStaticByteArray(30));
|
||||||
};
|
};
|
||||||
|
|
||||||
expect(t).toThrow("Unable to determine encType.");
|
expect(t).toThrowError("Unable to determine encType.");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
11097
package-lock.json
generated
11097
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
40
package.json
40
package.json
@@ -2,7 +2,7 @@
|
|||||||
"name": "@bitwarden/directory-connector",
|
"name": "@bitwarden/directory-connector",
|
||||||
"productName": "Bitwarden Directory Connector",
|
"productName": "Bitwarden Directory Connector",
|
||||||
"description": "Sync your user directory to your Bitwarden organization.",
|
"description": "Sync your user directory to your Bitwarden organization.",
|
||||||
"version": "2026.1.0",
|
"version": "2025.12.0",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"bitwarden",
|
"bitwarden",
|
||||||
"password",
|
"password",
|
||||||
@@ -73,17 +73,17 @@
|
|||||||
"test:types": "npx tsc --noEmit"
|
"test:types": "npx tsc --noEmit"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@angular-eslint/eslint-plugin-template": "21.1.0",
|
"@angular-devkit/build-angular": "20.3.3",
|
||||||
"@angular-eslint/template-parser": "21.1.0",
|
"@angular-eslint/eslint-plugin-template": "20.7.0",
|
||||||
"@angular/build": "21.0.5",
|
"@angular-eslint/template-parser": "20.7.0",
|
||||||
"@angular/compiler-cli": "21.0.8",
|
"@angular/compiler-cli": "20.3.15",
|
||||||
"@electron/notarize": "2.5.0",
|
"@electron/notarize": "2.5.0",
|
||||||
"@electron/rebuild": "4.0.1",
|
"@electron/rebuild": "4.0.1",
|
||||||
"@fluffy-spoon/substitute": "1.208.0",
|
"@fluffy-spoon/substitute": "1.208.0",
|
||||||
"@microsoft/microsoft-graph-types": "2.43.1",
|
"@microsoft/microsoft-graph-types": "2.43.1",
|
||||||
"@ngtools/webpack": "21.0.5",
|
"@ngtools/webpack": "20.3.3",
|
||||||
"@types/inquirer": "8.2.10",
|
"@types/inquirer": "8.2.10",
|
||||||
"@types/jest": "30.0.0",
|
"@types/jest": "29.5.14",
|
||||||
"@types/lowdb": "1.0.15",
|
"@types/lowdb": "1.0.15",
|
||||||
"@types/node": "22.19.2",
|
"@types/node": "22.19.2",
|
||||||
"@types/node-fetch": "2.6.12",
|
"@types/node-fetch": "2.6.12",
|
||||||
@@ -94,9 +94,7 @@
|
|||||||
"@typescript-eslint/eslint-plugin": "8.50.0",
|
"@typescript-eslint/eslint-plugin": "8.50.0",
|
||||||
"@typescript-eslint/parser": "8.50.0",
|
"@typescript-eslint/parser": "8.50.0",
|
||||||
"@yao-pkg/pkg": "5.16.1",
|
"@yao-pkg/pkg": "5.16.1",
|
||||||
"babel-loader": "9.2.1",
|
|
||||||
"clean-webpack-plugin": "4.0.0",
|
"clean-webpack-plugin": "4.0.0",
|
||||||
"jest-environment-jsdom": "30.2.0",
|
|
||||||
"concurrently": "9.2.0",
|
"concurrently": "9.2.0",
|
||||||
"copy-webpack-plugin": "13.0.0",
|
"copy-webpack-plugin": "13.0.0",
|
||||||
"cross-env": "7.0.3",
|
"cross-env": "7.0.3",
|
||||||
@@ -119,10 +117,10 @@
|
|||||||
"html-loader": "5.1.0",
|
"html-loader": "5.1.0",
|
||||||
"html-webpack-plugin": "5.6.3",
|
"html-webpack-plugin": "5.6.3",
|
||||||
"husky": "9.1.7",
|
"husky": "9.1.7",
|
||||||
"jest": "30.2.0",
|
"jest": "29.7.0",
|
||||||
"jest-junit": "16.0.0",
|
"jest-junit": "16.0.0",
|
||||||
"jest-mock-extended": "4.0.0",
|
"jest-mock-extended": "4.0.0",
|
||||||
"jest-preset-angular": "16.0.0",
|
"jest-preset-angular": "14.6.0",
|
||||||
"lint-staged": "16.2.6",
|
"lint-staged": "16.2.6",
|
||||||
"mini-css-extract-plugin": "2.9.2",
|
"mini-css-extract-plugin": "2.9.2",
|
||||||
"minimatch": "5.1.2",
|
"minimatch": "5.1.2",
|
||||||
@@ -145,16 +143,16 @@
|
|||||||
"zone.js": "0.15.1"
|
"zone.js": "0.15.1"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@angular/animations": "21.0.8",
|
"@angular/animations": "20.3.15",
|
||||||
"@angular/cdk": "21.0.6",
|
"@angular/cdk": "20.2.14",
|
||||||
"@angular/cli": "21.0.5",
|
"@angular/cli": "20.3.3",
|
||||||
"@angular/common": "21.0.8",
|
"@angular/common": "20.3.15",
|
||||||
"@angular/compiler": "21.0.8",
|
"@angular/compiler": "20.3.15",
|
||||||
"@angular/core": "21.0.8",
|
"@angular/core": "20.3.15",
|
||||||
"@angular/forms": "21.0.8",
|
"@angular/forms": "20.3.15",
|
||||||
"@angular/platform-browser": "21.0.8",
|
"@angular/platform-browser": "20.3.15",
|
||||||
"@angular/platform-browser-dynamic": "21.0.8",
|
"@angular/platform-browser-dynamic": "20.3.15",
|
||||||
"@angular/router": "21.0.8",
|
"@angular/router": "20.3.15",
|
||||||
"@microsoft/microsoft-graph-client": "3.0.7",
|
"@microsoft/microsoft-graph-client": "3.0.7",
|
||||||
"big-integer": "1.6.52",
|
"big-integer": "1.6.52",
|
||||||
"bootstrap": "5.3.7",
|
"bootstrap": "5.3.7",
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { enableProdMode, provideZoneChangeDetection } from "@angular/core";
|
import { enableProdMode } from "@angular/core";
|
||||||
import { platformBrowserDynamic } from "@angular/platform-browser-dynamic";
|
import { platformBrowserDynamic } from "@angular/platform-browser-dynamic";
|
||||||
|
|
||||||
import { isDev } from "@/jslib/electron/src/utils";
|
import { isDev } from "@/jslib/electron/src/utils";
|
||||||
@@ -11,7 +11,4 @@ if (!isDev()) {
|
|||||||
enableProdMode();
|
enableProdMode();
|
||||||
}
|
}
|
||||||
|
|
||||||
platformBrowserDynamic().bootstrapModule(AppModule, {
|
platformBrowserDynamic().bootstrapModule(AppModule, { preserveWhitespaces: true });
|
||||||
applicationProviders: [provideZoneChangeDetection()],
|
|
||||||
preserveWhitespaces: true,
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -3,25 +3,17 @@
|
|||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<p>
|
<p>
|
||||||
{{ "lastGroupSync" | i18n }}:
|
{{ "lastGroupSync" | i18n }}:
|
||||||
@if (!lastGroupSync) {
|
<span *ngIf="!lastGroupSync">-</span>
|
||||||
<span>-</span>
|
|
||||||
}
|
|
||||||
{{ lastGroupSync | date: "medium" }}
|
{{ lastGroupSync | date: "medium" }}
|
||||||
<br />
|
<br />
|
||||||
{{ "lastUserSync" | i18n }}:
|
{{ "lastUserSync" | i18n }}:
|
||||||
@if (!lastUserSync) {
|
<span *ngIf="!lastUserSync">-</span>
|
||||||
<span>-</span>
|
|
||||||
}
|
|
||||||
{{ lastUserSync | date: "medium" }}
|
{{ lastUserSync | date: "medium" }}
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
{{ "syncStatus" | i18n }}:
|
{{ "syncStatus" | i18n }}:
|
||||||
@if (syncRunning) {
|
<strong *ngIf="syncRunning" class="text-success">{{ "running" | i18n }}</strong>
|
||||||
<strong class="text-success">{{ "running" | i18n }}</strong>
|
<strong *ngIf="!syncRunning" class="text-danger">{{ "stopped" | i18n }}</strong>
|
||||||
}
|
|
||||||
@if (!syncRunning) {
|
|
||||||
<strong class="text-danger">{{ "stopped" | i18n }}</strong>
|
|
||||||
}
|
|
||||||
</p>
|
</p>
|
||||||
<form #startForm [appApiAction]="startPromise" class="d-inline">
|
<form #startForm [appApiAction]="startPromise" class="d-inline">
|
||||||
<button
|
<button
|
||||||
@@ -68,85 +60,57 @@
|
|||||||
/>
|
/>
|
||||||
<label class="form-check-label" for="simSinceLast">{{ "testLastSync" | i18n }}</label>
|
<label class="form-check-label" for="simSinceLast">{{ "testLastSync" | i18n }}</label>
|
||||||
</div>
|
</div>
|
||||||
@if (!simForm.loading && (simUsers || simGroups)) {
|
<ng-container *ngIf="!simForm.loading && (simUsers || simGroups)">
|
||||||
<hr />
|
<hr />
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-lg">
|
<div class="col-lg">
|
||||||
<h4>{{ "users" | i18n }}</h4>
|
<h4>{{ "users" | i18n }}</h4>
|
||||||
@if (simEnabledUsers && simEnabledUsers.length) {
|
<ul class="bwi-ul testing-list" *ngIf="simEnabledUsers && simEnabledUsers.length">
|
||||||
<ul class="bwi-ul testing-list">
|
<li *ngFor="let u of simEnabledUsers" title="{{ u.referenceId }}">
|
||||||
@for (u of simEnabledUsers; track u) {
|
<i class="bwi bwi-li bwi-user"></i>
|
||||||
<li title="{{ u.referenceId }}">
|
{{ u.displayName }}
|
||||||
<i class="bwi bwi-li bwi-user"></i>
|
</li>
|
||||||
{{ u.displayName }}
|
</ul>
|
||||||
</li>
|
<p *ngIf="!simEnabledUsers || !simEnabledUsers.length">
|
||||||
}
|
{{ "noUsers" | i18n }}
|
||||||
</ul>
|
</p>
|
||||||
}
|
|
||||||
@if (!simEnabledUsers || !simEnabledUsers.length) {
|
|
||||||
<p>
|
|
||||||
{{ "noUsers" | i18n }}
|
|
||||||
</p>
|
|
||||||
}
|
|
||||||
<h4>{{ "disabledUsers" | i18n }}</h4>
|
<h4>{{ "disabledUsers" | i18n }}</h4>
|
||||||
@if (simDisabledUsers && simDisabledUsers.length) {
|
<ul class="bwi-ul testing-list" *ngIf="simDisabledUsers && simDisabledUsers.length">
|
||||||
<ul class="bwi-ul testing-list">
|
<li *ngFor="let u of simDisabledUsers" title="{{ u.referenceId }}">
|
||||||
@for (u of simDisabledUsers; track u) {
|
<i class="bwi bwi-li bwi-user"></i>
|
||||||
<li title="{{ u.referenceId }}">
|
{{ u.displayName }}
|
||||||
<i class="bwi bwi-li bwi-user"></i>
|
</li>
|
||||||
{{ u.displayName }}
|
</ul>
|
||||||
</li>
|
<p *ngIf="!simDisabledUsers || !simDisabledUsers.length">
|
||||||
}
|
{{ "noUsers" | i18n }}
|
||||||
</ul>
|
</p>
|
||||||
}
|
|
||||||
@if (!simDisabledUsers || !simDisabledUsers.length) {
|
|
||||||
<p>
|
|
||||||
{{ "noUsers" | i18n }}
|
|
||||||
</p>
|
|
||||||
}
|
|
||||||
<h4>{{ "deletedUsers" | i18n }}</h4>
|
<h4>{{ "deletedUsers" | i18n }}</h4>
|
||||||
@if (simDeletedUsers && simDeletedUsers.length) {
|
<ul class="bwi-ul testing-list" *ngIf="simDeletedUsers && simDeletedUsers.length">
|
||||||
<ul class="bwi-ul testing-list">
|
<li *ngFor="let u of simDeletedUsers" title="{{ u.referenceId }}">
|
||||||
@for (u of simDeletedUsers; track u) {
|
<i class="bwi bwi-li bwi-user"></i>
|
||||||
<li title="{{ u.referenceId }}">
|
{{ u.displayName }}
|
||||||
<i class="bwi bwi-li bwi-user"></i>
|
</li>
|
||||||
{{ u.displayName }}
|
</ul>
|
||||||
</li>
|
<p *ngIf="!simDeletedUsers || !simDeletedUsers.length">
|
||||||
}
|
{{ "noUsers" | i18n }}
|
||||||
</ul>
|
</p>
|
||||||
}
|
|
||||||
@if (!simDeletedUsers || !simDeletedUsers.length) {
|
|
||||||
<p>
|
|
||||||
{{ "noUsers" | i18n }}
|
|
||||||
</p>
|
|
||||||
}
|
|
||||||
</div>
|
</div>
|
||||||
<div class="col-lg">
|
<div class="col-lg">
|
||||||
<h4>{{ "groups" | i18n }}</h4>
|
<h4>{{ "groups" | i18n }}</h4>
|
||||||
@if (simGroups && simGroups.length) {
|
<ul class="bwi-ul testing-list" *ngIf="simGroups && simGroups.length">
|
||||||
<ul class="bwi-ul testing-list">
|
<li *ngFor="let g of simGroups" title="{{ g.referenceId }}">
|
||||||
@for (g of simGroups; track g) {
|
<i class="bwi bwi-li bwi-sitemap"></i>
|
||||||
<li title="{{ g.referenceId }}">
|
{{ g.displayName }}
|
||||||
<i class="bwi bwi-li bwi-sitemap"></i>
|
<ul class="small" *ngIf="g.users && g.users.length">
|
||||||
{{ g.displayName }}
|
<li *ngFor="let u of g.users" title="{{ u.referenceId }}">
|
||||||
@if (g.users && g.users.length) {
|
{{ u.displayName }}
|
||||||
<ul class="small">
|
|
||||||
@for (u of g.users; track u) {
|
|
||||||
<li title="{{ u.referenceId }}">
|
|
||||||
{{ u.displayName }}
|
|
||||||
</li>
|
|
||||||
}
|
|
||||||
</ul>
|
|
||||||
}
|
|
||||||
</li>
|
</li>
|
||||||
}
|
</ul>
|
||||||
</ul>
|
</li>
|
||||||
}
|
</ul>
|
||||||
@if (!simGroups || !simGroups.length) {
|
<p *ngIf="!simGroups || !simGroups.length">{{ "noGroups" | i18n }}</p>
|
||||||
<p>{{ "noGroups" | i18n }}</p>
|
|
||||||
}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
</ng-container>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -6,11 +6,9 @@
|
|||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label for="directory" class="form-label">{{ "type" | i18n }}</label>
|
<label for="directory" class="form-label">{{ "type" | i18n }}</label>
|
||||||
<select class="form-select" id="directory" name="Directory" [(ngModel)]="directory">
|
<select class="form-select" id="directory" name="Directory" [(ngModel)]="directory">
|
||||||
@for (o of directoryOptions; track o) {
|
<option *ngFor="let o of directoryOptions" [ngValue]="o.value">
|
||||||
<option [ngValue]="o.value">
|
{{ o.name }}
|
||||||
{{ o.name }}
|
</option>
|
||||||
</option>
|
|
||||||
}
|
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div [hidden]="directory != directoryType.Ldap">
|
<div [hidden]="directory != directoryType.Ldap">
|
||||||
@@ -53,22 +51,20 @@
|
|||||||
<label class="form-check-label" for="ad">{{ "ldapAd" | i18n }}</label>
|
<label class="form-check-label" for="ad">{{ "ldapAd" | i18n }}</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@if (!ldap.ad) {
|
<div class="mb-3" *ngIf="!ldap.ad">
|
||||||
<div class="mb-3">
|
<div class="form-check">
|
||||||
<div class="form-check">
|
<input
|
||||||
<input
|
class="form-check-input"
|
||||||
class="form-check-input"
|
type="checkbox"
|
||||||
type="checkbox"
|
id="pagedSearch"
|
||||||
id="pagedSearch"
|
[(ngModel)]="ldap.pagedSearch"
|
||||||
[(ngModel)]="ldap.pagedSearch"
|
name="PagedSearch"
|
||||||
name="PagedSearch"
|
/>
|
||||||
/>
|
<label class="form-check-label" for="pagedSearch">{{
|
||||||
<label class="form-check-label" for="pagedSearch">{{
|
"ldapPagedResults" | i18n
|
||||||
"ldapPagedResults" | i18n
|
}}</label>
|
||||||
}}</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
}
|
</div>
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<div class="form-check">
|
<div class="form-check">
|
||||||
<input
|
<input
|
||||||
@@ -83,122 +79,116 @@
|
|||||||
}}</label>
|
}}</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@if (ldap.ssl) {
|
<div class="ms-4" *ngIf="ldap.ssl">
|
||||||
<div class="ms-4">
|
<div class="mb-3">
|
||||||
<div class="mb-3">
|
<div class="form-check">
|
||||||
<div class="form-check">
|
<input
|
||||||
<input
|
class="form-check-input"
|
||||||
class="form-check-input"
|
type="radio"
|
||||||
type="radio"
|
[value]="false"
|
||||||
[value]="false"
|
id="ssl"
|
||||||
id="ssl"
|
[(ngModel)]="ldap.startTls"
|
||||||
[(ngModel)]="ldap.startTls"
|
name="SSL"
|
||||||
name="SSL"
|
/>
|
||||||
/>
|
<label class="form-check-label" for="ssl">{{ "ldapSsl" | i18n }}</label>
|
||||||
<label class="form-check-label" for="ssl">{{ "ldapSsl" | i18n }}</label>
|
|
||||||
</div>
|
|
||||||
<div class="form-check">
|
|
||||||
<input
|
|
||||||
class="form-check-input"
|
|
||||||
type="radio"
|
|
||||||
[value]="true"
|
|
||||||
id="startTls"
|
|
||||||
[(ngModel)]="ldap.startTls"
|
|
||||||
name="StartTLS"
|
|
||||||
/>
|
|
||||||
<label class="form-check-label" for="startTls">{{ "ldapTls" | i18n }}</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
@if (ldap.startTls) {
|
<div class="form-check">
|
||||||
<div class="ms-4">
|
<input
|
||||||
<p>{{ "ldapTlsUntrustedDesc" | i18n }}</p>
|
class="form-check-input"
|
||||||
<div class="mb-3">
|
type="radio"
|
||||||
<label for="tlsCaPath" class="form-label">{{ "ldapTlsCa" | i18n }}</label>
|
[value]="true"
|
||||||
<input
|
id="startTls"
|
||||||
type="file"
|
[(ngModel)]="ldap.startTls"
|
||||||
class="form-control mb-2"
|
name="StartTLS"
|
||||||
id="tlsCaPath_file"
|
/>
|
||||||
(change)="setSslPath('tlsCaPath')"
|
<label class="form-check-label" for="startTls">{{ "ldapTls" | i18n }}</label>
|
||||||
/>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
class="form-control"
|
|
||||||
id="tlsCaPath"
|
|
||||||
name="TLSCaPath"
|
|
||||||
[(ngModel)]="ldap.tlsCaPath"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
@if (!ldap.startTls) {
|
|
||||||
<div class="ms-4">
|
|
||||||
<p>{{ "ldapSslUntrustedDesc" | i18n }}</p>
|
|
||||||
<div class="mb-3">
|
|
||||||
<label for="sslCertPath" class="form-label">{{ "ldapSslCert" | i18n }}</label>
|
|
||||||
<input
|
|
||||||
type="file"
|
|
||||||
class="form-control mb-2"
|
|
||||||
id="sslCertPath_file"
|
|
||||||
(change)="setSslPath('sslCertPath')"
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
class="form-control"
|
|
||||||
id="sslCertPath"
|
|
||||||
name="SSLCertPath"
|
|
||||||
[(ngModel)]="ldap.sslCertPath"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="mb-3">
|
|
||||||
<label for="sslKeyPath" class="form-label">{{ "ldapSslKey" | i18n }}</label>
|
|
||||||
<input
|
|
||||||
type="file"
|
|
||||||
class="form-control mb-2"
|
|
||||||
id="sslKeyPath_file"
|
|
||||||
(change)="setSslPath('sslKeyPath')"
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
class="form-control"
|
|
||||||
id="sslKeyPath"
|
|
||||||
name="SSLKeyPath"
|
|
||||||
[(ngModel)]="ldap.sslKeyPath"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="mb-3">
|
|
||||||
<label for="sslCaPath" class="form-label">{{ "ldapSslCa" | i18n }}</label>
|
|
||||||
<input
|
|
||||||
type="file"
|
|
||||||
class="form-control mb-2"
|
|
||||||
id="sslCaPath_file"
|
|
||||||
(change)="setSslPath('sslCaPath')"
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
class="form-control"
|
|
||||||
id="sslCaPath"
|
|
||||||
name="SSLCaPath"
|
|
||||||
[(ngModel)]="ldap.sslCaPath"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
<div class="mb-3">
|
|
||||||
<div class="form-check">
|
|
||||||
<input
|
|
||||||
class="form-check-input"
|
|
||||||
type="checkbox"
|
|
||||||
id="certDoNotVerify"
|
|
||||||
[(ngModel)]="ldap.sslAllowUnauthorized"
|
|
||||||
name="CertDoNoVerify"
|
|
||||||
/>
|
|
||||||
<label class="form-check-label" for="certDoNotVerify">{{
|
|
||||||
"ldapCertDoNotVerify" | i18n
|
|
||||||
}}</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
<div class="ms-4" *ngIf="ldap.startTls">
|
||||||
|
<p>{{ "ldapTlsUntrustedDesc" | i18n }}</p>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="tlsCaPath" class="form-label">{{ "ldapTlsCa" | i18n }}</label>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
class="form-control mb-2"
|
||||||
|
id="tlsCaPath_file"
|
||||||
|
(change)="setSslPath('tlsCaPath')"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="form-control"
|
||||||
|
id="tlsCaPath"
|
||||||
|
name="TLSCaPath"
|
||||||
|
[(ngModel)]="ldap.tlsCaPath"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="ms-4" *ngIf="!ldap.startTls">
|
||||||
|
<p>{{ "ldapSslUntrustedDesc" | i18n }}</p>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="sslCertPath" class="form-label">{{ "ldapSslCert" | i18n }}</label>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
class="form-control mb-2"
|
||||||
|
id="sslCertPath_file"
|
||||||
|
(change)="setSslPath('sslCertPath')"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="form-control"
|
||||||
|
id="sslCertPath"
|
||||||
|
name="SSLCertPath"
|
||||||
|
[(ngModel)]="ldap.sslCertPath"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="sslKeyPath" class="form-label">{{ "ldapSslKey" | i18n }}</label>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
class="form-control mb-2"
|
||||||
|
id="sslKeyPath_file"
|
||||||
|
(change)="setSslPath('sslKeyPath')"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="form-control"
|
||||||
|
id="sslKeyPath"
|
||||||
|
name="SSLKeyPath"
|
||||||
|
[(ngModel)]="ldap.sslKeyPath"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="sslCaPath" class="form-label">{{ "ldapSslCa" | i18n }}</label>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
class="form-control mb-2"
|
||||||
|
id="sslCaPath_file"
|
||||||
|
(change)="setSslPath('sslCaPath')"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="form-control"
|
||||||
|
id="sslCaPath"
|
||||||
|
name="SSLCaPath"
|
||||||
|
[(ngModel)]="ldap.sslCaPath"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<div class="form-check">
|
||||||
|
<input
|
||||||
|
class="form-check-input"
|
||||||
|
type="checkbox"
|
||||||
|
id="certDoNotVerify"
|
||||||
|
[(ngModel)]="ldap.sslAllowUnauthorized"
|
||||||
|
name="CertDoNoVerify"
|
||||||
|
/>
|
||||||
|
<label class="form-check-label" for="certDoNotVerify">{{
|
||||||
|
"ldapCertDoNotVerify" | i18n
|
||||||
|
}}</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="mb-3" [hidden]="true">
|
<div class="mb-3" [hidden]="true">
|
||||||
<div class="form-check">
|
<div class="form-check">
|
||||||
<input
|
<input
|
||||||
@@ -221,12 +211,10 @@
|
|||||||
name="Username"
|
name="Username"
|
||||||
[(ngModel)]="ldap.username"
|
[(ngModel)]="ldap.username"
|
||||||
/>
|
/>
|
||||||
@if (ldap.ad) {
|
<div class="form-text" *ngIf="ldap.ad">{{ "ex" | i18n }} company\admin</div>
|
||||||
<div class="form-text">{{ "ex" | i18n }} company\admin</div>
|
<div class="form-text" *ngIf="!ldap.ad">
|
||||||
}
|
{{ "ex" | i18n }} cn=admin,dc=company,dc=com
|
||||||
@if (!ldap.ad) {
|
</div>
|
||||||
<div class="form-text">{{ "ex" | i18n }} cn=admin,dc=company,dc=com</div>
|
|
||||||
}
|
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label for="password" class="form-label">{{ "password" | i18n }}</label>
|
<label for="password" class="form-label">{{ "password" | i18n }}</label>
|
||||||
@@ -616,24 +604,18 @@
|
|||||||
name="UserFilter"
|
name="UserFilter"
|
||||||
[(ngModel)]="sync.userFilter"
|
[(ngModel)]="sync.userFilter"
|
||||||
></textarea>
|
></textarea>
|
||||||
@if (directory === directoryType.Ldap) {
|
<div class="form-text" *ngIf="directory === directoryType.Ldap">
|
||||||
<div class="form-text">
|
{{ "ex" | i18n }} (&(givenName=John)(|(l=Dallas)(l=Austin)))
|
||||||
{{ "ex" | i18n }} (&(givenName=John)(|(l=Dallas)(l=Austin)))
|
</div>
|
||||||
</div>
|
<div class="form-text" *ngIf="directory === directoryType.EntraID">
|
||||||
}
|
{{ "ex" | i18n }} exclude:joe@company.com
|
||||||
@if (directory === directoryType.EntraID) {
|
</div>
|
||||||
<div class="form-text">{{ "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"
|
||||||
@if (directory === directoryType.Okta) {
|
</div>
|
||||||
<div class="form-text">
|
<div class="form-text" *ngIf="directory === directoryType.GSuite">
|
||||||
{{ "ex" | i18n }} exclude:joe@company.com | profile.firstName eq "John"
|
{{ "ex" | i18n }} exclude:joe@company.com | orgUnitPath=/Engineering
|
||||||
</div>
|
</div>
|
||||||
}
|
|
||||||
@if (directory === directoryType.GSuite) {
|
|
||||||
<div class="form-text">
|
|
||||||
{{ "ex" | i18n }} exclude:joe@company.com | orgUnitPath=/Engineering
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-3" [hidden]="directory != directoryType.Ldap">
|
<div class="mb-3" [hidden]="directory != directoryType.Ldap">
|
||||||
<label for="userPath" class="form-label">{{ "userPath" | i18n }}</label>
|
<label for="userPath" class="form-label">{{ "userPath" | i18n }}</label>
|
||||||
@@ -699,20 +681,18 @@
|
|||||||
name="GroupFilter"
|
name="GroupFilter"
|
||||||
[(ngModel)]="sync.groupFilter"
|
[(ngModel)]="sync.groupFilter"
|
||||||
></textarea>
|
></textarea>
|
||||||
@if (directory === directoryType.Ldap) {
|
<div class="form-text" *ngIf="directory === directoryType.Ldap">
|
||||||
<div class="form-text">
|
{{ "ex" | i18n }} (&(objectClass=group)(!(cn=Sales*))(!(cn=IT*)))
|
||||||
{{ "ex" | i18n }} (&(objectClass=group)(!(cn=Sales*))(!(cn=IT*)))
|
</div>
|
||||||
</div>
|
<div class="form-text" *ngIf="directory === directoryType.EntraID">
|
||||||
}
|
{{ "ex" | i18n }} include:Sales,IT
|
||||||
@if (directory === directoryType.EntraID) {
|
</div>
|
||||||
<div class="form-text">{{ "ex" | i18n }} include:Sales,IT</div>
|
<div class="form-text" *ngIf="directory === directoryType.Okta">
|
||||||
}
|
{{ "ex" | i18n }} include:Sales,IT | type eq "APP_GROUP"
|
||||||
@if (directory === directoryType.Okta) {
|
</div>
|
||||||
<div class="form-text">{{ "ex" | i18n }} include:Sales,IT | type eq "APP_GROUP"</div>
|
<div class="form-text" *ngIf="directory === directoryType.GSuite">
|
||||||
}
|
{{ "ex" | i18n }} include:Sales,IT
|
||||||
@if (directory === directoryType.GSuite) {
|
</div>
|
||||||
<div class="form-text">{{ "ex" | i18n }} include:Sales,IT</div>
|
|
||||||
}
|
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-3" [hidden]="directory != directoryType.Ldap">
|
<div class="mb-3" [hidden]="directory != directoryType.Ldap">
|
||||||
<label for="groupPath" class="form-label">{{ "groupPath" | i18n }}</label>
|
<label for="groupPath" class="form-label">{{ "groupPath" | i18n }}</label>
|
||||||
@@ -723,12 +703,8 @@
|
|||||||
name="GroupPath"
|
name="GroupPath"
|
||||||
[(ngModel)]="sync.groupPath"
|
[(ngModel)]="sync.groupPath"
|
||||||
/>
|
/>
|
||||||
@if (!ldap.ad) {
|
<div class="form-text" *ngIf="!ldap.ad">{{ "ex" | i18n }} CN=Groups</div>
|
||||||
<div class="form-text">{{ "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=Users</div>
|
|
||||||
}
|
|
||||||
</div>
|
</div>
|
||||||
<div [hidden]="directory != directoryType.Ldap || ldap.ad">
|
<div [hidden]="directory != directoryType.Ldap || ldap.ad">
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
|
|||||||
2
src/scss/bootstrap.scss
vendored
2
src/scss/bootstrap.scss
vendored
@@ -28,4 +28,4 @@ $danger: map_get($theme-colors, "danger");
|
|||||||
$secondary: map_get($theme-colors, "secondary");
|
$secondary: map_get($theme-colors, "secondary");
|
||||||
$secondary-alt: map_get($theme-colors, "secondary-alt");
|
$secondary-alt: map_get($theme-colors, "secondary-alt");
|
||||||
|
|
||||||
@import "bootstrap/scss/bootstrap.scss";
|
@import "~bootstrap/scss/bootstrap.scss";
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
@import "bootstrap/scss/_variables.scss";
|
@import "~bootstrap/scss/_variables.scss";
|
||||||
|
|
||||||
html.os_windows {
|
html.os_windows {
|
||||||
body {
|
body {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
@import "bootstrap/scss/_variables.scss";
|
@import "~bootstrap/scss/_variables.scss";
|
||||||
|
|
||||||
body {
|
body {
|
||||||
padding: 10px 0 20px 0;
|
padding: 10px 0 20px 0;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
@import "ngx-toastr/toastr";
|
@import "~ngx-toastr/toastr";
|
||||||
|
|
||||||
@import "bootstrap/scss/_variables.scss";
|
@import "~bootstrap/scss/_variables.scss";
|
||||||
|
|
||||||
.toast-container {
|
.toast-container {
|
||||||
.toast-close-button {
|
.toast-close-button {
|
||||||
|
|||||||
@@ -50,36 +50,221 @@ describe("gsuiteDirectoryService", () => {
|
|||||||
directoryService = new GSuiteDirectoryService(logService, i18nService, stateService);
|
directoryService = new GSuiteDirectoryService(logService, i18nService, stateService);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("syncs without using filters (includes test data)", async () => {
|
describe("basic sync fetching users and groups", () => {
|
||||||
const directoryConfig = getGSuiteConfiguration();
|
it("syncs without using filters (includes test data)", async () => {
|
||||||
stateService.getDirectory.calledWith(DirectoryType.GSuite).mockResolvedValue(directoryConfig);
|
const directoryConfig = getGSuiteConfiguration();
|
||||||
|
stateService.getDirectory.calledWith(DirectoryType.GSuite).mockResolvedValue(directoryConfig);
|
||||||
|
|
||||||
const syncConfig = getSyncConfiguration({
|
const syncConfig = getSyncConfiguration({
|
||||||
groups: true,
|
groups: true,
|
||||||
users: 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));
|
||||||
});
|
});
|
||||||
stateService.getSync.mockResolvedValue(syncConfig);
|
|
||||||
|
|
||||||
const result = await directoryService.getEntries(true, true);
|
it("syncs using user and group filters (exact match for test data)", async () => {
|
||||||
|
const directoryConfig = getGSuiteConfiguration();
|
||||||
|
stateService.getDirectory.calledWith(DirectoryType.GSuite).mockResolvedValue(directoryConfig);
|
||||||
|
|
||||||
expect(result[0]).toEqual(expect.arrayContaining(groupFixtures));
|
const syncConfig = getSyncConfiguration({
|
||||||
expect(result[1]).toEqual(expect.arrayContaining(userFixtures));
|
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]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("syncs only users when groups sync is disabled", async () => {
|
||||||
|
const directoryConfig = getGSuiteConfiguration();
|
||||||
|
stateService.getDirectory.calledWith(DirectoryType.GSuite).mockResolvedValue(directoryConfig);
|
||||||
|
|
||||||
|
const syncConfig = getSyncConfiguration({
|
||||||
|
groups: false,
|
||||||
|
users: true,
|
||||||
|
userFilter: INTEGRATION_USER_FILTER,
|
||||||
|
});
|
||||||
|
stateService.getSync.mockResolvedValue(syncConfig);
|
||||||
|
|
||||||
|
const result = await directoryService.getEntries(true, true);
|
||||||
|
|
||||||
|
expect(result[0]).toBeUndefined();
|
||||||
|
expect(result[1]).toEqual(expect.arrayContaining(userFixtures));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("syncs only groups when users sync is disabled", async () => {
|
||||||
|
const directoryConfig = getGSuiteConfiguration();
|
||||||
|
stateService.getDirectory.calledWith(DirectoryType.GSuite).mockResolvedValue(directoryConfig);
|
||||||
|
|
||||||
|
const syncConfig = getSyncConfiguration({
|
||||||
|
groups: true,
|
||||||
|
users: false,
|
||||||
|
groupFilter: INTEGRATION_GROUP_FILTER,
|
||||||
|
});
|
||||||
|
stateService.getSync.mockResolvedValue(syncConfig);
|
||||||
|
|
||||||
|
const result = await directoryService.getEntries(true, true);
|
||||||
|
|
||||||
|
expect(result[0]).toEqual(expect.arrayContaining(groupFixtures));
|
||||||
|
expect(result[1]).toEqual([]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("syncs using user and group filters (exact match for test data)", async () => {
|
describe("users", () => {
|
||||||
const directoryConfig = getGSuiteConfiguration();
|
it("includes disabled users in sync results", async () => {
|
||||||
stateService.getDirectory.calledWith(DirectoryType.GSuite).mockResolvedValue(directoryConfig);
|
const directoryConfig = getGSuiteConfiguration();
|
||||||
|
stateService.getDirectory.calledWith(DirectoryType.GSuite).mockResolvedValue(directoryConfig);
|
||||||
|
|
||||||
const syncConfig = getSyncConfiguration({
|
const syncConfig = getSyncConfiguration({
|
||||||
groups: true,
|
users: true,
|
||||||
users: true,
|
userFilter: INTEGRATION_USER_FILTER,
|
||||||
userFilter: INTEGRATION_USER_FILTER,
|
});
|
||||||
groupFilter: INTEGRATION_GROUP_FILTER,
|
stateService.getSync.mockResolvedValue(syncConfig);
|
||||||
|
|
||||||
|
const result = await directoryService.getEntries(true, true);
|
||||||
|
|
||||||
|
const disabledUser = userFixtures.find((u) => u.email === "testuser5@bwrox.dev");
|
||||||
|
expect(result[1]).toContainEqual(disabledUser);
|
||||||
|
expect(disabledUser.disabled).toBe(true);
|
||||||
});
|
});
|
||||||
stateService.getSync.mockResolvedValue(syncConfig);
|
|
||||||
|
|
||||||
const result = await directoryService.getEntries(true, true);
|
it("filters users by org unit path", async () => {
|
||||||
|
const directoryConfig = getGSuiteConfiguration();
|
||||||
|
stateService.getDirectory.calledWith(DirectoryType.GSuite).mockResolvedValue(directoryConfig);
|
||||||
|
|
||||||
expect(result).toEqual([groupFixtures, userFixtures]);
|
const syncConfig = getSyncConfiguration({
|
||||||
|
users: true,
|
||||||
|
userFilter: INTEGRATION_USER_FILTER,
|
||||||
|
});
|
||||||
|
stateService.getSync.mockResolvedValue(syncConfig);
|
||||||
|
|
||||||
|
const result = await directoryService.getEntries(true, true);
|
||||||
|
|
||||||
|
expect(result[1]).toEqual(userFixtures);
|
||||||
|
expect(result[1].length).toBe(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("filters users by email pattern", async () => {
|
||||||
|
const directoryConfig = getGSuiteConfiguration();
|
||||||
|
stateService.getDirectory.calledWith(DirectoryType.GSuite).mockResolvedValue(directoryConfig);
|
||||||
|
|
||||||
|
const syncConfig = getSyncConfiguration({
|
||||||
|
users: true,
|
||||||
|
userFilter: "|email:testuser1*",
|
||||||
|
});
|
||||||
|
stateService.getSync.mockResolvedValue(syncConfig);
|
||||||
|
|
||||||
|
const result = await directoryService.getEntries(true, true);
|
||||||
|
|
||||||
|
const testuser1 = userFixtures.find((u) => u.email === "testuser1@bwrox.dev");
|
||||||
|
expect(result[1]).toContainEqual(testuser1);
|
||||||
|
expect(result[1].length).toBeGreaterThanOrEqual(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("groups", () => {
|
||||||
|
it("filters groups by name pattern", 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[0]).toEqual(groupFixtures);
|
||||||
|
expect(result[0].length).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes group members correctly", 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);
|
||||||
|
|
||||||
|
const groupA = result[0].find((g) => g.name === "Integration Test Group A");
|
||||||
|
expect(groupA).toBeDefined();
|
||||||
|
expect(groupA.userMemberExternalIds.size).toBe(2);
|
||||||
|
expect(groupA.userMemberExternalIds.has("111605910541641314041")).toBe(true);
|
||||||
|
expect(groupA.userMemberExternalIds.has("111147009830456099026")).toBe(true);
|
||||||
|
|
||||||
|
const groupB = result[0].find((g) => g.name === "Integration Test Group B");
|
||||||
|
expect(groupB).toBeDefined();
|
||||||
|
expect(groupB.userMemberExternalIds.size).toBe(2);
|
||||||
|
expect(groupB.userMemberExternalIds.has("111147009830456099026")).toBe(true);
|
||||||
|
expect(groupB.userMemberExternalIds.has("100150970267699397306")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles groups with no members", async () => {
|
||||||
|
const directoryConfig = getGSuiteConfiguration();
|
||||||
|
stateService.getDirectory.calledWith(DirectoryType.GSuite).mockResolvedValue(directoryConfig);
|
||||||
|
|
||||||
|
const syncConfig = getSyncConfiguration({
|
||||||
|
groups: true,
|
||||||
|
users: true,
|
||||||
|
userFilter: INTEGRATION_USER_FILTER,
|
||||||
|
groupFilter: "|name:Integration*",
|
||||||
|
});
|
||||||
|
stateService.getSync.mockResolvedValue(syncConfig);
|
||||||
|
|
||||||
|
const result = await directoryService.getEntries(true, true);
|
||||||
|
|
||||||
|
// All test groups should have members, but ensure the code handles empty groups
|
||||||
|
expect(result[0]).toBeDefined();
|
||||||
|
expect(Array.isArray(result[0])).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("error handling", () => {
|
||||||
|
it("throws error when directory configuration is incomplete", async () => {
|
||||||
|
stateService.getDirectory.calledWith(DirectoryType.GSuite).mockResolvedValue(
|
||||||
|
getGSuiteConfiguration({
|
||||||
|
clientEmail: "",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const syncConfig = getSyncConfiguration({
|
||||||
|
users: true,
|
||||||
|
});
|
||||||
|
stateService.getSync.mockResolvedValue(syncConfig);
|
||||||
|
|
||||||
|
await expect(directoryService.getEntries(true, true)).rejects.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws error when authentication fails with invalid credentials", async () => {
|
||||||
|
const directoryConfig = getGSuiteConfiguration({
|
||||||
|
privateKey: "-----BEGIN PRIVATE KEY-----\nINVALID_KEY\n-----END PRIVATE KEY-----\n",
|
||||||
|
});
|
||||||
|
stateService.getDirectory.calledWith(DirectoryType.GSuite).mockResolvedValue(directoryConfig);
|
||||||
|
|
||||||
|
const syncConfig = getSyncConfiguration({
|
||||||
|
users: true,
|
||||||
|
});
|
||||||
|
stateService.getSync.mockResolvedValue(syncConfig);
|
||||||
|
|
||||||
|
await expect(directoryService.getEntries(true, true)).rejects.toThrow();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -14,6 +14,22 @@ import { BaseDirectoryService } from "../baseDirectory.service";
|
|||||||
|
|
||||||
import { IDirectoryService } from "./directory.service";
|
import { IDirectoryService } from "./directory.service";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Google Workspace (formerly G Suite) Directory Service
|
||||||
|
*
|
||||||
|
* This service integrates with Google Workspace to synchronize users and groups
|
||||||
|
* to Bitwarden organizations using the Google Admin SDK Directory API.
|
||||||
|
*
|
||||||
|
* @remarks
|
||||||
|
* Authentication is performed using a service account with domain-wide delegation.
|
||||||
|
* The service account must be granted the following OAuth 2.0 scopes:
|
||||||
|
* - https://www.googleapis.com/auth/admin.directory.user.readonly
|
||||||
|
* - https://www.googleapis.com/auth/admin.directory.group.readonly
|
||||||
|
* - https://www.googleapis.com/auth/admin.directory.group.member.readonly
|
||||||
|
*
|
||||||
|
* @see {@link https://developers.google.com/admin-sdk/directory/v1/guides | Google Admin SDK Directory API}
|
||||||
|
* @see {@link https://support.google.com/a/answer/162106 | Domain-wide delegation of authority}
|
||||||
|
*/
|
||||||
export class GSuiteDirectoryService extends BaseDirectoryService implements IDirectoryService {
|
export class GSuiteDirectoryService extends BaseDirectoryService implements IDirectoryService {
|
||||||
private client: JWT;
|
private client: JWT;
|
||||||
private service: admin_directory_v1.Admin;
|
private service: admin_directory_v1.Admin;
|
||||||
@@ -30,6 +46,29 @@ export class GSuiteDirectoryService extends BaseDirectoryService implements IDir
|
|||||||
this.service = google.admin("directory_v1");
|
this.service = google.admin("directory_v1");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves users and groups from Google Workspace directory
|
||||||
|
* @returns A tuple containing [groups, users] arrays
|
||||||
|
*
|
||||||
|
* @remarks
|
||||||
|
* This function:
|
||||||
|
* 1. Validates the directory type matches GSuite
|
||||||
|
* 2. Loads directory and sync configuration
|
||||||
|
* 3. Authenticates with Google Workspace using service account credentials
|
||||||
|
* 4. Retrieves users (if enabled in sync config)
|
||||||
|
* 5. Retrieves groups and their members (if enabled in sync config)
|
||||||
|
* 6. Applies any user/group filters specified in sync configuration
|
||||||
|
*
|
||||||
|
* User and group filters follow Google Workspace Directory API query syntax:
|
||||||
|
* - Use `|` prefix for custom filters (e.g., "|orgUnitPath='/Engineering'")
|
||||||
|
* - Multiple conditions can be combined with AND/OR operators
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* const [groups, users] = await service.getEntries(true, false);
|
||||||
|
* console.log(`Synced ${users.length} users and ${groups.length} groups`);
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
async getEntries(force: boolean, test: boolean): Promise<[GroupEntry[], UserEntry[]]> {
|
async getEntries(force: boolean, test: boolean): Promise<[GroupEntry[], UserEntry[]]> {
|
||||||
const type = await this.stateService.getDirectoryType();
|
const type = await this.stateService.getDirectoryType();
|
||||||
if (type !== DirectoryType.GSuite) {
|
if (type !== DirectoryType.GSuite) {
|
||||||
@@ -65,6 +104,26 @@ export class GSuiteDirectoryService extends BaseDirectoryService implements IDir
|
|||||||
return [groups, users];
|
return [groups, users];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves all users from Google Workspace directory
|
||||||
|
*
|
||||||
|
* @returns Array of UserEntry objects representing users in the directory
|
||||||
|
*
|
||||||
|
* @remarks
|
||||||
|
* This method performs two separate queries:
|
||||||
|
* 1. Active users (including suspended and archived)
|
||||||
|
* 2. Deleted users (marked with deleted flag)
|
||||||
|
*
|
||||||
|
* The method handles pagination automatically, fetching all pages of results.
|
||||||
|
* Users are filtered based on the userFilter specified in sync configuration.
|
||||||
|
*
|
||||||
|
* User properties mapped:
|
||||||
|
* - referenceId: User's unique Google ID
|
||||||
|
* - externalId: User's unique Google ID (same as referenceId)
|
||||||
|
* - email: User's primary email address (lowercase)
|
||||||
|
* - disabled: True if user is suspended or archived
|
||||||
|
* - deleted: True if user is deleted from the directory
|
||||||
|
*/
|
||||||
private async getUsers(): Promise<UserEntry[]> {
|
private async getUsers(): Promise<UserEntry[]> {
|
||||||
const entries: UserEntry[] = [];
|
const entries: UserEntry[] = [];
|
||||||
const query = this.createDirectoryQuery(this.syncConfig.userFilter);
|
const query = this.createDirectoryQuery(this.syncConfig.userFilter);
|
||||||
@@ -132,6 +191,13 @@ export class GSuiteDirectoryService extends BaseDirectoryService implements IDir
|
|||||||
return entries;
|
return entries;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transforms a Google Workspace user object into a UserEntry
|
||||||
|
*
|
||||||
|
* @param user - Google Workspace user object from the API
|
||||||
|
* @param deleted - Whether this user is from the deleted users list
|
||||||
|
* @returns UserEntry object or null if user data is invalid
|
||||||
|
*/
|
||||||
private buildUser(user: admin_directory_v1.Schema$User, deleted: boolean) {
|
private buildUser(user: admin_directory_v1.Schema$User, deleted: boolean) {
|
||||||
if ((user.emails == null || user.emails === "") && !deleted) {
|
if ((user.emails == null || user.emails === "") && !deleted) {
|
||||||
return null;
|
return null;
|
||||||
@@ -146,6 +212,17 @@ export class GSuiteDirectoryService extends BaseDirectoryService implements IDir
|
|||||||
return entry;
|
return entry;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves all groups from Google Workspace directory
|
||||||
|
*
|
||||||
|
* @param setFilter - Tuple of [isWhitelist, Set<string>] for filtering groups
|
||||||
|
* @param users - Array of UserEntry objects to reference when processing members
|
||||||
|
* @returns Array of GroupEntry objects representing groups in the directory
|
||||||
|
*
|
||||||
|
* @remarks
|
||||||
|
* For each group, the method also retrieves all group members by calling the
|
||||||
|
* members API. Groups are filtered based on the groupFilter in sync configuration.
|
||||||
|
*/
|
||||||
private async getGroups(
|
private async getGroups(
|
||||||
setFilter: [boolean, Set<string>],
|
setFilter: [boolean, Set<string>],
|
||||||
users: UserEntry[],
|
users: UserEntry[],
|
||||||
@@ -185,6 +262,19 @@ export class GSuiteDirectoryService extends BaseDirectoryService implements IDir
|
|||||||
return entries;
|
return entries;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transforms a Google Workspace group object into a GroupEntry with members
|
||||||
|
*
|
||||||
|
* @param group - Google Workspace group object from the API
|
||||||
|
* @param users - Array of UserEntry objects for reference
|
||||||
|
* @returns GroupEntry object with all members populated
|
||||||
|
*
|
||||||
|
* @remarks
|
||||||
|
* This method retrieves all members of the group, handling three member types:
|
||||||
|
* - USER: Individual user members (only active status users are included)
|
||||||
|
* - GROUP: Nested group members
|
||||||
|
* - CUSTOMER: Special type that includes all users in the domain
|
||||||
|
*/
|
||||||
private async buildGroup(group: admin_directory_v1.Schema$Group, users: UserEntry[]) {
|
private async buildGroup(group: admin_directory_v1.Schema$Group, users: UserEntry[]) {
|
||||||
let nextPageToken: string = null;
|
let nextPageToken: string = null;
|
||||||
|
|
||||||
@@ -230,6 +320,26 @@ export class GSuiteDirectoryService extends BaseDirectoryService implements IDir
|
|||||||
return entry;
|
return entry;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Authenticates with Google Workspace using service account credentials
|
||||||
|
*
|
||||||
|
* @throws Error if required configuration fields are missing or authentication fails
|
||||||
|
*
|
||||||
|
* @remarks
|
||||||
|
* Authentication uses a JWT with the following required fields:
|
||||||
|
* - clientEmail: Service account email address
|
||||||
|
* - privateKey: Service account private key (PEM format)
|
||||||
|
* - subject: Admin user email to impersonate (for domain-wide delegation)
|
||||||
|
*
|
||||||
|
* The service account must be configured with domain-wide delegation and granted
|
||||||
|
* the required OAuth scopes in the Google Workspace Admin Console.
|
||||||
|
*
|
||||||
|
* Optional configuration:
|
||||||
|
* - domain: Filters results to a specific domain
|
||||||
|
* - customer: Customer ID for multi-domain organizations
|
||||||
|
*
|
||||||
|
* @see {@link https://developers.google.com/identity/protocols/oauth2/service-account | Service account authentication}
|
||||||
|
*/
|
||||||
private async auth() {
|
private async auth() {
|
||||||
if (
|
if (
|
||||||
this.dirConfig.clientEmail == null ||
|
this.dirConfig.clientEmail == null ||
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { webcrypto } from "crypto";
|
import { webcrypto } from "crypto";
|
||||||
import { TextEncoder, TextDecoder } from "util";
|
|
||||||
|
|
||||||
Object.assign(globalThis, { TextEncoder, TextDecoder });
|
import "jest-preset-angular/setup-jest";
|
||||||
|
|
||||||
Object.defineProperty(window, "CSS", { value: null });
|
Object.defineProperty(window, "CSS", { value: null });
|
||||||
Object.defineProperty(window, "getComputedStyle", {
|
Object.defineProperty(window, "getComputedStyle", {
|
||||||
value: () => {
|
value: () => {
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
},
|
},
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"pretty": true,
|
"pretty": true,
|
||||||
"moduleResolution": "bundler",
|
"moduleResolution": "node",
|
||||||
"noImplicitAny": true,
|
"noImplicitAny": true,
|
||||||
"target": "ES2016",
|
"target": "ES2016",
|
||||||
"module": "ES2020",
|
"module": "ES2020",
|
||||||
|
|||||||
Reference in New Issue
Block a user