mirror of
https://github.com/bitwarden/browser.git
synced 2024-10-27 07:39:49 +01:00
[PM-8280] email forwarders (#11563)
* forwarder lookup and generation support * localize algorithm names and descriptions in the credential generator service * add encryption support to UserStateSubject * move generic rx utilities to common * move icon button labels to generator configurations
This commit is contained in:
parent
e67577cc39
commit
eff9a423da
@ -1395,6 +1395,10 @@
|
|||||||
"baseUrl": {
|
"baseUrl": {
|
||||||
"message": "Server URL"
|
"message": "Server URL"
|
||||||
},
|
},
|
||||||
|
"selfHostBaseUrl": {
|
||||||
|
"message": "Self-host server URL",
|
||||||
|
"description": "Label for field requesting a self-hosted integration service URL"
|
||||||
|
},
|
||||||
"apiUrl": {
|
"apiUrl": {
|
||||||
"message": "API server URL"
|
"message": "API server URL"
|
||||||
},
|
},
|
||||||
@ -2833,6 +2837,9 @@
|
|||||||
"generateUsername": {
|
"generateUsername": {
|
||||||
"message": "Generate username"
|
"message": "Generate username"
|
||||||
},
|
},
|
||||||
|
"generateEmail": {
|
||||||
|
"message": "Generate email"
|
||||||
|
},
|
||||||
"usernameType": {
|
"usernameType": {
|
||||||
"message": "Username type"
|
"message": "Username type"
|
||||||
},
|
},
|
||||||
@ -2873,6 +2880,14 @@
|
|||||||
"forwardedEmailDesc": {
|
"forwardedEmailDesc": {
|
||||||
"message": "Generate an email alias with an external forwarding service."
|
"message": "Generate an email alias with an external forwarding service."
|
||||||
},
|
},
|
||||||
|
"forwarderDomainName": {
|
||||||
|
"message": "Email domain",
|
||||||
|
"description": "Labels the domain name email forwarder service option"
|
||||||
|
},
|
||||||
|
"forwarderDomainNameHint": {
|
||||||
|
"message": "Choose a domain that is supported by the selected service",
|
||||||
|
"description": "Guidance provided for email forwarding services that support multiple email domains."
|
||||||
|
},
|
||||||
"forwarderError": {
|
"forwarderError": {
|
||||||
"message": "$SERVICENAME$ error: $ERRORMESSAGE$",
|
"message": "$SERVICENAME$ error: $ERRORMESSAGE$",
|
||||||
"description": "Reports an error returned by a forwarding service to the user.",
|
"description": "Reports an error returned by a forwarding service to the user.",
|
||||||
|
@ -835,6 +835,10 @@
|
|||||||
"baseUrl": {
|
"baseUrl": {
|
||||||
"message": "Server URL"
|
"message": "Server URL"
|
||||||
},
|
},
|
||||||
|
"selfHostBaseUrl": {
|
||||||
|
"message": "Self-host server URL",
|
||||||
|
"description": "Label for field requesting a self-hosted integration service URL"
|
||||||
|
},
|
||||||
"apiUrl": {
|
"apiUrl": {
|
||||||
"message": "API server URL"
|
"message": "API server URL"
|
||||||
},
|
},
|
||||||
@ -1225,6 +1229,9 @@
|
|||||||
"message": "Copy number",
|
"message": "Copy number",
|
||||||
"description": "Copy credit card number"
|
"description": "Copy credit card number"
|
||||||
},
|
},
|
||||||
|
"copyEmail": {
|
||||||
|
"message": "Copy email"
|
||||||
|
},
|
||||||
"copySecurityCode": {
|
"copySecurityCode": {
|
||||||
"message": "Copy security code",
|
"message": "Copy security code",
|
||||||
"description": "Copy credit card security code (CVV)"
|
"description": "Copy credit card security code (CVV)"
|
||||||
@ -2359,6 +2366,9 @@
|
|||||||
"generateUsername": {
|
"generateUsername": {
|
||||||
"message": "Generate username"
|
"message": "Generate username"
|
||||||
},
|
},
|
||||||
|
"generateEmail": {
|
||||||
|
"message": "Generate email"
|
||||||
|
},
|
||||||
"usernameType": {
|
"usernameType": {
|
||||||
"message": "Username type"
|
"message": "Username type"
|
||||||
},
|
},
|
||||||
@ -2402,6 +2412,14 @@
|
|||||||
"forwardedEmailDesc": {
|
"forwardedEmailDesc": {
|
||||||
"message": "Generate an email alias with an external forwarding service."
|
"message": "Generate an email alias with an external forwarding service."
|
||||||
},
|
},
|
||||||
|
"forwarderDomainName": {
|
||||||
|
"message": "Email domain",
|
||||||
|
"description": "Labels the domain name email forwarder service option"
|
||||||
|
},
|
||||||
|
"forwarderDomainNameHint": {
|
||||||
|
"message": "Choose a domain that is supported by the selected service",
|
||||||
|
"description": "Guidance provided for email forwarding services that support multiple email domains."
|
||||||
|
},
|
||||||
"forwarderError": {
|
"forwarderError": {
|
||||||
"message": "$SERVICENAME$ error: $ERRORMESSAGE$",
|
"message": "$SERVICENAME$ error: $ERRORMESSAGE$",
|
||||||
"description": "Reports an error returned by a forwarding service to the user.",
|
"description": "Reports an error returned by a forwarding service to the user.",
|
||||||
|
@ -6361,6 +6361,9 @@
|
|||||||
"generateUsername": {
|
"generateUsername": {
|
||||||
"message": "Generate username"
|
"message": "Generate username"
|
||||||
},
|
},
|
||||||
|
"generateEmail": {
|
||||||
|
"message": "Generate email"
|
||||||
|
},
|
||||||
"usernameType": {
|
"usernameType": {
|
||||||
"message": "Username type"
|
"message": "Username type"
|
||||||
},
|
},
|
||||||
@ -6466,6 +6469,14 @@
|
|||||||
"forwardedEmailDesc": {
|
"forwardedEmailDesc": {
|
||||||
"message": "Generate an email alias with an external forwarding service."
|
"message": "Generate an email alias with an external forwarding service."
|
||||||
},
|
},
|
||||||
|
"forwarderDomainName": {
|
||||||
|
"message": "Email domain",
|
||||||
|
"description": "Labels the domain name email forwarder service option"
|
||||||
|
},
|
||||||
|
"forwarderDomainNameHint": {
|
||||||
|
"message": "Choose a domain that is supported by the selected service",
|
||||||
|
"description": "Guidance provided for email forwarding services that support multiple email domains."
|
||||||
|
},
|
||||||
"forwarderError": {
|
"forwarderError": {
|
||||||
"message": "$SERVICENAME$ error: $ERRORMESSAGE$",
|
"message": "$SERVICENAME$ error: $ERRORMESSAGE$",
|
||||||
"description": "Reports an error returned by a forwarding service to the user.",
|
"description": "Reports an error returned by a forwarding service to the user.",
|
||||||
@ -8265,6 +8276,10 @@
|
|||||||
"baseUrl": {
|
"baseUrl": {
|
||||||
"message": "Server URL"
|
"message": "Server URL"
|
||||||
},
|
},
|
||||||
|
"selfHostBaseUrl": {
|
||||||
|
"message": "Self-host server URL",
|
||||||
|
"description": "Label for field requesting a self-hosted integration service URL"
|
||||||
|
},
|
||||||
"aliasDomain": {
|
"aliasDomain": {
|
||||||
"message": "Alias domain"
|
"message": "Alias domain"
|
||||||
},
|
},
|
||||||
|
@ -3,6 +3,8 @@ import { Observable } from "rxjs";
|
|||||||
import { Policy } from "@bitwarden/common/admin-console/models/domain/policy";
|
import { Policy } from "@bitwarden/common/admin-console/models/domain/policy";
|
||||||
import { UserId } from "@bitwarden/common/types/guid";
|
import { UserId } from "@bitwarden/common/types/guid";
|
||||||
|
|
||||||
|
import { UserEncryptor } from "./state/user-encryptor.abstraction";
|
||||||
|
|
||||||
/** error emitted when the `SingleUserDependency` changes Ids */
|
/** error emitted when the `SingleUserDependency` changes Ids */
|
||||||
export type UserChangedError = {
|
export type UserChangedError = {
|
||||||
/** the userId pinned by the single user dependency */
|
/** the userId pinned by the single user dependency */
|
||||||
@ -45,7 +47,35 @@ export type UserDependency = {
|
|||||||
userId$: Observable<UserId>;
|
userId$: Observable<UserId>;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** A pattern for types that depend upon a fixed userid and return
|
/** Decorates a type to indicate the user, if any, that the type is usable only by
|
||||||
|
* a specific user.
|
||||||
|
*/
|
||||||
|
export type UserBound<K extends keyof any, T> = { [P in K]: T } & {
|
||||||
|
/** The user to which T is bound. */
|
||||||
|
userId: UserId;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** A pattern for types that depend upon a fixed-key encryptor and return
|
||||||
|
* an observable.
|
||||||
|
*
|
||||||
|
* Consumers of this dependency should emit a `UserChangedError` if
|
||||||
|
* the bound UserId changes or if the encryptor changes. If
|
||||||
|
* `singleUserEncryptor$` completes, the consumer should complete
|
||||||
|
* once all events received prior to the completion event are
|
||||||
|
* finished processing. The consumer should, where possible,
|
||||||
|
* prioritize these events in order to complete as soon as possible.
|
||||||
|
* If `singleUserEncryptor$` emits an unrecoverable error, the consumer
|
||||||
|
* should also emit the error.
|
||||||
|
*/
|
||||||
|
export type SingleUserEncryptorDependency = {
|
||||||
|
/** A stream that emits an encryptor when subscribed and the user key
|
||||||
|
* is available, and completes when the user key is no longer available.
|
||||||
|
* The stream should not emit null or undefined.
|
||||||
|
*/
|
||||||
|
singleUserEncryptor$: Observable<UserBound<"encryptor", UserEncryptor>>;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** A pattern for types that depend upon a fixed-value userid and return
|
||||||
* an observable.
|
* an observable.
|
||||||
*
|
*
|
||||||
* Consumers of this dependency should emit a `UserChangedError` if
|
* Consumers of this dependency should emit a `UserChangedError` if
|
||||||
|
@ -1,7 +1,13 @@
|
|||||||
import { Opaque } from "type-fest";
|
import { Opaque } from "type-fest";
|
||||||
|
|
||||||
|
export const IntegrationIds = [
|
||||||
|
"anonaddy",
|
||||||
|
"duckduckgo",
|
||||||
|
"fastmail",
|
||||||
|
"firefoxrelay",
|
||||||
|
"forwardemail",
|
||||||
|
"simplelogin",
|
||||||
|
] as const;
|
||||||
|
|
||||||
/** Identifies a vendor integrated into bitwarden */
|
/** Identifies a vendor integrated into bitwarden */
|
||||||
export type IntegrationId = Opaque<
|
export type IntegrationId = Opaque<(typeof IntegrationIds)[number], "IntegrationId">;
|
||||||
"anonaddy" | "duckduckgo" | "fastmail" | "firefoxrelay" | "forwardemail" | "simplelogin",
|
|
||||||
"IntegrationId"
|
|
||||||
>;
|
|
||||||
|
31
libs/common/src/tools/private-classifier.ts
Normal file
31
libs/common/src/tools/private-classifier.ts
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
import { Jsonify } from "type-fest";
|
||||||
|
|
||||||
|
import { Classifier } from "@bitwarden/common/tools/state/classifier";
|
||||||
|
|
||||||
|
export class PrivateClassifier<Data> implements Classifier<Data, Record<string, never>, Data> {
|
||||||
|
constructor(private keys: (keyof Jsonify<Data>)[] = undefined) {}
|
||||||
|
|
||||||
|
classify(value: Data): { disclosed: Jsonify<Record<string, never>>; secret: Jsonify<Data> } {
|
||||||
|
const pickMe = JSON.parse(JSON.stringify(value));
|
||||||
|
const keys: (keyof Jsonify<Data>)[] = this.keys ?? (Object.keys(pickMe) as any);
|
||||||
|
|
||||||
|
const picked: Partial<Jsonify<Data>> = {};
|
||||||
|
for (const key of keys) {
|
||||||
|
picked[key] = pickMe[key];
|
||||||
|
}
|
||||||
|
const secret = picked as Jsonify<Data>;
|
||||||
|
|
||||||
|
return { disclosed: null, secret };
|
||||||
|
}
|
||||||
|
|
||||||
|
declassify(_disclosed: Jsonify<Record<keyof Data, never>>, secret: Jsonify<Data>) {
|
||||||
|
const result: Partial<Jsonify<Data>> = {};
|
||||||
|
const keys: (keyof Jsonify<Data>)[] = this.keys ?? (Object.keys(secret) as any);
|
||||||
|
|
||||||
|
for (const key of keys) {
|
||||||
|
result[key] = secret[key];
|
||||||
|
}
|
||||||
|
|
||||||
|
return result as Jsonify<Data>;
|
||||||
|
}
|
||||||
|
}
|
29
libs/common/src/tools/public-classifier.ts
Normal file
29
libs/common/src/tools/public-classifier.ts
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
import { Jsonify } from "type-fest";
|
||||||
|
|
||||||
|
import { Classifier } from "@bitwarden/common/tools/state/classifier";
|
||||||
|
|
||||||
|
export class PublicClassifier<Data> implements Classifier<Data, Data, Record<string, never>> {
|
||||||
|
constructor(private keys: (keyof Jsonify<Data>)[]) {}
|
||||||
|
|
||||||
|
classify(value: Data): { disclosed: Jsonify<Data>; secret: Jsonify<Record<string, never>> } {
|
||||||
|
const pickMe = JSON.parse(JSON.stringify(value));
|
||||||
|
|
||||||
|
const picked: Partial<Jsonify<Data>> = {};
|
||||||
|
for (const key of this.keys) {
|
||||||
|
picked[key] = pickMe[key];
|
||||||
|
}
|
||||||
|
const disclosed = picked as Jsonify<Data>;
|
||||||
|
|
||||||
|
return { disclosed, secret: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
declassify(disclosed: Jsonify<Data>, _secret: Jsonify<Record<keyof Data, never>>) {
|
||||||
|
const result: Partial<Jsonify<Data>> = {};
|
||||||
|
|
||||||
|
for (const key of this.keys) {
|
||||||
|
result[key] = disclosed[key];
|
||||||
|
}
|
||||||
|
|
||||||
|
return result as Jsonify<Data>;
|
||||||
|
}
|
||||||
|
}
|
@ -2,11 +2,18 @@
|
|||||||
* include structuredClone in test environment.
|
* include structuredClone in test environment.
|
||||||
* @jest-environment ../../../../shared/test.environment.ts
|
* @jest-environment ../../../../shared/test.environment.ts
|
||||||
*/
|
*/
|
||||||
import { of, firstValueFrom } from "rxjs";
|
import { of, firstValueFrom, Subject, tap, EmptyError } from "rxjs";
|
||||||
|
|
||||||
import { awaitAsync, trackEmissions } from "../../spec";
|
import { awaitAsync, trackEmissions } from "../../spec";
|
||||||
|
|
||||||
import { distinctIfShallowMatch, reduceCollection } from "./rx";
|
import {
|
||||||
|
anyComplete,
|
||||||
|
distinctIfShallowMatch,
|
||||||
|
on,
|
||||||
|
ready,
|
||||||
|
reduceCollection,
|
||||||
|
withLatestReady,
|
||||||
|
} from "./rx";
|
||||||
|
|
||||||
describe("reduceCollection", () => {
|
describe("reduceCollection", () => {
|
||||||
it.each([[null], [undefined], [[]]])(
|
it.each([[null], [undefined], [[]]])(
|
||||||
@ -84,3 +91,488 @@ describe("distinctIfShallowMatch", () => {
|
|||||||
expect(result).toEqual([{ foo: true, bar: true }]);
|
expect(result).toEqual([{ foo: true, bar: true }]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("anyComplete", () => {
|
||||||
|
it("emits true when its input completes", () => {
|
||||||
|
const input$ = new Subject<void>();
|
||||||
|
|
||||||
|
const emissions: boolean[] = [];
|
||||||
|
anyComplete(input$).subscribe((e) => emissions.push(e));
|
||||||
|
input$.complete();
|
||||||
|
|
||||||
|
expect(emissions).toEqual([true]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("completes when its input is already complete", () => {
|
||||||
|
const input = new Subject<void>();
|
||||||
|
input.complete();
|
||||||
|
|
||||||
|
let completed = false;
|
||||||
|
anyComplete(input).subscribe({ complete: () => (completed = true) });
|
||||||
|
|
||||||
|
expect(completed).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("completes when any input completes", () => {
|
||||||
|
const input$ = new Subject<void>();
|
||||||
|
const completing$ = new Subject<void>();
|
||||||
|
|
||||||
|
let completed = false;
|
||||||
|
anyComplete([input$, completing$]).subscribe({ complete: () => (completed = true) });
|
||||||
|
completing$.complete();
|
||||||
|
|
||||||
|
expect(completed).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores emissions", () => {
|
||||||
|
const input$ = new Subject<number>();
|
||||||
|
|
||||||
|
const emissions: boolean[] = [];
|
||||||
|
anyComplete(input$).subscribe((e) => emissions.push(e));
|
||||||
|
input$.next(1);
|
||||||
|
input$.next(2);
|
||||||
|
input$.complete();
|
||||||
|
|
||||||
|
expect(emissions).toEqual([true]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("forwards errors", () => {
|
||||||
|
const input$ = new Subject<void>();
|
||||||
|
const expected = { some: "error" };
|
||||||
|
|
||||||
|
let error = null;
|
||||||
|
anyComplete(input$).subscribe({ error: (e: unknown) => (error = e) });
|
||||||
|
input$.error(expected);
|
||||||
|
|
||||||
|
expect(error).toEqual(expected);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("ready", () => {
|
||||||
|
it("connects when subscribed", () => {
|
||||||
|
const watch$ = new Subject<void>();
|
||||||
|
let connected = false;
|
||||||
|
const source$ = new Subject<number>().pipe(tap({ subscribe: () => (connected = true) }));
|
||||||
|
|
||||||
|
// precondition: ready$ should be cold
|
||||||
|
const ready$ = source$.pipe(ready(watch$));
|
||||||
|
expect(connected).toBe(false);
|
||||||
|
|
||||||
|
ready$.subscribe();
|
||||||
|
|
||||||
|
expect(connected).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("suppresses source emissions until its watch emits", () => {
|
||||||
|
const watch$ = new Subject<void>();
|
||||||
|
const source$ = new Subject<number>();
|
||||||
|
const ready$ = source$.pipe(ready(watch$));
|
||||||
|
const results: number[] = [];
|
||||||
|
ready$.subscribe((n) => results.push(n));
|
||||||
|
|
||||||
|
// precondition: no emissions
|
||||||
|
source$.next(1);
|
||||||
|
expect(results).toEqual([]);
|
||||||
|
|
||||||
|
watch$.next();
|
||||||
|
|
||||||
|
expect(results).toEqual([1]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("suppresses source emissions until all watches emit", () => {
|
||||||
|
const watchA$ = new Subject<void>();
|
||||||
|
const watchB$ = new Subject<void>();
|
||||||
|
const source$ = new Subject<number>();
|
||||||
|
const ready$ = source$.pipe(ready([watchA$, watchB$]));
|
||||||
|
const results: number[] = [];
|
||||||
|
ready$.subscribe((n) => results.push(n));
|
||||||
|
|
||||||
|
// preconditions: no emissions
|
||||||
|
source$.next(1);
|
||||||
|
expect(results).toEqual([]);
|
||||||
|
watchA$.next();
|
||||||
|
expect(results).toEqual([]);
|
||||||
|
|
||||||
|
watchB$.next();
|
||||||
|
|
||||||
|
expect(results).toEqual([1]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("emits the last source emission when its watch emits", () => {
|
||||||
|
const watch$ = new Subject<void>();
|
||||||
|
const source$ = new Subject<number>();
|
||||||
|
const ready$ = source$.pipe(ready(watch$));
|
||||||
|
const results: number[] = [];
|
||||||
|
ready$.subscribe((n) => results.push(n));
|
||||||
|
|
||||||
|
// precondition: no emissions
|
||||||
|
source$.next(1);
|
||||||
|
expect(results).toEqual([]);
|
||||||
|
|
||||||
|
source$.next(2);
|
||||||
|
watch$.next();
|
||||||
|
|
||||||
|
expect(results).toEqual([2]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("emits all source emissions after its watch emits", () => {
|
||||||
|
const watch$ = new Subject<void>();
|
||||||
|
const source$ = new Subject<number>();
|
||||||
|
const ready$ = source$.pipe(ready(watch$));
|
||||||
|
const results: number[] = [];
|
||||||
|
ready$.subscribe((n) => results.push(n));
|
||||||
|
|
||||||
|
watch$.next();
|
||||||
|
source$.next(1);
|
||||||
|
source$.next(2);
|
||||||
|
|
||||||
|
expect(results).toEqual([1, 2]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores repeated watch emissions", () => {
|
||||||
|
const watch$ = new Subject<void>();
|
||||||
|
const source$ = new Subject<number>();
|
||||||
|
const ready$ = source$.pipe(ready(watch$));
|
||||||
|
const results: number[] = [];
|
||||||
|
ready$.subscribe((n) => results.push(n));
|
||||||
|
|
||||||
|
watch$.next();
|
||||||
|
source$.next(1);
|
||||||
|
watch$.next();
|
||||||
|
source$.next(2);
|
||||||
|
watch$.next();
|
||||||
|
|
||||||
|
expect(results).toEqual([1, 2]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("completes when its source completes", () => {
|
||||||
|
const watch$ = new Subject<void>();
|
||||||
|
const source$ = new Subject<number>();
|
||||||
|
const ready$ = source$.pipe(ready(watch$));
|
||||||
|
let completed = false;
|
||||||
|
ready$.subscribe({ complete: () => (completed = true) });
|
||||||
|
|
||||||
|
source$.complete();
|
||||||
|
|
||||||
|
expect(completed).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("errors when its source errors", () => {
|
||||||
|
const watch$ = new Subject<void>();
|
||||||
|
const source$ = new Subject<number>();
|
||||||
|
const ready$ = source$.pipe(ready(watch$));
|
||||||
|
const expected = { some: "error" };
|
||||||
|
let error = null;
|
||||||
|
ready$.subscribe({ error: (e: unknown) => (error = e) });
|
||||||
|
|
||||||
|
source$.error(expected);
|
||||||
|
|
||||||
|
expect(error).toEqual(expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("errors when its watch errors", () => {
|
||||||
|
const watch$ = new Subject<void>();
|
||||||
|
const source$ = new Subject<number>();
|
||||||
|
const ready$ = source$.pipe(ready(watch$));
|
||||||
|
const expected = { some: "error" };
|
||||||
|
let error = null;
|
||||||
|
ready$.subscribe({ error: (e: unknown) => (error = e) });
|
||||||
|
|
||||||
|
watch$.error(expected);
|
||||||
|
|
||||||
|
expect(error).toEqual(expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("errors when its watch completes before emitting", () => {
|
||||||
|
const watch$ = new Subject<void>();
|
||||||
|
const source$ = new Subject<number>();
|
||||||
|
const ready$ = source$.pipe(ready(watch$));
|
||||||
|
let error = null;
|
||||||
|
ready$.subscribe({ error: (e: unknown) => (error = e) });
|
||||||
|
|
||||||
|
watch$.complete();
|
||||||
|
|
||||||
|
expect(error).toBeInstanceOf(EmptyError);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("withLatestReady", () => {
|
||||||
|
it("connects when subscribed", () => {
|
||||||
|
const watch$ = new Subject<string>();
|
||||||
|
let connected = false;
|
||||||
|
const source$ = new Subject<number>().pipe(tap({ subscribe: () => (connected = true) }));
|
||||||
|
|
||||||
|
// precondition: ready$ should be cold
|
||||||
|
const ready$ = source$.pipe(withLatestReady(watch$));
|
||||||
|
expect(connected).toBe(false);
|
||||||
|
|
||||||
|
ready$.subscribe();
|
||||||
|
|
||||||
|
expect(connected).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("suppresses source emissions until its watch emits", () => {
|
||||||
|
const watch$ = new Subject<string>();
|
||||||
|
const source$ = new Subject<number>();
|
||||||
|
const ready$ = source$.pipe(withLatestReady(watch$));
|
||||||
|
const results: [number, string][] = [];
|
||||||
|
ready$.subscribe((n) => results.push(n));
|
||||||
|
|
||||||
|
// precondition: no emissions
|
||||||
|
source$.next(1);
|
||||||
|
expect(results).toEqual([]);
|
||||||
|
|
||||||
|
watch$.next("watch");
|
||||||
|
|
||||||
|
expect(results).toEqual([[1, "watch"]]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("emits the last source emission when its watch emits", () => {
|
||||||
|
const watch$ = new Subject<string>();
|
||||||
|
const source$ = new Subject<number>();
|
||||||
|
const ready$ = source$.pipe(withLatestReady(watch$));
|
||||||
|
const results: [number, string][] = [];
|
||||||
|
ready$.subscribe((n) => results.push(n));
|
||||||
|
|
||||||
|
// precondition: no emissions
|
||||||
|
source$.next(1);
|
||||||
|
expect(results).toEqual([]);
|
||||||
|
|
||||||
|
source$.next(2);
|
||||||
|
watch$.next("watch");
|
||||||
|
|
||||||
|
expect(results).toEqual([[2, "watch"]]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("emits all source emissions after its watch emits", () => {
|
||||||
|
const watch$ = new Subject<string>();
|
||||||
|
const source$ = new Subject<number>();
|
||||||
|
const ready$ = source$.pipe(withLatestReady(watch$));
|
||||||
|
const results: [number, string][] = [];
|
||||||
|
ready$.subscribe((n) => results.push(n));
|
||||||
|
|
||||||
|
watch$.next("watch");
|
||||||
|
source$.next(1);
|
||||||
|
source$.next(2);
|
||||||
|
|
||||||
|
expect(results).toEqual([
|
||||||
|
[1, "watch"],
|
||||||
|
[2, "watch"],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("appends the latest watch emission", () => {
|
||||||
|
const watch$ = new Subject<string>();
|
||||||
|
const source$ = new Subject<number>();
|
||||||
|
const ready$ = source$.pipe(withLatestReady(watch$));
|
||||||
|
const results: [number, string][] = [];
|
||||||
|
ready$.subscribe((n) => results.push(n));
|
||||||
|
|
||||||
|
watch$.next("ignored");
|
||||||
|
watch$.next("watch");
|
||||||
|
source$.next(1);
|
||||||
|
watch$.next("ignored");
|
||||||
|
watch$.next("watch");
|
||||||
|
source$.next(2);
|
||||||
|
|
||||||
|
expect(results).toEqual([
|
||||||
|
[1, "watch"],
|
||||||
|
[2, "watch"],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("completes when its source completes", () => {
|
||||||
|
const watch$ = new Subject<string>();
|
||||||
|
const source$ = new Subject<number>();
|
||||||
|
const ready$ = source$.pipe(withLatestReady(watch$));
|
||||||
|
let completed = false;
|
||||||
|
ready$.subscribe({ complete: () => (completed = true) });
|
||||||
|
|
||||||
|
source$.complete();
|
||||||
|
|
||||||
|
expect(completed).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("errors when its source errors", () => {
|
||||||
|
const watch$ = new Subject<void>();
|
||||||
|
const source$ = new Subject<number>();
|
||||||
|
const ready$ = source$.pipe(withLatestReady(watch$));
|
||||||
|
const expected = { some: "error" };
|
||||||
|
let error = null;
|
||||||
|
ready$.subscribe({ error: (e: unknown) => (error = e) });
|
||||||
|
|
||||||
|
source$.error(expected);
|
||||||
|
|
||||||
|
expect(error).toEqual(expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("errors when its watch errors", () => {
|
||||||
|
const watch$ = new Subject<string>();
|
||||||
|
const source$ = new Subject<number>();
|
||||||
|
const ready$ = source$.pipe(withLatestReady(watch$));
|
||||||
|
const expected = { some: "error" };
|
||||||
|
let error = null;
|
||||||
|
ready$.subscribe({ error: (e: unknown) => (error = e) });
|
||||||
|
|
||||||
|
watch$.error(expected);
|
||||||
|
|
||||||
|
expect(error).toEqual(expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("errors when its watch completes before emitting", () => {
|
||||||
|
const watch$ = new Subject<string>();
|
||||||
|
const source$ = new Subject<number>();
|
||||||
|
const ready$ = source$.pipe(withLatestReady(watch$));
|
||||||
|
let error = null;
|
||||||
|
ready$.subscribe({ error: (e: unknown) => (error = e) });
|
||||||
|
|
||||||
|
watch$.complete();
|
||||||
|
|
||||||
|
expect(error).toBeInstanceOf(EmptyError);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("on", () => {
|
||||||
|
it("connects when subscribed", () => {
|
||||||
|
const watch$ = new Subject<void>();
|
||||||
|
let connected = false;
|
||||||
|
const source$ = new Subject<number>().pipe(tap({ subscribe: () => (connected = true) }));
|
||||||
|
|
||||||
|
// precondition: on$ should be cold
|
||||||
|
const on$ = source$.pipe(on(watch$));
|
||||||
|
expect(connected).toBeFalsy();
|
||||||
|
|
||||||
|
on$.subscribe();
|
||||||
|
|
||||||
|
expect(connected).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("suppresses source emissions until `on` emits", () => {
|
||||||
|
const watch$ = new Subject<void>();
|
||||||
|
const source$ = new Subject<number>();
|
||||||
|
const results: number[] = [];
|
||||||
|
source$.pipe(on(watch$)).subscribe((n) => results.push(n));
|
||||||
|
|
||||||
|
// precondition: on$ should be cold
|
||||||
|
source$.next(1);
|
||||||
|
expect(results).toEqual([]);
|
||||||
|
|
||||||
|
watch$.next();
|
||||||
|
|
||||||
|
expect(results).toEqual([1]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("repeats source emissions when `on` emits", () => {
|
||||||
|
const watch$ = new Subject<void>();
|
||||||
|
const source$ = new Subject<number>();
|
||||||
|
const results: number[] = [];
|
||||||
|
source$.pipe(on(watch$)).subscribe((n) => results.push(n));
|
||||||
|
source$.next(1);
|
||||||
|
|
||||||
|
watch$.next();
|
||||||
|
watch$.next();
|
||||||
|
|
||||||
|
expect(results).toEqual([1, 1]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("updates source emissions when `on` emits", () => {
|
||||||
|
const watch$ = new Subject<void>();
|
||||||
|
const source$ = new Subject<number>();
|
||||||
|
const results: number[] = [];
|
||||||
|
source$.pipe(on(watch$)).subscribe((n) => results.push(n));
|
||||||
|
|
||||||
|
source$.next(1);
|
||||||
|
watch$.next();
|
||||||
|
source$.next(2);
|
||||||
|
watch$.next();
|
||||||
|
|
||||||
|
expect(results).toEqual([1, 2]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("emits a value when `on` emits before the source is ready", () => {
|
||||||
|
const watch$ = new Subject<void>();
|
||||||
|
const source$ = new Subject<number>();
|
||||||
|
const results: number[] = [];
|
||||||
|
source$.pipe(on(watch$)).subscribe((n) => results.push(n));
|
||||||
|
|
||||||
|
watch$.next();
|
||||||
|
source$.next(1);
|
||||||
|
|
||||||
|
expect(results).toEqual([1]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores repeated `on` emissions before the source is ready", () => {
|
||||||
|
const watch$ = new Subject<void>();
|
||||||
|
const source$ = new Subject<number>();
|
||||||
|
const results: number[] = [];
|
||||||
|
source$.pipe(on(watch$)).subscribe((n) => results.push(n));
|
||||||
|
|
||||||
|
watch$.next();
|
||||||
|
watch$.next();
|
||||||
|
source$.next(1);
|
||||||
|
|
||||||
|
expect(results).toEqual([1]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("emits only the latest source emission when `on` emits", () => {
|
||||||
|
const watch$ = new Subject<void>();
|
||||||
|
const source$ = new Subject<number>();
|
||||||
|
const results: number[] = [];
|
||||||
|
source$.pipe(on(watch$)).subscribe((n) => results.push(n));
|
||||||
|
source$.next(1);
|
||||||
|
|
||||||
|
watch$.next();
|
||||||
|
|
||||||
|
source$.next(2);
|
||||||
|
source$.next(3);
|
||||||
|
watch$.next();
|
||||||
|
|
||||||
|
expect(results).toEqual([1, 3]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("completes when its source completes", () => {
|
||||||
|
const watch$ = new Subject<void>();
|
||||||
|
const source$ = new Subject<number>();
|
||||||
|
let complete: boolean = false;
|
||||||
|
source$.pipe(on(watch$)).subscribe({ complete: () => (complete = true) });
|
||||||
|
|
||||||
|
source$.complete();
|
||||||
|
|
||||||
|
expect(complete).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("completes when its watch completes", () => {
|
||||||
|
const watch$ = new Subject<void>();
|
||||||
|
const source$ = new Subject<number>();
|
||||||
|
let complete: boolean = false;
|
||||||
|
source$.pipe(on(watch$)).subscribe({ complete: () => (complete = true) });
|
||||||
|
|
||||||
|
watch$.complete();
|
||||||
|
|
||||||
|
expect(complete).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("errors when its source errors", () => {
|
||||||
|
const watch$ = new Subject<void>();
|
||||||
|
const source$ = new Subject<number>();
|
||||||
|
const expected = { some: "error" };
|
||||||
|
let error = null;
|
||||||
|
source$.pipe(on(watch$)).subscribe({ error: (e: unknown) => (error = e) });
|
||||||
|
|
||||||
|
source$.error(expected);
|
||||||
|
|
||||||
|
expect(error).toEqual(expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("errors when its watch errors", () => {
|
||||||
|
const watch$ = new Subject<void>();
|
||||||
|
const source$ = new Subject<number>();
|
||||||
|
const expected = { some: "error" };
|
||||||
|
let error = null;
|
||||||
|
source$.pipe(on(watch$)).subscribe({ error: (e: unknown) => (error = e) });
|
||||||
|
|
||||||
|
watch$.error(expected);
|
||||||
|
|
||||||
|
expect(error).toEqual(expected);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
@ -1,4 +1,21 @@
|
|||||||
import { map, distinctUntilChanged, OperatorFunction } from "rxjs";
|
import {
|
||||||
|
map,
|
||||||
|
distinctUntilChanged,
|
||||||
|
OperatorFunction,
|
||||||
|
Observable,
|
||||||
|
ignoreElements,
|
||||||
|
endWith,
|
||||||
|
race,
|
||||||
|
pipe,
|
||||||
|
connect,
|
||||||
|
ReplaySubject,
|
||||||
|
concat,
|
||||||
|
zip,
|
||||||
|
first,
|
||||||
|
takeUntil,
|
||||||
|
withLatestFrom,
|
||||||
|
concatMap,
|
||||||
|
} from "rxjs";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* An observable operator that reduces an emitted collection to a single object,
|
* An observable operator that reduces an emitted collection to a single object,
|
||||||
@ -36,3 +53,109 @@ export function distinctIfShallowMatch<Item>(): OperatorFunction<Item, Item> {
|
|||||||
return isDistinct;
|
return isDistinct;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Create an observable that, once subscribed, emits `true` then completes when
|
||||||
|
* any input completes. If an input is already complete when the subscription
|
||||||
|
* occurs, it emits immediately.
|
||||||
|
* @param watch$ the observable(s) to watch for completion; if an array is passed,
|
||||||
|
* null and undefined members are ignored. If `watch$` is empty, `anyComplete`
|
||||||
|
* will never complete.
|
||||||
|
* @returns An observable that emits `true` when any of its inputs
|
||||||
|
* complete. The observable forwards the first error from its input.
|
||||||
|
* @remarks This method is particularly useful in combination with `takeUntil` and
|
||||||
|
* streams that are not guaranteed to complete on their own.
|
||||||
|
*/
|
||||||
|
export function anyComplete(watch$: Observable<any> | Observable<any>[]): Observable<any> {
|
||||||
|
if (Array.isArray(watch$)) {
|
||||||
|
const completes$ = watch$
|
||||||
|
.filter((w$) => !!w$)
|
||||||
|
.map((w$) => w$.pipe(ignoreElements(), endWith(true)));
|
||||||
|
const completed$ = race(completes$);
|
||||||
|
return completed$;
|
||||||
|
} else {
|
||||||
|
return watch$.pipe(ignoreElements(), endWith(true));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create an observable that delays the input stream until all watches have
|
||||||
|
* emitted a value. The watched values are not included in the source stream.
|
||||||
|
* The last emission from the source is output when all the watches have
|
||||||
|
* emitted at least once.
|
||||||
|
* @param watch$ the observable(s) to watch for readiness. If `watch$` is empty,
|
||||||
|
* `ready` will never emit.
|
||||||
|
* @returns An observable that emits when the source stream emits. The observable
|
||||||
|
* errors if one of its watches completes before emitting. It also errors if one
|
||||||
|
* of its watches errors.
|
||||||
|
*/
|
||||||
|
export function ready<T>(watch$: Observable<any> | Observable<any>[]) {
|
||||||
|
const watching$ = Array.isArray(watch$) ? watch$ : [watch$];
|
||||||
|
return pipe(
|
||||||
|
connect<T, Observable<T>>((source$) => {
|
||||||
|
// this subscription is safe because `source$` connects only after there
|
||||||
|
// is an external subscriber.
|
||||||
|
const source = new ReplaySubject<T>(1);
|
||||||
|
source$.subscribe(source);
|
||||||
|
|
||||||
|
// `concat` is subscribed immediately after it's returned, at which point
|
||||||
|
// `zip` blocks until all items in `watching$` are ready. If that occurs
|
||||||
|
// after `source$` is hot, then the replay subject sends the last-captured
|
||||||
|
// emission through immediately. Otherwise, `ready` waits for the next
|
||||||
|
// emission
|
||||||
|
return concat(zip(watching$).pipe(first(), ignoreElements()), source).pipe(
|
||||||
|
takeUntil(anyComplete(source)),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function withLatestReady<Source, Watch>(
|
||||||
|
watch$: Observable<Watch>,
|
||||||
|
): OperatorFunction<Source, [Source, Watch]> {
|
||||||
|
return connect((source$) => {
|
||||||
|
// these subscriptions are safe because `source$` connects only after there
|
||||||
|
// is an external subscriber.
|
||||||
|
const source = new ReplaySubject<Source>(1);
|
||||||
|
source$.subscribe(source);
|
||||||
|
const watch = new ReplaySubject<Watch>(1);
|
||||||
|
watch$.subscribe(watch);
|
||||||
|
|
||||||
|
// `concat` is subscribed immediately after it's returned, at which point
|
||||||
|
// `zip` blocks until all items in `watching$` are ready. If that occurs
|
||||||
|
// after `source$` is hot, then the replay subject sends the last-captured
|
||||||
|
// emission through immediately. Otherwise, `ready` waits for the next
|
||||||
|
// emission
|
||||||
|
return concat(zip(watch).pipe(first(), ignoreElements()), source).pipe(
|
||||||
|
withLatestFrom(watch),
|
||||||
|
takeUntil(anyComplete(source)),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create an observable that emits the latest value of the source stream
|
||||||
|
* when `watch$` emits. If `watch$` emits before the stream emits, then
|
||||||
|
* an emission occurs as soon as a value becomes ready.
|
||||||
|
* @param watch$ the observable that triggers emissions
|
||||||
|
* @returns An observable that emits when `watch$` emits. The observable
|
||||||
|
* errors if its source stream errors. It also errors if `on` errors. It
|
||||||
|
* completes if its watch completes.
|
||||||
|
*
|
||||||
|
* @remarks This works like `audit`, but it repeats emissions when
|
||||||
|
* watch$ fires.
|
||||||
|
*/
|
||||||
|
export function on<T>(watch$: Observable<any>) {
|
||||||
|
return pipe(
|
||||||
|
connect<T, Observable<T>>((source$) => {
|
||||||
|
const source = new ReplaySubject<T>(1);
|
||||||
|
source$.subscribe(source);
|
||||||
|
|
||||||
|
return watch$
|
||||||
|
.pipe(
|
||||||
|
ready(source),
|
||||||
|
concatMap(() => source.pipe(first())),
|
||||||
|
)
|
||||||
|
.pipe(takeUntil(anyComplete(source)));
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
@ -17,3 +17,9 @@ export type ClassifiedFormat<Id, Disclosed> = {
|
|||||||
*/
|
*/
|
||||||
readonly disclosed: Jsonify<Disclosed>;
|
readonly disclosed: Jsonify<Disclosed>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export function isClassifiedFormat<Id, Disclosed>(
|
||||||
|
value: any,
|
||||||
|
): value is ClassifiedFormat<Id, Disclosed> {
|
||||||
|
return "id" in value && "secret" in value && "disclosed" in value;
|
||||||
|
}
|
||||||
|
@ -1,4 +1,11 @@
|
|||||||
import { Constraints, StateConstraints } from "../types";
|
import { BehaviorSubject, Observable } from "rxjs";
|
||||||
|
|
||||||
|
import {
|
||||||
|
Constraints,
|
||||||
|
DynamicStateConstraints,
|
||||||
|
StateConstraints,
|
||||||
|
SubjectConstraints,
|
||||||
|
} from "../types";
|
||||||
|
|
||||||
// The constraints type shares the properties of the state,
|
// The constraints type shares the properties of the state,
|
||||||
// but never has any members
|
// but never has any members
|
||||||
@ -9,16 +16,31 @@ const EMPTY_CONSTRAINTS = new Proxy<any>(Object.freeze({}), {
|
|||||||
});
|
});
|
||||||
|
|
||||||
/** A constraint that does nothing. */
|
/** A constraint that does nothing. */
|
||||||
export class IdentityConstraint<State extends object> implements StateConstraints<State> {
|
export class IdentityConstraint<State extends object>
|
||||||
|
implements StateConstraints<State>, DynamicStateConstraints<State>
|
||||||
|
{
|
||||||
/** Instantiate the identity constraint */
|
/** Instantiate the identity constraint */
|
||||||
constructor() {}
|
constructor() {}
|
||||||
|
|
||||||
readonly constraints: Readonly<Constraints<State>> = EMPTY_CONSTRAINTS;
|
readonly constraints: Readonly<Constraints<State>> = EMPTY_CONSTRAINTS;
|
||||||
|
|
||||||
|
calibrate() {
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
adjust(state: State) {
|
adjust(state: State) {
|
||||||
return state;
|
return state;
|
||||||
}
|
}
|
||||||
|
|
||||||
fix(state: State) {
|
fix(state: State) {
|
||||||
return state;
|
return state;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Emits a constraint that does not alter the input state. */
|
||||||
|
export function unconstrained$<State extends object>(): Observable<SubjectConstraints<State>> {
|
||||||
|
const identity = new IdentityConstraint<State>();
|
||||||
|
const constraints$ = new BehaviorSubject(identity);
|
||||||
|
|
||||||
|
return constraints$;
|
||||||
|
}
|
||||||
|
53
libs/common/src/tools/state/object-key.ts
Normal file
53
libs/common/src/tools/state/object-key.ts
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
import { UserKeyDefinition, UserKeyDefinitionOptions } from "../../platform/state";
|
||||||
|
// eslint-disable-next-line -- `StateDefinition` used as a type
|
||||||
|
import type { StateDefinition } from "../../platform/state/state-definition";
|
||||||
|
|
||||||
|
import { ClassifiedFormat } from "./classified-format";
|
||||||
|
import { Classifier } from "./classifier";
|
||||||
|
|
||||||
|
/** A key for storing JavaScript objects (`{ an: "example" }`)
|
||||||
|
* in a UserStateSubject.
|
||||||
|
*/
|
||||||
|
// FIXME: promote to class: `ObjectConfiguration<State, Secret, Disclosed>`.
|
||||||
|
// The class receives `encryptor`, `prepareNext`, `adjust`, and `fix`
|
||||||
|
// From `UserStateSubject`. `UserStateSubject` keeps `classify` and
|
||||||
|
// `declassify`. The class should also include serialization
|
||||||
|
// facilities (to be used in place of JSON.parse/stringify) in it's
|
||||||
|
// options. Also allow swap between "classifier" and "classification"; the
|
||||||
|
// latter is a list of properties/arguments to the specific classifier in-use.
|
||||||
|
export type ObjectKey<State, Secret = State, Disclosed = Record<string, never>> = {
|
||||||
|
target: "object";
|
||||||
|
key: string;
|
||||||
|
state: StateDefinition;
|
||||||
|
classifier: Classifier<State, Disclosed, Secret>;
|
||||||
|
format: "plain" | "classified";
|
||||||
|
options: UserKeyDefinitionOptions<State>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function isObjectKey(key: any): key is ObjectKey<unknown> {
|
||||||
|
return key.target === "object" && "format" in key && "classifier" in key;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toUserKeyDefinition<State, Secret, Disclosed>(
|
||||||
|
key: ObjectKey<State, Secret, Disclosed>,
|
||||||
|
) {
|
||||||
|
if (key.format === "plain") {
|
||||||
|
const plain = new UserKeyDefinition<State>(key.state, key.key, key.options);
|
||||||
|
|
||||||
|
return plain;
|
||||||
|
} else if (key.format === "classified") {
|
||||||
|
const classified = new UserKeyDefinition<ClassifiedFormat<void, Disclosed>>(
|
||||||
|
key.state,
|
||||||
|
key.key,
|
||||||
|
{
|
||||||
|
cleanupDelayMs: key.options.cleanupDelayMs,
|
||||||
|
deserializer: (jsonValue) => jsonValue as ClassifiedFormat<void, Disclosed>,
|
||||||
|
clearOn: key.options.clearOn,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return classified;
|
||||||
|
} else {
|
||||||
|
throw new Error(`unknown format: ${key.format}`);
|
||||||
|
}
|
||||||
|
}
|
@ -1,6 +1,6 @@
|
|||||||
import { Observable } from "rxjs";
|
import { Observable } from "rxjs";
|
||||||
|
|
||||||
import { DynamicStateConstraints, StateConstraints } from "../types";
|
import { DynamicStateConstraints, StateConstraints, SubjectConstraints } from "../types";
|
||||||
|
|
||||||
/** A pattern for types that depend upon a dynamic set of constraints.
|
/** A pattern for types that depend upon a dynamic set of constraints.
|
||||||
*
|
*
|
||||||
@ -10,12 +10,12 @@ import { DynamicStateConstraints, StateConstraints } from "../types";
|
|||||||
* last-emitted constraints. If `constraints$` completes, the consumer should
|
* last-emitted constraints. If `constraints$` completes, the consumer should
|
||||||
* continue using the last-emitted constraints.
|
* continue using the last-emitted constraints.
|
||||||
*/
|
*/
|
||||||
export type StateConstraintsDependency<State> = {
|
export type SubjectConstraintsDependency<State> = {
|
||||||
/** A stream that emits constraints when subscribed and when the
|
/** A stream that emits constraints when subscribed and when the
|
||||||
* constraints change. The stream should not emit `null` or
|
* constraints change. The stream should not emit `null` or
|
||||||
* `undefined`.
|
* `undefined`.
|
||||||
*/
|
*/
|
||||||
constraints$: Observable<StateConstraints<State> | DynamicStateConstraints<State>>;
|
constraints$: Observable<SubjectConstraints<State>>;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Returns `true` if the input constraint is a `DynamicStateConstraints<T>`.
|
/** Returns `true` if the input constraint is a `DynamicStateConstraints<T>`.
|
||||||
|
@ -1,15 +1,23 @@
|
|||||||
import { Simplify } from "type-fest";
|
import { RequireExactlyOne, Simplify } from "type-fest";
|
||||||
|
|
||||||
import { Dependencies, SingleUserDependency, WhenDependency } from "../dependencies";
|
import {
|
||||||
|
Dependencies,
|
||||||
|
SingleUserDependency,
|
||||||
|
SingleUserEncryptorDependency,
|
||||||
|
WhenDependency,
|
||||||
|
} from "../dependencies";
|
||||||
|
|
||||||
import { StateConstraintsDependency } from "./state-constraints-dependency";
|
import { SubjectConstraintsDependency } from "./state-constraints-dependency";
|
||||||
|
|
||||||
/** dependencies accepted by the user state subject */
|
/** dependencies accepted by the user state subject */
|
||||||
export type UserStateSubjectDependencies<State, Dependency> = Simplify<
|
export type UserStateSubjectDependencies<State, Dependency> = Simplify<
|
||||||
SingleUserDependency &
|
RequireExactlyOne<
|
||||||
|
SingleUserDependency & SingleUserEncryptorDependency,
|
||||||
|
"singleUserEncryptor$" | "singleUserId$"
|
||||||
|
> &
|
||||||
Partial<WhenDependency> &
|
Partial<WhenDependency> &
|
||||||
Partial<Dependencies<Dependency>> &
|
Partial<Dependencies<Dependency>> &
|
||||||
Partial<StateConstraintsDependency<State>> & {
|
Partial<SubjectConstraintsDependency<State>> & {
|
||||||
/** Compute the next stored value. If this is not set, values
|
/** Compute the next stored value. If this is not set, values
|
||||||
* provided to `next` unconditionally override state.
|
* provided to `next` unconditionally override state.
|
||||||
* @param current the value stored in state
|
* @param current the value stored in state
|
||||||
|
@ -1,14 +1,50 @@
|
|||||||
import { BehaviorSubject, of, Subject } from "rxjs";
|
import { BehaviorSubject, of, Subject } from "rxjs";
|
||||||
|
|
||||||
|
import { GENERATOR_DISK, UserKeyDefinition } from "@bitwarden/common/platform/state";
|
||||||
import { UserId } from "@bitwarden/common/types/guid";
|
import { UserId } from "@bitwarden/common/types/guid";
|
||||||
|
|
||||||
import { awaitAsync, FakeSingleUserState, ObservableTracker } from "../../../spec";
|
import { awaitAsync, FakeSingleUserState, ObservableTracker } from "../../../spec";
|
||||||
|
import { UserBound } from "../dependencies";
|
||||||
|
import { PrivateClassifier } from "../private-classifier";
|
||||||
import { StateConstraints } from "../types";
|
import { StateConstraints } from "../types";
|
||||||
|
|
||||||
|
import { ClassifiedFormat } from "./classified-format";
|
||||||
|
import { ObjectKey } from "./object-key";
|
||||||
|
import { UserEncryptor } from "./user-encryptor.abstraction";
|
||||||
import { UserStateSubject } from "./user-state-subject";
|
import { UserStateSubject } from "./user-state-subject";
|
||||||
|
|
||||||
const SomeUser = "some user" as UserId;
|
const SomeUser = "some user" as UserId;
|
||||||
type TestType = { foo: string };
|
type TestType = { foo: string };
|
||||||
|
const SomeKey = new UserKeyDefinition<TestType>(GENERATOR_DISK, "TestKey", {
|
||||||
|
deserializer: (d) => d as TestType,
|
||||||
|
clearOn: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const SomeObjectKey = {
|
||||||
|
target: "object",
|
||||||
|
key: "TestObjectKey",
|
||||||
|
state: GENERATOR_DISK,
|
||||||
|
classifier: new PrivateClassifier(),
|
||||||
|
format: "classified",
|
||||||
|
options: {
|
||||||
|
deserializer: (d) => d as TestType,
|
||||||
|
clearOn: ["logout"],
|
||||||
|
},
|
||||||
|
} satisfies ObjectKey<TestType>;
|
||||||
|
|
||||||
|
const SomeEncryptor: UserEncryptor = {
|
||||||
|
userId: SomeUser,
|
||||||
|
|
||||||
|
encrypt(secret) {
|
||||||
|
const tmp: any = secret;
|
||||||
|
return Promise.resolve({ foo: `encrypt(${tmp.foo})` } as any);
|
||||||
|
},
|
||||||
|
|
||||||
|
decrypt(secret) {
|
||||||
|
const tmp: any = JSON.parse(secret.encryptedString);
|
||||||
|
return Promise.resolve({ foo: `decrypt(${tmp.foo})` } as any);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
function fooMaxLength(maxLength: number): StateConstraints<TestType> {
|
function fooMaxLength(maxLength: number): StateConstraints<TestType> {
|
||||||
return Object.freeze({
|
return Object.freeze({
|
||||||
@ -43,7 +79,11 @@ describe("UserStateSubject", () => {
|
|||||||
const singleUserId$ = new BehaviorSubject(SomeUser);
|
const singleUserId$ = new BehaviorSubject(SomeUser);
|
||||||
const nextValue = jest.fn((_, next) => next);
|
const nextValue = jest.fn((_, next) => next);
|
||||||
const when$ = new BehaviorSubject(true);
|
const when$ = new BehaviorSubject(true);
|
||||||
const subject = new UserStateSubject(state, { singleUserId$, nextValue, when$ });
|
const subject = new UserStateSubject(SomeKey, () => state, {
|
||||||
|
singleUserId$,
|
||||||
|
nextValue,
|
||||||
|
when$,
|
||||||
|
});
|
||||||
|
|
||||||
// the interleaved await asyncs are only necessary b/c `nextValue` is called asynchronously
|
// the interleaved await asyncs are only necessary b/c `nextValue` is called asynchronously
|
||||||
subject.next({ foo: "next" });
|
subject.next({ foo: "next" });
|
||||||
@ -65,7 +105,11 @@ describe("UserStateSubject", () => {
|
|||||||
const singleUserId$ = new BehaviorSubject(SomeUser);
|
const singleUserId$ = new BehaviorSubject(SomeUser);
|
||||||
const nextValue = jest.fn((_, next) => next);
|
const nextValue = jest.fn((_, next) => next);
|
||||||
const when$ = new BehaviorSubject(true);
|
const when$ = new BehaviorSubject(true);
|
||||||
const subject = new UserStateSubject(state, { singleUserId$, nextValue, when$ });
|
const subject = new UserStateSubject(SomeKey, () => state, {
|
||||||
|
singleUserId$,
|
||||||
|
nextValue,
|
||||||
|
when$,
|
||||||
|
});
|
||||||
|
|
||||||
// the interleaved await asyncs are only necessary b/c `nextValue` is called asynchronously
|
// the interleaved await asyncs are only necessary b/c `nextValue` is called asynchronously
|
||||||
subject.next({ foo: "next" });
|
subject.next({ foo: "next" });
|
||||||
@ -79,11 +123,35 @@ describe("UserStateSubject", () => {
|
|||||||
expect(nextValue).toHaveBeenCalledTimes(1);
|
expect(nextValue).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("ignores repeated singleUserEncryptor$ emissions", async () => {
|
||||||
|
// this test looks for `nextValue` because a subscription isn't necessary for
|
||||||
|
// the subject to update
|
||||||
|
const initialValue: TestType = { foo: "init" };
|
||||||
|
const state = new FakeSingleUserState<TestType>(SomeUser, initialValue);
|
||||||
|
const nextValue = jest.fn((_, next) => next);
|
||||||
|
const singleUserEncryptor$ = new BehaviorSubject({ userId: SomeUser, encryptor: null });
|
||||||
|
const subject = new UserStateSubject(SomeKey, () => state, {
|
||||||
|
nextValue,
|
||||||
|
singleUserEncryptor$,
|
||||||
|
});
|
||||||
|
|
||||||
|
// the interleaved await asyncs are only necessary b/c `nextValue` is called asynchronously
|
||||||
|
subject.next({ foo: "next" });
|
||||||
|
await awaitAsync();
|
||||||
|
singleUserEncryptor$.next({ userId: SomeUser, encryptor: null });
|
||||||
|
await awaitAsync();
|
||||||
|
singleUserEncryptor$.next({ userId: SomeUser, encryptor: null });
|
||||||
|
singleUserEncryptor$.next({ userId: SomeUser, encryptor: null });
|
||||||
|
await awaitAsync();
|
||||||
|
|
||||||
|
expect(nextValue).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
it("waits for constraints$", async () => {
|
it("waits for constraints$", async () => {
|
||||||
const state = new FakeSingleUserState<TestType>(SomeUser, { foo: "init" });
|
const state = new FakeSingleUserState<TestType>(SomeUser, { foo: "init" });
|
||||||
const singleUserId$ = new BehaviorSubject(SomeUser);
|
const singleUserId$ = new BehaviorSubject(SomeUser);
|
||||||
const constraints$ = new Subject<StateConstraints<TestType>>();
|
const constraints$ = new Subject<StateConstraints<TestType>>();
|
||||||
const subject = new UserStateSubject(state, { singleUserId$, constraints$ });
|
const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$, constraints$ });
|
||||||
const tracker = new ObservableTracker(subject);
|
const tracker = new ObservableTracker(subject);
|
||||||
|
|
||||||
constraints$.next(fooMaxLength(3));
|
constraints$.next(fooMaxLength(3));
|
||||||
@ -91,13 +159,28 @@ describe("UserStateSubject", () => {
|
|||||||
|
|
||||||
expect(initResult).toEqual({ foo: "ini" });
|
expect(initResult).toEqual({ foo: "ini" });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("waits for singleUserEncryptor$", async () => {
|
||||||
|
const state = new FakeSingleUserState<ClassifiedFormat<void, Record<string, never>>>(
|
||||||
|
SomeUser,
|
||||||
|
{ id: null, secret: '{"foo":"init"}', disclosed: {} },
|
||||||
|
);
|
||||||
|
const singleUserEncryptor$ = new Subject<UserBound<"encryptor", UserEncryptor>>();
|
||||||
|
const subject = new UserStateSubject(SomeObjectKey, () => state, { singleUserEncryptor$ });
|
||||||
|
const tracker = new ObservableTracker(subject);
|
||||||
|
|
||||||
|
singleUserEncryptor$.next({ userId: SomeUser, encryptor: SomeEncryptor });
|
||||||
|
const [initResult] = await tracker.pauseUntilReceived(1);
|
||||||
|
|
||||||
|
expect(initResult).toEqual({ foo: "decrypt(init)" });
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("next", () => {
|
describe("next", () => {
|
||||||
it("emits the next value", async () => {
|
it("emits the next value", async () => {
|
||||||
const state = new FakeSingleUserState<TestType>(SomeUser, { foo: "init" });
|
const state = new FakeSingleUserState<TestType>(SomeUser, { foo: "init" });
|
||||||
const singleUserId$ = new BehaviorSubject(SomeUser);
|
const singleUserId$ = new BehaviorSubject(SomeUser);
|
||||||
const subject = new UserStateSubject(state, { singleUserId$ });
|
const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$ });
|
||||||
const expected: TestType = { foo: "next" };
|
const expected: TestType = { foo: "next" };
|
||||||
|
|
||||||
let actual: TestType = null;
|
let actual: TestType = null;
|
||||||
@ -114,7 +197,7 @@ describe("UserStateSubject", () => {
|
|||||||
const initialState = { foo: "init" };
|
const initialState = { foo: "init" };
|
||||||
const state = new FakeSingleUserState<TestType>(SomeUser, initialState);
|
const state = new FakeSingleUserState<TestType>(SomeUser, initialState);
|
||||||
const singleUserId$ = new BehaviorSubject(SomeUser);
|
const singleUserId$ = new BehaviorSubject(SomeUser);
|
||||||
const subject = new UserStateSubject(state, { singleUserId$ });
|
const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$ });
|
||||||
|
|
||||||
let actual: TestType = null;
|
let actual: TestType = null;
|
||||||
subject.subscribe((value) => {
|
subject.subscribe((value) => {
|
||||||
@ -132,7 +215,7 @@ describe("UserStateSubject", () => {
|
|||||||
const state = new FakeSingleUserState<TestType>(SomeUser, initialValue);
|
const state = new FakeSingleUserState<TestType>(SomeUser, initialValue);
|
||||||
const singleUserId$ = new BehaviorSubject(SomeUser);
|
const singleUserId$ = new BehaviorSubject(SomeUser);
|
||||||
const shouldUpdate = jest.fn(() => true);
|
const shouldUpdate = jest.fn(() => true);
|
||||||
const subject = new UserStateSubject(state, { singleUserId$, shouldUpdate });
|
const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$, shouldUpdate });
|
||||||
|
|
||||||
const nextVal: TestType = { foo: "next" };
|
const nextVal: TestType = { foo: "next" };
|
||||||
subject.next(nextVal);
|
subject.next(nextVal);
|
||||||
@ -147,7 +230,7 @@ describe("UserStateSubject", () => {
|
|||||||
const singleUserId$ = new BehaviorSubject(SomeUser);
|
const singleUserId$ = new BehaviorSubject(SomeUser);
|
||||||
const shouldUpdate = jest.fn(() => true);
|
const shouldUpdate = jest.fn(() => true);
|
||||||
const dependencyValue = { bar: "dependency" };
|
const dependencyValue = { bar: "dependency" };
|
||||||
const subject = new UserStateSubject(state, {
|
const subject = new UserStateSubject(SomeKey, () => state, {
|
||||||
singleUserId$,
|
singleUserId$,
|
||||||
shouldUpdate,
|
shouldUpdate,
|
||||||
dependencies$: of(dependencyValue),
|
dependencies$: of(dependencyValue),
|
||||||
@ -165,7 +248,7 @@ describe("UserStateSubject", () => {
|
|||||||
const state = new FakeSingleUserState<TestType>(SomeUser, initialValue);
|
const state = new FakeSingleUserState<TestType>(SomeUser, initialValue);
|
||||||
const singleUserId$ = new BehaviorSubject(SomeUser);
|
const singleUserId$ = new BehaviorSubject(SomeUser);
|
||||||
const shouldUpdate = jest.fn(() => true);
|
const shouldUpdate = jest.fn(() => true);
|
||||||
const subject = new UserStateSubject(state, { singleUserId$, shouldUpdate });
|
const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$, shouldUpdate });
|
||||||
const expected: TestType = { foo: "next" };
|
const expected: TestType = { foo: "next" };
|
||||||
|
|
||||||
let actual: TestType = null;
|
let actual: TestType = null;
|
||||||
@ -183,7 +266,7 @@ describe("UserStateSubject", () => {
|
|||||||
const state = new FakeSingleUserState<TestType>(SomeUser, initialValue);
|
const state = new FakeSingleUserState<TestType>(SomeUser, initialValue);
|
||||||
const singleUserId$ = new BehaviorSubject(SomeUser);
|
const singleUserId$ = new BehaviorSubject(SomeUser);
|
||||||
const shouldUpdate = jest.fn(() => false);
|
const shouldUpdate = jest.fn(() => false);
|
||||||
const subject = new UserStateSubject(state, { singleUserId$, shouldUpdate });
|
const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$, shouldUpdate });
|
||||||
|
|
||||||
subject.next({ foo: "next" });
|
subject.next({ foo: "next" });
|
||||||
await awaitAsync();
|
await awaitAsync();
|
||||||
@ -200,7 +283,7 @@ describe("UserStateSubject", () => {
|
|||||||
const state = new FakeSingleUserState<TestType>(SomeUser, initialValue);
|
const state = new FakeSingleUserState<TestType>(SomeUser, initialValue);
|
||||||
const singleUserId$ = new BehaviorSubject(SomeUser);
|
const singleUserId$ = new BehaviorSubject(SomeUser);
|
||||||
const nextValue = jest.fn((_, next) => next);
|
const nextValue = jest.fn((_, next) => next);
|
||||||
const subject = new UserStateSubject(state, { singleUserId$, nextValue });
|
const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$, nextValue });
|
||||||
|
|
||||||
const nextVal: TestType = { foo: "next" };
|
const nextVal: TestType = { foo: "next" };
|
||||||
subject.next(nextVal);
|
subject.next(nextVal);
|
||||||
@ -215,7 +298,7 @@ describe("UserStateSubject", () => {
|
|||||||
const singleUserId$ = new BehaviorSubject(SomeUser);
|
const singleUserId$ = new BehaviorSubject(SomeUser);
|
||||||
const nextValue = jest.fn((_, next) => next);
|
const nextValue = jest.fn((_, next) => next);
|
||||||
const dependencyValue = { bar: "dependency" };
|
const dependencyValue = { bar: "dependency" };
|
||||||
const subject = new UserStateSubject(state, {
|
const subject = new UserStateSubject(SomeKey, () => state, {
|
||||||
singleUserId$,
|
singleUserId$,
|
||||||
nextValue,
|
nextValue,
|
||||||
dependencies$: of(dependencyValue),
|
dependencies$: of(dependencyValue),
|
||||||
@ -236,7 +319,11 @@ describe("UserStateSubject", () => {
|
|||||||
const singleUserId$ = new BehaviorSubject(SomeUser);
|
const singleUserId$ = new BehaviorSubject(SomeUser);
|
||||||
const nextValue = jest.fn((_, next) => next);
|
const nextValue = jest.fn((_, next) => next);
|
||||||
const when$ = new BehaviorSubject(true);
|
const when$ = new BehaviorSubject(true);
|
||||||
const subject = new UserStateSubject(state, { singleUserId$, nextValue, when$ });
|
const subject = new UserStateSubject(SomeKey, () => state, {
|
||||||
|
singleUserId$,
|
||||||
|
nextValue,
|
||||||
|
when$,
|
||||||
|
});
|
||||||
|
|
||||||
const nextVal: TestType = { foo: "next" };
|
const nextVal: TestType = { foo: "next" };
|
||||||
subject.next(nextVal);
|
subject.next(nextVal);
|
||||||
@ -253,7 +340,11 @@ describe("UserStateSubject", () => {
|
|||||||
const singleUserId$ = new BehaviorSubject(SomeUser);
|
const singleUserId$ = new BehaviorSubject(SomeUser);
|
||||||
const nextValue = jest.fn((_, next) => next);
|
const nextValue = jest.fn((_, next) => next);
|
||||||
const when$ = new BehaviorSubject(false);
|
const when$ = new BehaviorSubject(false);
|
||||||
const subject = new UserStateSubject(state, { singleUserId$, nextValue, when$ });
|
const subject = new UserStateSubject(SomeKey, () => state, {
|
||||||
|
singleUserId$,
|
||||||
|
nextValue,
|
||||||
|
when$,
|
||||||
|
});
|
||||||
|
|
||||||
const nextVal: TestType = { foo: "next" };
|
const nextVal: TestType = { foo: "next" };
|
||||||
subject.next(nextVal);
|
subject.next(nextVal);
|
||||||
@ -265,42 +356,52 @@ describe("UserStateSubject", () => {
|
|||||||
expect(nextValue).toHaveBeenCalled();
|
expect(nextValue).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("waits to evaluate nextValue until singleUserId$ emits", async () => {
|
it("waits to evaluate `UserState.update` until singleUserId$ emits", async () => {
|
||||||
// this test looks for `nextValue` because a subscription isn't necessary for
|
// this test looks for `nextMock` because a subscription isn't necessary for
|
||||||
// the subject to update.
|
// the subject to update.
|
||||||
const initialValue: TestType = { foo: "init" };
|
const initialValue: TestType = { foo: "init" };
|
||||||
const state = new FakeSingleUserState<TestType>(SomeUser, initialValue);
|
const state = new FakeSingleUserState<TestType>(SomeUser, initialValue);
|
||||||
const singleUserId$ = new Subject<UserId>();
|
const singleUserId$ = new Subject<UserId>();
|
||||||
const nextValue = jest.fn((_, next) => next);
|
const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$ });
|
||||||
const subject = new UserStateSubject(state, { singleUserId$, nextValue });
|
|
||||||
|
|
||||||
|
// precondition: subject doesn't update after `next`
|
||||||
const nextVal: TestType = { foo: "next" };
|
const nextVal: TestType = { foo: "next" };
|
||||||
subject.next(nextVal);
|
subject.next(nextVal);
|
||||||
await awaitAsync();
|
await awaitAsync();
|
||||||
expect(nextValue).not.toHaveBeenCalled();
|
expect(state.nextMock).not.toHaveBeenCalled();
|
||||||
|
|
||||||
singleUserId$.next(SomeUser);
|
singleUserId$.next(SomeUser);
|
||||||
await awaitAsync();
|
await awaitAsync();
|
||||||
|
|
||||||
expect(nextValue).toHaveBeenCalled();
|
expect(state.nextMock).toHaveBeenCalledWith({ foo: "next" });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("applies constraints$ on init", async () => {
|
it("waits to evaluate `UserState.update` until singleUserEncryptor$ emits", async () => {
|
||||||
const state = new FakeSingleUserState<TestType>(SomeUser, { foo: "init" });
|
const state = new FakeSingleUserState<ClassifiedFormat<void, Record<string, never>>>(
|
||||||
const singleUserId$ = new BehaviorSubject(SomeUser);
|
SomeUser,
|
||||||
const constraints$ = new BehaviorSubject(fooMaxLength(2));
|
{ id: null, secret: '{"foo":"init"}', disclosed: null },
|
||||||
const subject = new UserStateSubject(state, { singleUserId$, constraints$ });
|
);
|
||||||
const tracker = new ObservableTracker(subject);
|
const singleUserEncryptor$ = new Subject<UserBound<"encryptor", UserEncryptor>>();
|
||||||
|
const subject = new UserStateSubject(SomeObjectKey, () => state, { singleUserEncryptor$ });
|
||||||
|
|
||||||
const [result] = await tracker.pauseUntilReceived(1);
|
// precondition: subject doesn't update after `next`
|
||||||
|
const nextVal: TestType = { foo: "next" };
|
||||||
|
subject.next(nextVal);
|
||||||
|
await awaitAsync();
|
||||||
|
expect(state.nextMock).not.toHaveBeenCalled();
|
||||||
|
|
||||||
expect(result).toEqual({ foo: "in" });
|
singleUserEncryptor$.next({ userId: SomeUser, encryptor: SomeEncryptor });
|
||||||
|
await awaitAsync();
|
||||||
|
|
||||||
|
const encrypted = { foo: "encrypt(next)" };
|
||||||
|
expect(state.nextMock).toHaveBeenCalledWith({ id: null, secret: encrypted, disclosed: null });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("applies dynamic constraints", async () => {
|
it("applies dynamic constraints", async () => {
|
||||||
const state = new FakeSingleUserState<TestType>(SomeUser, { foo: "init" });
|
const state = new FakeSingleUserState<TestType>(SomeUser, { foo: "init" });
|
||||||
const singleUserId$ = new BehaviorSubject(SomeUser);
|
const singleUserId$ = new BehaviorSubject(SomeUser);
|
||||||
const constraints$ = new BehaviorSubject(DynamicFooMaxLength);
|
const constraints$ = new BehaviorSubject(DynamicFooMaxLength);
|
||||||
const subject = new UserStateSubject(state, { singleUserId$, constraints$ });
|
const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$, constraints$ });
|
||||||
const tracker = new ObservableTracker(subject);
|
const tracker = new ObservableTracker(subject);
|
||||||
const expected: TestType = { foo: "next" };
|
const expected: TestType = { foo: "next" };
|
||||||
const emission = tracker.expectEmission();
|
const emission = tracker.expectEmission();
|
||||||
@ -311,24 +412,11 @@ describe("UserStateSubject", () => {
|
|||||||
expect(actual).toEqual({ foo: "" });
|
expect(actual).toEqual({ foo: "" });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("applies constraints$ on constraints$ emission", async () => {
|
|
||||||
const state = new FakeSingleUserState<TestType>(SomeUser, { foo: "init" });
|
|
||||||
const singleUserId$ = new BehaviorSubject(SomeUser);
|
|
||||||
const constraints$ = new BehaviorSubject(fooMaxLength(2));
|
|
||||||
const subject = new UserStateSubject(state, { singleUserId$, constraints$ });
|
|
||||||
const tracker = new ObservableTracker(subject);
|
|
||||||
|
|
||||||
constraints$.next(fooMaxLength(1));
|
|
||||||
const [, result] = await tracker.pauseUntilReceived(2);
|
|
||||||
|
|
||||||
expect(result).toEqual({ foo: "i" });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("applies constraints$ on next", async () => {
|
it("applies constraints$ on next", async () => {
|
||||||
const state = new FakeSingleUserState<TestType>(SomeUser, { foo: "init" });
|
const state = new FakeSingleUserState<TestType>(SomeUser, { foo: "init" });
|
||||||
const singleUserId$ = new BehaviorSubject(SomeUser);
|
const singleUserId$ = new BehaviorSubject(SomeUser);
|
||||||
const constraints$ = new BehaviorSubject(fooMaxLength(2));
|
const constraints$ = new BehaviorSubject(fooMaxLength(2));
|
||||||
const subject = new UserStateSubject(state, { singleUserId$, constraints$ });
|
const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$, constraints$ });
|
||||||
const tracker = new ObservableTracker(subject);
|
const tracker = new ObservableTracker(subject);
|
||||||
|
|
||||||
subject.next({ foo: "next" });
|
subject.next({ foo: "next" });
|
||||||
@ -341,7 +429,7 @@ describe("UserStateSubject", () => {
|
|||||||
const state = new FakeSingleUserState<TestType>(SomeUser, { foo: "init" });
|
const state = new FakeSingleUserState<TestType>(SomeUser, { foo: "init" });
|
||||||
const singleUserId$ = new BehaviorSubject(SomeUser);
|
const singleUserId$ = new BehaviorSubject(SomeUser);
|
||||||
const constraints$ = new BehaviorSubject(fooMaxLength(2));
|
const constraints$ = new BehaviorSubject(fooMaxLength(2));
|
||||||
const subject = new UserStateSubject(state, { singleUserId$, constraints$ });
|
const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$, constraints$ });
|
||||||
const tracker = new ObservableTracker(subject);
|
const tracker = new ObservableTracker(subject);
|
||||||
|
|
||||||
constraints$.next(fooMaxLength(3));
|
constraints$.next(fooMaxLength(3));
|
||||||
@ -355,13 +443,17 @@ describe("UserStateSubject", () => {
|
|||||||
const state = new FakeSingleUserState<TestType>(SomeUser, { foo: "init" });
|
const state = new FakeSingleUserState<TestType>(SomeUser, { foo: "init" });
|
||||||
const singleUserId$ = new BehaviorSubject(SomeUser);
|
const singleUserId$ = new BehaviorSubject(SomeUser);
|
||||||
const constraints$ = new Subject<StateConstraints<TestType>>();
|
const constraints$ = new Subject<StateConstraints<TestType>>();
|
||||||
const subject = new UserStateSubject(state, { singleUserId$, constraints$ });
|
const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$, constraints$ });
|
||||||
const tracker = new ObservableTracker(subject);
|
const results: any[] = [];
|
||||||
|
subject.subscribe((r) => {
|
||||||
|
results.push(r);
|
||||||
|
});
|
||||||
|
|
||||||
subject.next({ foo: "next" });
|
subject.next({ foo: "next" });
|
||||||
constraints$.next(fooMaxLength(3));
|
constraints$.next(fooMaxLength(3));
|
||||||
|
await awaitAsync();
|
||||||
// `init` is also waiting and is processed before `next`
|
// `init` is also waiting and is processed before `next`
|
||||||
const [, nextResult] = await tracker.pauseUntilReceived(2);
|
const [, nextResult] = results;
|
||||||
|
|
||||||
expect(nextResult).toEqual({ foo: "nex" });
|
expect(nextResult).toEqual({ foo: "nex" });
|
||||||
});
|
});
|
||||||
@ -370,7 +462,7 @@ describe("UserStateSubject", () => {
|
|||||||
const state = new FakeSingleUserState<TestType>(SomeUser, { foo: "init" });
|
const state = new FakeSingleUserState<TestType>(SomeUser, { foo: "init" });
|
||||||
const singleUserId$ = new BehaviorSubject(SomeUser);
|
const singleUserId$ = new BehaviorSubject(SomeUser);
|
||||||
const constraints$ = new BehaviorSubject(fooMaxLength(3));
|
const constraints$ = new BehaviorSubject(fooMaxLength(3));
|
||||||
const subject = new UserStateSubject(state, { singleUserId$, constraints$ });
|
const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$, constraints$ });
|
||||||
const tracker = new ObservableTracker(subject);
|
const tracker = new ObservableTracker(subject);
|
||||||
|
|
||||||
constraints$.error({ some: "error" });
|
constraints$.error({ some: "error" });
|
||||||
@ -384,7 +476,7 @@ describe("UserStateSubject", () => {
|
|||||||
const state = new FakeSingleUserState<TestType>(SomeUser, { foo: "init" });
|
const state = new FakeSingleUserState<TestType>(SomeUser, { foo: "init" });
|
||||||
const singleUserId$ = new BehaviorSubject(SomeUser);
|
const singleUserId$ = new BehaviorSubject(SomeUser);
|
||||||
const constraints$ = new BehaviorSubject(fooMaxLength(3));
|
const constraints$ = new BehaviorSubject(fooMaxLength(3));
|
||||||
const subject = new UserStateSubject(state, { singleUserId$, constraints$ });
|
const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$, constraints$ });
|
||||||
const tracker = new ObservableTracker(subject);
|
const tracker = new ObservableTracker(subject);
|
||||||
|
|
||||||
constraints$.complete();
|
constraints$.complete();
|
||||||
@ -399,7 +491,7 @@ describe("UserStateSubject", () => {
|
|||||||
it("emits errors", async () => {
|
it("emits errors", async () => {
|
||||||
const state = new FakeSingleUserState<TestType>(SomeUser, { foo: "init" });
|
const state = new FakeSingleUserState<TestType>(SomeUser, { foo: "init" });
|
||||||
const singleUserId$ = new BehaviorSubject(SomeUser);
|
const singleUserId$ = new BehaviorSubject(SomeUser);
|
||||||
const subject = new UserStateSubject(state, { singleUserId$ });
|
const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$ });
|
||||||
const expected: TestType = { foo: "error" };
|
const expected: TestType = { foo: "error" };
|
||||||
|
|
||||||
let actual: TestType = null;
|
let actual: TestType = null;
|
||||||
@ -418,7 +510,7 @@ describe("UserStateSubject", () => {
|
|||||||
const initialState = { foo: "init" };
|
const initialState = { foo: "init" };
|
||||||
const state = new FakeSingleUserState<TestType>(SomeUser, initialState);
|
const state = new FakeSingleUserState<TestType>(SomeUser, initialState);
|
||||||
const singleUserId$ = new BehaviorSubject(SomeUser);
|
const singleUserId$ = new BehaviorSubject(SomeUser);
|
||||||
const subject = new UserStateSubject(state, { singleUserId$ });
|
const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$ });
|
||||||
|
|
||||||
let actual: TestType = null;
|
let actual: TestType = null;
|
||||||
subject.subscribe({
|
subject.subscribe({
|
||||||
@ -437,7 +529,7 @@ describe("UserStateSubject", () => {
|
|||||||
const initialState = { foo: "init" };
|
const initialState = { foo: "init" };
|
||||||
const state = new FakeSingleUserState<TestType>(SomeUser, initialState);
|
const state = new FakeSingleUserState<TestType>(SomeUser, initialState);
|
||||||
const singleUserId$ = new BehaviorSubject(SomeUser);
|
const singleUserId$ = new BehaviorSubject(SomeUser);
|
||||||
const subject = new UserStateSubject(state, { singleUserId$ });
|
const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$ });
|
||||||
|
|
||||||
let shouldNotRun = false;
|
let shouldNotRun = false;
|
||||||
subject.subscribe({
|
subject.subscribe({
|
||||||
@ -457,7 +549,7 @@ describe("UserStateSubject", () => {
|
|||||||
it("emits completes", async () => {
|
it("emits completes", async () => {
|
||||||
const state = new FakeSingleUserState<TestType>(SomeUser, { foo: "init" });
|
const state = new FakeSingleUserState<TestType>(SomeUser, { foo: "init" });
|
||||||
const singleUserId$ = new BehaviorSubject(SomeUser);
|
const singleUserId$ = new BehaviorSubject(SomeUser);
|
||||||
const subject = new UserStateSubject(state, { singleUserId$ });
|
const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$ });
|
||||||
|
|
||||||
let actual = false;
|
let actual = false;
|
||||||
subject.subscribe({
|
subject.subscribe({
|
||||||
@ -475,7 +567,7 @@ describe("UserStateSubject", () => {
|
|||||||
const initialState = { foo: "init" };
|
const initialState = { foo: "init" };
|
||||||
const state = new FakeSingleUserState<TestType>(SomeUser, initialState);
|
const state = new FakeSingleUserState<TestType>(SomeUser, initialState);
|
||||||
const singleUserId$ = new BehaviorSubject(SomeUser);
|
const singleUserId$ = new BehaviorSubject(SomeUser);
|
||||||
const subject = new UserStateSubject(state, { singleUserId$ });
|
const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$ });
|
||||||
|
|
||||||
let shouldNotRun = false;
|
let shouldNotRun = false;
|
||||||
subject.subscribe({
|
subject.subscribe({
|
||||||
@ -496,7 +588,7 @@ describe("UserStateSubject", () => {
|
|||||||
const initialState = { foo: "init" };
|
const initialState = { foo: "init" };
|
||||||
const state = new FakeSingleUserState<TestType>(SomeUser, initialState);
|
const state = new FakeSingleUserState<TestType>(SomeUser, initialState);
|
||||||
const singleUserId$ = new BehaviorSubject(SomeUser);
|
const singleUserId$ = new BehaviorSubject(SomeUser);
|
||||||
const subject = new UserStateSubject(state, { singleUserId$ });
|
const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$ });
|
||||||
|
|
||||||
let timesRun = 0;
|
let timesRun = 0;
|
||||||
subject.subscribe({
|
subject.subscribe({
|
||||||
@ -513,11 +605,36 @@ describe("UserStateSubject", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("subscribe", () => {
|
describe("subscribe", () => {
|
||||||
|
it("applies constraints$ on init", async () => {
|
||||||
|
const state = new FakeSingleUserState<TestType>(SomeUser, { foo: "init" });
|
||||||
|
const singleUserId$ = new BehaviorSubject(SomeUser);
|
||||||
|
const constraints$ = new BehaviorSubject(fooMaxLength(2));
|
||||||
|
const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$, constraints$ });
|
||||||
|
const tracker = new ObservableTracker(subject);
|
||||||
|
|
||||||
|
const [result] = await tracker.pauseUntilReceived(1);
|
||||||
|
|
||||||
|
expect(result).toEqual({ foo: "in" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("applies constraints$ on constraints$ emission", async () => {
|
||||||
|
const state = new FakeSingleUserState<TestType>(SomeUser, { foo: "init" });
|
||||||
|
const singleUserId$ = new BehaviorSubject(SomeUser);
|
||||||
|
const constraints$ = new BehaviorSubject(fooMaxLength(2));
|
||||||
|
const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$, constraints$ });
|
||||||
|
const tracker = new ObservableTracker(subject);
|
||||||
|
|
||||||
|
constraints$.next(fooMaxLength(1));
|
||||||
|
const [, result] = await tracker.pauseUntilReceived(2);
|
||||||
|
|
||||||
|
expect(result).toEqual({ foo: "i" });
|
||||||
|
});
|
||||||
|
|
||||||
it("completes when singleUserId$ completes", async () => {
|
it("completes when singleUserId$ completes", async () => {
|
||||||
const initialValue: TestType = { foo: "init" };
|
const initialValue: TestType = { foo: "init" };
|
||||||
const state = new FakeSingleUserState<TestType>(SomeUser, initialValue);
|
const state = new FakeSingleUserState<TestType>(SomeUser, initialValue);
|
||||||
const singleUserId$ = new BehaviorSubject(SomeUser);
|
const singleUserId$ = new BehaviorSubject(SomeUser);
|
||||||
const subject = new UserStateSubject(state, { singleUserId$ });
|
const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$ });
|
||||||
|
|
||||||
let actual = false;
|
let actual = false;
|
||||||
subject.subscribe({
|
subject.subscribe({
|
||||||
@ -531,12 +648,32 @@ describe("UserStateSubject", () => {
|
|||||||
expect(actual).toBeTruthy();
|
expect(actual).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("completes when singleUserId$ completes", async () => {
|
||||||
|
const state = new FakeSingleUserState<ClassifiedFormat<void, Record<string, never>>>(
|
||||||
|
SomeUser,
|
||||||
|
{ id: null, secret: '{"foo":"init"}', disclosed: null },
|
||||||
|
);
|
||||||
|
const singleUserEncryptor$ = new Subject<UserBound<"encryptor", UserEncryptor>>();
|
||||||
|
const subject = new UserStateSubject(SomeObjectKey, () => state, { singleUserEncryptor$ });
|
||||||
|
|
||||||
|
let actual = false;
|
||||||
|
subject.subscribe({
|
||||||
|
complete: () => {
|
||||||
|
actual = true;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
singleUserEncryptor$.complete();
|
||||||
|
await awaitAsync();
|
||||||
|
|
||||||
|
expect(actual).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
it("completes when when$ completes", async () => {
|
it("completes when when$ completes", async () => {
|
||||||
const initialValue: TestType = { foo: "init" };
|
const initialValue: TestType = { foo: "init" };
|
||||||
const state = new FakeSingleUserState<TestType>(SomeUser, initialValue);
|
const state = new FakeSingleUserState<TestType>(SomeUser, initialValue);
|
||||||
const singleUserId$ = new BehaviorSubject(SomeUser);
|
const singleUserId$ = new BehaviorSubject(SomeUser);
|
||||||
const when$ = new BehaviorSubject(true);
|
const when$ = new BehaviorSubject(true);
|
||||||
const subject = new UserStateSubject(state, { singleUserId$, when$ });
|
const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$, when$ });
|
||||||
|
|
||||||
let actual = false;
|
let actual = false;
|
||||||
subject.subscribe({
|
subject.subscribe({
|
||||||
@ -557,7 +694,7 @@ describe("UserStateSubject", () => {
|
|||||||
const initialValue: TestType = { foo: "init" };
|
const initialValue: TestType = { foo: "init" };
|
||||||
const state = new FakeSingleUserState<TestType>(SomeUser, initialValue);
|
const state = new FakeSingleUserState<TestType>(SomeUser, initialValue);
|
||||||
const singleUserId$ = new BehaviorSubject(SomeUser);
|
const singleUserId$ = new BehaviorSubject(SomeUser);
|
||||||
const subject = new UserStateSubject(state, { singleUserId$ });
|
const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$ });
|
||||||
const errorUserId = "error" as UserId;
|
const errorUserId = "error" as UserId;
|
||||||
|
|
||||||
let error = false;
|
let error = false;
|
||||||
@ -572,11 +709,32 @@ describe("UserStateSubject", () => {
|
|||||||
expect(error).toEqual({ expectedUserId: SomeUser, actualUserId: errorUserId });
|
expect(error).toEqual({ expectedUserId: SomeUser, actualUserId: errorUserId });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("errors when singleUserEncryptor$ changes", async () => {
|
||||||
|
const state = new FakeSingleUserState<ClassifiedFormat<void, Record<string, never>>>(
|
||||||
|
SomeUser,
|
||||||
|
{ id: null, secret: '{"foo":"init"}', disclosed: null },
|
||||||
|
);
|
||||||
|
const singleUserEncryptor$ = new Subject<UserBound<"encryptor", UserEncryptor>>();
|
||||||
|
const subject = new UserStateSubject(SomeObjectKey, () => state, { singleUserEncryptor$ });
|
||||||
|
const errorUserId = "error" as UserId;
|
||||||
|
|
||||||
|
let error = false;
|
||||||
|
subject.subscribe({
|
||||||
|
error: (e: unknown) => {
|
||||||
|
error = e as any;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
singleUserEncryptor$.next({ userId: errorUserId, encryptor: SomeEncryptor });
|
||||||
|
await awaitAsync();
|
||||||
|
|
||||||
|
expect(error).toEqual({ expectedUserId: SomeUser, actualUserId: errorUserId });
|
||||||
|
});
|
||||||
|
|
||||||
it("errors when singleUserId$ errors", async () => {
|
it("errors when singleUserId$ errors", async () => {
|
||||||
const initialValue: TestType = { foo: "init" };
|
const initialValue: TestType = { foo: "init" };
|
||||||
const state = new FakeSingleUserState<TestType>(SomeUser, initialValue);
|
const state = new FakeSingleUserState<TestType>(SomeUser, initialValue);
|
||||||
const singleUserId$ = new BehaviorSubject(SomeUser);
|
const singleUserId$ = new BehaviorSubject(SomeUser);
|
||||||
const subject = new UserStateSubject(state, { singleUserId$ });
|
const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$ });
|
||||||
const expected = { error: "description" };
|
const expected = { error: "description" };
|
||||||
|
|
||||||
let actual = false;
|
let actual = false;
|
||||||
@ -591,12 +749,31 @@ describe("UserStateSubject", () => {
|
|||||||
expect(actual).toEqual(expected);
|
expect(actual).toEqual(expected);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("errors when singleUserEncryptor$ errors", async () => {
|
||||||
|
const initialValue: TestType = { foo: "init" };
|
||||||
|
const state = new FakeSingleUserState<TestType>(SomeUser, initialValue);
|
||||||
|
const singleUserEncryptor$ = new Subject<UserBound<"encryptor", UserEncryptor>>();
|
||||||
|
const subject = new UserStateSubject(SomeKey, () => state, { singleUserEncryptor$ });
|
||||||
|
const expected = { error: "description" };
|
||||||
|
|
||||||
|
let actual = false;
|
||||||
|
subject.subscribe({
|
||||||
|
error: (e: unknown) => {
|
||||||
|
actual = e as any;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
singleUserEncryptor$.error(expected);
|
||||||
|
await awaitAsync();
|
||||||
|
|
||||||
|
expect(actual).toEqual(expected);
|
||||||
|
});
|
||||||
|
|
||||||
it("errors when when$ errors", async () => {
|
it("errors when when$ errors", async () => {
|
||||||
const initialValue: TestType = { foo: "init" };
|
const initialValue: TestType = { foo: "init" };
|
||||||
const state = new FakeSingleUserState<TestType>(SomeUser, initialValue);
|
const state = new FakeSingleUserState<TestType>(SomeUser, initialValue);
|
||||||
const singleUserId$ = new BehaviorSubject(SomeUser);
|
const singleUserId$ = new BehaviorSubject(SomeUser);
|
||||||
const when$ = new BehaviorSubject(true);
|
const when$ = new BehaviorSubject(true);
|
||||||
const subject = new UserStateSubject(state, { singleUserId$, when$ });
|
const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$, when$ });
|
||||||
const expected = { error: "description" };
|
const expected = { error: "description" };
|
||||||
|
|
||||||
let actual = false;
|
let actual = false;
|
||||||
@ -616,7 +793,7 @@ describe("UserStateSubject", () => {
|
|||||||
it("returns the userId to which the subject is bound", () => {
|
it("returns the userId to which the subject is bound", () => {
|
||||||
const state = new FakeSingleUserState<TestType>(SomeUser, { foo: "init" });
|
const state = new FakeSingleUserState<TestType>(SomeUser, { foo: "init" });
|
||||||
const singleUserId$ = new Subject<UserId>();
|
const singleUserId$ = new Subject<UserId>();
|
||||||
const subject = new UserStateSubject(state, { singleUserId$ });
|
const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$ });
|
||||||
|
|
||||||
expect(subject.userId).toEqual(SomeUser);
|
expect(subject.userId).toEqual(SomeUser);
|
||||||
});
|
});
|
||||||
@ -626,7 +803,7 @@ describe("UserStateSubject", () => {
|
|||||||
it("emits the next value with an empty constraint", async () => {
|
it("emits the next value with an empty constraint", async () => {
|
||||||
const state = new FakeSingleUserState<TestType>(SomeUser, { foo: "init" });
|
const state = new FakeSingleUserState<TestType>(SomeUser, { foo: "init" });
|
||||||
const singleUserId$ = new BehaviorSubject(SomeUser);
|
const singleUserId$ = new BehaviorSubject(SomeUser);
|
||||||
const subject = new UserStateSubject(state, { singleUserId$ });
|
const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$ });
|
||||||
const tracker = new ObservableTracker(subject.withConstraints$);
|
const tracker = new ObservableTracker(subject.withConstraints$);
|
||||||
const expected: TestType = { foo: "next" };
|
const expected: TestType = { foo: "next" };
|
||||||
const emission = tracker.expectEmission();
|
const emission = tracker.expectEmission();
|
||||||
@ -642,7 +819,7 @@ describe("UserStateSubject", () => {
|
|||||||
const initialState = { foo: "init" };
|
const initialState = { foo: "init" };
|
||||||
const state = new FakeSingleUserState<TestType>(SomeUser, initialState);
|
const state = new FakeSingleUserState<TestType>(SomeUser, initialState);
|
||||||
const singleUserId$ = new BehaviorSubject(SomeUser);
|
const singleUserId$ = new BehaviorSubject(SomeUser);
|
||||||
const subject = new UserStateSubject(state, { singleUserId$ });
|
const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$ });
|
||||||
const tracker = new ObservableTracker(subject.withConstraints$);
|
const tracker = new ObservableTracker(subject.withConstraints$);
|
||||||
|
|
||||||
subject.complete();
|
subject.complete();
|
||||||
@ -657,7 +834,7 @@ describe("UserStateSubject", () => {
|
|||||||
const state = new FakeSingleUserState<TestType>(SomeUser, { foo: "init" });
|
const state = new FakeSingleUserState<TestType>(SomeUser, { foo: "init" });
|
||||||
const singleUserId$ = new BehaviorSubject(SomeUser);
|
const singleUserId$ = new BehaviorSubject(SomeUser);
|
||||||
const constraints$ = new BehaviorSubject(fooMaxLength(2));
|
const constraints$ = new BehaviorSubject(fooMaxLength(2));
|
||||||
const subject = new UserStateSubject(state, { singleUserId$, constraints$ });
|
const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$, constraints$ });
|
||||||
const tracker = new ObservableTracker(subject.withConstraints$);
|
const tracker = new ObservableTracker(subject.withConstraints$);
|
||||||
const expected = fooMaxLength(1);
|
const expected = fooMaxLength(1);
|
||||||
const emission = tracker.expectEmission();
|
const emission = tracker.expectEmission();
|
||||||
@ -673,7 +850,7 @@ describe("UserStateSubject", () => {
|
|||||||
const state = new FakeSingleUserState<TestType>(SomeUser, { foo: "init" });
|
const state = new FakeSingleUserState<TestType>(SomeUser, { foo: "init" });
|
||||||
const singleUserId$ = new BehaviorSubject(SomeUser);
|
const singleUserId$ = new BehaviorSubject(SomeUser);
|
||||||
const constraints$ = new BehaviorSubject(DynamicFooMaxLength);
|
const constraints$ = new BehaviorSubject(DynamicFooMaxLength);
|
||||||
const subject = new UserStateSubject(state, { singleUserId$, constraints$ });
|
const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$, constraints$ });
|
||||||
const tracker = new ObservableTracker(subject.withConstraints$);
|
const tracker = new ObservableTracker(subject.withConstraints$);
|
||||||
const expected: TestType = { foo: "next" };
|
const expected: TestType = { foo: "next" };
|
||||||
const emission = tracker.expectEmission();
|
const emission = tracker.expectEmission();
|
||||||
@ -690,7 +867,7 @@ describe("UserStateSubject", () => {
|
|||||||
const singleUserId$ = new BehaviorSubject(SomeUser);
|
const singleUserId$ = new BehaviorSubject(SomeUser);
|
||||||
const expected = fooMaxLength(2);
|
const expected = fooMaxLength(2);
|
||||||
const constraints$ = new BehaviorSubject(expected);
|
const constraints$ = new BehaviorSubject(expected);
|
||||||
const subject = new UserStateSubject(state, { singleUserId$, constraints$ });
|
const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$, constraints$ });
|
||||||
const tracker = new ObservableTracker(subject.withConstraints$);
|
const tracker = new ObservableTracker(subject.withConstraints$);
|
||||||
const emission = tracker.expectEmission();
|
const emission = tracker.expectEmission();
|
||||||
|
|
||||||
@ -705,7 +882,7 @@ describe("UserStateSubject", () => {
|
|||||||
const state = new FakeSingleUserState<TestType>(SomeUser, { foo: "init" });
|
const state = new FakeSingleUserState<TestType>(SomeUser, { foo: "init" });
|
||||||
const singleUserId$ = new BehaviorSubject(SomeUser);
|
const singleUserId$ = new BehaviorSubject(SomeUser);
|
||||||
const constraints$ = new BehaviorSubject(fooMaxLength(2));
|
const constraints$ = new BehaviorSubject(fooMaxLength(2));
|
||||||
const subject = new UserStateSubject(state, { singleUserId$, constraints$ });
|
const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$, constraints$ });
|
||||||
const tracker = new ObservableTracker(subject.withConstraints$);
|
const tracker = new ObservableTracker(subject.withConstraints$);
|
||||||
const expected = fooMaxLength(3);
|
const expected = fooMaxLength(3);
|
||||||
constraints$.next(expected);
|
constraints$.next(expected);
|
||||||
@ -722,7 +899,7 @@ describe("UserStateSubject", () => {
|
|||||||
const state = new FakeSingleUserState<TestType>(SomeUser, { foo: "init" });
|
const state = new FakeSingleUserState<TestType>(SomeUser, { foo: "init" });
|
||||||
const singleUserId$ = new BehaviorSubject(SomeUser);
|
const singleUserId$ = new BehaviorSubject(SomeUser);
|
||||||
const constraints$ = new Subject<StateConstraints<TestType>>();
|
const constraints$ = new Subject<StateConstraints<TestType>>();
|
||||||
const subject = new UserStateSubject(state, { singleUserId$, constraints$ });
|
const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$, constraints$ });
|
||||||
const tracker = new ObservableTracker(subject.withConstraints$);
|
const tracker = new ObservableTracker(subject.withConstraints$);
|
||||||
const expected = fooMaxLength(3);
|
const expected = fooMaxLength(3);
|
||||||
|
|
||||||
@ -740,7 +917,7 @@ describe("UserStateSubject", () => {
|
|||||||
const singleUserId$ = new BehaviorSubject(SomeUser);
|
const singleUserId$ = new BehaviorSubject(SomeUser);
|
||||||
const expected = fooMaxLength(3);
|
const expected = fooMaxLength(3);
|
||||||
const constraints$ = new BehaviorSubject(expected);
|
const constraints$ = new BehaviorSubject(expected);
|
||||||
const subject = new UserStateSubject(state, { singleUserId$, constraints$ });
|
const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$, constraints$ });
|
||||||
const tracker = new ObservableTracker(subject.withConstraints$);
|
const tracker = new ObservableTracker(subject.withConstraints$);
|
||||||
|
|
||||||
constraints$.error({ some: "error" });
|
constraints$.error({ some: "error" });
|
||||||
@ -756,7 +933,7 @@ describe("UserStateSubject", () => {
|
|||||||
const singleUserId$ = new BehaviorSubject(SomeUser);
|
const singleUserId$ = new BehaviorSubject(SomeUser);
|
||||||
const expected = fooMaxLength(3);
|
const expected = fooMaxLength(3);
|
||||||
const constraints$ = new BehaviorSubject(expected);
|
const constraints$ = new BehaviorSubject(expected);
|
||||||
const subject = new UserStateSubject(state, { singleUserId$, constraints$ });
|
const subject = new UserStateSubject(SomeKey, () => state, { singleUserId$, constraints$ });
|
||||||
const tracker = new ObservableTracker(subject.withConstraints$);
|
const tracker = new ObservableTracker(subject.withConstraints$);
|
||||||
|
|
||||||
constraints$.complete();
|
constraints$.complete();
|
||||||
|
@ -5,15 +5,10 @@ import {
|
|||||||
ReplaySubject,
|
ReplaySubject,
|
||||||
filter,
|
filter,
|
||||||
map,
|
map,
|
||||||
Subject,
|
|
||||||
takeUntil,
|
takeUntil,
|
||||||
pairwise,
|
pairwise,
|
||||||
combineLatest,
|
|
||||||
distinctUntilChanged,
|
distinctUntilChanged,
|
||||||
BehaviorSubject,
|
BehaviorSubject,
|
||||||
race,
|
|
||||||
ignoreElements,
|
|
||||||
endWith,
|
|
||||||
startWith,
|
startWith,
|
||||||
Observable,
|
Observable,
|
||||||
Subscription,
|
Subscription,
|
||||||
@ -22,16 +17,32 @@ import {
|
|||||||
combineLatestWith,
|
combineLatestWith,
|
||||||
catchError,
|
catchError,
|
||||||
EMPTY,
|
EMPTY,
|
||||||
|
concatMap,
|
||||||
|
OperatorFunction,
|
||||||
|
pipe,
|
||||||
|
first,
|
||||||
|
withLatestFrom,
|
||||||
|
scan,
|
||||||
|
skip,
|
||||||
} from "rxjs";
|
} from "rxjs";
|
||||||
|
|
||||||
import { SingleUserState } from "@bitwarden/common/platform/state";
|
import { EncString } from "@bitwarden/common/platform/models/domain/enc-string";
|
||||||
|
import { SingleUserState, UserKeyDefinition } from "@bitwarden/common/platform/state";
|
||||||
|
import { UserId } from "@bitwarden/common/types/guid";
|
||||||
|
|
||||||
import { WithConstraints } from "../types";
|
import { UserBound } from "../dependencies";
|
||||||
|
import { anyComplete, ready, withLatestReady } from "../rx";
|
||||||
|
import { Constraints, SubjectConstraints, WithConstraints } from "../types";
|
||||||
|
|
||||||
import { IdentityConstraint } from "./identity-state-constraint";
|
import { ClassifiedFormat, isClassifiedFormat } from "./classified-format";
|
||||||
|
import { unconstrained$ } from "./identity-state-constraint";
|
||||||
|
import { isObjectKey, ObjectKey, toUserKeyDefinition } from "./object-key";
|
||||||
import { isDynamic } from "./state-constraints-dependency";
|
import { isDynamic } from "./state-constraints-dependency";
|
||||||
|
import { UserEncryptor } from "./user-encryptor.abstraction";
|
||||||
import { UserStateSubjectDependencies } from "./user-state-subject-dependencies";
|
import { UserStateSubjectDependencies } from "./user-state-subject-dependencies";
|
||||||
|
|
||||||
|
type Constrained<State> = { constraints: Readonly<Constraints<State>>; state: State };
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Adapt a state provider to an rxjs subject.
|
* Adapt a state provider to an rxjs subject.
|
||||||
*
|
*
|
||||||
@ -44,14 +55,20 @@ import { UserStateSubjectDependencies } from "./user-state-subject-dependencies"
|
|||||||
* @template State the state stored by the subject
|
* @template State the state stored by the subject
|
||||||
* @template Dependencies use-specific dependencies provided by the user.
|
* @template Dependencies use-specific dependencies provided by the user.
|
||||||
*/
|
*/
|
||||||
export class UserStateSubject<State extends object, Dependencies = null>
|
export class UserStateSubject<
|
||||||
|
State extends object,
|
||||||
|
Secret = State,
|
||||||
|
Disclosed = never,
|
||||||
|
Dependencies = null,
|
||||||
|
>
|
||||||
extends Observable<State>
|
extends Observable<State>
|
||||||
implements SubjectLike<State>
|
implements SubjectLike<State>
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* Instantiates the user state subject
|
* Instantiates the user state subject bound to a persistent backing store
|
||||||
* @param state the backing store of the subject
|
* @param key identifies the persistent backing store
|
||||||
* @param dependencies tailor the subject's behavior for a particular
|
* @param getState creates a persistent backing store using a key
|
||||||
|
* @param context tailor the subject's behavior for a particular
|
||||||
* purpose.
|
* purpose.
|
||||||
* @param dependencies.when$ blocks updates to the state subject until
|
* @param dependencies.when$ blocks updates to the state subject until
|
||||||
* this becomes true. When this occurs, only the last-received update
|
* this becomes true. When this occurs, only the last-received update
|
||||||
@ -61,93 +78,306 @@ export class UserStateSubject<State extends object, Dependencies = null>
|
|||||||
* is available.
|
* is available.
|
||||||
*/
|
*/
|
||||||
constructor(
|
constructor(
|
||||||
private state: SingleUserState<State>,
|
private key: UserKeyDefinition<State> | ObjectKey<State, Secret, Disclosed>,
|
||||||
private dependencies: UserStateSubjectDependencies<State, Dependencies>,
|
getState: (key: UserKeyDefinition<unknown>) => SingleUserState<unknown>,
|
||||||
|
private context: UserStateSubjectDependencies<State, Dependencies>,
|
||||||
) {
|
) {
|
||||||
super();
|
super();
|
||||||
|
|
||||||
|
if (isObjectKey(this.key)) {
|
||||||
|
// classification and encryption only supported with `ObjectKey`
|
||||||
|
this.objectKey = this.key;
|
||||||
|
this.stateKey = toUserKeyDefinition(this.key);
|
||||||
|
this.state = getState(this.stateKey);
|
||||||
|
} else {
|
||||||
|
// raw state access granted with `UserKeyDefinition`
|
||||||
|
this.objectKey = null;
|
||||||
|
this.stateKey = this.key as UserKeyDefinition<State>;
|
||||||
|
this.state = getState(this.stateKey);
|
||||||
|
}
|
||||||
|
|
||||||
// normalize dependencies
|
// normalize dependencies
|
||||||
const when$ = (this.dependencies.when$ ?? new BehaviorSubject(true)).pipe(
|
const when$ = (this.context.when$ ?? new BehaviorSubject(true)).pipe(distinctUntilChanged());
|
||||||
distinctUntilChanged(),
|
|
||||||
);
|
|
||||||
const userIdAvailable$ = this.dependencies.singleUserId$.pipe(
|
|
||||||
startWith(state.userId),
|
|
||||||
pairwise(),
|
|
||||||
map(([expectedUserId, actualUserId]) => {
|
|
||||||
if (expectedUserId === actualUserId) {
|
|
||||||
return true;
|
|
||||||
} else {
|
|
||||||
throw { expectedUserId, actualUserId };
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
distinctUntilChanged(),
|
|
||||||
);
|
|
||||||
const constraints$ = (
|
|
||||||
this.dependencies.constraints$ ?? new BehaviorSubject(new IdentityConstraint<State>())
|
|
||||||
).pipe(
|
|
||||||
// FIXME: this should probably log that an error occurred
|
|
||||||
catchError(() => EMPTY),
|
|
||||||
);
|
|
||||||
|
|
||||||
// normalize input in case this `UserStateSubject` is not the only
|
// manage dependencies through replay subjects since `UserStateSubject`
|
||||||
// observer of the backing store
|
// reads them in multiple places
|
||||||
const input$ = combineLatest([this.input, constraints$]).pipe(
|
const encryptor$ = new ReplaySubject<UserEncryptor>(1);
|
||||||
map(([input, constraints]) => {
|
const { singleUserId$, singleUserEncryptor$ } = this.context;
|
||||||
const calibration = isDynamic(constraints) ? constraints.calibrate(input) : constraints;
|
this.encryptor(singleUserEncryptor$ ?? singleUserId$).subscribe(encryptor$);
|
||||||
const state = calibration.adjust(input);
|
|
||||||
return state;
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
// when the output subscription completes, its last-emitted value
|
const constraints$ = new ReplaySubject<SubjectConstraints<State>>(1);
|
||||||
// loops around to the input for finalization
|
(this.context.constraints$ ?? unconstrained$<State>())
|
||||||
const finalize$ = this.pipe(
|
.pipe(
|
||||||
last(),
|
// FIXME: this should probably log that an error occurred
|
||||||
combineLatestWith(constraints$),
|
catchError(() => EMPTY),
|
||||||
map(([output, constraints]) => {
|
)
|
||||||
const calibration = isDynamic(constraints) ? constraints.calibrate(output) : constraints;
|
.subscribe(constraints$);
|
||||||
const state = calibration.fix(output);
|
|
||||||
return state;
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
const updates$ = concat(input$, finalize$);
|
|
||||||
|
|
||||||
// observe completion
|
const dependencies$ = new ReplaySubject<Dependencies>(1);
|
||||||
const whenComplete$ = when$.pipe(ignoreElements(), endWith(true));
|
if (this.context.dependencies$) {
|
||||||
const inputComplete$ = this.input.pipe(ignoreElements(), endWith(true));
|
this.context.dependencies$.subscribe(dependencies$);
|
||||||
const userIdComplete$ = this.dependencies.singleUserId$.pipe(ignoreElements(), endWith(true));
|
} else {
|
||||||
const completion$ = race(whenComplete$, inputComplete$, userIdComplete$);
|
dependencies$.next(null);
|
||||||
|
}
|
||||||
|
|
||||||
// wire output before input so that output normalizes the current state
|
// wire output before input so that output normalizes the current state
|
||||||
// before any `next` value is processed
|
// before any `next` value is processed
|
||||||
this.outputSubscription = this.state.state$
|
this.outputSubscription = this.state.state$
|
||||||
.pipe(
|
.pipe(this.declassify(encryptor$), this.adjust(combineLatestWith(constraints$)))
|
||||||
combineLatestWith(constraints$),
|
|
||||||
map(([rawState, constraints]) => {
|
|
||||||
const calibration = isDynamic(constraints)
|
|
||||||
? constraints.calibrate(rawState)
|
|
||||||
: constraints;
|
|
||||||
const state = calibration.adjust(rawState);
|
|
||||||
return {
|
|
||||||
constraints: calibration.constraints,
|
|
||||||
state,
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.subscribe(this.output);
|
.subscribe(this.output);
|
||||||
this.inputSubscription = combineLatest([updates$, when$, userIdAvailable$])
|
|
||||||
|
const last$ = new ReplaySubject<State>(1);
|
||||||
|
this.output
|
||||||
.pipe(
|
.pipe(
|
||||||
filter(([_, when]) => when),
|
last(),
|
||||||
map(([state]) => state),
|
map((o) => o.state),
|
||||||
takeUntil(completion$),
|
|
||||||
)
|
)
|
||||||
|
.subscribe(last$);
|
||||||
|
|
||||||
|
// the update stream simulates the stateProvider's "shouldUpdate"
|
||||||
|
// functionality & applies policy
|
||||||
|
const updates$ = concat(
|
||||||
|
this.input.pipe(
|
||||||
|
this.when(when$),
|
||||||
|
this.adjust(withLatestReady(constraints$)),
|
||||||
|
this.prepareUpdate(this, dependencies$),
|
||||||
|
),
|
||||||
|
// when the output subscription completes, its last-emitted value
|
||||||
|
// loops around to the input for finalization
|
||||||
|
last$.pipe(this.fix(constraints$), this.prepareUpdate(last$, dependencies$)),
|
||||||
|
);
|
||||||
|
|
||||||
|
// classification/encryption bound to the input subscription's lifetime
|
||||||
|
// to ensure that `fix` has access to the encryptor key
|
||||||
|
//
|
||||||
|
// FIXME: this should probably timeout when a lock occurs
|
||||||
|
this.inputSubscription = updates$
|
||||||
|
.pipe(this.classify(encryptor$), takeUntil(anyComplete([when$, this.input, encryptor$])))
|
||||||
.subscribe({
|
.subscribe({
|
||||||
next: (r) => this.onNext(r),
|
next: (state) => this.onNext(state),
|
||||||
error: (e: unknown) => this.onError(e),
|
error: (e: unknown) => this.onError(e),
|
||||||
complete: () => this.onComplete(),
|
complete: () => this.onComplete(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private stateKey: UserKeyDefinition<unknown>;
|
||||||
|
private objectKey: ObjectKey<State, Secret, Disclosed>;
|
||||||
|
|
||||||
|
private encryptor(
|
||||||
|
singleUserEncryptor$: Observable<UserBound<"encryptor", UserEncryptor> | UserId>,
|
||||||
|
): Observable<UserEncryptor> {
|
||||||
|
return singleUserEncryptor$.pipe(
|
||||||
|
// normalize inputs
|
||||||
|
map((maybe): UserBound<"encryptor", UserEncryptor> => {
|
||||||
|
if (typeof maybe === "object" && "encryptor" in maybe) {
|
||||||
|
return maybe;
|
||||||
|
} else if (typeof maybe === "string") {
|
||||||
|
return { encryptor: null, userId: maybe as UserId };
|
||||||
|
} else {
|
||||||
|
throw new Error(`Invalid encryptor input received for ${this.key.key}.`);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
// fail the stream if the state desyncs from the bound userId
|
||||||
|
startWith({ userId: this.state.userId, encryptor: null } as UserBound<
|
||||||
|
"encryptor",
|
||||||
|
UserEncryptor
|
||||||
|
>),
|
||||||
|
pairwise(),
|
||||||
|
map(([expected, actual]) => {
|
||||||
|
if (expected.userId === actual.userId) {
|
||||||
|
return actual;
|
||||||
|
} else {
|
||||||
|
throw {
|
||||||
|
expectedUserId: expected.userId,
|
||||||
|
actualUserId: actual.userId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
// reduce emissions to when encryptor changes
|
||||||
|
distinctUntilChanged(),
|
||||||
|
map(({ encryptor }) => encryptor),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private when(when$: Observable<boolean>): OperatorFunction<State, State> {
|
||||||
|
return pipe(
|
||||||
|
combineLatestWith(when$.pipe(distinctUntilChanged())),
|
||||||
|
filter(([_, when]) => !!when),
|
||||||
|
map(([input]) => input),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private prepareUpdate(
|
||||||
|
init$: Observable<State>,
|
||||||
|
dependencies$: Observable<Dependencies>,
|
||||||
|
): OperatorFunction<Constrained<State>, State> {
|
||||||
|
return (input$) =>
|
||||||
|
concat(
|
||||||
|
// `init$` becomes the accumulator for `scan`
|
||||||
|
init$.pipe(
|
||||||
|
first(),
|
||||||
|
map((init) => [init, null] as const),
|
||||||
|
),
|
||||||
|
input$.pipe(
|
||||||
|
map((constrained) => constrained.state),
|
||||||
|
withLatestFrom(dependencies$),
|
||||||
|
),
|
||||||
|
).pipe(
|
||||||
|
// scan only emits values that can cause updates
|
||||||
|
scan(([prev], [pending, dependencies]) => {
|
||||||
|
const shouldUpdate = this.context.shouldUpdate?.(prev, pending, dependencies) ?? true;
|
||||||
|
if (shouldUpdate) {
|
||||||
|
// actual update
|
||||||
|
const next = this.context.nextValue?.(prev, pending, dependencies) ?? pending;
|
||||||
|
return [next, dependencies];
|
||||||
|
} else {
|
||||||
|
// false update
|
||||||
|
return [prev, null];
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
// the first emission primes `scan`s aggregator
|
||||||
|
skip(1),
|
||||||
|
map(([state]) => state),
|
||||||
|
|
||||||
|
// clean up false updates
|
||||||
|
distinctUntilChanged(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private adjust(
|
||||||
|
withConstraints: OperatorFunction<State, [State, SubjectConstraints<State>]>,
|
||||||
|
): OperatorFunction<State, Constrained<State>> {
|
||||||
|
return pipe(
|
||||||
|
// how constraints are blended with incoming emissions varies:
|
||||||
|
// * `output` needs to emit when constraints update
|
||||||
|
// * `input` needs to wait until a message flows through the pipe
|
||||||
|
withConstraints,
|
||||||
|
map(([loadedState, constraints]) => {
|
||||||
|
// bypass nulls
|
||||||
|
if (!loadedState) {
|
||||||
|
return {
|
||||||
|
constraints: {} as Constraints<State>,
|
||||||
|
state: null,
|
||||||
|
} satisfies Constrained<State>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const calibration = isDynamic(constraints)
|
||||||
|
? constraints.calibrate(loadedState)
|
||||||
|
: constraints;
|
||||||
|
const adjusted = calibration.adjust(loadedState);
|
||||||
|
|
||||||
|
return {
|
||||||
|
constraints: calibration.constraints,
|
||||||
|
state: adjusted,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private fix(
|
||||||
|
constraints$: Observable<SubjectConstraints<State>>,
|
||||||
|
): OperatorFunction<State, Constrained<State>> {
|
||||||
|
return pipe(
|
||||||
|
combineLatestWith(constraints$),
|
||||||
|
map(([loadedState, constraints]) => {
|
||||||
|
const calibration = isDynamic(constraints)
|
||||||
|
? constraints.calibrate(loadedState)
|
||||||
|
: constraints;
|
||||||
|
const fixed = calibration.fix(loadedState);
|
||||||
|
|
||||||
|
return {
|
||||||
|
constraints: calibration.constraints,
|
||||||
|
state: fixed,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private declassify(encryptor$: Observable<UserEncryptor>): OperatorFunction<unknown, State> {
|
||||||
|
// short-circuit if they key lacks encryption support
|
||||||
|
if (!this.objectKey || this.objectKey.format === "plain") {
|
||||||
|
return (input$) => input$ as Observable<State>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// if the key supports encryption, enable encryptor support
|
||||||
|
if (this.objectKey && this.objectKey.format === "classified") {
|
||||||
|
return pipe(
|
||||||
|
combineLatestWith(encryptor$),
|
||||||
|
concatMap(async ([input, encryptor]) => {
|
||||||
|
// pass through null values
|
||||||
|
if (input === null || input === undefined) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// fail fast if the format is incorrect
|
||||||
|
if (!isClassifiedFormat(input)) {
|
||||||
|
throw new Error(`Cannot declassify ${this.key.key}; unknown format.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// decrypt classified data
|
||||||
|
const { secret, disclosed } = input;
|
||||||
|
const encrypted = EncString.fromJSON(secret);
|
||||||
|
const decryptedSecret = await encryptor.decrypt<Secret>(encrypted);
|
||||||
|
|
||||||
|
// assemble into proper state
|
||||||
|
const declassified = this.objectKey.classifier.declassify(disclosed, decryptedSecret);
|
||||||
|
const state = this.objectKey.options.deserializer(declassified);
|
||||||
|
|
||||||
|
return state;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`unknown serialization format: ${this.objectKey.format}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
private classify(encryptor$: Observable<UserEncryptor>): OperatorFunction<State, unknown> {
|
||||||
|
// short-circuit if they key lacks encryption support; `encryptor` is
|
||||||
|
// readied to preserve `dependencies.singleUserId$` emission contract
|
||||||
|
if (!this.objectKey || this.objectKey.format === "plain") {
|
||||||
|
return pipe(
|
||||||
|
ready(encryptor$),
|
||||||
|
map((input) => input as unknown),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// if the key supports encryption, enable encryptor support
|
||||||
|
if (this.objectKey && this.objectKey.format === "classified") {
|
||||||
|
return pipe(
|
||||||
|
withLatestReady(encryptor$),
|
||||||
|
concatMap(async ([input, encryptor]) => {
|
||||||
|
// fail fast if there's no value
|
||||||
|
if (input === null || input === undefined) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// split data by classification level
|
||||||
|
const serialized = JSON.parse(JSON.stringify(input));
|
||||||
|
const classified = this.objectKey.classifier.classify(serialized);
|
||||||
|
|
||||||
|
// protect data
|
||||||
|
const encrypted = await encryptor.encrypt(classified.secret);
|
||||||
|
const secret = JSON.parse(JSON.stringify(encrypted));
|
||||||
|
|
||||||
|
// wrap result in classified format envelope for storage
|
||||||
|
const envelope = {
|
||||||
|
id: null as void,
|
||||||
|
secret,
|
||||||
|
disclosed: classified.disclosed,
|
||||||
|
} satisfies ClassifiedFormat<void, Disclosed>;
|
||||||
|
|
||||||
|
// deliberate type erasure; the type is restored during `declassify`
|
||||||
|
return envelope as unknown;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// FIXME: add "encrypted" format --> key contains encryption logic
|
||||||
|
// CONSIDER: should "classified format" algorithm be embedded in subject keys...?
|
||||||
|
|
||||||
|
throw new Error(`unknown serialization format: ${this.objectKey.format}`);
|
||||||
|
}
|
||||||
|
|
||||||
/** The userId to which the subject is bound.
|
/** The userId to which the subject is bound.
|
||||||
*/
|
*/
|
||||||
get userId() {
|
get userId() {
|
||||||
@ -177,7 +407,8 @@ export class UserStateSubject<State extends object, Dependencies = null>
|
|||||||
// using subjects to ensure the right semantics are followed;
|
// using subjects to ensure the right semantics are followed;
|
||||||
// if greater efficiency becomes desirable, consider implementing
|
// if greater efficiency becomes desirable, consider implementing
|
||||||
// `SubjectLike` directly
|
// `SubjectLike` directly
|
||||||
private input = new Subject<State>();
|
private input = new ReplaySubject<State>(1);
|
||||||
|
private state: SingleUserState<unknown>;
|
||||||
private readonly output = new ReplaySubject<WithConstraints<State>>(1);
|
private readonly output = new ReplaySubject<WithConstraints<State>>(1);
|
||||||
|
|
||||||
/** A stream containing settings and their last-applied constraints. */
|
/** A stream containing settings and their last-applied constraints. */
|
||||||
@ -188,25 +419,8 @@ export class UserStateSubject<State extends object, Dependencies = null>
|
|||||||
private inputSubscription: Unsubscribable;
|
private inputSubscription: Unsubscribable;
|
||||||
private outputSubscription: Unsubscribable;
|
private outputSubscription: Unsubscribable;
|
||||||
|
|
||||||
private onNext(value: State) {
|
private onNext(value: unknown) {
|
||||||
const nextValue = this.dependencies.nextValue ?? ((_: State, next: State) => next);
|
this.state.update(() => value).catch((e: any) => this.onError(e));
|
||||||
const shouldUpdate = this.dependencies.shouldUpdate ?? ((_: State) => true);
|
|
||||||
|
|
||||||
this.state
|
|
||||||
.update(
|
|
||||||
(state, dependencies) => {
|
|
||||||
const next = nextValue(state, value, dependencies);
|
|
||||||
return next;
|
|
||||||
},
|
|
||||||
{
|
|
||||||
shouldUpdate(current, dependencies) {
|
|
||||||
const update = shouldUpdate(current, value, dependencies);
|
|
||||||
return update;
|
|
||||||
},
|
|
||||||
combineLatestWith: this.dependencies.dependencies$,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.catch((e: any) => this.onError(e));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private onError(value: any) {
|
private onError(value: any) {
|
||||||
@ -232,8 +446,8 @@ export class UserStateSubject<State extends object, Dependencies = null>
|
|||||||
private dispose() {
|
private dispose() {
|
||||||
if (!this.isDisposed) {
|
if (!this.isDisposed) {
|
||||||
// clean up internal subscriptions
|
// clean up internal subscriptions
|
||||||
this.inputSubscription.unsubscribe();
|
this.inputSubscription?.unsubscribe();
|
||||||
this.outputSubscription.unsubscribe();
|
this.outputSubscription?.unsubscribe();
|
||||||
this.inputSubscription = null;
|
this.inputSubscription = null;
|
||||||
this.outputSubscription = null;
|
this.outputSubscription = null;
|
||||||
|
|
||||||
|
@ -1,5 +1,7 @@
|
|||||||
import { Simplify } from "type-fest";
|
import { Simplify } from "type-fest";
|
||||||
|
|
||||||
|
import { IntegrationId } from "./integration";
|
||||||
|
|
||||||
/** Constraints that are shared by all primitive field types */
|
/** Constraints that are shared by all primitive field types */
|
||||||
type PrimitiveConstraint = {
|
type PrimitiveConstraint = {
|
||||||
/** `true` indicates the field is required; otherwise the field is optional */
|
/** `true` indicates the field is required; otherwise the field is optional */
|
||||||
@ -129,6 +131,8 @@ export type StateConstraints<State> = {
|
|||||||
fix: (state: State) => State;
|
fix: (state: State) => State;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type SubjectConstraints<T> = StateConstraints<T> | DynamicStateConstraints<T>;
|
||||||
|
|
||||||
/** Options that provide contextual information about the application state
|
/** Options that provide contextual information about the application state
|
||||||
* when a generator is invoked.
|
* when a generator is invoked.
|
||||||
*/
|
*/
|
||||||
@ -144,4 +148,7 @@ export type VaultItemRequest = {
|
|||||||
/** Options that provide contextual information about the application state
|
/** Options that provide contextual information about the application state
|
||||||
* when a generator is invoked.
|
* when a generator is invoked.
|
||||||
*/
|
*/
|
||||||
export type GenerationRequest = Partial<VaultItemRequest>;
|
export type GenerationRequest = Partial<VaultItemRequest> &
|
||||||
|
Partial<{
|
||||||
|
integration: IntegrationId | null;
|
||||||
|
}>;
|
||||||
|
@ -3,7 +3,7 @@
|
|||||||
fullWidth
|
fullWidth
|
||||||
class="tw-mb-4"
|
class="tw-mb-4"
|
||||||
[selected]="(root$ | async).nav"
|
[selected]="(root$ | async).nav"
|
||||||
(selectedChange)="onRootChanged($event)"
|
(selectedChange)="onRootChanged({ nav: $event })"
|
||||||
attr.aria-label="{{ 'type' | i18n }}"
|
attr.aria-label="{{ 'type' | i18n }}"
|
||||||
>
|
>
|
||||||
<bit-toggle *ngFor="let option of rootOptions$ | async" [value]="option.value">
|
<bit-toggle *ngFor="let option of rootOptions$ | async" [value]="option.value">
|
||||||
@ -35,23 +35,23 @@
|
|||||||
</bit-card>
|
</bit-card>
|
||||||
<tools-password-settings
|
<tools-password-settings
|
||||||
class="tw-mt-6"
|
class="tw-mt-6"
|
||||||
*ngIf="(algorithm$ | async)?.id === 'password'"
|
*ngIf="(showAlgorithm$ | async)?.id === 'password'"
|
||||||
[userId]="userId$ | async"
|
[userId]="userId$ | async"
|
||||||
(onUpdated)="generate$.next()"
|
(onUpdated)="generate$.next()"
|
||||||
/>
|
/>
|
||||||
<tools-passphrase-settings
|
<tools-passphrase-settings
|
||||||
class="tw-mt-6"
|
class="tw-mt-6"
|
||||||
*ngIf="(algorithm$ | async)?.id === 'passphrase'"
|
*ngIf="(showAlgorithm$ | async)?.id === 'passphrase'"
|
||||||
[userId]="userId$ | async"
|
[userId]="userId$ | async"
|
||||||
(onUpdated)="generate$.next()"
|
(onUpdated)="generate$.next()"
|
||||||
/>
|
/>
|
||||||
<bit-section *ngIf="(category$ | async) !== 'password'">
|
<bit-section *ngIf="(category$ | async) !== 'password'">
|
||||||
<bit-section-header>
|
<bit-section-header>
|
||||||
<h6 bitTypography="h6">{{ "options" | i18n }}</h6>
|
<h2 bitTypography="h6">{{ "options" | i18n }}</h2>
|
||||||
</bit-section-header>
|
</bit-section-header>
|
||||||
<div class="tw-mb-4">
|
<div class="tw-mb-4">
|
||||||
<bit-card>
|
<bit-card>
|
||||||
<form class="box" [formGroup]="username" class="tw-container">
|
<form [formGroup]="username" class="box tw-container">
|
||||||
<bit-form-field>
|
<bit-form-field>
|
||||||
<bit-label>{{ "type" | i18n }}</bit-label>
|
<bit-label>{{ "type" | i18n }}</bit-label>
|
||||||
<bit-select [items]="usernameOptions$ | async" formControlName="nav"> </bit-select>
|
<bit-select [items]="usernameOptions$ | async" formControlName="nav"> </bit-select>
|
||||||
@ -60,18 +60,29 @@
|
|||||||
}}</bit-hint>
|
}}</bit-hint>
|
||||||
</bit-form-field>
|
</bit-form-field>
|
||||||
</form>
|
</form>
|
||||||
|
<form *ngIf="showForwarder$ | async" [formGroup]="forwarder" class="box tw-container">
|
||||||
|
<bit-form-field>
|
||||||
|
<bit-label>{{ "service" | i18n }}</bit-label>
|
||||||
|
<bit-select [items]="forwarderOptions$ | async" formControlName="nav"> </bit-select>
|
||||||
|
</bit-form-field>
|
||||||
|
</form>
|
||||||
<tools-catchall-settings
|
<tools-catchall-settings
|
||||||
*ngIf="(algorithm$ | async)?.id === 'catchall'"
|
*ngIf="(showAlgorithm$ | async)?.id === 'catchall'"
|
||||||
[userId]="userId$ | async"
|
[userId]="userId$ | async"
|
||||||
(onUpdated)="generate$.next()"
|
(onUpdated)="generate$.next()"
|
||||||
/>
|
/>
|
||||||
|
<tools-forwarder-settings
|
||||||
|
*ngIf="!!(forwarderId$ | async)"
|
||||||
|
[forwarder]="forwarderId$ | async"
|
||||||
|
[userId]="this.userId$ | async"
|
||||||
|
/>
|
||||||
<tools-subaddress-settings
|
<tools-subaddress-settings
|
||||||
*ngIf="(algorithm$ | async)?.id === 'subaddress'"
|
*ngIf="(showAlgorithm$ | async)?.id === 'subaddress'"
|
||||||
[userId]="userId$ | async"
|
[userId]="userId$ | async"
|
||||||
(onUpdated)="generate$.next()"
|
(onUpdated)="generate$.next()"
|
||||||
/>
|
/>
|
||||||
<tools-username-settings
|
<tools-username-settings
|
||||||
*ngIf="(algorithm$ | async)?.id === 'username'"
|
*ngIf="(showAlgorithm$ | async)?.id === 'username'"
|
||||||
[userId]="userId$ | async"
|
[userId]="userId$ | async"
|
||||||
(onUpdated)="generate$.next()"
|
(onUpdated)="generate$.next()"
|
||||||
/>
|
/>
|
||||||
|
@ -2,11 +2,12 @@ import { Component, EventEmitter, Input, NgZone, OnDestroy, OnInit, Output } fro
|
|||||||
import { FormBuilder } from "@angular/forms";
|
import { FormBuilder } from "@angular/forms";
|
||||||
import {
|
import {
|
||||||
BehaviorSubject,
|
BehaviorSubject,
|
||||||
concat,
|
catchError,
|
||||||
|
combineLatest,
|
||||||
|
combineLatestWith,
|
||||||
distinctUntilChanged,
|
distinctUntilChanged,
|
||||||
filter,
|
filter,
|
||||||
map,
|
map,
|
||||||
of,
|
|
||||||
ReplaySubject,
|
ReplaySubject,
|
||||||
Subject,
|
Subject,
|
||||||
switchMap,
|
switchMap,
|
||||||
@ -16,25 +17,32 @@ import {
|
|||||||
|
|
||||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||||
|
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||||
|
import { IntegrationId } from "@bitwarden/common/tools/integration";
|
||||||
import { UserId } from "@bitwarden/common/types/guid";
|
import { UserId } from "@bitwarden/common/types/guid";
|
||||||
|
import { ToastService } from "@bitwarden/components";
|
||||||
import { Option } from "@bitwarden/components/src/select/option";
|
import { Option } from "@bitwarden/components/src/select/option";
|
||||||
import {
|
import {
|
||||||
|
AlgorithmInfo,
|
||||||
CredentialAlgorithm,
|
CredentialAlgorithm,
|
||||||
CredentialCategory,
|
CredentialCategory,
|
||||||
CredentialGeneratorInfo,
|
|
||||||
CredentialGeneratorService,
|
CredentialGeneratorService,
|
||||||
GeneratedCredential,
|
GeneratedCredential,
|
||||||
Generators,
|
Generators,
|
||||||
|
getForwarderConfiguration,
|
||||||
isEmailAlgorithm,
|
isEmailAlgorithm,
|
||||||
|
isForwarderIntegration,
|
||||||
isPasswordAlgorithm,
|
isPasswordAlgorithm,
|
||||||
|
isSameAlgorithm,
|
||||||
isUsernameAlgorithm,
|
isUsernameAlgorithm,
|
||||||
PasswordAlgorithm,
|
toCredentialGeneratorConfiguration,
|
||||||
} from "@bitwarden/generator-core";
|
} from "@bitwarden/generator-core";
|
||||||
|
|
||||||
/** root category that drills into username and email categories */
|
// constants used to identify navigation selections that are not
|
||||||
|
// generator algorithms
|
||||||
const IDENTIFIER = "identifier";
|
const IDENTIFIER = "identifier";
|
||||||
/** options available for the top-level navigation */
|
const FORWARDER = "forwarder";
|
||||||
type RootNavValue = PasswordAlgorithm | typeof IDENTIFIER;
|
const NONE_SELECTED = "none";
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: "tools-credential-generator",
|
selector: "tools-credential-generator",
|
||||||
@ -43,6 +51,8 @@ type RootNavValue = PasswordAlgorithm | typeof IDENTIFIER;
|
|||||||
export class CredentialGeneratorComponent implements OnInit, OnDestroy {
|
export class CredentialGeneratorComponent implements OnInit, OnDestroy {
|
||||||
constructor(
|
constructor(
|
||||||
private generatorService: CredentialGeneratorService,
|
private generatorService: CredentialGeneratorService,
|
||||||
|
private toastService: ToastService,
|
||||||
|
private logService: LogService,
|
||||||
private i18nService: I18nService,
|
private i18nService: I18nService,
|
||||||
private accountService: AccountService,
|
private accountService: AccountService,
|
||||||
private zone: NgZone,
|
private zone: NgZone,
|
||||||
@ -59,59 +69,25 @@ export class CredentialGeneratorComponent implements OnInit, OnDestroy {
|
|||||||
@Output()
|
@Output()
|
||||||
readonly onGenerated = new EventEmitter<GeneratedCredential>();
|
readonly onGenerated = new EventEmitter<GeneratedCredential>();
|
||||||
|
|
||||||
protected root$ = new BehaviorSubject<{ nav: RootNavValue }>({
|
protected root$ = new BehaviorSubject<{ nav: string }>({
|
||||||
nav: null,
|
nav: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
protected onRootChanged(value: { nav: string }) {
|
||||||
* Emits the copy button aria-label respective of the selected credential type
|
|
||||||
*
|
|
||||||
* FIXME: Move label and logic to `AlgorithmInfo` within the `CredentialGeneratorService`.
|
|
||||||
*/
|
|
||||||
protected credentialTypeCopyLabel$ = this.root$.pipe(
|
|
||||||
map(({ nav }) => {
|
|
||||||
if (nav === "password") {
|
|
||||||
return this.i18nService.t("copyPassword");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (nav === "passphrase") {
|
|
||||||
return this.i18nService.t("copyPassphrase");
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.i18nService.t("copyUsername");
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Emits the generate button aria-label respective of the selected credential type
|
|
||||||
*
|
|
||||||
* FIXME: Move label and logic to `AlgorithmInfo` within the `CredentialGeneratorService`.
|
|
||||||
*/
|
|
||||||
protected credentialTypeGenerateLabel$ = this.root$.pipe(
|
|
||||||
map(({ nav }) => {
|
|
||||||
if (nav === "password") {
|
|
||||||
return this.i18nService.t("generatePassword");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (nav === "passphrase") {
|
|
||||||
return this.i18nService.t("generatePassphrase");
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.i18nService.t("generateUsername");
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
protected onRootChanged(nav: RootNavValue) {
|
|
||||||
// prevent subscription cycle
|
// prevent subscription cycle
|
||||||
if (this.root$.value.nav !== nav) {
|
if (this.root$.value.nav !== value.nav) {
|
||||||
this.zone.run(() => {
|
this.zone.run(() => {
|
||||||
this.root$.next({ nav });
|
this.root$.next(value);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
protected username = this.formBuilder.group({
|
protected username = this.formBuilder.group({
|
||||||
nav: [null as CredentialAlgorithm],
|
nav: [null as string],
|
||||||
|
});
|
||||||
|
|
||||||
|
protected forwarder = this.formBuilder.group({
|
||||||
|
nav: [null as string],
|
||||||
});
|
});
|
||||||
|
|
||||||
async ngOnInit() {
|
async ngOnInit() {
|
||||||
@ -130,16 +106,29 @@ export class CredentialGeneratorComponent implements OnInit, OnDestroy {
|
|||||||
this.generatorService
|
this.generatorService
|
||||||
.algorithms$(["email", "username"], { userId$: this.userId$ })
|
.algorithms$(["email", "username"], { userId$: this.userId$ })
|
||||||
.pipe(
|
.pipe(
|
||||||
map((algorithms) => this.toOptions(algorithms)),
|
map((algorithms) => {
|
||||||
|
const usernames = algorithms.filter((a) => !isForwarderIntegration(a.id));
|
||||||
|
const usernameOptions = this.toOptions(usernames);
|
||||||
|
usernameOptions.push({ value: FORWARDER, label: this.i18nService.t("forwardedEmail") });
|
||||||
|
|
||||||
|
const forwarders = algorithms.filter((a) => isForwarderIntegration(a.id));
|
||||||
|
const forwarderOptions = this.toOptions(forwarders);
|
||||||
|
forwarderOptions.unshift({ value: NONE_SELECTED, label: this.i18nService.t("select") });
|
||||||
|
|
||||||
|
return [usernameOptions, forwarderOptions] as const;
|
||||||
|
}),
|
||||||
takeUntil(this.destroyed),
|
takeUntil(this.destroyed),
|
||||||
)
|
)
|
||||||
.subscribe(this.usernameOptions$);
|
.subscribe(([usernames, forwarders]) => {
|
||||||
|
this.usernameOptions$.next(usernames);
|
||||||
|
this.forwarderOptions$.next(forwarders);
|
||||||
|
});
|
||||||
|
|
||||||
this.generatorService
|
this.generatorService
|
||||||
.algorithms$("password", { userId$: this.userId$ })
|
.algorithms$("password", { userId$: this.userId$ })
|
||||||
.pipe(
|
.pipe(
|
||||||
map((algorithms) => {
|
map((algorithms) => {
|
||||||
const options = this.toOptions(algorithms) as Option<RootNavValue>[];
|
const options = this.toOptions(algorithms);
|
||||||
options.push({ value: IDENTIFIER, label: this.i18nService.t("username") });
|
options.push({ value: IDENTIFIER, label: this.i18nService.t("username") });
|
||||||
return options;
|
return options;
|
||||||
}),
|
}),
|
||||||
@ -149,7 +138,7 @@ export class CredentialGeneratorComponent implements OnInit, OnDestroy {
|
|||||||
|
|
||||||
this.algorithm$
|
this.algorithm$
|
||||||
.pipe(
|
.pipe(
|
||||||
map((a) => a?.descriptionKey && this.i18nService.t(a?.descriptionKey)),
|
map((a) => a?.description),
|
||||||
takeUntil(this.destroyed),
|
takeUntil(this.destroyed),
|
||||||
)
|
)
|
||||||
.subscribe((hint) => {
|
.subscribe((hint) => {
|
||||||
@ -162,7 +151,7 @@ export class CredentialGeneratorComponent implements OnInit, OnDestroy {
|
|||||||
|
|
||||||
this.algorithm$
|
this.algorithm$
|
||||||
.pipe(
|
.pipe(
|
||||||
map((a) => a.category),
|
map((a) => a?.category),
|
||||||
distinctUntilChanged(),
|
distinctUntilChanged(),
|
||||||
takeUntil(this.destroyed),
|
takeUntil(this.destroyed),
|
||||||
)
|
)
|
||||||
@ -177,7 +166,22 @@ export class CredentialGeneratorComponent implements OnInit, OnDestroy {
|
|||||||
// wire up the generator
|
// wire up the generator
|
||||||
this.algorithm$
|
this.algorithm$
|
||||||
.pipe(
|
.pipe(
|
||||||
|
filter((algorithm) => !!algorithm),
|
||||||
switchMap((algorithm) => this.typeToGenerator$(algorithm.id)),
|
switchMap((algorithm) => this.typeToGenerator$(algorithm.id)),
|
||||||
|
catchError((error: unknown, generator) => {
|
||||||
|
if (typeof error === "string") {
|
||||||
|
this.toastService.showToast({
|
||||||
|
message: error,
|
||||||
|
variant: "error",
|
||||||
|
title: "",
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
this.logService.error(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
// continue with origin stream
|
||||||
|
return generator;
|
||||||
|
}),
|
||||||
takeUntil(this.destroyed),
|
takeUntil(this.destroyed),
|
||||||
)
|
)
|
||||||
.subscribe((generated) => {
|
.subscribe((generated) => {
|
||||||
@ -189,35 +193,116 @@ export class CredentialGeneratorComponent implements OnInit, OnDestroy {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// assume the last-visible generator algorithm is the user's preferred one
|
// normalize cascade selections; introduce subjects to allow changes
|
||||||
const preferences = await this.generatorService.preferences({ singleUserId$: this.userId$ });
|
// from user selections and changes from preference updates to
|
||||||
|
// update the template
|
||||||
|
type CascadeValue = { nav: string; algorithm?: CredentialAlgorithm };
|
||||||
|
const activeRoot$ = new Subject<CascadeValue>();
|
||||||
|
const activeIdentifier$ = new Subject<CascadeValue>();
|
||||||
|
const activeForwarder$ = new Subject<CascadeValue>();
|
||||||
|
|
||||||
this.root$
|
this.root$
|
||||||
.pipe(
|
.pipe(
|
||||||
filter(({ nav }) => !!nav),
|
map(
|
||||||
switchMap((root) => {
|
(root): CascadeValue =>
|
||||||
if (root.nav === IDENTIFIER) {
|
root.nav === IDENTIFIER
|
||||||
return concat(of(this.username.value), this.username.valueChanges);
|
? { nav: root.nav }
|
||||||
|
: { nav: root.nav, algorithm: JSON.parse(root.nav) },
|
||||||
|
),
|
||||||
|
takeUntil(this.destroyed),
|
||||||
|
)
|
||||||
|
.subscribe(activeRoot$);
|
||||||
|
|
||||||
|
this.username.valueChanges
|
||||||
|
.pipe(
|
||||||
|
map(
|
||||||
|
(username): CascadeValue =>
|
||||||
|
username.nav === FORWARDER
|
||||||
|
? { nav: username.nav }
|
||||||
|
: { nav: username.nav, algorithm: JSON.parse(username.nav) },
|
||||||
|
),
|
||||||
|
takeUntil(this.destroyed),
|
||||||
|
)
|
||||||
|
.subscribe(activeIdentifier$);
|
||||||
|
|
||||||
|
this.forwarder.valueChanges
|
||||||
|
.pipe(
|
||||||
|
map(
|
||||||
|
(forwarder): CascadeValue =>
|
||||||
|
forwarder.nav === NONE_SELECTED
|
||||||
|
? { nav: forwarder.nav }
|
||||||
|
: { nav: forwarder.nav, algorithm: JSON.parse(forwarder.nav) },
|
||||||
|
),
|
||||||
|
takeUntil(this.destroyed),
|
||||||
|
)
|
||||||
|
.subscribe(activeForwarder$);
|
||||||
|
|
||||||
|
// update forwarder cascade visibility
|
||||||
|
combineLatest([activeRoot$, activeIdentifier$, activeForwarder$])
|
||||||
|
.pipe(
|
||||||
|
map(([root, username, forwarder]) => {
|
||||||
|
const showForwarder = !root.algorithm && !username.algorithm;
|
||||||
|
const forwarderId =
|
||||||
|
showForwarder && isForwarderIntegration(forwarder.algorithm)
|
||||||
|
? forwarder.algorithm.forwarder
|
||||||
|
: null;
|
||||||
|
return [showForwarder, forwarderId] as const;
|
||||||
|
}),
|
||||||
|
distinctUntilChanged((prev, next) => prev[0] === next[0] && prev[1] === next[1]),
|
||||||
|
takeUntil(this.destroyed),
|
||||||
|
)
|
||||||
|
.subscribe(([showForwarder, forwarderId]) => {
|
||||||
|
// update subjects within the angular zone so that the
|
||||||
|
// template bindings refresh immediately
|
||||||
|
this.zone.run(() => {
|
||||||
|
this.showForwarder$.next(showForwarder);
|
||||||
|
this.forwarderId$.next(forwarderId);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// update active algorithm
|
||||||
|
combineLatest([activeRoot$, activeIdentifier$, activeForwarder$])
|
||||||
|
.pipe(
|
||||||
|
map(([root, username, forwarder]) => {
|
||||||
|
const selection = root.algorithm ?? username.algorithm ?? forwarder.algorithm;
|
||||||
|
if (selection) {
|
||||||
|
return this.generatorService.algorithm(selection);
|
||||||
} else {
|
} else {
|
||||||
return of(root as { nav: PasswordAlgorithm });
|
return null;
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
filter(({ nav }) => !!nav),
|
distinctUntilChanged((prev, next) => isSameAlgorithm(prev?.id, next?.id)),
|
||||||
|
takeUntil(this.destroyed),
|
||||||
|
)
|
||||||
|
.subscribe((algorithm) => {
|
||||||
|
// update subjects within the angular zone so that the
|
||||||
|
// template bindings refresh immediately
|
||||||
|
this.zone.run(() => {
|
||||||
|
this.algorithm$.next(algorithm);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// assume the last-selected generator algorithm is the user's preferred one
|
||||||
|
const preferences = await this.generatorService.preferences({ singleUserId$: this.userId$ });
|
||||||
|
this.algorithm$
|
||||||
|
.pipe(
|
||||||
|
filter((algorithm) => !!algorithm),
|
||||||
withLatestFrom(preferences),
|
withLatestFrom(preferences),
|
||||||
takeUntil(this.destroyed),
|
takeUntil(this.destroyed),
|
||||||
)
|
)
|
||||||
.subscribe(([{ nav: algorithm }, preference]) => {
|
.subscribe(([algorithm, preference]) => {
|
||||||
function setPreference(category: CredentialCategory) {
|
function setPreference(category: CredentialCategory) {
|
||||||
const p = preference[category];
|
const p = preference[category];
|
||||||
p.algorithm = algorithm;
|
p.algorithm = algorithm.id;
|
||||||
p.updated = new Date();
|
p.updated = new Date();
|
||||||
}
|
}
|
||||||
|
|
||||||
// `is*Algorithm` decides `algorithm`'s type, which flows into `setPreference`
|
// `is*Algorithm` decides `algorithm`'s type, which flows into `setPreference`
|
||||||
if (isEmailAlgorithm(algorithm)) {
|
if (isEmailAlgorithm(algorithm.id)) {
|
||||||
setPreference("email");
|
setPreference("email");
|
||||||
} else if (isUsernameAlgorithm(algorithm)) {
|
} else if (isUsernameAlgorithm(algorithm.id)) {
|
||||||
setPreference("username");
|
setPreference("username");
|
||||||
} else if (isPasswordAlgorithm(algorithm)) {
|
} else if (isPasswordAlgorithm(algorithm.id)) {
|
||||||
setPreference("password");
|
setPreference("password");
|
||||||
} else {
|
} else {
|
||||||
return;
|
return;
|
||||||
@ -227,34 +312,74 @@ export class CredentialGeneratorComponent implements OnInit, OnDestroy {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// populate the form with the user's preferences to kick off interactivity
|
// populate the form with the user's preferences to kick off interactivity
|
||||||
preferences.pipe(takeUntil(this.destroyed)).subscribe(({ email, username, password }) => {
|
preferences
|
||||||
// the last preference set by the user "wins"
|
|
||||||
const userNav = email.updated > username.updated ? email : username;
|
|
||||||
const rootNav: any = userNav.updated > password.updated ? IDENTIFIER : password.algorithm;
|
|
||||||
const credentialType = rootNav === IDENTIFIER ? userNav.algorithm : password.algorithm;
|
|
||||||
|
|
||||||
// update navigation; break subscription loop
|
|
||||||
this.onRootChanged(rootNav);
|
|
||||||
this.username.setValue({ nav: userNav.algorithm }, { emitEvent: false });
|
|
||||||
|
|
||||||
// load algorithm metadata
|
|
||||||
const algorithm = this.generatorService.algorithm(credentialType);
|
|
||||||
|
|
||||||
// update subjects within the angular zone so that the
|
|
||||||
// template bindings refresh immediately
|
|
||||||
this.zone.run(() => {
|
|
||||||
this.algorithm$.next(algorithm);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// generate on load unless the generator prohibits it
|
|
||||||
this.algorithm$
|
|
||||||
.pipe(
|
.pipe(
|
||||||
distinctUntilChanged((prev, next) => prev.id === next.id),
|
map(({ email, username, password }) => {
|
||||||
filter((a) => !a.onlyOnRequest),
|
const forwarderPref = isForwarderIntegration(email.algorithm) ? email : null;
|
||||||
|
const usernamePref = email.updated > username.updated ? email : username;
|
||||||
|
|
||||||
|
// inject drilldown flags
|
||||||
|
const forwarderNav = !forwarderPref
|
||||||
|
? NONE_SELECTED
|
||||||
|
: JSON.stringify(forwarderPref.algorithm);
|
||||||
|
const userNav = forwarderPref ? FORWARDER : JSON.stringify(usernamePref.algorithm);
|
||||||
|
const rootNav =
|
||||||
|
usernamePref.updated > password.updated
|
||||||
|
? IDENTIFIER
|
||||||
|
: JSON.stringify(password.algorithm);
|
||||||
|
|
||||||
|
// construct cascade metadata
|
||||||
|
const cascade = {
|
||||||
|
root: {
|
||||||
|
selection: { nav: rootNav },
|
||||||
|
active: {
|
||||||
|
nav: rootNav,
|
||||||
|
algorithm: rootNav === IDENTIFIER ? null : password.algorithm,
|
||||||
|
} as CascadeValue,
|
||||||
|
},
|
||||||
|
username: {
|
||||||
|
selection: { nav: userNav },
|
||||||
|
active: {
|
||||||
|
nav: userNav,
|
||||||
|
algorithm: forwarderPref ? null : usernamePref.algorithm,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
forwarder: {
|
||||||
|
selection: { nav: forwarderNav },
|
||||||
|
active: {
|
||||||
|
nav: forwarderNav,
|
||||||
|
algorithm: forwarderPref?.algorithm,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return cascade;
|
||||||
|
}),
|
||||||
takeUntil(this.destroyed),
|
takeUntil(this.destroyed),
|
||||||
)
|
)
|
||||||
.subscribe(() => this.generate$.next());
|
.subscribe(({ root, username, forwarder }) => {
|
||||||
|
// update navigation; break subscription loop
|
||||||
|
this.onRootChanged(root.selection);
|
||||||
|
this.username.setValue(username.selection, { emitEvent: false });
|
||||||
|
this.forwarder.setValue(forwarder.selection, { emitEvent: false });
|
||||||
|
|
||||||
|
// update cascade visibility
|
||||||
|
activeRoot$.next(root.active);
|
||||||
|
activeIdentifier$.next(username.active);
|
||||||
|
activeForwarder$.next(forwarder.active);
|
||||||
|
});
|
||||||
|
|
||||||
|
// automatically regenerate when the algorithm switches if the algorithm
|
||||||
|
// allows it; otherwise set a placeholder
|
||||||
|
this.algorithm$.pipe(takeUntil(this.destroyed)).subscribe((a) => {
|
||||||
|
this.zone.run(() => {
|
||||||
|
if (!a || a.onlyOnRequest) {
|
||||||
|
this.value$.next("-");
|
||||||
|
} else {
|
||||||
|
this.generate$.next();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private typeToGenerator$(type: CredentialAlgorithm) {
|
private typeToGenerator$(type: CredentialAlgorithm) {
|
||||||
@ -278,20 +403,61 @@ export class CredentialGeneratorComponent implements OnInit, OnDestroy {
|
|||||||
|
|
||||||
case "passphrase":
|
case "passphrase":
|
||||||
return this.generatorService.generate$(Generators.passphrase, dependencies);
|
return this.generatorService.generate$(Generators.passphrase, dependencies);
|
||||||
|
|
||||||
default:
|
|
||||||
throw new Error(`Invalid generator type: "${type}"`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isForwarderIntegration(type)) {
|
||||||
|
const forwarder = getForwarderConfiguration(type.forwarder);
|
||||||
|
const configuration = toCredentialGeneratorConfiguration(forwarder);
|
||||||
|
const generator = this.generatorService.generate$(configuration, dependencies);
|
||||||
|
return generator;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`Invalid generator type: "${type}"`);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Lists the credential types of the username algorithm box. */
|
/** Lists the top-level credential types supported by the component.
|
||||||
protected usernameOptions$ = new BehaviorSubject<Option<CredentialAlgorithm>[]>([]);
|
* @remarks This is string-typed because angular doesn't support
|
||||||
|
* structural equality for objects, which prevents `CredentialAlgorithm`
|
||||||
|
* from being selectable within a dropdown when its value contains a
|
||||||
|
* `ForwarderIntegration`.
|
||||||
|
*/
|
||||||
|
protected rootOptions$ = new BehaviorSubject<Option<string>[]>([]);
|
||||||
|
|
||||||
/** Lists the top-level credential types supported by the component. */
|
/** Lists the credential types of the username algorithm box. */
|
||||||
protected rootOptions$ = new BehaviorSubject<Option<RootNavValue>[]>([]);
|
protected usernameOptions$ = new BehaviorSubject<Option<string>[]>([]);
|
||||||
|
|
||||||
|
/** Lists the credential types of the username algorithm box. */
|
||||||
|
protected forwarderOptions$ = new BehaviorSubject<Option<string>[]>([]);
|
||||||
|
|
||||||
|
/** Tracks the currently selected forwarder. */
|
||||||
|
protected forwarderId$ = new BehaviorSubject<IntegrationId>(null);
|
||||||
|
|
||||||
|
/** Tracks forwarder control visibility */
|
||||||
|
protected showForwarder$ = new BehaviorSubject<boolean>(false);
|
||||||
|
|
||||||
/** tracks the currently selected credential type */
|
/** tracks the currently selected credential type */
|
||||||
protected algorithm$ = new ReplaySubject<CredentialGeneratorInfo>(1);
|
protected algorithm$ = new ReplaySubject<AlgorithmInfo>(1);
|
||||||
|
|
||||||
|
protected showAlgorithm$ = this.algorithm$.pipe(
|
||||||
|
combineLatestWith(this.showForwarder$),
|
||||||
|
map(([algorithm, showForwarder]) => (showForwarder ? null : algorithm)),
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Emits the copy button aria-label respective of the selected credential type
|
||||||
|
*/
|
||||||
|
protected credentialTypeCopyLabel$ = this.algorithm$.pipe(
|
||||||
|
filter((algorithm) => !!algorithm),
|
||||||
|
map(({ copy }) => copy),
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Emits the generate button aria-label respective of the selected credential type
|
||||||
|
*/
|
||||||
|
protected credentialTypeGenerateLabel$ = this.algorithm$.pipe(
|
||||||
|
filter((algorithm) => !!algorithm),
|
||||||
|
map(({ copy }) => copy),
|
||||||
|
);
|
||||||
|
|
||||||
/** Emits hint key for the currently selected credential type */
|
/** Emits hint key for the currently selected credential type */
|
||||||
protected credentialTypeHint$ = new ReplaySubject<string>(1);
|
protected credentialTypeHint$ = new ReplaySubject<string>(1);
|
||||||
@ -308,10 +474,10 @@ export class CredentialGeneratorComponent implements OnInit, OnDestroy {
|
|||||||
/** Emits when a new credential is requested */
|
/** Emits when a new credential is requested */
|
||||||
protected readonly generate$ = new Subject<void>();
|
protected readonly generate$ = new Subject<void>();
|
||||||
|
|
||||||
private toOptions(algorithms: CredentialGeneratorInfo[]) {
|
private toOptions(algorithms: AlgorithmInfo[]) {
|
||||||
const options: Option<CredentialAlgorithm>[] = algorithms.map((algorithm) => ({
|
const options: Option<string>[] = algorithms.map((algorithm) => ({
|
||||||
value: algorithm.id,
|
value: JSON.stringify(algorithm.id),
|
||||||
label: this.i18nService.t(algorithm.nameKey),
|
label: algorithm.name,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return options;
|
return options;
|
||||||
|
@ -0,0 +1,16 @@
|
|||||||
|
<form class="box" [formGroup]="settings" class="tw-container">
|
||||||
|
<bit-form-field *ngIf="displayDomain">
|
||||||
|
<bit-label>{{ "forwarderDomainName" | i18n }}</bit-label>
|
||||||
|
<input bitInput formControlName="domain" type="text" placeholder="example.com" />
|
||||||
|
<bit-hint>{{ "forwarderDomainNameHint" | i18n }}</bit-hint>
|
||||||
|
</bit-form-field>
|
||||||
|
<bit-form-field *ngIf="displayToken">
|
||||||
|
<bit-label>{{ "apiKey" | i18n }}</bit-label>
|
||||||
|
<input bitInput formControlName="token" type="password" />
|
||||||
|
<button type="button" bitIconButton bitSuffix bitPasswordInputToggle></button>
|
||||||
|
</bit-form-field>
|
||||||
|
<bit-form-field *ngIf="displayBaseUrl" disableMargin>
|
||||||
|
<bit-label>{{ "selfHostBaseUrl" | i18n }}</bit-label>
|
||||||
|
<input bitInput formControlName="baseUrl" type="text" />
|
||||||
|
</bit-form-field>
|
||||||
|
</form>
|
@ -0,0 +1,195 @@
|
|||||||
|
import {
|
||||||
|
Component,
|
||||||
|
EventEmitter,
|
||||||
|
Input,
|
||||||
|
OnChanges,
|
||||||
|
OnDestroy,
|
||||||
|
OnInit,
|
||||||
|
Output,
|
||||||
|
SimpleChanges,
|
||||||
|
} from "@angular/core";
|
||||||
|
import { FormBuilder } from "@angular/forms";
|
||||||
|
import {
|
||||||
|
BehaviorSubject,
|
||||||
|
concatMap,
|
||||||
|
map,
|
||||||
|
ReplaySubject,
|
||||||
|
skip,
|
||||||
|
Subject,
|
||||||
|
switchAll,
|
||||||
|
switchMap,
|
||||||
|
takeUntil,
|
||||||
|
withLatestFrom,
|
||||||
|
} from "rxjs";
|
||||||
|
|
||||||
|
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||||
|
import { IntegrationId } from "@bitwarden/common/tools/integration";
|
||||||
|
import { UserId } from "@bitwarden/common/types/guid";
|
||||||
|
import {
|
||||||
|
CredentialGeneratorConfiguration,
|
||||||
|
CredentialGeneratorService,
|
||||||
|
getForwarderConfiguration,
|
||||||
|
NoPolicy,
|
||||||
|
toCredentialGeneratorConfiguration,
|
||||||
|
} from "@bitwarden/generator-core";
|
||||||
|
|
||||||
|
import { completeOnAccountSwitch, toValidators } from "./util";
|
||||||
|
|
||||||
|
const Controls = Object.freeze({
|
||||||
|
domain: "domain",
|
||||||
|
token: "token",
|
||||||
|
baseUrl: "baseUrl",
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Options group for forwarder integrations */
|
||||||
|
@Component({
|
||||||
|
selector: "tools-forwarder-settings",
|
||||||
|
templateUrl: "forwarder-settings.component.html",
|
||||||
|
})
|
||||||
|
export class ForwarderSettingsComponent implements OnInit, OnChanges, OnDestroy {
|
||||||
|
/** Instantiates the component
|
||||||
|
* @param accountService queries user availability
|
||||||
|
* @param generatorService settings and policy logic
|
||||||
|
* @param formBuilder reactive form controls
|
||||||
|
*/
|
||||||
|
constructor(
|
||||||
|
private formBuilder: FormBuilder,
|
||||||
|
private generatorService: CredentialGeneratorService,
|
||||||
|
private accountService: AccountService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/** Binds the component to a specific user's settings.
|
||||||
|
* When this input is not provided, the form binds to the active
|
||||||
|
* user
|
||||||
|
*/
|
||||||
|
@Input()
|
||||||
|
userId: UserId | null;
|
||||||
|
|
||||||
|
@Input({ required: true })
|
||||||
|
forwarder: IntegrationId;
|
||||||
|
|
||||||
|
/** Emits settings updates and completes if the settings become unavailable.
|
||||||
|
* @remarks this does not emit the initial settings. If you would like
|
||||||
|
* to receive live settings updates including the initial update,
|
||||||
|
* use `CredentialGeneratorService.settings$(...)` instead.
|
||||||
|
*/
|
||||||
|
@Output()
|
||||||
|
readonly onUpdated = new EventEmitter<unknown>();
|
||||||
|
|
||||||
|
/** The template's control bindings */
|
||||||
|
protected settings = this.formBuilder.group({
|
||||||
|
[Controls.domain]: [""],
|
||||||
|
[Controls.token]: [""],
|
||||||
|
[Controls.baseUrl]: [""],
|
||||||
|
});
|
||||||
|
|
||||||
|
private forwarderId$ = new ReplaySubject<IntegrationId>(1);
|
||||||
|
|
||||||
|
async ngOnInit() {
|
||||||
|
const singleUserId$ = this.singleUserId$();
|
||||||
|
|
||||||
|
const forwarder$ = new ReplaySubject<CredentialGeneratorConfiguration<any, NoPolicy>>(1);
|
||||||
|
this.forwarderId$
|
||||||
|
.pipe(
|
||||||
|
map((id) => getForwarderConfiguration(id)),
|
||||||
|
// type erasure necessary because the configuration properties are
|
||||||
|
// determined dynamically at runtime
|
||||||
|
// FIXME: this can be eliminated by unifying the forwarder settings types;
|
||||||
|
// see `ForwarderConfiguration<...>` for details.
|
||||||
|
map((forwarder) => toCredentialGeneratorConfiguration<any>(forwarder)),
|
||||||
|
takeUntil(this.destroyed$),
|
||||||
|
)
|
||||||
|
.subscribe((forwarder) => {
|
||||||
|
this.displayDomain = forwarder.request.includes("domain");
|
||||||
|
this.displayToken = forwarder.request.includes("token");
|
||||||
|
this.displayBaseUrl = forwarder.request.includes("baseUrl");
|
||||||
|
|
||||||
|
forwarder$.next(forwarder);
|
||||||
|
});
|
||||||
|
|
||||||
|
const settings$$ = forwarder$.pipe(
|
||||||
|
concatMap((forwarder) => this.generatorService.settings(forwarder, { singleUserId$ })),
|
||||||
|
);
|
||||||
|
|
||||||
|
// bind settings to the reactive form
|
||||||
|
settings$$.pipe(switchAll(), takeUntil(this.destroyed$)).subscribe((settings) => {
|
||||||
|
// skips reactive event emissions to break a subscription cycle
|
||||||
|
this.settings.patchValue(settings as any, { emitEvent: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
// bind policy to the reactive form
|
||||||
|
forwarder$
|
||||||
|
.pipe(
|
||||||
|
switchMap((forwarder) => {
|
||||||
|
const constraints$ = this.generatorService
|
||||||
|
.policy$(forwarder, { userId$: singleUserId$ })
|
||||||
|
.pipe(map(({ constraints }) => [constraints, forwarder] as const));
|
||||||
|
|
||||||
|
return constraints$;
|
||||||
|
}),
|
||||||
|
takeUntil(this.destroyed$),
|
||||||
|
)
|
||||||
|
.subscribe(([constraints, forwarder]) => {
|
||||||
|
for (const name in Controls) {
|
||||||
|
const control = this.settings.get(name);
|
||||||
|
if (forwarder.request.includes(name as any)) {
|
||||||
|
control.enable({ emitEvent: false });
|
||||||
|
control.setValidators(
|
||||||
|
// the configuration's type erasure affects `toValidators` as well
|
||||||
|
toValidators(name, forwarder, constraints),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
control.disable({ emitEvent: false });
|
||||||
|
control.clearValidators();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// the first emission is the current value; subsequent emissions are updates
|
||||||
|
settings$$
|
||||||
|
.pipe(
|
||||||
|
map((settings$) => settings$.pipe(skip(1))),
|
||||||
|
switchAll(),
|
||||||
|
takeUntil(this.destroyed$),
|
||||||
|
)
|
||||||
|
.subscribe(this.onUpdated);
|
||||||
|
|
||||||
|
// now that outputs are set up, connect inputs
|
||||||
|
this.settings.valueChanges
|
||||||
|
.pipe(withLatestFrom(settings$$), takeUntil(this.destroyed$))
|
||||||
|
.subscribe(([value, settings]) => {
|
||||||
|
settings.next(value);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnChanges(changes: SimpleChanges): void {
|
||||||
|
this.refresh$.complete();
|
||||||
|
if ("forwarder" in changes) {
|
||||||
|
this.forwarderId$.next(this.forwarder);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected displayDomain: boolean;
|
||||||
|
protected displayToken: boolean;
|
||||||
|
protected displayBaseUrl: boolean;
|
||||||
|
|
||||||
|
private singleUserId$() {
|
||||||
|
// FIXME: this branch should probably scan for the user and make sure
|
||||||
|
// the account is unlocked
|
||||||
|
if (this.userId) {
|
||||||
|
return new BehaviorSubject(this.userId as UserId).asObservable();
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.accountService.activeAccount$.pipe(
|
||||||
|
completeOnAccountSwitch(),
|
||||||
|
takeUntil(this.destroyed$),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private readonly refresh$ = new Subject<void>();
|
||||||
|
|
||||||
|
private readonly destroyed$ = new Subject<void>();
|
||||||
|
ngOnDestroy(): void {
|
||||||
|
this.destroyed$.complete();
|
||||||
|
}
|
||||||
|
}
|
@ -5,8 +5,11 @@ import { ReactiveFormsModule } from "@angular/forms";
|
|||||||
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
||||||
import { safeProvider } from "@bitwarden/angular/platform/utils/safe-provider";
|
import { safeProvider } from "@bitwarden/angular/platform/utils/safe-provider";
|
||||||
import { SafeInjectionToken } from "@bitwarden/angular/services/injection-tokens";
|
import { SafeInjectionToken } from "@bitwarden/angular/services/injection-tokens";
|
||||||
|
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||||
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
|
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
|
||||||
import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service";
|
import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service";
|
||||||
|
import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service";
|
||||||
|
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||||
import { StateProvider } from "@bitwarden/common/platform/state";
|
import { StateProvider } from "@bitwarden/common/platform/state";
|
||||||
import {
|
import {
|
||||||
CardComponent,
|
CardComponent,
|
||||||
@ -30,6 +33,7 @@ import {
|
|||||||
|
|
||||||
import { CatchallSettingsComponent } from "./catchall-settings.component";
|
import { CatchallSettingsComponent } from "./catchall-settings.component";
|
||||||
import { CredentialGeneratorComponent } from "./credential-generator.component";
|
import { CredentialGeneratorComponent } from "./credential-generator.component";
|
||||||
|
import { ForwarderSettingsComponent } from "./forwarder-settings.component";
|
||||||
import { PassphraseSettingsComponent } from "./passphrase-settings.component";
|
import { PassphraseSettingsComponent } from "./passphrase-settings.component";
|
||||||
import { PasswordGeneratorComponent } from "./password-generator.component";
|
import { PasswordGeneratorComponent } from "./password-generator.component";
|
||||||
import { PasswordSettingsComponent } from "./password-settings.component";
|
import { PasswordSettingsComponent } from "./password-settings.component";
|
||||||
@ -67,18 +71,27 @@ const RANDOMIZER = new SafeInjectionToken<Randomizer>("Randomizer");
|
|||||||
safeProvider({
|
safeProvider({
|
||||||
provide: CredentialGeneratorService,
|
provide: CredentialGeneratorService,
|
||||||
useClass: CredentialGeneratorService,
|
useClass: CredentialGeneratorService,
|
||||||
deps: [RANDOMIZER, StateProvider, PolicyService],
|
deps: [
|
||||||
|
RANDOMIZER,
|
||||||
|
StateProvider,
|
||||||
|
PolicyService,
|
||||||
|
ApiService,
|
||||||
|
I18nService,
|
||||||
|
EncryptService,
|
||||||
|
CryptoService,
|
||||||
|
],
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
declarations: [
|
declarations: [
|
||||||
CatchallSettingsComponent,
|
CatchallSettingsComponent,
|
||||||
CredentialGeneratorComponent,
|
CredentialGeneratorComponent,
|
||||||
|
ForwarderSettingsComponent,
|
||||||
SubaddressSettingsComponent,
|
SubaddressSettingsComponent,
|
||||||
UsernameSettingsComponent,
|
|
||||||
PasswordGeneratorComponent,
|
PasswordGeneratorComponent,
|
||||||
PasswordSettingsComponent,
|
|
||||||
PassphraseSettingsComponent,
|
PassphraseSettingsComponent,
|
||||||
|
PasswordSettingsComponent,
|
||||||
UsernameGeneratorComponent,
|
UsernameGeneratorComponent,
|
||||||
|
UsernameSettingsComponent,
|
||||||
],
|
],
|
||||||
exports: [CredentialGeneratorComponent, PasswordGeneratorComponent, UsernameGeneratorComponent],
|
exports: [CredentialGeneratorComponent, PasswordGeneratorComponent, UsernameGeneratorComponent],
|
||||||
})
|
})
|
||||||
|
@ -21,9 +21,9 @@ import {
|
|||||||
Generators,
|
Generators,
|
||||||
PasswordAlgorithm,
|
PasswordAlgorithm,
|
||||||
GeneratedCredential,
|
GeneratedCredential,
|
||||||
CredentialGeneratorInfo,
|
|
||||||
CredentialAlgorithm,
|
CredentialAlgorithm,
|
||||||
isPasswordAlgorithm,
|
isPasswordAlgorithm,
|
||||||
|
AlgorithmInfo,
|
||||||
} from "@bitwarden/generator-core";
|
} from "@bitwarden/generator-core";
|
||||||
|
|
||||||
/** Options group for passwords */
|
/** Options group for passwords */
|
||||||
@ -52,36 +52,6 @@ export class PasswordGeneratorComponent implements OnInit, OnDestroy {
|
|||||||
/** tracks the currently selected credential type */
|
/** tracks the currently selected credential type */
|
||||||
protected credentialType$ = new BehaviorSubject<PasswordAlgorithm>(null);
|
protected credentialType$ = new BehaviorSubject<PasswordAlgorithm>(null);
|
||||||
|
|
||||||
/**
|
|
||||||
* Emits the copy button aria-label respective of the selected credential
|
|
||||||
*
|
|
||||||
* FIXME: Move label and logic to `AlgorithmInfo` within the `CredentialGeneratorService`.
|
|
||||||
*/
|
|
||||||
protected credentialTypeCopyLabel$ = this.credentialType$.pipe(
|
|
||||||
map((cred) => {
|
|
||||||
if (cred === "password") {
|
|
||||||
return this.i18nService.t("copyPassword");
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.i18nService.t("copyPassphrase");
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Emits the generate button aria-label respective of the selected credential
|
|
||||||
*
|
|
||||||
* FIXME: Move label and logic to `AlgorithmInfo` within the `CredentialGeneratorService`.
|
|
||||||
*/
|
|
||||||
protected credentialTypeGenerateLabel$ = this.credentialType$.pipe(
|
|
||||||
map((cred) => {
|
|
||||||
if (cred === "password") {
|
|
||||||
return this.i18nService.t("generatePassword");
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.i18nService.t("generatePassphrase");
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
/** Emits the last generated value. */
|
/** Emits the last generated value. */
|
||||||
protected readonly value$ = new BehaviorSubject<string>("");
|
protected readonly value$ = new BehaviorSubject<string>("");
|
||||||
|
|
||||||
@ -208,12 +178,28 @@ export class PasswordGeneratorComponent implements OnInit, OnDestroy {
|
|||||||
protected passwordOptions$ = new BehaviorSubject<Option<CredentialAlgorithm>[]>([]);
|
protected passwordOptions$ = new BehaviorSubject<Option<CredentialAlgorithm>[]>([]);
|
||||||
|
|
||||||
/** tracks the currently selected credential type */
|
/** tracks the currently selected credential type */
|
||||||
protected algorithm$ = new ReplaySubject<CredentialGeneratorInfo>(1);
|
protected algorithm$ = new ReplaySubject<AlgorithmInfo>(1);
|
||||||
|
|
||||||
private toOptions(algorithms: CredentialGeneratorInfo[]) {
|
/**
|
||||||
|
* Emits the copy button aria-label respective of the selected credential type
|
||||||
|
*/
|
||||||
|
protected credentialTypeCopyLabel$ = this.algorithm$.pipe(
|
||||||
|
filter((algorithm) => !!algorithm),
|
||||||
|
map(({ copy }) => copy),
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Emits the generate button aria-label respective of the selected credential type
|
||||||
|
*/
|
||||||
|
protected credentialTypeGenerateLabel$ = this.algorithm$.pipe(
|
||||||
|
filter((algorithm) => !!algorithm),
|
||||||
|
map(({ copy }) => copy),
|
||||||
|
);
|
||||||
|
|
||||||
|
private toOptions(algorithms: AlgorithmInfo[]) {
|
||||||
const options: Option<CredentialAlgorithm>[] = algorithms.map((algorithm) => ({
|
const options: Option<CredentialAlgorithm>[] = algorithms.map((algorithm) => ({
|
||||||
value: algorithm.id,
|
value: algorithm.id,
|
||||||
label: this.i18nService.t(algorithm.nameKey),
|
label: this.i18nService.t(algorithm.name),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return options;
|
return options;
|
||||||
|
@ -3,45 +3,54 @@
|
|||||||
<bit-color-password class="tw-font-mono" [password]="value$ | async"></bit-color-password>
|
<bit-color-password class="tw-font-mono" [password]="value$ | async"></bit-color-password>
|
||||||
</div>
|
</div>
|
||||||
<div class="tw-flex tw-items-center tw-space-x-1">
|
<div class="tw-flex tw-items-center tw-space-x-1">
|
||||||
<!-- FIXME: Move appA11yTitle translation to `AlgorithmInfo` within the `CredentialGeneratorService`. -->
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
bitIconButton="bwi-generate"
|
bitIconButton="bwi-generate"
|
||||||
buttonType="main"
|
buttonType="main"
|
||||||
(click)="generate$.next()"
|
(click)="generate$.next()"
|
||||||
[appA11yTitle]="'generateUsername' | i18n"
|
[appA11yTitle]="credentialTypeGenerateLabel$ | async"
|
||||||
></button>
|
></button>
|
||||||
<!-- FIXME: Move appA11yTitle translation to `AlgorithmInfo` within the `CredentialGeneratorService`. -->
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
bitIconButton="bwi-clone"
|
bitIconButton="bwi-clone"
|
||||||
buttonType="main"
|
buttonType="main"
|
||||||
showToast
|
showToast
|
||||||
[appA11yTitle]="'copyUsername' | i18n"
|
[appA11yTitle]="credentialTypeCopyLabel$ | async"
|
||||||
[appCopyClick]="value$ | async"
|
[appCopyClick]="value$ | async"
|
||||||
></button>
|
></button>
|
||||||
</div>
|
</div>
|
||||||
</bit-card>
|
</bit-card>
|
||||||
<bit-section [disableMargin]="disableMargin">
|
<bit-section [disableMargin]="disableMargin">
|
||||||
<bit-section-header>
|
<bit-section-header>
|
||||||
<h6 bitTypography="h6">{{ "options" | i18n }}</h6>
|
<h2 bitTypography="h6">{{ "options" | i18n }}</h2>
|
||||||
</bit-section-header>
|
</bit-section-header>
|
||||||
<div [ngClass]="{ 'tw-mb-4': !disableMargin }">
|
<div [ngClass]="{ 'tw-mb-4': !disableMargin }">
|
||||||
<bit-card>
|
<bit-card>
|
||||||
<form class="box" [formGroup]="credential" class="tw-container">
|
<form class="box" [formGroup]="username" class="tw-container">
|
||||||
<bit-form-field>
|
<bit-form-field>
|
||||||
<bit-label>{{ "type" | i18n }}</bit-label>
|
<bit-label>{{ "type" | i18n }}</bit-label>
|
||||||
<bit-select [items]="typeOptions$ | async" formControlName="type"> </bit-select>
|
<bit-select [items]="typeOptions$ | async" formControlName="nav"> </bit-select>
|
||||||
<bit-hint *ngIf="!!(credentialTypeHint$ | async)">{{
|
<bit-hint *ngIf="!!(credentialTypeHint$ | async)">{{
|
||||||
credentialTypeHint$ | async
|
credentialTypeHint$ | async
|
||||||
}}</bit-hint>
|
}}</bit-hint>
|
||||||
</bit-form-field>
|
</bit-form-field>
|
||||||
</form>
|
</form>
|
||||||
|
<form *ngIf="showForwarder$ | async" [formGroup]="forwarder" class="box tw-container">
|
||||||
|
<bit-form-field>
|
||||||
|
<bit-label>{{ "service" | i18n }}</bit-label>
|
||||||
|
<bit-select [items]="forwarderOptions$ | async" formControlName="nav"> </bit-select>
|
||||||
|
</bit-form-field>
|
||||||
|
</form>
|
||||||
<tools-catchall-settings
|
<tools-catchall-settings
|
||||||
*ngIf="(algorithm$ | async)?.id === 'catchall'"
|
*ngIf="(algorithm$ | async)?.id === 'catchall'"
|
||||||
[userId]="this.userId$ | async"
|
[userId]="this.userId$ | async"
|
||||||
(onUpdated)="generate$.next()"
|
(onUpdated)="generate$.next()"
|
||||||
/>
|
/>
|
||||||
|
<tools-forwarder-settings
|
||||||
|
*ngIf="!!(forwarderId$ | async)"
|
||||||
|
[forwarder]="forwarderId$ | async"
|
||||||
|
[userId]="this.userId$ | async"
|
||||||
|
/>
|
||||||
<tools-subaddress-settings
|
<tools-subaddress-settings
|
||||||
*ngIf="(algorithm$ | async)?.id === 'subaddress'"
|
*ngIf="(algorithm$ | async)?.id === 'subaddress'"
|
||||||
[userId]="this.userId$ | async"
|
[userId]="this.userId$ | async"
|
||||||
|
@ -3,6 +3,9 @@ import { Component, EventEmitter, Input, NgZone, OnDestroy, OnInit, Output } fro
|
|||||||
import { FormBuilder } from "@angular/forms";
|
import { FormBuilder } from "@angular/forms";
|
||||||
import {
|
import {
|
||||||
BehaviorSubject,
|
BehaviorSubject,
|
||||||
|
catchError,
|
||||||
|
combineLatest,
|
||||||
|
combineLatestWith,
|
||||||
distinctUntilChanged,
|
distinctUntilChanged,
|
||||||
filter,
|
filter,
|
||||||
map,
|
map,
|
||||||
@ -15,18 +18,30 @@ import {
|
|||||||
|
|
||||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||||
|
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||||
|
import { IntegrationId } from "@bitwarden/common/tools/integration";
|
||||||
import { UserId } from "@bitwarden/common/types/guid";
|
import { UserId } from "@bitwarden/common/types/guid";
|
||||||
|
import { ToastService } from "@bitwarden/components";
|
||||||
import { Option } from "@bitwarden/components/src/select/option";
|
import { Option } from "@bitwarden/components/src/select/option";
|
||||||
import {
|
import {
|
||||||
|
AlgorithmInfo,
|
||||||
CredentialAlgorithm,
|
CredentialAlgorithm,
|
||||||
CredentialGeneratorInfo,
|
|
||||||
CredentialGeneratorService,
|
CredentialGeneratorService,
|
||||||
GeneratedCredential,
|
GeneratedCredential,
|
||||||
Generators,
|
Generators,
|
||||||
|
getForwarderConfiguration,
|
||||||
isEmailAlgorithm,
|
isEmailAlgorithm,
|
||||||
|
isForwarderIntegration,
|
||||||
|
isSameAlgorithm,
|
||||||
isUsernameAlgorithm,
|
isUsernameAlgorithm,
|
||||||
|
toCredentialGeneratorConfiguration,
|
||||||
} from "@bitwarden/generator-core";
|
} from "@bitwarden/generator-core";
|
||||||
|
|
||||||
|
// constants used to identify navigation selections that are not
|
||||||
|
// generator algorithms
|
||||||
|
const FORWARDER = "forwarder";
|
||||||
|
const NONE_SELECTED = "none";
|
||||||
|
|
||||||
/** Component that generates usernames and emails */
|
/** Component that generates usernames and emails */
|
||||||
@Component({
|
@Component({
|
||||||
selector: "tools-username-generator",
|
selector: "tools-username-generator",
|
||||||
@ -42,6 +57,8 @@ export class UsernameGeneratorComponent implements OnInit, OnDestroy {
|
|||||||
*/
|
*/
|
||||||
constructor(
|
constructor(
|
||||||
private generatorService: CredentialGeneratorService,
|
private generatorService: CredentialGeneratorService,
|
||||||
|
private toastService: ToastService,
|
||||||
|
private logService: LogService,
|
||||||
private i18nService: I18nService,
|
private i18nService: I18nService,
|
||||||
private accountService: AccountService,
|
private accountService: AccountService,
|
||||||
private zone: NgZone,
|
private zone: NgZone,
|
||||||
@ -62,8 +79,12 @@ export class UsernameGeneratorComponent implements OnInit, OnDestroy {
|
|||||||
@Input({ transform: coerceBooleanProperty }) disableMargin = false;
|
@Input({ transform: coerceBooleanProperty }) disableMargin = false;
|
||||||
|
|
||||||
/** Tracks the selected generation algorithm */
|
/** Tracks the selected generation algorithm */
|
||||||
protected credential = this.formBuilder.group({
|
protected username = this.formBuilder.group({
|
||||||
type: [null as CredentialAlgorithm],
|
nav: [null as string],
|
||||||
|
});
|
||||||
|
|
||||||
|
protected forwarder = this.formBuilder.group({
|
||||||
|
nav: [null as string],
|
||||||
});
|
});
|
||||||
|
|
||||||
async ngOnInit() {
|
async ngOnInit() {
|
||||||
@ -82,14 +103,27 @@ export class UsernameGeneratorComponent implements OnInit, OnDestroy {
|
|||||||
this.generatorService
|
this.generatorService
|
||||||
.algorithms$(["email", "username"], { userId$: this.userId$ })
|
.algorithms$(["email", "username"], { userId$: this.userId$ })
|
||||||
.pipe(
|
.pipe(
|
||||||
map((algorithms) => this.toOptions(algorithms)),
|
map((algorithms) => {
|
||||||
|
const usernames = algorithms.filter((a) => !isForwarderIntegration(a.id));
|
||||||
|
const usernameOptions = this.toOptions(usernames);
|
||||||
|
usernameOptions.push({ value: FORWARDER, label: this.i18nService.t("forwarder") });
|
||||||
|
|
||||||
|
const forwarders = algorithms.filter((a) => isForwarderIntegration(a.id));
|
||||||
|
const forwarderOptions = this.toOptions(forwarders);
|
||||||
|
forwarderOptions.unshift({ value: NONE_SELECTED, label: this.i18nService.t("select") });
|
||||||
|
|
||||||
|
return [usernameOptions, forwarderOptions] as const;
|
||||||
|
}),
|
||||||
takeUntil(this.destroyed),
|
takeUntil(this.destroyed),
|
||||||
)
|
)
|
||||||
.subscribe(this.typeOptions$);
|
.subscribe(([usernames, forwarders]) => {
|
||||||
|
this.typeOptions$.next(usernames);
|
||||||
|
this.forwarderOptions$.next(forwarders);
|
||||||
|
});
|
||||||
|
|
||||||
this.algorithm$
|
this.algorithm$
|
||||||
.pipe(
|
.pipe(
|
||||||
map((a) => a?.descriptionKey && this.i18nService.t(a?.descriptionKey)),
|
map((a) => a?.description),
|
||||||
takeUntil(this.destroyed),
|
takeUntil(this.destroyed),
|
||||||
)
|
)
|
||||||
.subscribe((hint) => {
|
.subscribe((hint) => {
|
||||||
@ -103,7 +137,22 @@ export class UsernameGeneratorComponent implements OnInit, OnDestroy {
|
|||||||
// wire up the generator
|
// wire up the generator
|
||||||
this.algorithm$
|
this.algorithm$
|
||||||
.pipe(
|
.pipe(
|
||||||
|
filter((algorithm) => !!algorithm),
|
||||||
switchMap((algorithm) => this.typeToGenerator$(algorithm.id)),
|
switchMap((algorithm) => this.typeToGenerator$(algorithm.id)),
|
||||||
|
catchError((error: unknown, generator) => {
|
||||||
|
if (typeof error === "string") {
|
||||||
|
this.toastService.showToast({
|
||||||
|
message: error,
|
||||||
|
variant: "error",
|
||||||
|
title: "",
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
this.logService.error(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
// continue with origin stream
|
||||||
|
return generator;
|
||||||
|
}),
|
||||||
takeUntil(this.destroyed),
|
takeUntil(this.destroyed),
|
||||||
)
|
)
|
||||||
.subscribe((generated) => {
|
.subscribe((generated) => {
|
||||||
@ -115,20 +164,96 @@ export class UsernameGeneratorComponent implements OnInit, OnDestroy {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// normalize cascade selections; introduce subjects to allow changes
|
||||||
|
// from user selections and changes from preference updates to
|
||||||
|
// update the template
|
||||||
|
type CascadeValue = { nav: string; algorithm?: CredentialAlgorithm };
|
||||||
|
const activeIdentifier$ = new Subject<CascadeValue>();
|
||||||
|
const activeForwarder$ = new Subject<CascadeValue>();
|
||||||
|
|
||||||
|
this.username.valueChanges
|
||||||
|
.pipe(
|
||||||
|
map(
|
||||||
|
(username): CascadeValue =>
|
||||||
|
username.nav === FORWARDER
|
||||||
|
? { nav: username.nav }
|
||||||
|
: { nav: username.nav, algorithm: JSON.parse(username.nav) },
|
||||||
|
),
|
||||||
|
takeUntil(this.destroyed),
|
||||||
|
)
|
||||||
|
.subscribe(activeIdentifier$);
|
||||||
|
|
||||||
|
this.forwarder.valueChanges
|
||||||
|
.pipe(
|
||||||
|
map(
|
||||||
|
(forwarder): CascadeValue =>
|
||||||
|
forwarder.nav === NONE_SELECTED
|
||||||
|
? { nav: forwarder.nav }
|
||||||
|
: { nav: forwarder.nav, algorithm: JSON.parse(forwarder.nav) },
|
||||||
|
),
|
||||||
|
takeUntil(this.destroyed),
|
||||||
|
)
|
||||||
|
.subscribe(activeForwarder$);
|
||||||
|
|
||||||
|
// update forwarder cascade visibility
|
||||||
|
combineLatest([activeIdentifier$, activeForwarder$])
|
||||||
|
.pipe(
|
||||||
|
map(([username, forwarder]) => {
|
||||||
|
const showForwarder = !username.algorithm;
|
||||||
|
const forwarderId =
|
||||||
|
showForwarder && isForwarderIntegration(forwarder.algorithm)
|
||||||
|
? forwarder.algorithm.forwarder
|
||||||
|
: null;
|
||||||
|
return [showForwarder, forwarderId] as const;
|
||||||
|
}),
|
||||||
|
distinctUntilChanged((prev, next) => prev[0] === next[0] && prev[1] === next[1]),
|
||||||
|
takeUntil(this.destroyed),
|
||||||
|
)
|
||||||
|
.subscribe(([showForwarder, forwarderId]) => {
|
||||||
|
// update subjects within the angular zone so that the
|
||||||
|
// template bindings refresh immediately
|
||||||
|
this.zone.run(() => {
|
||||||
|
this.showForwarder$.next(showForwarder);
|
||||||
|
this.forwarderId$.next(forwarderId);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// update active algorithm
|
||||||
|
combineLatest([activeIdentifier$, activeForwarder$])
|
||||||
|
.pipe(
|
||||||
|
map(([username, forwarder]) => {
|
||||||
|
const selection = username.algorithm ?? forwarder.algorithm;
|
||||||
|
if (selection) {
|
||||||
|
return this.generatorService.algorithm(selection);
|
||||||
|
} else {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
distinctUntilChanged((prev, next) => isSameAlgorithm(prev?.id, next?.id)),
|
||||||
|
takeUntil(this.destroyed),
|
||||||
|
)
|
||||||
|
.subscribe((algorithm) => {
|
||||||
|
// update subjects within the angular zone so that the
|
||||||
|
// template bindings refresh immediately
|
||||||
|
this.zone.run(() => {
|
||||||
|
this.algorithm$.next(algorithm);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// assume the last-visible generator algorithm is the user's preferred one
|
// assume the last-visible generator algorithm is the user's preferred one
|
||||||
const preferences = await this.generatorService.preferences({ singleUserId$: this.userId$ });
|
const preferences = await this.generatorService.preferences({ singleUserId$: this.userId$ });
|
||||||
this.credential.valueChanges
|
this.algorithm$
|
||||||
.pipe(
|
.pipe(
|
||||||
filter(({ type }) => !!type),
|
filter((algorithm) => !!algorithm),
|
||||||
withLatestFrom(preferences),
|
withLatestFrom(preferences),
|
||||||
takeUntil(this.destroyed),
|
takeUntil(this.destroyed),
|
||||||
)
|
)
|
||||||
.subscribe(([{ type }, preference]) => {
|
.subscribe(([algorithm, preference]) => {
|
||||||
if (isEmailAlgorithm(type)) {
|
if (isEmailAlgorithm(algorithm.id)) {
|
||||||
preference.email.algorithm = type;
|
preference.email.algorithm = algorithm.id;
|
||||||
preference.email.updated = new Date();
|
preference.email.updated = new Date();
|
||||||
} else if (isUsernameAlgorithm(type)) {
|
} else if (isUsernameAlgorithm(algorithm.id)) {
|
||||||
preference.username.algorithm = type;
|
preference.username.algorithm = algorithm.id;
|
||||||
preference.username.updated = new Date();
|
preference.username.updated = new Date();
|
||||||
} else {
|
} else {
|
||||||
return;
|
return;
|
||||||
@ -137,31 +262,61 @@ export class UsernameGeneratorComponent implements OnInit, OnDestroy {
|
|||||||
preferences.next(preference);
|
preferences.next(preference);
|
||||||
});
|
});
|
||||||
|
|
||||||
// populate the form with the user's preferences to kick off interactivity
|
preferences
|
||||||
preferences.pipe(takeUntil(this.destroyed)).subscribe(({ email, username }) => {
|
|
||||||
// this generator supports email & username; the last preference
|
|
||||||
// set by the user "wins"
|
|
||||||
const preference = email.updated > username.updated ? email.algorithm : username.algorithm;
|
|
||||||
|
|
||||||
// break subscription loop
|
|
||||||
this.credential.setValue({ type: preference }, { emitEvent: false });
|
|
||||||
|
|
||||||
const algorithm = this.generatorService.algorithm(preference);
|
|
||||||
// update subjects within the angular zone so that the
|
|
||||||
// template bindings refresh immediately
|
|
||||||
this.zone.run(() => {
|
|
||||||
this.algorithm$.next(algorithm);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// generate on load unless the generator prohibits it
|
|
||||||
this.algorithm$
|
|
||||||
.pipe(
|
.pipe(
|
||||||
distinctUntilChanged((prev, next) => prev.id === next.id),
|
map(({ email, username }) => {
|
||||||
filter((a) => !a.onlyOnRequest),
|
const forwarderPref = isForwarderIntegration(email.algorithm) ? email : null;
|
||||||
|
const usernamePref = email.updated > username.updated ? email : username;
|
||||||
|
|
||||||
|
// inject drilldown flags
|
||||||
|
const forwarderNav = !forwarderPref
|
||||||
|
? NONE_SELECTED
|
||||||
|
: JSON.stringify(forwarderPref.algorithm);
|
||||||
|
const userNav = forwarderPref ? FORWARDER : JSON.stringify(usernamePref.algorithm);
|
||||||
|
|
||||||
|
// construct cascade metadata
|
||||||
|
const cascade = {
|
||||||
|
username: {
|
||||||
|
selection: { nav: userNav },
|
||||||
|
active: {
|
||||||
|
nav: userNav,
|
||||||
|
algorithm: forwarderPref ? null : usernamePref.algorithm,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
forwarder: {
|
||||||
|
selection: { nav: forwarderNav },
|
||||||
|
active: {
|
||||||
|
nav: forwarderNav,
|
||||||
|
algorithm: forwarderPref?.algorithm,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return cascade;
|
||||||
|
}),
|
||||||
takeUntil(this.destroyed),
|
takeUntil(this.destroyed),
|
||||||
)
|
)
|
||||||
.subscribe(() => this.generate$.next());
|
.subscribe(({ username, forwarder }) => {
|
||||||
|
// update navigation; break subscription loop
|
||||||
|
this.username.setValue(username.selection, { emitEvent: false });
|
||||||
|
this.forwarder.setValue(forwarder.selection, { emitEvent: false });
|
||||||
|
|
||||||
|
// update cascade visibility
|
||||||
|
activeIdentifier$.next(username.active);
|
||||||
|
activeForwarder$.next(forwarder.active);
|
||||||
|
});
|
||||||
|
|
||||||
|
// automatically regenerate when the algorithm switches if the algorithm
|
||||||
|
// allows it; otherwise set a placeholder
|
||||||
|
this.algorithm$.pipe(takeUntil(this.destroyed)).subscribe((a) => {
|
||||||
|
this.zone.run(() => {
|
||||||
|
if (!a || a.onlyOnRequest) {
|
||||||
|
this.value$.next("-");
|
||||||
|
} else {
|
||||||
|
this.generate$.next();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private typeToGenerator$(type: CredentialAlgorithm) {
|
private typeToGenerator$(type: CredentialAlgorithm) {
|
||||||
@ -179,17 +334,52 @@ export class UsernameGeneratorComponent implements OnInit, OnDestroy {
|
|||||||
|
|
||||||
case "username":
|
case "username":
|
||||||
return this.generatorService.generate$(Generators.username, dependencies);
|
return this.generatorService.generate$(Generators.username, dependencies);
|
||||||
|
|
||||||
default:
|
|
||||||
throw new Error(`Invalid generator type: "${type}"`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isForwarderIntegration(type)) {
|
||||||
|
const forwarder = getForwarderConfiguration(type.forwarder);
|
||||||
|
const configuration = toCredentialGeneratorConfiguration(forwarder);
|
||||||
|
return this.generatorService.generate$(configuration, dependencies);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`Invalid generator type: "${type}"`);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Lists the credential types supported by the component. */
|
/** Lists the credential types supported by the component. */
|
||||||
protected typeOptions$ = new BehaviorSubject<Option<CredentialAlgorithm>[]>([]);
|
protected typeOptions$ = new BehaviorSubject<Option<string>[]>([]);
|
||||||
|
|
||||||
|
/** Tracks the currently selected forwarder. */
|
||||||
|
protected forwarderId$ = new BehaviorSubject<IntegrationId>(null);
|
||||||
|
|
||||||
|
/** Lists the credential types supported by the component. */
|
||||||
|
protected forwarderOptions$ = new BehaviorSubject<Option<string>[]>([]);
|
||||||
|
|
||||||
|
/** Tracks forwarder control visibility */
|
||||||
|
protected showForwarder$ = new BehaviorSubject<boolean>(false);
|
||||||
|
|
||||||
/** tracks the currently selected credential type */
|
/** tracks the currently selected credential type */
|
||||||
protected algorithm$ = new ReplaySubject<CredentialGeneratorInfo>(1);
|
protected algorithm$ = new ReplaySubject<AlgorithmInfo>(1);
|
||||||
|
|
||||||
|
protected showAlgorithm$ = this.algorithm$.pipe(
|
||||||
|
combineLatestWith(this.showForwarder$),
|
||||||
|
map(([algorithm, showForwarder]) => (showForwarder ? null : algorithm)),
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Emits the copy button aria-label respective of the selected credential type
|
||||||
|
*/
|
||||||
|
protected credentialTypeCopyLabel$ = this.algorithm$.pipe(
|
||||||
|
filter((algorithm) => !!algorithm),
|
||||||
|
map(({ copy }) => copy),
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Emits the generate button aria-label respective of the selected credential type
|
||||||
|
*/
|
||||||
|
protected credentialTypeGenerateLabel$ = this.algorithm$.pipe(
|
||||||
|
filter((algorithm) => !!algorithm),
|
||||||
|
map(({ copy }) => copy),
|
||||||
|
);
|
||||||
|
|
||||||
/** Emits hint key for the currently selected credential type */
|
/** Emits hint key for the currently selected credential type */
|
||||||
protected credentialTypeHint$ = new ReplaySubject<string>(1);
|
protected credentialTypeHint$ = new ReplaySubject<string>(1);
|
||||||
@ -203,10 +393,10 @@ export class UsernameGeneratorComponent implements OnInit, OnDestroy {
|
|||||||
/** Emits when a new credential is requested */
|
/** Emits when a new credential is requested */
|
||||||
protected readonly generate$ = new Subject<void>();
|
protected readonly generate$ = new Subject<void>();
|
||||||
|
|
||||||
private toOptions(algorithms: CredentialGeneratorInfo[]) {
|
private toOptions(algorithms: AlgorithmInfo[]) {
|
||||||
const options: Option<CredentialAlgorithm>[] = algorithms.map((algorithm) => ({
|
const options: Option<string>[] = algorithms.map((algorithm) => ({
|
||||||
value: algorithm.id,
|
value: JSON.stringify(algorithm.id),
|
||||||
label: this.i18nService.t(algorithm.nameKey),
|
label: this.i18nService.t(algorithm.name),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return options;
|
return options;
|
||||||
|
@ -63,7 +63,7 @@ function getConstraint<Key extends keyof AnyConstraint>(
|
|||||||
) {
|
) {
|
||||||
if (policy && key in policy) {
|
if (policy && key in policy) {
|
||||||
return policy[key] ?? config[key];
|
return policy[key] ?? config[key];
|
||||||
} else if (key in config) {
|
} else if (config && key in config) {
|
||||||
return config[key];
|
return config[key];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -5,7 +5,7 @@ export const PasswordAlgorithms = Object.freeze(["password", "passphrase"] as co
|
|||||||
export const UsernameAlgorithms = Object.freeze(["username"] as const);
|
export const UsernameAlgorithms = Object.freeze(["username"] as const);
|
||||||
|
|
||||||
/** Types of email addresses that may be generated by the credential generator */
|
/** Types of email addresses that may be generated by the credential generator */
|
||||||
export const EmailAlgorithms = Object.freeze(["catchall", "forwarder", "subaddress"] as const);
|
export const EmailAlgorithms = Object.freeze(["catchall", "subaddress"] as const);
|
||||||
|
|
||||||
/** All types of credentials that may be generated by the credential generator */
|
/** All types of credentials that may be generated by the credential generator */
|
||||||
export const CredentialAlgorithms = Object.freeze([
|
export const CredentialAlgorithms = Object.freeze([
|
||||||
|
@ -1,9 +1,15 @@
|
|||||||
import { PolicyType } from "@bitwarden/common/admin-console/enums";
|
import { PolicyType } from "@bitwarden/common/admin-console/enums";
|
||||||
import { Policy } from "@bitwarden/common/admin-console/models/domain/policy";
|
import { Policy } from "@bitwarden/common/admin-console/models/domain/policy";
|
||||||
|
import { ApiSettings } from "@bitwarden/common/tools/integration/rpc";
|
||||||
import { IdentityConstraint } from "@bitwarden/common/tools/state/identity-state-constraint";
|
import { IdentityConstraint } from "@bitwarden/common/tools/state/identity-state-constraint";
|
||||||
|
|
||||||
import { Randomizer } from "../abstractions";
|
import {
|
||||||
import { EmailRandomizer, PasswordRandomizer, UsernameRandomizer } from "../engine";
|
EmailRandomizer,
|
||||||
|
ForwarderConfiguration,
|
||||||
|
PasswordRandomizer,
|
||||||
|
UsernameRandomizer,
|
||||||
|
} from "../engine";
|
||||||
|
import { Forwarder } from "../engine/forwarder";
|
||||||
import {
|
import {
|
||||||
DefaultPolicyEvaluator,
|
DefaultPolicyEvaluator,
|
||||||
DynamicPasswordPolicyConstraints,
|
DynamicPasswordPolicyConstraints,
|
||||||
@ -25,6 +31,7 @@ import {
|
|||||||
CredentialGenerator,
|
CredentialGenerator,
|
||||||
CredentialGeneratorConfiguration,
|
CredentialGeneratorConfiguration,
|
||||||
EffUsernameGenerationOptions,
|
EffUsernameGenerationOptions,
|
||||||
|
GeneratorDependencyProvider,
|
||||||
NoPolicy,
|
NoPolicy,
|
||||||
PassphraseGenerationOptions,
|
PassphraseGenerationOptions,
|
||||||
PassphraseGeneratorPolicy,
|
PassphraseGeneratorPolicy,
|
||||||
@ -45,10 +52,15 @@ const PASSPHRASE = Object.freeze({
|
|||||||
id: "passphrase",
|
id: "passphrase",
|
||||||
category: "password",
|
category: "password",
|
||||||
nameKey: "passphrase",
|
nameKey: "passphrase",
|
||||||
|
generateKey: "generatePassphrase",
|
||||||
|
copyKey: "copyPassphrase",
|
||||||
onlyOnRequest: false,
|
onlyOnRequest: false,
|
||||||
|
request: [],
|
||||||
engine: {
|
engine: {
|
||||||
create(randomizer: Randomizer): CredentialGenerator<PassphraseGenerationOptions> {
|
create(
|
||||||
return new PasswordRandomizer(randomizer);
|
dependencies: GeneratorDependencyProvider,
|
||||||
|
): CredentialGenerator<PassphraseGenerationOptions> {
|
||||||
|
return new PasswordRandomizer(dependencies.randomizer);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
settings: {
|
settings: {
|
||||||
@ -82,10 +94,15 @@ const PASSWORD = Object.freeze({
|
|||||||
id: "password",
|
id: "password",
|
||||||
category: "password",
|
category: "password",
|
||||||
nameKey: "password",
|
nameKey: "password",
|
||||||
|
generateKey: "generatePassword",
|
||||||
|
copyKey: "copyPassword",
|
||||||
onlyOnRequest: false,
|
onlyOnRequest: false,
|
||||||
|
request: [],
|
||||||
engine: {
|
engine: {
|
||||||
create(randomizer: Randomizer): CredentialGenerator<PasswordGenerationOptions> {
|
create(
|
||||||
return new PasswordRandomizer(randomizer);
|
dependencies: GeneratorDependencyProvider,
|
||||||
|
): CredentialGenerator<PasswordGenerationOptions> {
|
||||||
|
return new PasswordRandomizer(dependencies.randomizer);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
settings: {
|
settings: {
|
||||||
@ -127,10 +144,15 @@ const USERNAME = Object.freeze({
|
|||||||
id: "username",
|
id: "username",
|
||||||
category: "username",
|
category: "username",
|
||||||
nameKey: "randomWord",
|
nameKey: "randomWord",
|
||||||
|
generateKey: "generateUsername",
|
||||||
|
copyKey: "copyUsername",
|
||||||
onlyOnRequest: false,
|
onlyOnRequest: false,
|
||||||
|
request: [],
|
||||||
engine: {
|
engine: {
|
||||||
create(randomizer: Randomizer): CredentialGenerator<EffUsernameGenerationOptions> {
|
create(
|
||||||
return new UsernameRandomizer(randomizer);
|
dependencies: GeneratorDependencyProvider,
|
||||||
|
): CredentialGenerator<EffUsernameGenerationOptions> {
|
||||||
|
return new UsernameRandomizer(dependencies.randomizer);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
settings: {
|
settings: {
|
||||||
@ -158,10 +180,15 @@ const CATCHALL = Object.freeze({
|
|||||||
category: "email",
|
category: "email",
|
||||||
nameKey: "catchallEmail",
|
nameKey: "catchallEmail",
|
||||||
descriptionKey: "catchallEmailDesc",
|
descriptionKey: "catchallEmailDesc",
|
||||||
|
generateKey: "generateEmail",
|
||||||
|
copyKey: "copyEmail",
|
||||||
onlyOnRequest: false,
|
onlyOnRequest: false,
|
||||||
|
request: [],
|
||||||
engine: {
|
engine: {
|
||||||
create(randomizer: Randomizer): CredentialGenerator<CatchallGenerationOptions> {
|
create(
|
||||||
return new EmailRandomizer(randomizer);
|
dependencies: GeneratorDependencyProvider,
|
||||||
|
): CredentialGenerator<CatchallGenerationOptions> {
|
||||||
|
return new EmailRandomizer(dependencies.randomizer);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
settings: {
|
settings: {
|
||||||
@ -189,10 +216,15 @@ const SUBADDRESS = Object.freeze({
|
|||||||
category: "email",
|
category: "email",
|
||||||
nameKey: "plusAddressedEmail",
|
nameKey: "plusAddressedEmail",
|
||||||
descriptionKey: "plusAddressedEmailDesc",
|
descriptionKey: "plusAddressedEmailDesc",
|
||||||
|
generateKey: "generateEmail",
|
||||||
|
copyKey: "copyEmail",
|
||||||
onlyOnRequest: false,
|
onlyOnRequest: false,
|
||||||
|
request: [],
|
||||||
engine: {
|
engine: {
|
||||||
create(randomizer: Randomizer): CredentialGenerator<SubaddressGenerationOptions> {
|
create(
|
||||||
return new EmailRandomizer(randomizer);
|
dependencies: GeneratorDependencyProvider,
|
||||||
|
): CredentialGenerator<SubaddressGenerationOptions> {
|
||||||
|
return new EmailRandomizer(dependencies.randomizer);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
settings: {
|
settings: {
|
||||||
@ -215,6 +247,48 @@ const SUBADDRESS = Object.freeze({
|
|||||||
},
|
},
|
||||||
} satisfies CredentialGeneratorConfiguration<SubaddressGenerationOptions, NoPolicy>);
|
} satisfies CredentialGeneratorConfiguration<SubaddressGenerationOptions, NoPolicy>);
|
||||||
|
|
||||||
|
export function toCredentialGeneratorConfiguration<Settings extends ApiSettings = ApiSettings>(
|
||||||
|
configuration: ForwarderConfiguration<Settings>,
|
||||||
|
) {
|
||||||
|
const forwarder = Object.freeze({
|
||||||
|
id: { forwarder: configuration.id },
|
||||||
|
category: "email",
|
||||||
|
nameKey: configuration.name,
|
||||||
|
descriptionKey: "forwardedEmailDesc",
|
||||||
|
generateKey: "generateEmail",
|
||||||
|
copyKey: "copyEmail",
|
||||||
|
onlyOnRequest: true,
|
||||||
|
request: configuration.forwarder.request,
|
||||||
|
engine: {
|
||||||
|
create(dependencies: GeneratorDependencyProvider) {
|
||||||
|
// FIXME: figure out why `configuration` fails to typecheck
|
||||||
|
const config: any = configuration;
|
||||||
|
return new Forwarder(config, dependencies.client, dependencies.i18nService);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
settings: {
|
||||||
|
initial: configuration.forwarder.defaultSettings,
|
||||||
|
constraints: configuration.forwarder.settingsConstraints,
|
||||||
|
account: configuration.forwarder.settings,
|
||||||
|
},
|
||||||
|
policy: {
|
||||||
|
type: PolicyType.PasswordGenerator,
|
||||||
|
disabledValue: {},
|
||||||
|
combine(_acc: NoPolicy, _policy: Policy) {
|
||||||
|
return {};
|
||||||
|
},
|
||||||
|
createEvaluator(_policy: NoPolicy) {
|
||||||
|
return new DefaultPolicyEvaluator<Settings>();
|
||||||
|
},
|
||||||
|
toConstraints(_policy: NoPolicy) {
|
||||||
|
return new IdentityConstraint<Settings>();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} satisfies CredentialGeneratorConfiguration<Settings, NoPolicy>);
|
||||||
|
|
||||||
|
return forwarder;
|
||||||
|
}
|
||||||
|
|
||||||
/** Generator configurations */
|
/** Generator configurations */
|
||||||
export const Generators = Object.freeze({
|
export const Generators = Object.freeze({
|
||||||
/** Passphrase generator configuration */
|
/** Passphrase generator configuration */
|
||||||
|
@ -1,3 +1,7 @@
|
|||||||
|
import { IntegrationId } from "@bitwarden/common/tools/integration";
|
||||||
|
import { ApiSettings } from "@bitwarden/common/tools/integration/rpc";
|
||||||
|
|
||||||
|
import { ForwarderConfiguration } from "../engine";
|
||||||
import { AddyIo } from "../integration/addy-io";
|
import { AddyIo } from "../integration/addy-io";
|
||||||
import { DuckDuckGo } from "../integration/duck-duck-go";
|
import { DuckDuckGo } from "../integration/duck-duck-go";
|
||||||
import { Fastmail } from "../integration/fastmail";
|
import { Fastmail } from "../integration/fastmail";
|
||||||
@ -5,6 +9,13 @@ import { FirefoxRelay } from "../integration/firefox-relay";
|
|||||||
import { ForwardEmail } from "../integration/forward-email";
|
import { ForwardEmail } from "../integration/forward-email";
|
||||||
import { SimpleLogin } from "../integration/simple-login";
|
import { SimpleLogin } from "../integration/simple-login";
|
||||||
|
|
||||||
|
/** Fixed list of integrations available to the application
|
||||||
|
* @example
|
||||||
|
*
|
||||||
|
* // Use `toCredentialGeneratorConfiguration(id :ForwarderIntegration)`
|
||||||
|
* // to convert an integration to a generator configuration
|
||||||
|
* const generator = toCredentialGeneratorConfiguration(Integrations.AddyIo);
|
||||||
|
*/
|
||||||
export const Integrations = Object.freeze({
|
export const Integrations = Object.freeze({
|
||||||
AddyIo,
|
AddyIo,
|
||||||
DuckDuckGo,
|
DuckDuckGo,
|
||||||
@ -13,3 +24,15 @@ export const Integrations = Object.freeze({
|
|||||||
ForwardEmail,
|
ForwardEmail,
|
||||||
SimpleLogin,
|
SimpleLogin,
|
||||||
} as const);
|
} as const);
|
||||||
|
|
||||||
|
const integrations = new Map(Object.values(Integrations).map((i) => [i.id, i]));
|
||||||
|
|
||||||
|
export function getForwarderConfiguration(id: IntegrationId): ForwarderConfiguration<ApiSettings> {
|
||||||
|
const maybeForwarder = integrations.get(id);
|
||||||
|
|
||||||
|
if (maybeForwarder && "forwarder" in maybeForwarder) {
|
||||||
|
return maybeForwarder as ForwarderConfiguration<ApiSettings>;
|
||||||
|
} else {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ -1,11 +1,14 @@
|
|||||||
import { UserKeyDefinition } from "@bitwarden/common/platform/state";
|
import { UserKeyDefinition } from "@bitwarden/common/platform/state";
|
||||||
import { IntegrationConfiguration } from "@bitwarden/common/tools/integration/integration-configuration";
|
import { IntegrationConfiguration } from "@bitwarden/common/tools/integration/integration-configuration";
|
||||||
import { ApiSettings } from "@bitwarden/common/tools/integration/rpc";
|
import { ApiSettings, SelfHostedApiSettings } from "@bitwarden/common/tools/integration/rpc";
|
||||||
import { IntegrationRequest } from "@bitwarden/common/tools/integration/rpc/integration-request";
|
import { IntegrationRequest } from "@bitwarden/common/tools/integration/rpc/integration-request";
|
||||||
import { RpcConfiguration } from "@bitwarden/common/tools/integration/rpc/rpc-definition";
|
import { RpcConfiguration } from "@bitwarden/common/tools/integration/rpc/rpc-definition";
|
||||||
import { BufferedKeyDefinition } from "@bitwarden/common/tools/state/buffered-key-definition";
|
import { BufferedKeyDefinition } from "@bitwarden/common/tools/state/buffered-key-definition";
|
||||||
|
import { ObjectKey } from "@bitwarden/common/tools/state/object-key";
|
||||||
|
import { Constraints } from "@bitwarden/common/tools/types";
|
||||||
|
|
||||||
import { ForwarderContext } from "./forwarder-context";
|
import { ForwarderContext } from "./forwarder-context";
|
||||||
|
import { EmailDomainSettings, EmailPrefixSettings } from "./settings";
|
||||||
|
|
||||||
/** Mixin for transmitting `getAccountId` result. */
|
/** Mixin for transmitting `getAccountId` result. */
|
||||||
export type AccountRequest = {
|
export type AccountRequest = {
|
||||||
@ -24,8 +27,16 @@ export type GetAccountIdRpcDef<
|
|||||||
Request extends IntegrationRequest = IntegrationRequest,
|
Request extends IntegrationRequest = IntegrationRequest,
|
||||||
> = RpcConfiguration<Request, ForwarderContext<Settings>, string>;
|
> = RpcConfiguration<Request, ForwarderContext<Settings>, string>;
|
||||||
|
|
||||||
|
export type ForwarderRequestFields = keyof (ApiSettings &
|
||||||
|
SelfHostedApiSettings &
|
||||||
|
EmailDomainSettings &
|
||||||
|
EmailPrefixSettings);
|
||||||
|
|
||||||
/** Forwarder-specific static definition */
|
/** Forwarder-specific static definition */
|
||||||
export type ForwarderConfiguration<
|
export type ForwarderConfiguration<
|
||||||
|
// FIXME: simply forwarder settings to an object that has all
|
||||||
|
// settings properties. The runtime dynamism should be limited
|
||||||
|
// to which have values, not which have properties listed.
|
||||||
Settings extends ApiSettings,
|
Settings extends ApiSettings,
|
||||||
Request extends IntegrationRequest = IntegrationRequest,
|
Request extends IntegrationRequest = IntegrationRequest,
|
||||||
> = IntegrationConfiguration & {
|
> = IntegrationConfiguration & {
|
||||||
@ -34,12 +45,30 @@ export type ForwarderConfiguration<
|
|||||||
/** default value of all fields */
|
/** default value of all fields */
|
||||||
defaultSettings: Partial<Settings>;
|
defaultSettings: Partial<Settings>;
|
||||||
|
|
||||||
/** forwarder settings storage */
|
settingsConstraints: Constraints<Settings>;
|
||||||
|
|
||||||
|
/** Well-known fields to display on the forwarder screen */
|
||||||
|
request: readonly ForwarderRequestFields[];
|
||||||
|
|
||||||
|
/** forwarder settings storage
|
||||||
|
* @deprecated use local.settings instead
|
||||||
|
*/
|
||||||
settings: UserKeyDefinition<Settings>;
|
settings: UserKeyDefinition<Settings>;
|
||||||
|
|
||||||
/** forwarder settings import buffer; `undefined` when there is no buffer. */
|
/** forwarder settings import buffer; `undefined` when there is no buffer.
|
||||||
|
* @deprecated use local.settings import
|
||||||
|
*/
|
||||||
importBuffer?: BufferedKeyDefinition<Settings>;
|
importBuffer?: BufferedKeyDefinition<Settings>;
|
||||||
|
|
||||||
|
/** locally stored data; forwarder-partitioned */
|
||||||
|
local: {
|
||||||
|
/** integration settings storage */
|
||||||
|
settings: ObjectKey<Settings>;
|
||||||
|
|
||||||
|
/** plaintext import buffer - used during data migrations */
|
||||||
|
import?: ObjectKey<Settings, Record<string, never>, Settings>;
|
||||||
|
};
|
||||||
|
|
||||||
/** createForwardingEmail RPC definition */
|
/** createForwardingEmail RPC definition */
|
||||||
createForwardingEmail: CreateForwardingEmailRpcDef<Settings, Request>;
|
createForwardingEmail: CreateForwardingEmailRpcDef<Settings, Request>;
|
||||||
|
|
||||||
|
75
libs/tools/generator/core/src/engine/forwarder.ts
Normal file
75
libs/tools/generator/core/src/engine/forwarder.ts
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||||
|
import {
|
||||||
|
ApiSettings,
|
||||||
|
IntegrationRequest,
|
||||||
|
RestClient,
|
||||||
|
} from "@bitwarden/common/tools/integration/rpc";
|
||||||
|
import { GenerationRequest } from "@bitwarden/common/tools/types";
|
||||||
|
|
||||||
|
import { CredentialGenerator, GeneratedCredential } from "../types";
|
||||||
|
|
||||||
|
import { AccountRequest, ForwarderConfiguration } from "./forwarder-configuration";
|
||||||
|
import { ForwarderContext } from "./forwarder-context";
|
||||||
|
import { CreateForwardingAddressRpc, GetAccountIdRpc } from "./rpc";
|
||||||
|
|
||||||
|
/** Generation algorithms that query an email forwarding service to
|
||||||
|
* create anonymized email addresses.
|
||||||
|
*/
|
||||||
|
export class Forwarder implements CredentialGenerator<ApiSettings> {
|
||||||
|
/** Instantiates the email forwarder engine
|
||||||
|
* @param configuration The forwarder to query
|
||||||
|
* @param client requests data from the forwarding service
|
||||||
|
* @param i18nService localizes messages sent to the forwarding service
|
||||||
|
* and user-addressable errors
|
||||||
|
*/
|
||||||
|
constructor(
|
||||||
|
private configuration: ForwarderConfiguration<ApiSettings>,
|
||||||
|
private client: RestClient,
|
||||||
|
private i18nService: I18nService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async generate(request: GenerationRequest, settings: ApiSettings) {
|
||||||
|
const requestOptions: IntegrationRequest & AccountRequest = { website: request.website };
|
||||||
|
|
||||||
|
const getAccount = await this.getAccountId(this.configuration, settings);
|
||||||
|
if (getAccount) {
|
||||||
|
requestOptions.accountId = await this.client.fetchJson(getAccount, requestOptions);
|
||||||
|
}
|
||||||
|
|
||||||
|
const create = this.createForwardingAddress(this.configuration, settings);
|
||||||
|
const result = await this.client.fetchJson(create, requestOptions);
|
||||||
|
const id = { forwarder: this.configuration.id };
|
||||||
|
|
||||||
|
return new GeneratedCredential(result, id, Date.now());
|
||||||
|
}
|
||||||
|
|
||||||
|
private createContext<Settings>(
|
||||||
|
configuration: ForwarderConfiguration<Settings>,
|
||||||
|
settings: Settings,
|
||||||
|
) {
|
||||||
|
return new ForwarderContext(configuration, settings, this.i18nService);
|
||||||
|
}
|
||||||
|
|
||||||
|
private createForwardingAddress<Settings extends ApiSettings>(
|
||||||
|
configuration: ForwarderConfiguration<Settings>,
|
||||||
|
settings: Settings,
|
||||||
|
) {
|
||||||
|
const context = this.createContext(configuration, settings);
|
||||||
|
const rpc = new CreateForwardingAddressRpc<Settings>(configuration, context);
|
||||||
|
return rpc;
|
||||||
|
}
|
||||||
|
|
||||||
|
private getAccountId<Settings extends ApiSettings>(
|
||||||
|
configuration: ForwarderConfiguration<Settings>,
|
||||||
|
settings: Settings,
|
||||||
|
) {
|
||||||
|
if (!configuration.forwarder.getAccountId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const context = this.createContext(configuration, settings);
|
||||||
|
const rpc = new GetAccountIdRpc<Settings>(configuration, context);
|
||||||
|
|
||||||
|
return rpc;
|
||||||
|
}
|
||||||
|
}
|
@ -1,11 +1,18 @@
|
|||||||
import { GENERATOR_DISK, UserKeyDefinition } from "@bitwarden/common/platform/state";
|
import {
|
||||||
|
GENERATOR_DISK,
|
||||||
|
GENERATOR_MEMORY,
|
||||||
|
UserKeyDefinition,
|
||||||
|
} from "@bitwarden/common/platform/state";
|
||||||
import { IntegrationContext, IntegrationId } from "@bitwarden/common/tools/integration";
|
import { IntegrationContext, IntegrationId } from "@bitwarden/common/tools/integration";
|
||||||
import {
|
import {
|
||||||
ApiSettings,
|
ApiSettings,
|
||||||
IntegrationRequest,
|
IntegrationRequest,
|
||||||
SelfHostedApiSettings,
|
SelfHostedApiSettings,
|
||||||
} from "@bitwarden/common/tools/integration/rpc";
|
} from "@bitwarden/common/tools/integration/rpc";
|
||||||
|
import { PrivateClassifier } from "@bitwarden/common/tools/private-classifier";
|
||||||
|
import { PublicClassifier } from "@bitwarden/common/tools/public-classifier";
|
||||||
import { BufferedKeyDefinition } from "@bitwarden/common/tools/state/buffered-key-definition";
|
import { BufferedKeyDefinition } from "@bitwarden/common/tools/state/buffered-key-definition";
|
||||||
|
import { ObjectKey } from "@bitwarden/common/tools/state/object-key";
|
||||||
|
|
||||||
import { ForwarderConfiguration, ForwarderContext, EmailDomainSettings } from "../engine";
|
import { ForwarderConfiguration, ForwarderContext, EmailDomainSettings } from "../engine";
|
||||||
import { CreateForwardingEmailRpcDef } from "../engine/forwarder-configuration";
|
import { CreateForwardingEmailRpcDef } from "../engine/forwarder-configuration";
|
||||||
@ -44,6 +51,40 @@ const createForwardingEmail = Object.freeze({
|
|||||||
// forwarder configuration
|
// forwarder configuration
|
||||||
const forwarder = Object.freeze({
|
const forwarder = Object.freeze({
|
||||||
defaultSettings,
|
defaultSettings,
|
||||||
|
createForwardingEmail,
|
||||||
|
request: ["token", "baseUrl", "domain"],
|
||||||
|
settingsConstraints: {
|
||||||
|
token: { required: true },
|
||||||
|
domain: { required: true },
|
||||||
|
baseUrl: {},
|
||||||
|
},
|
||||||
|
local: {
|
||||||
|
settings: {
|
||||||
|
// FIXME: integration should issue keys at runtime
|
||||||
|
// based on integrationId & extension metadata
|
||||||
|
// e.g. key: "forwarder.AddyIo.local.settings",
|
||||||
|
key: "addyIoForwarder",
|
||||||
|
target: "object",
|
||||||
|
format: "classified",
|
||||||
|
classifier: new PrivateClassifier<AddyIoSettings>(),
|
||||||
|
state: GENERATOR_DISK,
|
||||||
|
options: {
|
||||||
|
deserializer: (value) => value,
|
||||||
|
clearOn: ["logout"],
|
||||||
|
},
|
||||||
|
} satisfies ObjectKey<AddyIoSettings>,
|
||||||
|
import: {
|
||||||
|
key: "forwarder.AddyIo.local.import",
|
||||||
|
target: "object",
|
||||||
|
format: "plain",
|
||||||
|
classifier: new PublicClassifier<AddyIoSettings>(["token", "baseUrl", "domain"]),
|
||||||
|
state: GENERATOR_MEMORY,
|
||||||
|
options: {
|
||||||
|
deserializer: (value) => value,
|
||||||
|
clearOn: ["logout", "lock"],
|
||||||
|
},
|
||||||
|
} satisfies ObjectKey<AddyIoSettings, Record<string, never>, AddyIoSettings>,
|
||||||
|
},
|
||||||
settings: new UserKeyDefinition<AddyIoSettings>(GENERATOR_DISK, "addyIoForwarder", {
|
settings: new UserKeyDefinition<AddyIoSettings>(GENERATOR_DISK, "addyIoForwarder", {
|
||||||
deserializer: (value) => value,
|
deserializer: (value) => value,
|
||||||
clearOn: [],
|
clearOn: [],
|
||||||
@ -52,7 +93,6 @@ const forwarder = Object.freeze({
|
|||||||
deserializer: (value) => value,
|
deserializer: (value) => value,
|
||||||
clearOn: ["logout"],
|
clearOn: ["logout"],
|
||||||
}),
|
}),
|
||||||
createForwardingEmail,
|
|
||||||
} as const);
|
} as const);
|
||||||
|
|
||||||
export const AddyIo = Object.freeze({
|
export const AddyIo = Object.freeze({
|
||||||
|
@ -1,7 +1,14 @@
|
|||||||
import { GENERATOR_DISK, UserKeyDefinition } from "@bitwarden/common/platform/state";
|
import {
|
||||||
|
GENERATOR_DISK,
|
||||||
|
GENERATOR_MEMORY,
|
||||||
|
UserKeyDefinition,
|
||||||
|
} from "@bitwarden/common/platform/state";
|
||||||
import { IntegrationContext, IntegrationId } from "@bitwarden/common/tools/integration";
|
import { IntegrationContext, IntegrationId } from "@bitwarden/common/tools/integration";
|
||||||
import { ApiSettings, IntegrationRequest } from "@bitwarden/common/tools/integration/rpc";
|
import { ApiSettings, IntegrationRequest } from "@bitwarden/common/tools/integration/rpc";
|
||||||
|
import { PrivateClassifier } from "@bitwarden/common/tools/private-classifier";
|
||||||
|
import { PublicClassifier } from "@bitwarden/common/tools/public-classifier";
|
||||||
import { BufferedKeyDefinition } from "@bitwarden/common/tools/state/buffered-key-definition";
|
import { BufferedKeyDefinition } from "@bitwarden/common/tools/state/buffered-key-definition";
|
||||||
|
import { ObjectKey } from "@bitwarden/common/tools/state/object-key";
|
||||||
|
|
||||||
import { ForwarderConfiguration, ForwarderContext } from "../engine";
|
import { ForwarderConfiguration, ForwarderContext } from "../engine";
|
||||||
import { CreateForwardingEmailRpcDef } from "../engine/forwarder-configuration";
|
import { CreateForwardingEmailRpcDef } from "../engine/forwarder-configuration";
|
||||||
@ -36,6 +43,38 @@ const createForwardingEmail = Object.freeze({
|
|||||||
// forwarder configuration
|
// forwarder configuration
|
||||||
const forwarder = Object.freeze({
|
const forwarder = Object.freeze({
|
||||||
defaultSettings,
|
defaultSettings,
|
||||||
|
createForwardingEmail,
|
||||||
|
request: ["token"],
|
||||||
|
settingsConstraints: {
|
||||||
|
token: { required: true },
|
||||||
|
},
|
||||||
|
local: {
|
||||||
|
settings: {
|
||||||
|
// FIXME: integration should issue keys at runtime
|
||||||
|
// based on integrationId & extension metadata
|
||||||
|
// e.g. key: "forwarder.DuckDuckGo.local.settings",
|
||||||
|
key: "duckDuckGoForwarder",
|
||||||
|
target: "object",
|
||||||
|
format: "classified",
|
||||||
|
classifier: new PrivateClassifier<DuckDuckGoSettings>(),
|
||||||
|
state: GENERATOR_DISK,
|
||||||
|
options: {
|
||||||
|
deserializer: (value) => value,
|
||||||
|
clearOn: ["logout"],
|
||||||
|
},
|
||||||
|
} satisfies ObjectKey<DuckDuckGoSettings>,
|
||||||
|
import: {
|
||||||
|
key: "forwarder.DuckDuckGo.local.import",
|
||||||
|
target: "object",
|
||||||
|
format: "plain",
|
||||||
|
classifier: new PublicClassifier<DuckDuckGoSettings>(["token"]),
|
||||||
|
state: GENERATOR_MEMORY,
|
||||||
|
options: {
|
||||||
|
deserializer: (value) => value,
|
||||||
|
clearOn: ["logout", "lock"],
|
||||||
|
},
|
||||||
|
} satisfies ObjectKey<DuckDuckGoSettings, Record<string, never>, DuckDuckGoSettings>,
|
||||||
|
},
|
||||||
settings: new UserKeyDefinition<DuckDuckGoSettings>(GENERATOR_DISK, "duckDuckGoForwarder", {
|
settings: new UserKeyDefinition<DuckDuckGoSettings>(GENERATOR_DISK, "duckDuckGoForwarder", {
|
||||||
deserializer: (value) => value,
|
deserializer: (value) => value,
|
||||||
clearOn: [],
|
clearOn: [],
|
||||||
@ -44,7 +83,6 @@ const forwarder = Object.freeze({
|
|||||||
deserializer: (value) => value,
|
deserializer: (value) => value,
|
||||||
clearOn: ["logout"],
|
clearOn: ["logout"],
|
||||||
}),
|
}),
|
||||||
createForwardingEmail,
|
|
||||||
} as const);
|
} as const);
|
||||||
|
|
||||||
// integration-wide configuration
|
// integration-wide configuration
|
||||||
|
@ -1,7 +1,14 @@
|
|||||||
import { GENERATOR_DISK, UserKeyDefinition } from "@bitwarden/common/platform/state";
|
import {
|
||||||
|
GENERATOR_DISK,
|
||||||
|
GENERATOR_MEMORY,
|
||||||
|
UserKeyDefinition,
|
||||||
|
} from "@bitwarden/common/platform/state";
|
||||||
import { IntegrationContext, IntegrationId } from "@bitwarden/common/tools/integration";
|
import { IntegrationContext, IntegrationId } from "@bitwarden/common/tools/integration";
|
||||||
import { ApiSettings, IntegrationRequest } from "@bitwarden/common/tools/integration/rpc";
|
import { ApiSettings, IntegrationRequest } from "@bitwarden/common/tools/integration/rpc";
|
||||||
|
import { PrivateClassifier } from "@bitwarden/common/tools/private-classifier";
|
||||||
|
import { PublicClassifier } from "@bitwarden/common/tools/public-classifier";
|
||||||
import { BufferedKeyDefinition } from "@bitwarden/common/tools/state/buffered-key-definition";
|
import { BufferedKeyDefinition } from "@bitwarden/common/tools/state/buffered-key-definition";
|
||||||
|
import { ObjectKey } from "@bitwarden/common/tools/state/object-key";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
ForwarderConfiguration,
|
ForwarderConfiguration,
|
||||||
@ -101,6 +108,41 @@ const createForwardingEmail = Object.freeze({
|
|||||||
// forwarder configuration
|
// forwarder configuration
|
||||||
const forwarder = Object.freeze({
|
const forwarder = Object.freeze({
|
||||||
defaultSettings,
|
defaultSettings,
|
||||||
|
createForwardingEmail,
|
||||||
|
getAccountId,
|
||||||
|
request: ["token"],
|
||||||
|
settingsConstraints: {
|
||||||
|
token: { required: true },
|
||||||
|
domain: { required: true },
|
||||||
|
prefix: {},
|
||||||
|
},
|
||||||
|
local: {
|
||||||
|
settings: {
|
||||||
|
// FIXME: integration should issue keys at runtime
|
||||||
|
// based on integrationId & extension metadata
|
||||||
|
// e.g. key: "forwarder.Fastmail.local.settings"
|
||||||
|
key: "fastmailForwarder",
|
||||||
|
target: "object",
|
||||||
|
format: "classified",
|
||||||
|
classifier: new PrivateClassifier<FastmailSettings>(),
|
||||||
|
state: GENERATOR_DISK,
|
||||||
|
options: {
|
||||||
|
deserializer: (value) => value,
|
||||||
|
clearOn: ["logout"],
|
||||||
|
},
|
||||||
|
} satisfies ObjectKey<FastmailSettings>,
|
||||||
|
import: {
|
||||||
|
key: "forwarder.Fastmail.local.import",
|
||||||
|
target: "object",
|
||||||
|
format: "plain",
|
||||||
|
classifier: new PublicClassifier<FastmailSettings>(["token"]),
|
||||||
|
state: GENERATOR_MEMORY,
|
||||||
|
options: {
|
||||||
|
deserializer: (value) => value,
|
||||||
|
clearOn: ["logout", "lock"],
|
||||||
|
},
|
||||||
|
} satisfies ObjectKey<FastmailSettings, Record<string, never>, FastmailSettings>,
|
||||||
|
},
|
||||||
settings: new UserKeyDefinition<FastmailSettings>(GENERATOR_DISK, "fastmailForwarder", {
|
settings: new UserKeyDefinition<FastmailSettings>(GENERATOR_DISK, "fastmailForwarder", {
|
||||||
deserializer: (value) => value,
|
deserializer: (value) => value,
|
||||||
clearOn: [],
|
clearOn: [],
|
||||||
@ -109,8 +151,6 @@ const forwarder = Object.freeze({
|
|||||||
deserializer: (value) => value,
|
deserializer: (value) => value,
|
||||||
clearOn: ["logout"],
|
clearOn: ["logout"],
|
||||||
}),
|
}),
|
||||||
createForwardingEmail,
|
|
||||||
getAccountId,
|
|
||||||
} as const);
|
} as const);
|
||||||
|
|
||||||
// integration-wide configuration
|
// integration-wide configuration
|
||||||
|
@ -1,7 +1,14 @@
|
|||||||
import { GENERATOR_DISK, UserKeyDefinition } from "@bitwarden/common/platform/state";
|
import {
|
||||||
|
GENERATOR_DISK,
|
||||||
|
GENERATOR_MEMORY,
|
||||||
|
UserKeyDefinition,
|
||||||
|
} from "@bitwarden/common/platform/state";
|
||||||
import { IntegrationContext, IntegrationId } from "@bitwarden/common/tools/integration";
|
import { IntegrationContext, IntegrationId } from "@bitwarden/common/tools/integration";
|
||||||
import { ApiSettings, IntegrationRequest } from "@bitwarden/common/tools/integration/rpc";
|
import { ApiSettings, IntegrationRequest } from "@bitwarden/common/tools/integration/rpc";
|
||||||
|
import { PrivateClassifier } from "@bitwarden/common/tools/private-classifier";
|
||||||
|
import { PublicClassifier } from "@bitwarden/common/tools/public-classifier";
|
||||||
import { BufferedKeyDefinition } from "@bitwarden/common/tools/state/buffered-key-definition";
|
import { BufferedKeyDefinition } from "@bitwarden/common/tools/state/buffered-key-definition";
|
||||||
|
import { ObjectKey } from "@bitwarden/common/tools/state/object-key";
|
||||||
|
|
||||||
import { ForwarderConfiguration, ForwarderContext } from "../engine";
|
import { ForwarderConfiguration, ForwarderContext } from "../engine";
|
||||||
import { CreateForwardingEmailRpcDef } from "../engine/forwarder-configuration";
|
import { CreateForwardingEmailRpcDef } from "../engine/forwarder-configuration";
|
||||||
@ -40,6 +47,38 @@ const createForwardingEmail = Object.freeze({
|
|||||||
// forwarder configuration
|
// forwarder configuration
|
||||||
const forwarder = Object.freeze({
|
const forwarder = Object.freeze({
|
||||||
defaultSettings,
|
defaultSettings,
|
||||||
|
createForwardingEmail,
|
||||||
|
request: ["token"],
|
||||||
|
settingsConstraints: {
|
||||||
|
token: { required: true },
|
||||||
|
},
|
||||||
|
local: {
|
||||||
|
settings: {
|
||||||
|
// FIXME: integration should issue keys at runtime
|
||||||
|
// based on integrationId & extension metadata
|
||||||
|
// e.g. key: "forwarder.Firefox.local.settings",
|
||||||
|
key: "firefoxRelayForwarder",
|
||||||
|
target: "object",
|
||||||
|
format: "classified",
|
||||||
|
classifier: new PrivateClassifier<FirefoxRelaySettings>(),
|
||||||
|
state: GENERATOR_DISK,
|
||||||
|
options: {
|
||||||
|
deserializer: (value) => value,
|
||||||
|
clearOn: ["logout"],
|
||||||
|
},
|
||||||
|
} satisfies ObjectKey<FirefoxRelaySettings>,
|
||||||
|
import: {
|
||||||
|
key: "forwarder.Firefox.local.import",
|
||||||
|
target: "object",
|
||||||
|
format: "plain",
|
||||||
|
classifier: new PublicClassifier<FirefoxRelaySettings>(["token"]),
|
||||||
|
state: GENERATOR_MEMORY,
|
||||||
|
options: {
|
||||||
|
deserializer: (value) => value,
|
||||||
|
clearOn: ["logout", "lock"],
|
||||||
|
},
|
||||||
|
} satisfies ObjectKey<FirefoxRelaySettings, Record<string, never>, FirefoxRelaySettings>,
|
||||||
|
},
|
||||||
settings: new UserKeyDefinition<FirefoxRelaySettings>(GENERATOR_DISK, "firefoxRelayForwarder", {
|
settings: new UserKeyDefinition<FirefoxRelaySettings>(GENERATOR_DISK, "firefoxRelayForwarder", {
|
||||||
deserializer: (value) => value,
|
deserializer: (value) => value,
|
||||||
clearOn: [],
|
clearOn: [],
|
||||||
@ -52,7 +91,6 @@ const forwarder = Object.freeze({
|
|||||||
clearOn: ["logout"],
|
clearOn: ["logout"],
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
createForwardingEmail,
|
|
||||||
} as const);
|
} as const);
|
||||||
|
|
||||||
// integration-wide configuration
|
// integration-wide configuration
|
||||||
|
@ -1,7 +1,14 @@
|
|||||||
import { GENERATOR_DISK, UserKeyDefinition } from "@bitwarden/common/platform/state";
|
import {
|
||||||
|
GENERATOR_DISK,
|
||||||
|
GENERATOR_MEMORY,
|
||||||
|
UserKeyDefinition,
|
||||||
|
} from "@bitwarden/common/platform/state";
|
||||||
import { IntegrationContext, IntegrationId } from "@bitwarden/common/tools/integration";
|
import { IntegrationContext, IntegrationId } from "@bitwarden/common/tools/integration";
|
||||||
import { ApiSettings, IntegrationRequest } from "@bitwarden/common/tools/integration/rpc";
|
import { ApiSettings, IntegrationRequest } from "@bitwarden/common/tools/integration/rpc";
|
||||||
|
import { PrivateClassifier } from "@bitwarden/common/tools/private-classifier";
|
||||||
|
import { PublicClassifier } from "@bitwarden/common/tools/public-classifier";
|
||||||
import { BufferedKeyDefinition } from "@bitwarden/common/tools/state/buffered-key-definition";
|
import { BufferedKeyDefinition } from "@bitwarden/common/tools/state/buffered-key-definition";
|
||||||
|
import { ObjectKey } from "@bitwarden/common/tools/state/object-key";
|
||||||
|
|
||||||
import { ForwarderConfiguration, ForwarderContext, EmailDomainSettings } from "../engine";
|
import { ForwarderConfiguration, ForwarderContext, EmailDomainSettings } from "../engine";
|
||||||
import { CreateForwardingEmailRpcDef } from "../engine/forwarder-configuration";
|
import { CreateForwardingEmailRpcDef } from "../engine/forwarder-configuration";
|
||||||
@ -43,6 +50,38 @@ const createForwardingEmail = Object.freeze({
|
|||||||
// forwarder configuration
|
// forwarder configuration
|
||||||
const forwarder = Object.freeze({
|
const forwarder = Object.freeze({
|
||||||
defaultSettings,
|
defaultSettings,
|
||||||
|
request: ["token", "domain"],
|
||||||
|
settingsConstraints: {
|
||||||
|
token: { required: true },
|
||||||
|
domain: { required: true },
|
||||||
|
},
|
||||||
|
local: {
|
||||||
|
settings: {
|
||||||
|
// FIXME: integration should issue keys at runtime
|
||||||
|
// based on integrationId & extension metadata
|
||||||
|
// e.g. key: "forwarder.ForwardEmail.local.settings",
|
||||||
|
key: "forwardEmailForwarder",
|
||||||
|
target: "object",
|
||||||
|
format: "classified",
|
||||||
|
classifier: new PrivateClassifier<ForwardEmailSettings>(),
|
||||||
|
state: GENERATOR_DISK,
|
||||||
|
options: {
|
||||||
|
deserializer: (value) => value,
|
||||||
|
clearOn: ["logout"],
|
||||||
|
},
|
||||||
|
} satisfies ObjectKey<ForwardEmailSettings>,
|
||||||
|
import: {
|
||||||
|
key: "forwarder.ForwardEmail.local.import",
|
||||||
|
target: "object",
|
||||||
|
format: "plain",
|
||||||
|
classifier: new PublicClassifier<ForwardEmailSettings>(["token", "domain"]),
|
||||||
|
state: GENERATOR_MEMORY,
|
||||||
|
options: {
|
||||||
|
deserializer: (value) => value,
|
||||||
|
clearOn: ["logout", "lock"],
|
||||||
|
},
|
||||||
|
} satisfies ObjectKey<ForwardEmailSettings, Record<string, never>, ForwardEmailSettings>,
|
||||||
|
},
|
||||||
settings: new UserKeyDefinition<ForwardEmailSettings>(GENERATOR_DISK, "forwardEmailForwarder", {
|
settings: new UserKeyDefinition<ForwardEmailSettings>(GENERATOR_DISK, "forwardEmailForwarder", {
|
||||||
deserializer: (value) => value,
|
deserializer: (value) => value,
|
||||||
clearOn: [],
|
clearOn: [],
|
||||||
|
@ -1,11 +1,18 @@
|
|||||||
import { GENERATOR_DISK, UserKeyDefinition } from "@bitwarden/common/platform/state";
|
import {
|
||||||
|
GENERATOR_DISK,
|
||||||
|
GENERATOR_MEMORY,
|
||||||
|
UserKeyDefinition,
|
||||||
|
} from "@bitwarden/common/platform/state";
|
||||||
import { IntegrationContext, IntegrationId } from "@bitwarden/common/tools/integration";
|
import { IntegrationContext, IntegrationId } from "@bitwarden/common/tools/integration";
|
||||||
import {
|
import {
|
||||||
ApiSettings,
|
ApiSettings,
|
||||||
IntegrationRequest,
|
IntegrationRequest,
|
||||||
SelfHostedApiSettings,
|
SelfHostedApiSettings,
|
||||||
} from "@bitwarden/common/tools/integration/rpc";
|
} from "@bitwarden/common/tools/integration/rpc";
|
||||||
|
import { PrivateClassifier } from "@bitwarden/common/tools/private-classifier";
|
||||||
|
import { PublicClassifier } from "@bitwarden/common/tools/public-classifier";
|
||||||
import { BufferedKeyDefinition } from "@bitwarden/common/tools/state/buffered-key-definition";
|
import { BufferedKeyDefinition } from "@bitwarden/common/tools/state/buffered-key-definition";
|
||||||
|
import { ObjectKey } from "@bitwarden/common/tools/state/object-key";
|
||||||
|
|
||||||
import { ForwarderConfiguration, ForwarderContext } from "../engine";
|
import { ForwarderConfiguration, ForwarderContext } from "../engine";
|
||||||
import { CreateForwardingEmailRpcDef } from "../engine/forwarder-configuration";
|
import { CreateForwardingEmailRpcDef } from "../engine/forwarder-configuration";
|
||||||
@ -45,6 +52,38 @@ const createForwardingEmail = Object.freeze({
|
|||||||
// forwarder configuration
|
// forwarder configuration
|
||||||
const forwarder = Object.freeze({
|
const forwarder = Object.freeze({
|
||||||
defaultSettings,
|
defaultSettings,
|
||||||
|
createForwardingEmail,
|
||||||
|
request: ["token", "baseUrl"],
|
||||||
|
settingsConstraints: {
|
||||||
|
token: { required: true },
|
||||||
|
},
|
||||||
|
local: {
|
||||||
|
settings: {
|
||||||
|
// FIXME: integration should issue keys at runtime
|
||||||
|
// based on integrationId & extension metadata
|
||||||
|
// e.g. key: "forwarder.SimpleLogin.local.settings",
|
||||||
|
key: "simpleLoginForwarder",
|
||||||
|
target: "object",
|
||||||
|
format: "classified",
|
||||||
|
classifier: new PrivateClassifier<SimpleLoginSettings>(),
|
||||||
|
state: GENERATOR_DISK,
|
||||||
|
options: {
|
||||||
|
deserializer: (value) => value,
|
||||||
|
clearOn: ["logout"],
|
||||||
|
},
|
||||||
|
} satisfies ObjectKey<SimpleLoginSettings>,
|
||||||
|
import: {
|
||||||
|
key: "forwarder.SimpleLogin.local.import",
|
||||||
|
target: "object",
|
||||||
|
format: "plain",
|
||||||
|
classifier: new PublicClassifier<SimpleLoginSettings>(["token", "baseUrl"]),
|
||||||
|
state: GENERATOR_MEMORY,
|
||||||
|
options: {
|
||||||
|
deserializer: (value) => value,
|
||||||
|
clearOn: ["logout", "lock"],
|
||||||
|
},
|
||||||
|
} satisfies ObjectKey<SimpleLoginSettings, Record<string, never>, SimpleLoginSettings>,
|
||||||
|
},
|
||||||
settings: new UserKeyDefinition<SimpleLoginSettings>(GENERATOR_DISK, "simpleLoginForwarder", {
|
settings: new UserKeyDefinition<SimpleLoginSettings>(GENERATOR_DISK, "simpleLoginForwarder", {
|
||||||
deserializer: (value) => value,
|
deserializer: (value) => value,
|
||||||
clearOn: [],
|
clearOn: [],
|
||||||
@ -57,7 +96,6 @@ const forwarder = Object.freeze({
|
|||||||
clearOn: ["logout"],
|
clearOn: ["logout"],
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
createForwardingEmail,
|
|
||||||
} as const);
|
} as const);
|
||||||
|
|
||||||
// integration-wide configuration
|
// integration-wide configuration
|
||||||
|
@ -1,352 +0,0 @@
|
|||||||
import { EmptyError, Subject, tap } from "rxjs";
|
|
||||||
|
|
||||||
import { anyComplete, on, ready } from "./rx";
|
|
||||||
|
|
||||||
describe("anyComplete", () => {
|
|
||||||
it("emits true when its input completes", () => {
|
|
||||||
const input$ = new Subject<void>();
|
|
||||||
|
|
||||||
const emissions: boolean[] = [];
|
|
||||||
anyComplete(input$).subscribe((e) => emissions.push(e));
|
|
||||||
input$.complete();
|
|
||||||
|
|
||||||
expect(emissions).toEqual([true]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("completes when its input is already complete", () => {
|
|
||||||
const input = new Subject<void>();
|
|
||||||
input.complete();
|
|
||||||
|
|
||||||
let completed = false;
|
|
||||||
anyComplete(input).subscribe({ complete: () => (completed = true) });
|
|
||||||
|
|
||||||
expect(completed).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("completes when any input completes", () => {
|
|
||||||
const input$ = new Subject<void>();
|
|
||||||
const completing$ = new Subject<void>();
|
|
||||||
|
|
||||||
let completed = false;
|
|
||||||
anyComplete([input$, completing$]).subscribe({ complete: () => (completed = true) });
|
|
||||||
completing$.complete();
|
|
||||||
|
|
||||||
expect(completed).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("ignores emissions", () => {
|
|
||||||
const input$ = new Subject<number>();
|
|
||||||
|
|
||||||
const emissions: boolean[] = [];
|
|
||||||
anyComplete(input$).subscribe((e) => emissions.push(e));
|
|
||||||
input$.next(1);
|
|
||||||
input$.next(2);
|
|
||||||
input$.complete();
|
|
||||||
|
|
||||||
expect(emissions).toEqual([true]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("forwards errors", () => {
|
|
||||||
const input$ = new Subject<void>();
|
|
||||||
const expected = { some: "error" };
|
|
||||||
|
|
||||||
let error = null;
|
|
||||||
anyComplete(input$).subscribe({ error: (e: unknown) => (error = e) });
|
|
||||||
input$.error(expected);
|
|
||||||
|
|
||||||
expect(error).toEqual(expected);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("ready", () => {
|
|
||||||
it("connects when subscribed", () => {
|
|
||||||
const watch$ = new Subject<void>();
|
|
||||||
let connected = false;
|
|
||||||
const source$ = new Subject<number>().pipe(tap({ subscribe: () => (connected = true) }));
|
|
||||||
|
|
||||||
// precondition: ready$ should be cold
|
|
||||||
const ready$ = source$.pipe(ready(watch$));
|
|
||||||
expect(connected).toBe(false);
|
|
||||||
|
|
||||||
ready$.subscribe();
|
|
||||||
|
|
||||||
expect(connected).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("suppresses source emissions until its watch emits", () => {
|
|
||||||
const watch$ = new Subject<void>();
|
|
||||||
const source$ = new Subject<number>();
|
|
||||||
const ready$ = source$.pipe(ready(watch$));
|
|
||||||
const results: number[] = [];
|
|
||||||
ready$.subscribe((n) => results.push(n));
|
|
||||||
|
|
||||||
// precondition: no emissions
|
|
||||||
source$.next(1);
|
|
||||||
expect(results).toEqual([]);
|
|
||||||
|
|
||||||
watch$.next();
|
|
||||||
|
|
||||||
expect(results).toEqual([1]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("suppresses source emissions until all watches emit", () => {
|
|
||||||
const watchA$ = new Subject<void>();
|
|
||||||
const watchB$ = new Subject<void>();
|
|
||||||
const source$ = new Subject<number>();
|
|
||||||
const ready$ = source$.pipe(ready([watchA$, watchB$]));
|
|
||||||
const results: number[] = [];
|
|
||||||
ready$.subscribe((n) => results.push(n));
|
|
||||||
|
|
||||||
// preconditions: no emissions
|
|
||||||
source$.next(1);
|
|
||||||
expect(results).toEqual([]);
|
|
||||||
watchA$.next();
|
|
||||||
expect(results).toEqual([]);
|
|
||||||
|
|
||||||
watchB$.next();
|
|
||||||
|
|
||||||
expect(results).toEqual([1]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("emits the last source emission when its watch emits", () => {
|
|
||||||
const watch$ = new Subject<void>();
|
|
||||||
const source$ = new Subject<number>();
|
|
||||||
const ready$ = source$.pipe(ready(watch$));
|
|
||||||
const results: number[] = [];
|
|
||||||
ready$.subscribe((n) => results.push(n));
|
|
||||||
|
|
||||||
// precondition: no emissions
|
|
||||||
source$.next(1);
|
|
||||||
expect(results).toEqual([]);
|
|
||||||
|
|
||||||
source$.next(2);
|
|
||||||
watch$.next();
|
|
||||||
|
|
||||||
expect(results).toEqual([2]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("emits all source emissions after its watch emits", () => {
|
|
||||||
const watch$ = new Subject<void>();
|
|
||||||
const source$ = new Subject<number>();
|
|
||||||
const ready$ = source$.pipe(ready(watch$));
|
|
||||||
const results: number[] = [];
|
|
||||||
ready$.subscribe((n) => results.push(n));
|
|
||||||
|
|
||||||
watch$.next();
|
|
||||||
source$.next(1);
|
|
||||||
source$.next(2);
|
|
||||||
|
|
||||||
expect(results).toEqual([1, 2]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("ignores repeated watch emissions", () => {
|
|
||||||
const watch$ = new Subject<void>();
|
|
||||||
const source$ = new Subject<number>();
|
|
||||||
const ready$ = source$.pipe(ready(watch$));
|
|
||||||
const results: number[] = [];
|
|
||||||
ready$.subscribe((n) => results.push(n));
|
|
||||||
|
|
||||||
watch$.next();
|
|
||||||
source$.next(1);
|
|
||||||
watch$.next();
|
|
||||||
source$.next(2);
|
|
||||||
watch$.next();
|
|
||||||
|
|
||||||
expect(results).toEqual([1, 2]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("completes when its source completes", () => {
|
|
||||||
const watch$ = new Subject<void>();
|
|
||||||
const source$ = new Subject<number>();
|
|
||||||
const ready$ = source$.pipe(ready(watch$));
|
|
||||||
let completed = false;
|
|
||||||
ready$.subscribe({ complete: () => (completed = true) });
|
|
||||||
|
|
||||||
source$.complete();
|
|
||||||
|
|
||||||
expect(completed).toBeTruthy();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("errors when its source errors", () => {
|
|
||||||
const watch$ = new Subject<void>();
|
|
||||||
const source$ = new Subject<number>();
|
|
||||||
const ready$ = source$.pipe(ready(watch$));
|
|
||||||
const expected = { some: "error" };
|
|
||||||
let error = null;
|
|
||||||
ready$.subscribe({ error: (e: unknown) => (error = e) });
|
|
||||||
|
|
||||||
source$.error(expected);
|
|
||||||
|
|
||||||
expect(error).toEqual(expected);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("errors when its watch errors", () => {
|
|
||||||
const watch$ = new Subject<void>();
|
|
||||||
const source$ = new Subject<number>();
|
|
||||||
const ready$ = source$.pipe(ready(watch$));
|
|
||||||
const expected = { some: "error" };
|
|
||||||
let error = null;
|
|
||||||
ready$.subscribe({ error: (e: unknown) => (error = e) });
|
|
||||||
|
|
||||||
watch$.error(expected);
|
|
||||||
|
|
||||||
expect(error).toEqual(expected);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("errors when its watch completes before emitting", () => {
|
|
||||||
const watch$ = new Subject<void>();
|
|
||||||
const source$ = new Subject<number>();
|
|
||||||
const ready$ = source$.pipe(ready(watch$));
|
|
||||||
let error = null;
|
|
||||||
ready$.subscribe({ error: (e: unknown) => (error = e) });
|
|
||||||
|
|
||||||
watch$.complete();
|
|
||||||
|
|
||||||
expect(error).toBeInstanceOf(EmptyError);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("on", () => {
|
|
||||||
it("connects when subscribed", () => {
|
|
||||||
const watch$ = new Subject<void>();
|
|
||||||
let connected = false;
|
|
||||||
const source$ = new Subject<number>().pipe(tap({ subscribe: () => (connected = true) }));
|
|
||||||
|
|
||||||
// precondition: on$ should be cold
|
|
||||||
const on$ = source$.pipe(on(watch$));
|
|
||||||
expect(connected).toBeFalsy();
|
|
||||||
|
|
||||||
on$.subscribe();
|
|
||||||
|
|
||||||
expect(connected).toBeTruthy();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("suppresses source emissions until `on` emits", () => {
|
|
||||||
const watch$ = new Subject<void>();
|
|
||||||
const source$ = new Subject<number>();
|
|
||||||
const results: number[] = [];
|
|
||||||
source$.pipe(on(watch$)).subscribe((n) => results.push(n));
|
|
||||||
|
|
||||||
// precondition: on$ should be cold
|
|
||||||
source$.next(1);
|
|
||||||
expect(results).toEqual([]);
|
|
||||||
|
|
||||||
watch$.next();
|
|
||||||
|
|
||||||
expect(results).toEqual([1]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("repeats source emissions when `on` emits", () => {
|
|
||||||
const watch$ = new Subject<void>();
|
|
||||||
const source$ = new Subject<number>();
|
|
||||||
const results: number[] = [];
|
|
||||||
source$.pipe(on(watch$)).subscribe((n) => results.push(n));
|
|
||||||
source$.next(1);
|
|
||||||
|
|
||||||
watch$.next();
|
|
||||||
watch$.next();
|
|
||||||
|
|
||||||
expect(results).toEqual([1, 1]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("updates source emissions when `on` emits", () => {
|
|
||||||
const watch$ = new Subject<void>();
|
|
||||||
const source$ = new Subject<number>();
|
|
||||||
const results: number[] = [];
|
|
||||||
source$.pipe(on(watch$)).subscribe((n) => results.push(n));
|
|
||||||
|
|
||||||
source$.next(1);
|
|
||||||
watch$.next();
|
|
||||||
source$.next(2);
|
|
||||||
watch$.next();
|
|
||||||
|
|
||||||
expect(results).toEqual([1, 2]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("emits a value when `on` emits before the source is ready", () => {
|
|
||||||
const watch$ = new Subject<void>();
|
|
||||||
const source$ = new Subject<number>();
|
|
||||||
const results: number[] = [];
|
|
||||||
source$.pipe(on(watch$)).subscribe((n) => results.push(n));
|
|
||||||
|
|
||||||
watch$.next();
|
|
||||||
source$.next(1);
|
|
||||||
|
|
||||||
expect(results).toEqual([1]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("ignores repeated `on` emissions before the source is ready", () => {
|
|
||||||
const watch$ = new Subject<void>();
|
|
||||||
const source$ = new Subject<number>();
|
|
||||||
const results: number[] = [];
|
|
||||||
source$.pipe(on(watch$)).subscribe((n) => results.push(n));
|
|
||||||
|
|
||||||
watch$.next();
|
|
||||||
watch$.next();
|
|
||||||
source$.next(1);
|
|
||||||
|
|
||||||
expect(results).toEqual([1]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("emits only the latest source emission when `on` emits", () => {
|
|
||||||
const watch$ = new Subject<void>();
|
|
||||||
const source$ = new Subject<number>();
|
|
||||||
const results: number[] = [];
|
|
||||||
source$.pipe(on(watch$)).subscribe((n) => results.push(n));
|
|
||||||
source$.next(1);
|
|
||||||
|
|
||||||
watch$.next();
|
|
||||||
|
|
||||||
source$.next(2);
|
|
||||||
source$.next(3);
|
|
||||||
watch$.next();
|
|
||||||
|
|
||||||
expect(results).toEqual([1, 3]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("completes when its source completes", () => {
|
|
||||||
const watch$ = new Subject<void>();
|
|
||||||
const source$ = new Subject<number>();
|
|
||||||
let complete: boolean = false;
|
|
||||||
source$.pipe(on(watch$)).subscribe({ complete: () => (complete = true) });
|
|
||||||
|
|
||||||
source$.complete();
|
|
||||||
|
|
||||||
expect(complete).toBeTruthy();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("completes when its watch completes", () => {
|
|
||||||
const watch$ = new Subject<void>();
|
|
||||||
const source$ = new Subject<number>();
|
|
||||||
let complete: boolean = false;
|
|
||||||
source$.pipe(on(watch$)).subscribe({ complete: () => (complete = true) });
|
|
||||||
|
|
||||||
watch$.complete();
|
|
||||||
|
|
||||||
expect(complete).toBeTruthy();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("errors when its source errors", () => {
|
|
||||||
const watch$ = new Subject<void>();
|
|
||||||
const source$ = new Subject<number>();
|
|
||||||
const expected = { some: "error" };
|
|
||||||
let error = null;
|
|
||||||
source$.pipe(on(watch$)).subscribe({ error: (e: unknown) => (error = e) });
|
|
||||||
|
|
||||||
source$.error(expected);
|
|
||||||
|
|
||||||
expect(error).toEqual(expected);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("errors when its watch errors", () => {
|
|
||||||
const watch$ = new Subject<void>();
|
|
||||||
const source$ = new Subject<number>();
|
|
||||||
const expected = { some: "error" };
|
|
||||||
let error = null;
|
|
||||||
source$.pipe(on(watch$)).subscribe({ error: (e: unknown) => (error = e) });
|
|
||||||
|
|
||||||
watch$.error(expected);
|
|
||||||
|
|
||||||
expect(error).toEqual(expected);
|
|
||||||
});
|
|
||||||
});
|
|
@ -1,18 +1,4 @@
|
|||||||
import {
|
import { map, pipe } from "rxjs";
|
||||||
concat,
|
|
||||||
concatMap,
|
|
||||||
connect,
|
|
||||||
endWith,
|
|
||||||
first,
|
|
||||||
ignoreElements,
|
|
||||||
map,
|
|
||||||
Observable,
|
|
||||||
pipe,
|
|
||||||
race,
|
|
||||||
ReplaySubject,
|
|
||||||
takeUntil,
|
|
||||||
zip,
|
|
||||||
} from "rxjs";
|
|
||||||
|
|
||||||
import { reduceCollection, distinctIfShallowMatch } from "@bitwarden/common/tools/rx";
|
import { reduceCollection, distinctIfShallowMatch } from "@bitwarden/common/tools/rx";
|
||||||
|
|
||||||
@ -51,86 +37,3 @@ export function newDefaultEvaluator<Target>() {
|
|||||||
return pipe(map((_) => new DefaultPolicyEvaluator<Target>()));
|
return pipe(map((_) => new DefaultPolicyEvaluator<Target>()));
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Create an observable that, once subscribed, emits `true` then completes when
|
|
||||||
* any input completes. If an input is already complete when the subscription
|
|
||||||
* occurs, it emits immediately.
|
|
||||||
* @param watch$ the observable(s) to watch for completion; if an array is passed,
|
|
||||||
* null and undefined members are ignored. If `watch$` is empty, `anyComplete`
|
|
||||||
* will never complete.
|
|
||||||
* @returns An observable that emits `true` when any of its inputs
|
|
||||||
* complete. The observable forwards the first error from its input.
|
|
||||||
* @remarks This method is particularly useful in combination with `takeUntil` and
|
|
||||||
* streams that are not guaranteed to complete on their own.
|
|
||||||
*/
|
|
||||||
export function anyComplete(watch$: Observable<any> | Observable<any>[]): Observable<any> {
|
|
||||||
if (Array.isArray(watch$)) {
|
|
||||||
const completes$ = watch$
|
|
||||||
.filter((w$) => !!w$)
|
|
||||||
.map((w$) => w$.pipe(ignoreElements(), endWith(true)));
|
|
||||||
const completed$ = race(completes$);
|
|
||||||
return completed$;
|
|
||||||
} else {
|
|
||||||
return watch$.pipe(ignoreElements(), endWith(true));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create an observable that delays the input stream until all watches have
|
|
||||||
* emitted a value. The watched values are not included in the source stream.
|
|
||||||
* The last emission from the source is output when all the watches have
|
|
||||||
* emitted at least once.
|
|
||||||
* @param watch$ the observable(s) to watch for readiness. If `watch$` is empty,
|
|
||||||
* `ready` will never emit.
|
|
||||||
* @returns An observable that emits when the source stream emits. The observable
|
|
||||||
* errors if one of its watches completes before emitting. It also errors if one
|
|
||||||
* of its watches errors.
|
|
||||||
*/
|
|
||||||
export function ready<T>(watch$: Observable<any> | Observable<any>[]) {
|
|
||||||
const watching$ = Array.isArray(watch$) ? watch$ : [watch$];
|
|
||||||
return pipe(
|
|
||||||
connect<T, Observable<T>>((source$) => {
|
|
||||||
// this subscription is safe because `source$` connects only after there
|
|
||||||
// is an external subscriber.
|
|
||||||
const source = new ReplaySubject<T>(1);
|
|
||||||
source$.subscribe(source);
|
|
||||||
|
|
||||||
// `concat` is subscribed immediately after it's returned, at which point
|
|
||||||
// `zip` blocks until all items in `watching$` are ready. If that occurs
|
|
||||||
// after `source$` is hot, then the replay subject sends the last-captured
|
|
||||||
// emission through immediately. Otherwise, `ready` waits for the next
|
|
||||||
// emission
|
|
||||||
return concat(zip(watching$).pipe(first(), ignoreElements()), source).pipe(
|
|
||||||
takeUntil(anyComplete(source)),
|
|
||||||
);
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create an observable that emits the latest value of the source stream
|
|
||||||
* when `watch$` emits. If `watch$` emits before the stream emits, then
|
|
||||||
* an emission occurs as soon as a value becomes ready.
|
|
||||||
* @param watch$ the observable that triggers emissions
|
|
||||||
* @returns An observable that emits when `watch$` emits. The observable
|
|
||||||
* errors if its source stream errors. It also errors if `on` errors. It
|
|
||||||
* completes if its watch completes.
|
|
||||||
*
|
|
||||||
* @remarks This works like `audit`, but it repeats emissions when
|
|
||||||
* watch$ fires.
|
|
||||||
*/
|
|
||||||
export function on<T>(watch$: Observable<any>) {
|
|
||||||
return pipe(
|
|
||||||
connect<T, Observable<T>>((source$) => {
|
|
||||||
const source = new ReplaySubject<T>(1);
|
|
||||||
source$.subscribe(source);
|
|
||||||
|
|
||||||
return watch$
|
|
||||||
.pipe(
|
|
||||||
ready(source),
|
|
||||||
concatMap(() => source.pipe(first())),
|
|
||||||
)
|
|
||||||
.pipe(takeUntil(anyComplete(source)));
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
@ -1,12 +1,17 @@
|
|||||||
import { mock } from "jest-mock-extended";
|
import { mock } from "jest-mock-extended";
|
||||||
import { BehaviorSubject, filter, firstValueFrom, Subject } from "rxjs";
|
import { BehaviorSubject, filter, firstValueFrom, Subject } from "rxjs";
|
||||||
|
|
||||||
|
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||||
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
|
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
|
||||||
import { PolicyType } from "@bitwarden/common/admin-console/enums";
|
import { PolicyType } from "@bitwarden/common/admin-console/enums";
|
||||||
import { Policy } from "@bitwarden/common/admin-console/models/domain/policy";
|
import { Policy } from "@bitwarden/common/admin-console/models/domain/policy";
|
||||||
|
import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service";
|
||||||
|
import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service";
|
||||||
|
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||||
import { GENERATOR_DISK, UserKeyDefinition } from "@bitwarden/common/platform/state";
|
import { GENERATOR_DISK, UserKeyDefinition } from "@bitwarden/common/platform/state";
|
||||||
import { StateConstraints } from "@bitwarden/common/tools/types";
|
import { StateConstraints } from "@bitwarden/common/tools/types";
|
||||||
import { OrganizationId, PolicyId, UserId } from "@bitwarden/common/types/guid";
|
import { OrganizationId, PolicyId, UserId } from "@bitwarden/common/types/guid";
|
||||||
|
import { UserKey } from "@bitwarden/common/types/key";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
FakeStateProvider,
|
FakeStateProvider,
|
||||||
@ -67,15 +72,20 @@ const SomeTime = new Date(1);
|
|||||||
const SomeAlgorithm = "passphrase";
|
const SomeAlgorithm = "passphrase";
|
||||||
const SomeCategory = "password";
|
const SomeCategory = "password";
|
||||||
const SomeNameKey = "passphraseKey";
|
const SomeNameKey = "passphraseKey";
|
||||||
|
const SomeGenerateKey = "generateKey";
|
||||||
|
const SomeCopyKey = "copyKey";
|
||||||
|
|
||||||
// fake the configuration
|
// fake the configuration
|
||||||
const SomeConfiguration: CredentialGeneratorConfiguration<SomeSettings, SomePolicy> = {
|
const SomeConfiguration: CredentialGeneratorConfiguration<SomeSettings, SomePolicy> = {
|
||||||
id: SomeAlgorithm,
|
id: SomeAlgorithm,
|
||||||
category: SomeCategory,
|
category: SomeCategory,
|
||||||
nameKey: SomeNameKey,
|
nameKey: SomeNameKey,
|
||||||
|
generateKey: SomeGenerateKey,
|
||||||
|
copyKey: SomeCopyKey,
|
||||||
onlyOnRequest: false,
|
onlyOnRequest: false,
|
||||||
|
request: [],
|
||||||
engine: {
|
engine: {
|
||||||
create: (randomizer) => {
|
create: (_randomizer) => {
|
||||||
return {
|
return {
|
||||||
generate: (request, settings) => {
|
generate: (request, settings) => {
|
||||||
const credential = request.website ? `${request.website}|${settings.foo}` : settings.foo;
|
const credential = request.website ? `${request.website}|${settings.foo}` : settings.foo;
|
||||||
@ -159,10 +169,22 @@ const stateProvider = new FakeStateProvider(accountService);
|
|||||||
// fake randomizer
|
// fake randomizer
|
||||||
const randomizer = mock<Randomizer>();
|
const randomizer = mock<Randomizer>();
|
||||||
|
|
||||||
|
const i18nService = mock<I18nService>();
|
||||||
|
|
||||||
|
const apiService = mock<ApiService>();
|
||||||
|
|
||||||
|
const encryptService = mock<EncryptService>();
|
||||||
|
|
||||||
|
const cryptoService = mock<CryptoService>();
|
||||||
|
|
||||||
describe("CredentialGeneratorService", () => {
|
describe("CredentialGeneratorService", () => {
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
await accountService.switchAccount(SomeUser);
|
await accountService.switchAccount(SomeUser);
|
||||||
policyService.getAll$.mockImplementation(() => new BehaviorSubject([]).asObservable());
|
policyService.getAll$.mockImplementation(() => new BehaviorSubject([]).asObservable());
|
||||||
|
i18nService.t.mockImplementation((key) => key);
|
||||||
|
apiService.fetch.mockImplementation(() => Promise.resolve(mock<Response>()));
|
||||||
|
const keyAvailable = new BehaviorSubject({} as UserKey);
|
||||||
|
cryptoService.userKey$.mockReturnValue(keyAvailable);
|
||||||
jest.clearAllMocks();
|
jest.clearAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -170,7 +192,15 @@ describe("CredentialGeneratorService", () => {
|
|||||||
it("emits a generation for the active user when subscribed", async () => {
|
it("emits a generation for the active user when subscribed", async () => {
|
||||||
const settings = { foo: "value" };
|
const settings = { foo: "value" };
|
||||||
await stateProvider.setUserState(SettingsKey, settings, SomeUser);
|
await stateProvider.setUserState(SettingsKey, settings, SomeUser);
|
||||||
const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService);
|
const generator = new CredentialGeneratorService(
|
||||||
|
randomizer,
|
||||||
|
stateProvider,
|
||||||
|
policyService,
|
||||||
|
apiService,
|
||||||
|
i18nService,
|
||||||
|
encryptService,
|
||||||
|
cryptoService,
|
||||||
|
);
|
||||||
const generated = new ObservableTracker(generator.generate$(SomeConfiguration));
|
const generated = new ObservableTracker(generator.generate$(SomeConfiguration));
|
||||||
|
|
||||||
const result = await generated.expectEmission();
|
const result = await generated.expectEmission();
|
||||||
@ -183,7 +213,15 @@ describe("CredentialGeneratorService", () => {
|
|||||||
const anotherSettings = { foo: "another value" };
|
const anotherSettings = { foo: "another value" };
|
||||||
await stateProvider.setUserState(SettingsKey, someSettings, SomeUser);
|
await stateProvider.setUserState(SettingsKey, someSettings, SomeUser);
|
||||||
await stateProvider.setUserState(SettingsKey, anotherSettings, AnotherUser);
|
await stateProvider.setUserState(SettingsKey, anotherSettings, AnotherUser);
|
||||||
const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService);
|
const generator = new CredentialGeneratorService(
|
||||||
|
randomizer,
|
||||||
|
stateProvider,
|
||||||
|
policyService,
|
||||||
|
apiService,
|
||||||
|
i18nService,
|
||||||
|
encryptService,
|
||||||
|
cryptoService,
|
||||||
|
);
|
||||||
const generated = new ObservableTracker(generator.generate$(SomeConfiguration));
|
const generated = new ObservableTracker(generator.generate$(SomeConfiguration));
|
||||||
|
|
||||||
await accountService.switchAccount(AnotherUser);
|
await accountService.switchAccount(AnotherUser);
|
||||||
@ -200,7 +238,15 @@ describe("CredentialGeneratorService", () => {
|
|||||||
const someSettings = { foo: "some value" };
|
const someSettings = { foo: "some value" };
|
||||||
const anotherSettings = { foo: "another value" };
|
const anotherSettings = { foo: "another value" };
|
||||||
await stateProvider.setUserState(SettingsKey, someSettings, SomeUser);
|
await stateProvider.setUserState(SettingsKey, someSettings, SomeUser);
|
||||||
const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService);
|
const generator = new CredentialGeneratorService(
|
||||||
|
randomizer,
|
||||||
|
stateProvider,
|
||||||
|
policyService,
|
||||||
|
apiService,
|
||||||
|
i18nService,
|
||||||
|
encryptService,
|
||||||
|
cryptoService,
|
||||||
|
);
|
||||||
const generated = new ObservableTracker(generator.generate$(SomeConfiguration));
|
const generated = new ObservableTracker(generator.generate$(SomeConfiguration));
|
||||||
|
|
||||||
await stateProvider.setUserState(SettingsKey, anotherSettings, SomeUser);
|
await stateProvider.setUserState(SettingsKey, anotherSettings, SomeUser);
|
||||||
@ -220,7 +266,15 @@ describe("CredentialGeneratorService", () => {
|
|||||||
it("includes `website$`'s last emitted value", async () => {
|
it("includes `website$`'s last emitted value", async () => {
|
||||||
const settings = { foo: "value" };
|
const settings = { foo: "value" };
|
||||||
await stateProvider.setUserState(SettingsKey, settings, SomeUser);
|
await stateProvider.setUserState(SettingsKey, settings, SomeUser);
|
||||||
const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService);
|
const generator = new CredentialGeneratorService(
|
||||||
|
randomizer,
|
||||||
|
stateProvider,
|
||||||
|
policyService,
|
||||||
|
apiService,
|
||||||
|
i18nService,
|
||||||
|
encryptService,
|
||||||
|
cryptoService,
|
||||||
|
);
|
||||||
const website$ = new BehaviorSubject("some website");
|
const website$ = new BehaviorSubject("some website");
|
||||||
const generated = new ObservableTracker(generator.generate$(SomeConfiguration, { website$ }));
|
const generated = new ObservableTracker(generator.generate$(SomeConfiguration, { website$ }));
|
||||||
|
|
||||||
@ -233,7 +287,15 @@ describe("CredentialGeneratorService", () => {
|
|||||||
|
|
||||||
it("errors when `website$` errors", async () => {
|
it("errors when `website$` errors", async () => {
|
||||||
await stateProvider.setUserState(SettingsKey, null, SomeUser);
|
await stateProvider.setUserState(SettingsKey, null, SomeUser);
|
||||||
const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService);
|
const generator = new CredentialGeneratorService(
|
||||||
|
randomizer,
|
||||||
|
stateProvider,
|
||||||
|
policyService,
|
||||||
|
apiService,
|
||||||
|
i18nService,
|
||||||
|
encryptService,
|
||||||
|
cryptoService,
|
||||||
|
);
|
||||||
const website$ = new BehaviorSubject("some website");
|
const website$ = new BehaviorSubject("some website");
|
||||||
let error = null;
|
let error = null;
|
||||||
|
|
||||||
@ -250,7 +312,15 @@ describe("CredentialGeneratorService", () => {
|
|||||||
|
|
||||||
it("completes when `website$` completes", async () => {
|
it("completes when `website$` completes", async () => {
|
||||||
await stateProvider.setUserState(SettingsKey, null, SomeUser);
|
await stateProvider.setUserState(SettingsKey, null, SomeUser);
|
||||||
const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService);
|
const generator = new CredentialGeneratorService(
|
||||||
|
randomizer,
|
||||||
|
stateProvider,
|
||||||
|
policyService,
|
||||||
|
apiService,
|
||||||
|
i18nService,
|
||||||
|
encryptService,
|
||||||
|
cryptoService,
|
||||||
|
);
|
||||||
const website$ = new BehaviorSubject("some website");
|
const website$ = new BehaviorSubject("some website");
|
||||||
let completed = false;
|
let completed = false;
|
||||||
|
|
||||||
@ -268,7 +338,15 @@ describe("CredentialGeneratorService", () => {
|
|||||||
it("emits a generation for a specific user when `user$` supplied", async () => {
|
it("emits a generation for a specific user when `user$` supplied", async () => {
|
||||||
await stateProvider.setUserState(SettingsKey, { foo: "value" }, SomeUser);
|
await stateProvider.setUserState(SettingsKey, { foo: "value" }, SomeUser);
|
||||||
await stateProvider.setUserState(SettingsKey, { foo: "another" }, AnotherUser);
|
await stateProvider.setUserState(SettingsKey, { foo: "another" }, AnotherUser);
|
||||||
const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService);
|
const generator = new CredentialGeneratorService(
|
||||||
|
randomizer,
|
||||||
|
stateProvider,
|
||||||
|
policyService,
|
||||||
|
apiService,
|
||||||
|
i18nService,
|
||||||
|
encryptService,
|
||||||
|
cryptoService,
|
||||||
|
);
|
||||||
const userId$ = new BehaviorSubject(AnotherUser).asObservable();
|
const userId$ = new BehaviorSubject(AnotherUser).asObservable();
|
||||||
const generated = new ObservableTracker(generator.generate$(SomeConfiguration, { userId$ }));
|
const generated = new ObservableTracker(generator.generate$(SomeConfiguration, { userId$ }));
|
||||||
|
|
||||||
@ -280,7 +358,15 @@ describe("CredentialGeneratorService", () => {
|
|||||||
it("emits a generation for a specific user when `user$` emits", async () => {
|
it("emits a generation for a specific user when `user$` emits", async () => {
|
||||||
await stateProvider.setUserState(SettingsKey, { foo: "value" }, SomeUser);
|
await stateProvider.setUserState(SettingsKey, { foo: "value" }, SomeUser);
|
||||||
await stateProvider.setUserState(SettingsKey, { foo: "another" }, AnotherUser);
|
await stateProvider.setUserState(SettingsKey, { foo: "another" }, AnotherUser);
|
||||||
const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService);
|
const generator = new CredentialGeneratorService(
|
||||||
|
randomizer,
|
||||||
|
stateProvider,
|
||||||
|
policyService,
|
||||||
|
apiService,
|
||||||
|
i18nService,
|
||||||
|
encryptService,
|
||||||
|
cryptoService,
|
||||||
|
);
|
||||||
const userId = new BehaviorSubject(SomeUser);
|
const userId = new BehaviorSubject(SomeUser);
|
||||||
const userId$ = userId.pipe(filter((u) => !!u));
|
const userId$ = userId.pipe(filter((u) => !!u));
|
||||||
const generated = new ObservableTracker(generator.generate$(SomeConfiguration, { userId$ }));
|
const generated = new ObservableTracker(generator.generate$(SomeConfiguration, { userId$ }));
|
||||||
@ -296,7 +382,15 @@ describe("CredentialGeneratorService", () => {
|
|||||||
|
|
||||||
it("errors when `user$` errors", async () => {
|
it("errors when `user$` errors", async () => {
|
||||||
await stateProvider.setUserState(SettingsKey, null, SomeUser);
|
await stateProvider.setUserState(SettingsKey, null, SomeUser);
|
||||||
const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService);
|
const generator = new CredentialGeneratorService(
|
||||||
|
randomizer,
|
||||||
|
stateProvider,
|
||||||
|
policyService,
|
||||||
|
apiService,
|
||||||
|
i18nService,
|
||||||
|
encryptService,
|
||||||
|
cryptoService,
|
||||||
|
);
|
||||||
const userId$ = new BehaviorSubject(SomeUser);
|
const userId$ = new BehaviorSubject(SomeUser);
|
||||||
let error = null;
|
let error = null;
|
||||||
|
|
||||||
@ -313,7 +407,15 @@ describe("CredentialGeneratorService", () => {
|
|||||||
|
|
||||||
it("completes when `user$` completes", async () => {
|
it("completes when `user$` completes", async () => {
|
||||||
await stateProvider.setUserState(SettingsKey, null, SomeUser);
|
await stateProvider.setUserState(SettingsKey, null, SomeUser);
|
||||||
const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService);
|
const generator = new CredentialGeneratorService(
|
||||||
|
randomizer,
|
||||||
|
stateProvider,
|
||||||
|
policyService,
|
||||||
|
apiService,
|
||||||
|
i18nService,
|
||||||
|
encryptService,
|
||||||
|
cryptoService,
|
||||||
|
);
|
||||||
const userId$ = new BehaviorSubject(SomeUser);
|
const userId$ = new BehaviorSubject(SomeUser);
|
||||||
let completed = false;
|
let completed = false;
|
||||||
|
|
||||||
@ -331,7 +433,15 @@ describe("CredentialGeneratorService", () => {
|
|||||||
it("emits a generation only when `on$` emits", async () => {
|
it("emits a generation only when `on$` emits", async () => {
|
||||||
// This test breaks from arrange/act/assert because it is testing causality
|
// This test breaks from arrange/act/assert because it is testing causality
|
||||||
await stateProvider.setUserState(SettingsKey, { foo: "value" }, SomeUser);
|
await stateProvider.setUserState(SettingsKey, { foo: "value" }, SomeUser);
|
||||||
const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService);
|
const generator = new CredentialGeneratorService(
|
||||||
|
randomizer,
|
||||||
|
stateProvider,
|
||||||
|
policyService,
|
||||||
|
apiService,
|
||||||
|
i18nService,
|
||||||
|
encryptService,
|
||||||
|
cryptoService,
|
||||||
|
);
|
||||||
const on$ = new Subject<void>();
|
const on$ = new Subject<void>();
|
||||||
const results: any[] = [];
|
const results: any[] = [];
|
||||||
|
|
||||||
@ -365,7 +475,15 @@ describe("CredentialGeneratorService", () => {
|
|||||||
|
|
||||||
it("errors when `on$` errors", async () => {
|
it("errors when `on$` errors", async () => {
|
||||||
await stateProvider.setUserState(SettingsKey, { foo: "value" }, SomeUser);
|
await stateProvider.setUserState(SettingsKey, { foo: "value" }, SomeUser);
|
||||||
const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService);
|
const generator = new CredentialGeneratorService(
|
||||||
|
randomizer,
|
||||||
|
stateProvider,
|
||||||
|
policyService,
|
||||||
|
apiService,
|
||||||
|
i18nService,
|
||||||
|
encryptService,
|
||||||
|
cryptoService,
|
||||||
|
);
|
||||||
const on$ = new Subject<void>();
|
const on$ = new Subject<void>();
|
||||||
let error: any = null;
|
let error: any = null;
|
||||||
|
|
||||||
@ -383,7 +501,15 @@ describe("CredentialGeneratorService", () => {
|
|||||||
|
|
||||||
it("completes when `on$` completes", async () => {
|
it("completes when `on$` completes", async () => {
|
||||||
await stateProvider.setUserState(SettingsKey, { foo: "value" }, SomeUser);
|
await stateProvider.setUserState(SettingsKey, { foo: "value" }, SomeUser);
|
||||||
const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService);
|
const generator = new CredentialGeneratorService(
|
||||||
|
randomizer,
|
||||||
|
stateProvider,
|
||||||
|
policyService,
|
||||||
|
apiService,
|
||||||
|
i18nService,
|
||||||
|
encryptService,
|
||||||
|
cryptoService,
|
||||||
|
);
|
||||||
const on$ = new Subject<void>();
|
const on$ = new Subject<void>();
|
||||||
let complete = false;
|
let complete = false;
|
||||||
|
|
||||||
@ -406,54 +532,86 @@ describe("CredentialGeneratorService", () => {
|
|||||||
|
|
||||||
describe("algorithms", () => {
|
describe("algorithms", () => {
|
||||||
it("outputs password generation metadata", () => {
|
it("outputs password generation metadata", () => {
|
||||||
const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService);
|
const generator = new CredentialGeneratorService(
|
||||||
|
randomizer,
|
||||||
|
stateProvider,
|
||||||
|
policyService,
|
||||||
|
apiService,
|
||||||
|
i18nService,
|
||||||
|
encryptService,
|
||||||
|
cryptoService,
|
||||||
|
);
|
||||||
|
|
||||||
const result = generator.algorithms("password");
|
const result = generator.algorithms("password");
|
||||||
|
|
||||||
expect(result).toContain(Generators.password);
|
expect(result.some((a) => a.id === Generators.password.id)).toBeTruthy();
|
||||||
expect(result).toContain(Generators.passphrase);
|
expect(result.some((a) => a.id === Generators.passphrase.id)).toBeTruthy();
|
||||||
|
|
||||||
// this test shouldn't contain entries outside of the current category
|
// this test shouldn't contain entries outside of the current category
|
||||||
expect(result).not.toContain(Generators.username);
|
expect(result.some((a) => a.id === Generators.username.id)).toBeFalsy();
|
||||||
expect(result).not.toContain(Generators.catchall);
|
expect(result.some((a) => a.id === Generators.catchall.id)).toBeFalsy();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("outputs username generation metadata", () => {
|
it("outputs username generation metadata", () => {
|
||||||
const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService);
|
const generator = new CredentialGeneratorService(
|
||||||
|
randomizer,
|
||||||
|
stateProvider,
|
||||||
|
policyService,
|
||||||
|
apiService,
|
||||||
|
i18nService,
|
||||||
|
encryptService,
|
||||||
|
cryptoService,
|
||||||
|
);
|
||||||
|
|
||||||
const result = generator.algorithms("username");
|
const result = generator.algorithms("username");
|
||||||
|
|
||||||
expect(result).toContain(Generators.username);
|
expect(result.some((a) => a.id === Generators.username.id)).toBeTruthy();
|
||||||
|
|
||||||
// this test shouldn't contain entries outside of the current category
|
// this test shouldn't contain entries outside of the current category
|
||||||
expect(result).not.toContain(Generators.catchall);
|
expect(result.some((a) => a.id === Generators.catchall.id)).toBeFalsy();
|
||||||
expect(result).not.toContain(Generators.password);
|
expect(result.some((a) => a.id === Generators.password.id)).toBeFalsy();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("outputs email generation metadata", () => {
|
it("outputs email generation metadata", () => {
|
||||||
const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService);
|
const generator = new CredentialGeneratorService(
|
||||||
|
randomizer,
|
||||||
|
stateProvider,
|
||||||
|
policyService,
|
||||||
|
apiService,
|
||||||
|
i18nService,
|
||||||
|
encryptService,
|
||||||
|
cryptoService,
|
||||||
|
);
|
||||||
|
|
||||||
const result = generator.algorithms("email");
|
const result = generator.algorithms("email");
|
||||||
|
|
||||||
expect(result).toContain(Generators.catchall);
|
expect(result.some((a) => a.id === Generators.catchall.id)).toBeTruthy();
|
||||||
expect(result).toContain(Generators.subaddress);
|
expect(result.some((a) => a.id === Generators.subaddress.id)).toBeTruthy();
|
||||||
|
|
||||||
// this test shouldn't contain entries outside of the current category
|
// this test shouldn't contain entries outside of the current category
|
||||||
expect(result).not.toContain(Generators.username);
|
expect(result.some((a) => a.id === Generators.username.id)).toBeFalsy();
|
||||||
expect(result).not.toContain(Generators.password);
|
expect(result.some((a) => a.id === Generators.password.id)).toBeFalsy();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("combines metadata across categories", () => {
|
it("combines metadata across categories", () => {
|
||||||
const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService);
|
const generator = new CredentialGeneratorService(
|
||||||
|
randomizer,
|
||||||
|
stateProvider,
|
||||||
|
policyService,
|
||||||
|
apiService,
|
||||||
|
i18nService,
|
||||||
|
encryptService,
|
||||||
|
cryptoService,
|
||||||
|
);
|
||||||
|
|
||||||
const result = generator.algorithms(["username", "email"]);
|
const result = generator.algorithms(["username", "email"]);
|
||||||
|
|
||||||
expect(result).toContain(Generators.username);
|
expect(result.some((a) => a.id === Generators.username.id)).toBeTruthy();
|
||||||
expect(result).toContain(Generators.catchall);
|
expect(result.some((a) => a.id === Generators.catchall.id)).toBeTruthy();
|
||||||
expect(result).toContain(Generators.subaddress);
|
expect(result.some((a) => a.id === Generators.subaddress.id)).toBeTruthy();
|
||||||
|
|
||||||
// this test shouldn't contain entries outside of the current categories
|
// this test shouldn't contain entries outside of the current categories
|
||||||
expect(result).not.toContain(Generators.password);
|
expect(result.some((a) => a.id === Generators.password.id)).toBeFalsy();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -461,39 +619,71 @@ describe("CredentialGeneratorService", () => {
|
|||||||
// these tests cannot use the observable tracker because they return
|
// these tests cannot use the observable tracker because they return
|
||||||
// data that cannot be cloned
|
// data that cannot be cloned
|
||||||
it("returns password metadata", async () => {
|
it("returns password metadata", async () => {
|
||||||
const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService);
|
const generator = new CredentialGeneratorService(
|
||||||
|
randomizer,
|
||||||
|
stateProvider,
|
||||||
|
policyService,
|
||||||
|
apiService,
|
||||||
|
i18nService,
|
||||||
|
encryptService,
|
||||||
|
cryptoService,
|
||||||
|
);
|
||||||
|
|
||||||
const result = await firstValueFrom(generator.algorithms$("password"));
|
const result = await firstValueFrom(generator.algorithms$("password"));
|
||||||
|
|
||||||
expect(result).toContain(Generators.password);
|
expect(result.some((a) => a.id === Generators.password.id)).toBeTruthy();
|
||||||
expect(result).toContain(Generators.passphrase);
|
expect(result.some((a) => a.id === Generators.passphrase.id)).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns username metadata", async () => {
|
it("returns username metadata", async () => {
|
||||||
const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService);
|
const generator = new CredentialGeneratorService(
|
||||||
|
randomizer,
|
||||||
|
stateProvider,
|
||||||
|
policyService,
|
||||||
|
apiService,
|
||||||
|
i18nService,
|
||||||
|
encryptService,
|
||||||
|
cryptoService,
|
||||||
|
);
|
||||||
|
|
||||||
const result = await firstValueFrom(generator.algorithms$("username"));
|
const result = await firstValueFrom(generator.algorithms$("username"));
|
||||||
|
|
||||||
expect(result).toContain(Generators.username);
|
expect(result.some((a) => a.id === Generators.username.id)).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns email metadata", async () => {
|
it("returns email metadata", async () => {
|
||||||
const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService);
|
const generator = new CredentialGeneratorService(
|
||||||
|
randomizer,
|
||||||
|
stateProvider,
|
||||||
|
policyService,
|
||||||
|
apiService,
|
||||||
|
i18nService,
|
||||||
|
encryptService,
|
||||||
|
cryptoService,
|
||||||
|
);
|
||||||
|
|
||||||
const result = await firstValueFrom(generator.algorithms$("email"));
|
const result = await firstValueFrom(generator.algorithms$("email"));
|
||||||
|
|
||||||
expect(result).toContain(Generators.catchall);
|
expect(result.some((a) => a.id === Generators.catchall.id)).toBeTruthy();
|
||||||
expect(result).toContain(Generators.subaddress);
|
expect(result.some((a) => a.id === Generators.subaddress.id)).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns username and email metadata", async () => {
|
it("returns username and email metadata", async () => {
|
||||||
const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService);
|
const generator = new CredentialGeneratorService(
|
||||||
|
randomizer,
|
||||||
|
stateProvider,
|
||||||
|
policyService,
|
||||||
|
apiService,
|
||||||
|
i18nService,
|
||||||
|
encryptService,
|
||||||
|
cryptoService,
|
||||||
|
);
|
||||||
|
|
||||||
const result = await firstValueFrom(generator.algorithms$(["username", "email"]));
|
const result = await firstValueFrom(generator.algorithms$(["username", "email"]));
|
||||||
|
|
||||||
expect(result).toContain(Generators.username);
|
expect(result.some((a) => a.id === Generators.username.id)).toBeTruthy();
|
||||||
expect(result).toContain(Generators.catchall);
|
expect(result.some((a) => a.id === Generators.catchall.id)).toBeTruthy();
|
||||||
expect(result).toContain(Generators.subaddress);
|
expect(result.some((a) => a.id === Generators.subaddress.id)).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Subsequent tests focus on passwords and passphrases as an example of policy
|
// Subsequent tests focus on passwords and passphrases as an example of policy
|
||||||
@ -501,13 +691,21 @@ describe("CredentialGeneratorService", () => {
|
|||||||
it("enforces the active user's policy", async () => {
|
it("enforces the active user's policy", async () => {
|
||||||
const policy$ = new BehaviorSubject([passwordOverridePolicy]);
|
const policy$ = new BehaviorSubject([passwordOverridePolicy]);
|
||||||
policyService.getAll$.mockReturnValue(policy$);
|
policyService.getAll$.mockReturnValue(policy$);
|
||||||
const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService);
|
const generator = new CredentialGeneratorService(
|
||||||
|
randomizer,
|
||||||
|
stateProvider,
|
||||||
|
policyService,
|
||||||
|
apiService,
|
||||||
|
i18nService,
|
||||||
|
encryptService,
|
||||||
|
cryptoService,
|
||||||
|
);
|
||||||
|
|
||||||
const result = await firstValueFrom(generator.algorithms$(["password"]));
|
const result = await firstValueFrom(generator.algorithms$(["password"]));
|
||||||
|
|
||||||
expect(policyService.getAll$).toHaveBeenCalledWith(PolicyType.PasswordGenerator, SomeUser);
|
expect(policyService.getAll$).toHaveBeenCalledWith(PolicyType.PasswordGenerator, SomeUser);
|
||||||
expect(result).toContain(Generators.password);
|
expect(result.some((a) => a.id === Generators.password.id)).toBeTruthy();
|
||||||
expect(result).not.toContain(Generators.passphrase);
|
expect(result.some((a) => a.id === Generators.passphrase.id)).toBeFalsy();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("follows changes to the active user", async () => {
|
it("follows changes to the active user", async () => {
|
||||||
@ -518,7 +716,15 @@ describe("CredentialGeneratorService", () => {
|
|||||||
await accountService.switchAccount(SomeUser);
|
await accountService.switchAccount(SomeUser);
|
||||||
policyService.getAll$.mockReturnValueOnce(new BehaviorSubject([passwordOverridePolicy]));
|
policyService.getAll$.mockReturnValueOnce(new BehaviorSubject([passwordOverridePolicy]));
|
||||||
policyService.getAll$.mockReturnValueOnce(new BehaviorSubject([passphraseOverridePolicy]));
|
policyService.getAll$.mockReturnValueOnce(new BehaviorSubject([passphraseOverridePolicy]));
|
||||||
const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService);
|
const generator = new CredentialGeneratorService(
|
||||||
|
randomizer,
|
||||||
|
stateProvider,
|
||||||
|
policyService,
|
||||||
|
apiService,
|
||||||
|
i18nService,
|
||||||
|
encryptService,
|
||||||
|
cryptoService,
|
||||||
|
);
|
||||||
const results: any = [];
|
const results: any = [];
|
||||||
const sub = generator.algorithms$("password").subscribe((r) => results.push(r));
|
const sub = generator.algorithms$("password").subscribe((r) => results.push(r));
|
||||||
|
|
||||||
@ -533,34 +739,50 @@ describe("CredentialGeneratorService", () => {
|
|||||||
PolicyType.PasswordGenerator,
|
PolicyType.PasswordGenerator,
|
||||||
SomeUser,
|
SomeUser,
|
||||||
);
|
);
|
||||||
expect(someResult).toContain(Generators.password);
|
expect(someResult.some((a: any) => a.id === Generators.password.id)).toBeTruthy();
|
||||||
expect(someResult).not.toContain(Generators.passphrase);
|
expect(someResult.some((a: any) => a.id === Generators.passphrase.id)).toBeFalsy();
|
||||||
|
|
||||||
expect(policyService.getAll$).toHaveBeenNthCalledWith(
|
expect(policyService.getAll$).toHaveBeenNthCalledWith(
|
||||||
2,
|
2,
|
||||||
PolicyType.PasswordGenerator,
|
PolicyType.PasswordGenerator,
|
||||||
AnotherUser,
|
AnotherUser,
|
||||||
);
|
);
|
||||||
expect(anotherResult).toContain(Generators.passphrase);
|
expect(anotherResult.some((a: any) => a.id === Generators.passphrase.id)).toBeTruthy();
|
||||||
expect(anotherResult).not.toContain(Generators.password);
|
expect(anotherResult.some((a: any) => a.id === Generators.password.id)).toBeFalsy();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("reads an arbitrary user's settings", async () => {
|
it("reads an arbitrary user's settings", async () => {
|
||||||
policyService.getAll$.mockReturnValueOnce(new BehaviorSubject([passwordOverridePolicy]));
|
policyService.getAll$.mockReturnValueOnce(new BehaviorSubject([passwordOverridePolicy]));
|
||||||
const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService);
|
const generator = new CredentialGeneratorService(
|
||||||
|
randomizer,
|
||||||
|
stateProvider,
|
||||||
|
policyService,
|
||||||
|
apiService,
|
||||||
|
i18nService,
|
||||||
|
encryptService,
|
||||||
|
cryptoService,
|
||||||
|
);
|
||||||
const userId$ = new BehaviorSubject(AnotherUser).asObservable();
|
const userId$ = new BehaviorSubject(AnotherUser).asObservable();
|
||||||
|
|
||||||
const result = await firstValueFrom(generator.algorithms$("password", { userId$ }));
|
const result = await firstValueFrom(generator.algorithms$("password", { userId$ }));
|
||||||
|
|
||||||
expect(policyService.getAll$).toHaveBeenCalledWith(PolicyType.PasswordGenerator, AnotherUser);
|
expect(policyService.getAll$).toHaveBeenCalledWith(PolicyType.PasswordGenerator, AnotherUser);
|
||||||
expect(result).toContain(Generators.password);
|
expect(result.some((a: any) => a.id === Generators.password.id)).toBeTruthy();
|
||||||
expect(result).not.toContain(Generators.passphrase);
|
expect(result.some((a: any) => a.id === Generators.passphrase.id)).toBeFalsy();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("follows changes to the arbitrary user", async () => {
|
it("follows changes to the arbitrary user", async () => {
|
||||||
policyService.getAll$.mockReturnValueOnce(new BehaviorSubject([passwordOverridePolicy]));
|
policyService.getAll$.mockReturnValueOnce(new BehaviorSubject([passwordOverridePolicy]));
|
||||||
policyService.getAll$.mockReturnValueOnce(new BehaviorSubject([passphraseOverridePolicy]));
|
policyService.getAll$.mockReturnValueOnce(new BehaviorSubject([passphraseOverridePolicy]));
|
||||||
const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService);
|
const generator = new CredentialGeneratorService(
|
||||||
|
randomizer,
|
||||||
|
stateProvider,
|
||||||
|
policyService,
|
||||||
|
apiService,
|
||||||
|
i18nService,
|
||||||
|
encryptService,
|
||||||
|
cryptoService,
|
||||||
|
);
|
||||||
const userId = new BehaviorSubject(SomeUser);
|
const userId = new BehaviorSubject(SomeUser);
|
||||||
const userId$ = userId.asObservable();
|
const userId$ = userId.asObservable();
|
||||||
const results: any = [];
|
const results: any = [];
|
||||||
@ -572,17 +794,25 @@ describe("CredentialGeneratorService", () => {
|
|||||||
|
|
||||||
const [someResult, anotherResult] = results;
|
const [someResult, anotherResult] = results;
|
||||||
expect(policyService.getAll$).toHaveBeenCalledWith(PolicyType.PasswordGenerator, SomeUser);
|
expect(policyService.getAll$).toHaveBeenCalledWith(PolicyType.PasswordGenerator, SomeUser);
|
||||||
expect(someResult).toContain(Generators.password);
|
expect(someResult.some((a: any) => a.id === Generators.password.id)).toBeTruthy();
|
||||||
expect(someResult).not.toContain(Generators.passphrase);
|
expect(someResult.some((a: any) => a.id === Generators.passphrase.id)).toBeFalsy();
|
||||||
|
|
||||||
expect(policyService.getAll$).toHaveBeenCalledWith(PolicyType.PasswordGenerator, AnotherUser);
|
expect(policyService.getAll$).toHaveBeenCalledWith(PolicyType.PasswordGenerator, AnotherUser);
|
||||||
expect(anotherResult).toContain(Generators.passphrase);
|
expect(anotherResult.some((a: any) => a.id === Generators.passphrase.id)).toBeTruthy();
|
||||||
expect(anotherResult).not.toContain(Generators.password);
|
expect(anotherResult.some((a: any) => a.id === Generators.password.id)).toBeFalsy();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("errors when the arbitrary user's stream errors", async () => {
|
it("errors when the arbitrary user's stream errors", async () => {
|
||||||
policyService.getAll$.mockReturnValueOnce(new BehaviorSubject([passwordOverridePolicy]));
|
policyService.getAll$.mockReturnValueOnce(new BehaviorSubject([passwordOverridePolicy]));
|
||||||
const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService);
|
const generator = new CredentialGeneratorService(
|
||||||
|
randomizer,
|
||||||
|
stateProvider,
|
||||||
|
policyService,
|
||||||
|
apiService,
|
||||||
|
i18nService,
|
||||||
|
encryptService,
|
||||||
|
cryptoService,
|
||||||
|
);
|
||||||
const userId = new BehaviorSubject(SomeUser);
|
const userId = new BehaviorSubject(SomeUser);
|
||||||
const userId$ = userId.asObservable();
|
const userId$ = userId.asObservable();
|
||||||
let error = null;
|
let error = null;
|
||||||
@ -600,7 +830,15 @@ describe("CredentialGeneratorService", () => {
|
|||||||
|
|
||||||
it("completes when the arbitrary user's stream completes", async () => {
|
it("completes when the arbitrary user's stream completes", async () => {
|
||||||
policyService.getAll$.mockReturnValueOnce(new BehaviorSubject([passwordOverridePolicy]));
|
policyService.getAll$.mockReturnValueOnce(new BehaviorSubject([passwordOverridePolicy]));
|
||||||
const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService);
|
const generator = new CredentialGeneratorService(
|
||||||
|
randomizer,
|
||||||
|
stateProvider,
|
||||||
|
policyService,
|
||||||
|
apiService,
|
||||||
|
i18nService,
|
||||||
|
encryptService,
|
||||||
|
cryptoService,
|
||||||
|
);
|
||||||
const userId = new BehaviorSubject(SomeUser);
|
const userId = new BehaviorSubject(SomeUser);
|
||||||
const userId$ = userId.asObservable();
|
const userId$ = userId.asObservable();
|
||||||
let completed = false;
|
let completed = false;
|
||||||
@ -618,7 +856,15 @@ describe("CredentialGeneratorService", () => {
|
|||||||
|
|
||||||
it("ignores repeated arbitrary user emissions", async () => {
|
it("ignores repeated arbitrary user emissions", async () => {
|
||||||
policyService.getAll$.mockReturnValueOnce(new BehaviorSubject([passwordOverridePolicy]));
|
policyService.getAll$.mockReturnValueOnce(new BehaviorSubject([passwordOverridePolicy]));
|
||||||
const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService);
|
const generator = new CredentialGeneratorService(
|
||||||
|
randomizer,
|
||||||
|
stateProvider,
|
||||||
|
policyService,
|
||||||
|
apiService,
|
||||||
|
i18nService,
|
||||||
|
encryptService,
|
||||||
|
cryptoService,
|
||||||
|
);
|
||||||
const userId = new BehaviorSubject(SomeUser);
|
const userId = new BehaviorSubject(SomeUser);
|
||||||
const userId$ = userId.asObservable();
|
const userId$ = userId.asObservable();
|
||||||
let count = 0;
|
let count = 0;
|
||||||
@ -642,7 +888,15 @@ describe("CredentialGeneratorService", () => {
|
|||||||
describe("settings$", () => {
|
describe("settings$", () => {
|
||||||
it("defaults to the configuration's initial settings if settings aren't found", async () => {
|
it("defaults to the configuration's initial settings if settings aren't found", async () => {
|
||||||
await stateProvider.setUserState(SettingsKey, null, SomeUser);
|
await stateProvider.setUserState(SettingsKey, null, SomeUser);
|
||||||
const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService);
|
const generator = new CredentialGeneratorService(
|
||||||
|
randomizer,
|
||||||
|
stateProvider,
|
||||||
|
policyService,
|
||||||
|
apiService,
|
||||||
|
i18nService,
|
||||||
|
encryptService,
|
||||||
|
cryptoService,
|
||||||
|
);
|
||||||
|
|
||||||
const result = await firstValueFrom(generator.settings$(SomeConfiguration));
|
const result = await firstValueFrom(generator.settings$(SomeConfiguration));
|
||||||
|
|
||||||
@ -652,7 +906,15 @@ describe("CredentialGeneratorService", () => {
|
|||||||
it("reads from the active user's configuration-defined storage", async () => {
|
it("reads from the active user's configuration-defined storage", async () => {
|
||||||
const settings = { foo: "value" };
|
const settings = { foo: "value" };
|
||||||
await stateProvider.setUserState(SettingsKey, settings, SomeUser);
|
await stateProvider.setUserState(SettingsKey, settings, SomeUser);
|
||||||
const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService);
|
const generator = new CredentialGeneratorService(
|
||||||
|
randomizer,
|
||||||
|
stateProvider,
|
||||||
|
policyService,
|
||||||
|
apiService,
|
||||||
|
i18nService,
|
||||||
|
encryptService,
|
||||||
|
cryptoService,
|
||||||
|
);
|
||||||
|
|
||||||
const result = await firstValueFrom(generator.settings$(SomeConfiguration));
|
const result = await firstValueFrom(generator.settings$(SomeConfiguration));
|
||||||
|
|
||||||
@ -664,7 +926,15 @@ describe("CredentialGeneratorService", () => {
|
|||||||
await stateProvider.setUserState(SettingsKey, settings, SomeUser);
|
await stateProvider.setUserState(SettingsKey, settings, SomeUser);
|
||||||
const policy$ = new BehaviorSubject([somePolicy]);
|
const policy$ = new BehaviorSubject([somePolicy]);
|
||||||
policyService.getAll$.mockReturnValue(policy$);
|
policyService.getAll$.mockReturnValue(policy$);
|
||||||
const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService);
|
const generator = new CredentialGeneratorService(
|
||||||
|
randomizer,
|
||||||
|
stateProvider,
|
||||||
|
policyService,
|
||||||
|
apiService,
|
||||||
|
i18nService,
|
||||||
|
encryptService,
|
||||||
|
cryptoService,
|
||||||
|
);
|
||||||
|
|
||||||
const result = await firstValueFrom(generator.settings$(SomeConfiguration));
|
const result = await firstValueFrom(generator.settings$(SomeConfiguration));
|
||||||
|
|
||||||
@ -672,7 +942,7 @@ describe("CredentialGeneratorService", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("follows changes to the active user", async () => {
|
it("follows changes to the active user", async () => {
|
||||||
// initialize local accound service and state provider because this test is sensitive
|
// initialize local account service and state provider because this test is sensitive
|
||||||
// to some shared data in `FakeAccountService`.
|
// to some shared data in `FakeAccountService`.
|
||||||
const accountService = new FakeAccountService(accounts);
|
const accountService = new FakeAccountService(accounts);
|
||||||
const stateProvider = new FakeStateProvider(accountService);
|
const stateProvider = new FakeStateProvider(accountService);
|
||||||
@ -681,7 +951,15 @@ describe("CredentialGeneratorService", () => {
|
|||||||
const anotherSettings = { foo: "another" };
|
const anotherSettings = { foo: "another" };
|
||||||
await stateProvider.setUserState(SettingsKey, someSettings, SomeUser);
|
await stateProvider.setUserState(SettingsKey, someSettings, SomeUser);
|
||||||
await stateProvider.setUserState(SettingsKey, anotherSettings, AnotherUser);
|
await stateProvider.setUserState(SettingsKey, anotherSettings, AnotherUser);
|
||||||
const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService);
|
const generator = new CredentialGeneratorService(
|
||||||
|
randomizer,
|
||||||
|
stateProvider,
|
||||||
|
policyService,
|
||||||
|
apiService,
|
||||||
|
i18nService,
|
||||||
|
encryptService,
|
||||||
|
cryptoService,
|
||||||
|
);
|
||||||
const results: any = [];
|
const results: any = [];
|
||||||
const sub = generator.settings$(SomeConfiguration).subscribe((r) => results.push(r));
|
const sub = generator.settings$(SomeConfiguration).subscribe((r) => results.push(r));
|
||||||
|
|
||||||
@ -698,7 +976,15 @@ describe("CredentialGeneratorService", () => {
|
|||||||
await stateProvider.setUserState(SettingsKey, { foo: "value" }, SomeUser);
|
await stateProvider.setUserState(SettingsKey, { foo: "value" }, SomeUser);
|
||||||
const anotherSettings = { foo: "another" };
|
const anotherSettings = { foo: "another" };
|
||||||
await stateProvider.setUserState(SettingsKey, anotherSettings, AnotherUser);
|
await stateProvider.setUserState(SettingsKey, anotherSettings, AnotherUser);
|
||||||
const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService);
|
const generator = new CredentialGeneratorService(
|
||||||
|
randomizer,
|
||||||
|
stateProvider,
|
||||||
|
policyService,
|
||||||
|
apiService,
|
||||||
|
i18nService,
|
||||||
|
encryptService,
|
||||||
|
cryptoService,
|
||||||
|
);
|
||||||
const userId$ = new BehaviorSubject(AnotherUser).asObservable();
|
const userId$ = new BehaviorSubject(AnotherUser).asObservable();
|
||||||
|
|
||||||
const result = await firstValueFrom(generator.settings$(SomeConfiguration, { userId$ }));
|
const result = await firstValueFrom(generator.settings$(SomeConfiguration, { userId$ }));
|
||||||
@ -711,7 +997,15 @@ describe("CredentialGeneratorService", () => {
|
|||||||
await stateProvider.setUserState(SettingsKey, someSettings, SomeUser);
|
await stateProvider.setUserState(SettingsKey, someSettings, SomeUser);
|
||||||
const anotherSettings = { foo: "another" };
|
const anotherSettings = { foo: "another" };
|
||||||
await stateProvider.setUserState(SettingsKey, anotherSettings, AnotherUser);
|
await stateProvider.setUserState(SettingsKey, anotherSettings, AnotherUser);
|
||||||
const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService);
|
const generator = new CredentialGeneratorService(
|
||||||
|
randomizer,
|
||||||
|
stateProvider,
|
||||||
|
policyService,
|
||||||
|
apiService,
|
||||||
|
i18nService,
|
||||||
|
encryptService,
|
||||||
|
cryptoService,
|
||||||
|
);
|
||||||
const userId = new BehaviorSubject(SomeUser);
|
const userId = new BehaviorSubject(SomeUser);
|
||||||
const userId$ = userId.asObservable();
|
const userId$ = userId.asObservable();
|
||||||
const results: any = [];
|
const results: any = [];
|
||||||
@ -730,7 +1024,15 @@ describe("CredentialGeneratorService", () => {
|
|||||||
|
|
||||||
it("errors when the arbitrary user's stream errors", async () => {
|
it("errors when the arbitrary user's stream errors", async () => {
|
||||||
await stateProvider.setUserState(SettingsKey, null, SomeUser);
|
await stateProvider.setUserState(SettingsKey, null, SomeUser);
|
||||||
const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService);
|
const generator = new CredentialGeneratorService(
|
||||||
|
randomizer,
|
||||||
|
stateProvider,
|
||||||
|
policyService,
|
||||||
|
apiService,
|
||||||
|
i18nService,
|
||||||
|
encryptService,
|
||||||
|
cryptoService,
|
||||||
|
);
|
||||||
const userId = new BehaviorSubject(SomeUser);
|
const userId = new BehaviorSubject(SomeUser);
|
||||||
const userId$ = userId.asObservable();
|
const userId$ = userId.asObservable();
|
||||||
let error = null;
|
let error = null;
|
||||||
@ -748,7 +1050,15 @@ describe("CredentialGeneratorService", () => {
|
|||||||
|
|
||||||
it("completes when the arbitrary user's stream completes", async () => {
|
it("completes when the arbitrary user's stream completes", async () => {
|
||||||
await stateProvider.setUserState(SettingsKey, null, SomeUser);
|
await stateProvider.setUserState(SettingsKey, null, SomeUser);
|
||||||
const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService);
|
const generator = new CredentialGeneratorService(
|
||||||
|
randomizer,
|
||||||
|
stateProvider,
|
||||||
|
policyService,
|
||||||
|
apiService,
|
||||||
|
i18nService,
|
||||||
|
encryptService,
|
||||||
|
cryptoService,
|
||||||
|
);
|
||||||
const userId = new BehaviorSubject(SomeUser);
|
const userId = new BehaviorSubject(SomeUser);
|
||||||
const userId$ = userId.asObservable();
|
const userId$ = userId.asObservable();
|
||||||
let completed = false;
|
let completed = false;
|
||||||
@ -766,7 +1076,15 @@ describe("CredentialGeneratorService", () => {
|
|||||||
|
|
||||||
it("ignores repeated arbitrary user emissions", async () => {
|
it("ignores repeated arbitrary user emissions", async () => {
|
||||||
await stateProvider.setUserState(SettingsKey, null, SomeUser);
|
await stateProvider.setUserState(SettingsKey, null, SomeUser);
|
||||||
const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService);
|
const generator = new CredentialGeneratorService(
|
||||||
|
randomizer,
|
||||||
|
stateProvider,
|
||||||
|
policyService,
|
||||||
|
apiService,
|
||||||
|
i18nService,
|
||||||
|
encryptService,
|
||||||
|
cryptoService,
|
||||||
|
);
|
||||||
const userId = new BehaviorSubject(SomeUser);
|
const userId = new BehaviorSubject(SomeUser);
|
||||||
const userId$ = userId.asObservable();
|
const userId$ = userId.asObservable();
|
||||||
let count = 0;
|
let count = 0;
|
||||||
@ -790,7 +1108,15 @@ describe("CredentialGeneratorService", () => {
|
|||||||
describe("settings", () => {
|
describe("settings", () => {
|
||||||
it("writes to the user's state", async () => {
|
it("writes to the user's state", async () => {
|
||||||
const singleUserId$ = new BehaviorSubject(SomeUser).asObservable();
|
const singleUserId$ = new BehaviorSubject(SomeUser).asObservable();
|
||||||
const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService);
|
const generator = new CredentialGeneratorService(
|
||||||
|
randomizer,
|
||||||
|
stateProvider,
|
||||||
|
policyService,
|
||||||
|
apiService,
|
||||||
|
i18nService,
|
||||||
|
encryptService,
|
||||||
|
cryptoService,
|
||||||
|
);
|
||||||
const subject = await generator.settings(SomeConfiguration, { singleUserId$ });
|
const subject = await generator.settings(SomeConfiguration, { singleUserId$ });
|
||||||
|
|
||||||
subject.next({ foo: "next value" });
|
subject.next({ foo: "next value" });
|
||||||
@ -803,7 +1129,15 @@ describe("CredentialGeneratorService", () => {
|
|||||||
it("waits for the user to become available", async () => {
|
it("waits for the user to become available", async () => {
|
||||||
const singleUserId = new BehaviorSubject(null);
|
const singleUserId = new BehaviorSubject(null);
|
||||||
const singleUserId$ = singleUserId.asObservable();
|
const singleUserId$ = singleUserId.asObservable();
|
||||||
const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService);
|
const generator = new CredentialGeneratorService(
|
||||||
|
randomizer,
|
||||||
|
stateProvider,
|
||||||
|
policyService,
|
||||||
|
apiService,
|
||||||
|
i18nService,
|
||||||
|
encryptService,
|
||||||
|
cryptoService,
|
||||||
|
);
|
||||||
|
|
||||||
let completed = false;
|
let completed = false;
|
||||||
const promise = generator.settings(SomeConfiguration, { singleUserId$ }).then((settings) => {
|
const promise = generator.settings(SomeConfiguration, { singleUserId$ }).then((settings) => {
|
||||||
@ -821,7 +1155,15 @@ describe("CredentialGeneratorService", () => {
|
|||||||
|
|
||||||
describe("policy$", () => {
|
describe("policy$", () => {
|
||||||
it("creates constraints without policy in effect when there is no policy", async () => {
|
it("creates constraints without policy in effect when there is no policy", async () => {
|
||||||
const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService);
|
const generator = new CredentialGeneratorService(
|
||||||
|
randomizer,
|
||||||
|
stateProvider,
|
||||||
|
policyService,
|
||||||
|
apiService,
|
||||||
|
i18nService,
|
||||||
|
encryptService,
|
||||||
|
cryptoService,
|
||||||
|
);
|
||||||
const userId$ = new BehaviorSubject(SomeUser).asObservable();
|
const userId$ = new BehaviorSubject(SomeUser).asObservable();
|
||||||
|
|
||||||
const result = await firstValueFrom(generator.policy$(SomeConfiguration, { userId$ }));
|
const result = await firstValueFrom(generator.policy$(SomeConfiguration, { userId$ }));
|
||||||
@ -830,7 +1172,15 @@ describe("CredentialGeneratorService", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("creates constraints with policy in effect when there is a policy", async () => {
|
it("creates constraints with policy in effect when there is a policy", async () => {
|
||||||
const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService);
|
const generator = new CredentialGeneratorService(
|
||||||
|
randomizer,
|
||||||
|
stateProvider,
|
||||||
|
policyService,
|
||||||
|
apiService,
|
||||||
|
i18nService,
|
||||||
|
encryptService,
|
||||||
|
cryptoService,
|
||||||
|
);
|
||||||
const userId$ = new BehaviorSubject(SomeUser).asObservable();
|
const userId$ = new BehaviorSubject(SomeUser).asObservable();
|
||||||
const policy$ = new BehaviorSubject([somePolicy]);
|
const policy$ = new BehaviorSubject([somePolicy]);
|
||||||
policyService.getAll$.mockReturnValue(policy$);
|
policyService.getAll$.mockReturnValue(policy$);
|
||||||
@ -841,7 +1191,15 @@ describe("CredentialGeneratorService", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("follows policy emissions", async () => {
|
it("follows policy emissions", async () => {
|
||||||
const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService);
|
const generator = new CredentialGeneratorService(
|
||||||
|
randomizer,
|
||||||
|
stateProvider,
|
||||||
|
policyService,
|
||||||
|
apiService,
|
||||||
|
i18nService,
|
||||||
|
encryptService,
|
||||||
|
cryptoService,
|
||||||
|
);
|
||||||
const userId = new BehaviorSubject(SomeUser);
|
const userId = new BehaviorSubject(SomeUser);
|
||||||
const userId$ = userId.asObservable();
|
const userId$ = userId.asObservable();
|
||||||
const somePolicySubject = new BehaviorSubject([somePolicy]);
|
const somePolicySubject = new BehaviorSubject([somePolicy]);
|
||||||
@ -862,7 +1220,15 @@ describe("CredentialGeneratorService", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("follows user emissions", async () => {
|
it("follows user emissions", async () => {
|
||||||
const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService);
|
const generator = new CredentialGeneratorService(
|
||||||
|
randomizer,
|
||||||
|
stateProvider,
|
||||||
|
policyService,
|
||||||
|
apiService,
|
||||||
|
i18nService,
|
||||||
|
encryptService,
|
||||||
|
cryptoService,
|
||||||
|
);
|
||||||
const userId = new BehaviorSubject(SomeUser);
|
const userId = new BehaviorSubject(SomeUser);
|
||||||
const userId$ = userId.asObservable();
|
const userId$ = userId.asObservable();
|
||||||
const somePolicy$ = new BehaviorSubject([somePolicy]).asObservable();
|
const somePolicy$ = new BehaviorSubject([somePolicy]).asObservable();
|
||||||
@ -884,7 +1250,15 @@ describe("CredentialGeneratorService", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("errors when the user errors", async () => {
|
it("errors when the user errors", async () => {
|
||||||
const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService);
|
const generator = new CredentialGeneratorService(
|
||||||
|
randomizer,
|
||||||
|
stateProvider,
|
||||||
|
policyService,
|
||||||
|
apiService,
|
||||||
|
i18nService,
|
||||||
|
encryptService,
|
||||||
|
cryptoService,
|
||||||
|
);
|
||||||
const userId = new BehaviorSubject(SomeUser);
|
const userId = new BehaviorSubject(SomeUser);
|
||||||
const userId$ = userId.asObservable();
|
const userId$ = userId.asObservable();
|
||||||
const expectedError = { some: "error" };
|
const expectedError = { some: "error" };
|
||||||
@ -902,7 +1276,15 @@ describe("CredentialGeneratorService", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("completes when the user completes", async () => {
|
it("completes when the user completes", async () => {
|
||||||
const generator = new CredentialGeneratorService(randomizer, stateProvider, policyService);
|
const generator = new CredentialGeneratorService(
|
||||||
|
randomizer,
|
||||||
|
stateProvider,
|
||||||
|
policyService,
|
||||||
|
apiService,
|
||||||
|
i18nService,
|
||||||
|
encryptService,
|
||||||
|
cryptoService,
|
||||||
|
);
|
||||||
const userId = new BehaviorSubject(SomeUser);
|
const userId = new BehaviorSubject(SomeUser);
|
||||||
const userId$ = userId.asObservable();
|
const userId$ = userId.asObservable();
|
||||||
|
|
||||||
|
@ -11,38 +11,60 @@ import {
|
|||||||
ignoreElements,
|
ignoreElements,
|
||||||
map,
|
map,
|
||||||
Observable,
|
Observable,
|
||||||
race,
|
|
||||||
share,
|
share,
|
||||||
skipUntil,
|
skipUntil,
|
||||||
switchMap,
|
switchMap,
|
||||||
takeUntil,
|
takeUntil,
|
||||||
|
takeWhile,
|
||||||
withLatestFrom,
|
withLatestFrom,
|
||||||
} from "rxjs";
|
} from "rxjs";
|
||||||
import { Simplify } from "type-fest";
|
import { Simplify } from "type-fest";
|
||||||
|
|
||||||
|
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||||
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
|
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
|
||||||
import { PolicyType } from "@bitwarden/common/admin-console/enums";
|
import { PolicyType } from "@bitwarden/common/admin-console/enums";
|
||||||
|
import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service";
|
||||||
|
import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service";
|
||||||
|
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||||
import { StateProvider } from "@bitwarden/common/platform/state";
|
import { StateProvider } from "@bitwarden/common/platform/state";
|
||||||
import {
|
import {
|
||||||
OnDependency,
|
OnDependency,
|
||||||
SingleUserDependency,
|
SingleUserDependency,
|
||||||
|
UserBound,
|
||||||
UserDependency,
|
UserDependency,
|
||||||
} from "@bitwarden/common/tools/dependencies";
|
} from "@bitwarden/common/tools/dependencies";
|
||||||
import { isDynamic } from "@bitwarden/common/tools/state/state-constraints-dependency";
|
import { IntegrationId, IntegrationMetadata } from "@bitwarden/common/tools/integration";
|
||||||
|
import { RestClient } from "@bitwarden/common/tools/integration/rpc";
|
||||||
|
import { anyComplete } from "@bitwarden/common/tools/rx";
|
||||||
|
import { PaddedDataPacker } from "@bitwarden/common/tools/state/padded-data-packer";
|
||||||
|
import { UserEncryptor } from "@bitwarden/common/tools/state/user-encryptor.abstraction";
|
||||||
|
import { UserKeyEncryptor } from "@bitwarden/common/tools/state/user-key-encryptor";
|
||||||
import { UserStateSubject } from "@bitwarden/common/tools/state/user-state-subject";
|
import { UserStateSubject } from "@bitwarden/common/tools/state/user-state-subject";
|
||||||
|
import { UserId } from "@bitwarden/common/types/guid";
|
||||||
|
|
||||||
import { Randomizer } from "../abstractions";
|
import { Randomizer } from "../abstractions";
|
||||||
import { Generators } from "../data";
|
import {
|
||||||
|
Generators,
|
||||||
|
getForwarderConfiguration,
|
||||||
|
Integrations,
|
||||||
|
toCredentialGeneratorConfiguration,
|
||||||
|
} from "../data";
|
||||||
import { availableAlgorithms } from "../policies/available-algorithms-policy";
|
import { availableAlgorithms } from "../policies/available-algorithms-policy";
|
||||||
import { mapPolicyToConstraints } from "../rx";
|
import { mapPolicyToConstraints } from "../rx";
|
||||||
import {
|
import {
|
||||||
CredentialAlgorithm,
|
CredentialAlgorithm,
|
||||||
CredentialCategories,
|
CredentialCategories,
|
||||||
CredentialCategory,
|
CredentialCategory,
|
||||||
CredentialGeneratorInfo,
|
AlgorithmInfo,
|
||||||
CredentialPreference,
|
CredentialPreference,
|
||||||
|
isForwarderIntegration,
|
||||||
|
ForwarderIntegration,
|
||||||
} from "../types";
|
} from "../types";
|
||||||
import { CredentialGeneratorConfiguration as Configuration } from "../types/credential-generator-configuration";
|
import {
|
||||||
|
CredentialGeneratorConfiguration as Configuration,
|
||||||
|
CredentialGeneratorInfo,
|
||||||
|
GeneratorDependencyProvider,
|
||||||
|
} from "../types/credential-generator-configuration";
|
||||||
import { GeneratorConstraints } from "../types/generator-constraints";
|
import { GeneratorConstraints } from "../types/generator-constraints";
|
||||||
|
|
||||||
import { PREFERENCES } from "./credential-preferences";
|
import { PREFERENCES } from "./credential-preferences";
|
||||||
@ -59,17 +81,33 @@ type Generate$Dependencies = Simplify<Partial<OnDependency> & Partial<UserDepend
|
|||||||
* When `website$` errors, the generator forwards the error.
|
* When `website$` errors, the generator forwards the error.
|
||||||
*/
|
*/
|
||||||
website$?: Observable<string>;
|
website$?: Observable<string>;
|
||||||
|
|
||||||
|
integration$?: Observable<IntegrationId>;
|
||||||
};
|
};
|
||||||
|
|
||||||
type Algorithms$Dependencies = Partial<UserDependency>;
|
type Algorithms$Dependencies = Partial<UserDependency>;
|
||||||
|
|
||||||
|
const OPTIONS_FRAME_SIZE = 512;
|
||||||
|
|
||||||
export class CredentialGeneratorService {
|
export class CredentialGeneratorService {
|
||||||
constructor(
|
constructor(
|
||||||
private randomizer: Randomizer,
|
private readonly randomizer: Randomizer,
|
||||||
private stateProvider: StateProvider,
|
private readonly stateProvider: StateProvider,
|
||||||
private policyService: PolicyService,
|
private readonly policyService: PolicyService,
|
||||||
|
private readonly apiService: ApiService,
|
||||||
|
private readonly i18nService: I18nService,
|
||||||
|
private readonly encryptService: EncryptService,
|
||||||
|
private readonly cryptoService: CryptoService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
private getDependencyProvider(): GeneratorDependencyProvider {
|
||||||
|
return {
|
||||||
|
client: new RestClient(this.apiService, this.i18nService),
|
||||||
|
i18nService: this.i18nService,
|
||||||
|
randomizer: this.randomizer,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// FIXME: the rxjs methods of this service can be a lot more resilient if
|
// FIXME: the rxjs methods of this service can be a lot more resilient if
|
||||||
// `Subjects` are introduced where sharing occurs
|
// `Subjects` are introduced where sharing occurs
|
||||||
|
|
||||||
@ -84,18 +122,13 @@ export class CredentialGeneratorService {
|
|||||||
dependencies?: Generate$Dependencies,
|
dependencies?: Generate$Dependencies,
|
||||||
) {
|
) {
|
||||||
// instantiate the engine
|
// instantiate the engine
|
||||||
const engine = configuration.engine.create(this.randomizer);
|
const engine = configuration.engine.create(this.getDependencyProvider());
|
||||||
|
|
||||||
// stream blocks until all of these values are received
|
// stream blocks until all of these values are received
|
||||||
const website$ = dependencies?.website$ ?? new BehaviorSubject<string>(null);
|
const website$ = dependencies?.website$ ?? new BehaviorSubject<string>(null);
|
||||||
const request$ = website$.pipe(map((website) => ({ website })));
|
const request$ = website$.pipe(map((website) => ({ website })));
|
||||||
const settings$ = this.settings$(configuration, dependencies);
|
const settings$ = this.settings$(configuration, dependencies);
|
||||||
|
|
||||||
// monitor completion
|
|
||||||
const requestComplete$ = request$.pipe(ignoreElements(), endWith(true));
|
|
||||||
const settingsComplete$ = request$.pipe(ignoreElements(), endWith(true));
|
|
||||||
const complete$ = race(requestComplete$, settingsComplete$);
|
|
||||||
|
|
||||||
// if on$ triggers before settings are loaded, trigger as soon
|
// if on$ triggers before settings are loaded, trigger as soon
|
||||||
// as they become available.
|
// as they become available.
|
||||||
let readyOn$: Observable<any> = null;
|
let readyOn$: Observable<any> = null;
|
||||||
@ -116,7 +149,7 @@ export class CredentialGeneratorService {
|
|||||||
const generate$ = (readyOn$ ?? settings$).pipe(
|
const generate$ = (readyOn$ ?? settings$).pipe(
|
||||||
withLatestFrom(request$, settings$),
|
withLatestFrom(request$, settings$),
|
||||||
concatMap(([, request, settings]) => engine.generate(request, settings)),
|
concatMap(([, request, settings]) => engine.generate(request, settings)),
|
||||||
takeUntil(complete$),
|
takeUntil(anyComplete([request$, settings$])),
|
||||||
);
|
);
|
||||||
|
|
||||||
return generate$;
|
return generate$;
|
||||||
@ -132,11 +165,11 @@ export class CredentialGeneratorService {
|
|||||||
algorithms$(
|
algorithms$(
|
||||||
category: CredentialCategory,
|
category: CredentialCategory,
|
||||||
dependencies?: Algorithms$Dependencies,
|
dependencies?: Algorithms$Dependencies,
|
||||||
): Observable<CredentialGeneratorInfo[]>;
|
): Observable<AlgorithmInfo[]>;
|
||||||
algorithms$(
|
algorithms$(
|
||||||
category: CredentialCategory[],
|
category: CredentialCategory[],
|
||||||
dependencies?: Algorithms$Dependencies,
|
dependencies?: Algorithms$Dependencies,
|
||||||
): Observable<CredentialGeneratorInfo[]>;
|
): Observable<AlgorithmInfo[]>;
|
||||||
algorithms$(
|
algorithms$(
|
||||||
category: CredentialCategory | CredentialCategory[],
|
category: CredentialCategory | CredentialCategory[],
|
||||||
dependencies?: Algorithms$Dependencies,
|
dependencies?: Algorithms$Dependencies,
|
||||||
@ -163,7 +196,9 @@ export class CredentialGeneratorService {
|
|||||||
return policies$;
|
return policies$;
|
||||||
}),
|
}),
|
||||||
map((available) => {
|
map((available) => {
|
||||||
const filtered = algorithms.filter((c) => available.has(c.id));
|
const filtered = algorithms.filter(
|
||||||
|
(c) => isForwarderIntegration(c.id) || available.has(c.id),
|
||||||
|
);
|
||||||
return filtered;
|
return filtered;
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@ -175,24 +210,79 @@ export class CredentialGeneratorService {
|
|||||||
* @param category the category or categories of interest
|
* @param category the category or categories of interest
|
||||||
* @returns A list containing the requested metadata.
|
* @returns A list containing the requested metadata.
|
||||||
*/
|
*/
|
||||||
algorithms(category: CredentialCategory): CredentialGeneratorInfo[];
|
algorithms(category: CredentialCategory): AlgorithmInfo[];
|
||||||
algorithms(category: CredentialCategory[]): CredentialGeneratorInfo[];
|
algorithms(category: CredentialCategory[]): AlgorithmInfo[];
|
||||||
algorithms(category: CredentialCategory | CredentialCategory[]): CredentialGeneratorInfo[] {
|
algorithms(category: CredentialCategory | CredentialCategory[]): AlgorithmInfo[] {
|
||||||
const categories = Array.isArray(category) ? category : [category];
|
const categories: CredentialCategory[] = Array.isArray(category) ? category : [category];
|
||||||
|
|
||||||
const algorithms = categories
|
const algorithms = categories
|
||||||
.flatMap((c) => CredentialCategories[c])
|
.flatMap((c) => CredentialCategories[c] as CredentialAlgorithm[])
|
||||||
.map((c) => (c === "forwarder" ? null : Generators[c]))
|
.map((id) => this.algorithm(id))
|
||||||
.filter((info) => info !== null);
|
.filter((info) => info !== null);
|
||||||
|
|
||||||
return algorithms;
|
const forwarders = Object.keys(Integrations)
|
||||||
|
.map((key: keyof typeof Integrations) => {
|
||||||
|
const forwarder: ForwarderIntegration = { forwarder: Integrations[key].id };
|
||||||
|
return this.algorithm(forwarder);
|
||||||
|
})
|
||||||
|
.filter((forwarder) => categories.includes(forwarder.category));
|
||||||
|
|
||||||
|
return algorithms.concat(forwarders);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Look up the metadata for a specific generator algorithm
|
/** Look up the metadata for a specific generator algorithm
|
||||||
* @param id identifies the algorithm
|
* @param id identifies the algorithm
|
||||||
* @returns the requested metadata, or `null` if the metadata wasn't found.
|
* @returns the requested metadata, or `null` if the metadata wasn't found.
|
||||||
*/
|
*/
|
||||||
algorithm(id: CredentialAlgorithm): CredentialGeneratorInfo {
|
algorithm(id: CredentialAlgorithm): AlgorithmInfo {
|
||||||
return (id === "forwarder" ? null : Generators[id]) ?? null;
|
let generator: CredentialGeneratorInfo = null;
|
||||||
|
let integration: IntegrationMetadata = null;
|
||||||
|
|
||||||
|
if (isForwarderIntegration(id)) {
|
||||||
|
const forwarderConfig = getForwarderConfiguration(id.forwarder);
|
||||||
|
integration = forwarderConfig;
|
||||||
|
|
||||||
|
if (forwarderConfig) {
|
||||||
|
generator = toCredentialGeneratorConfiguration(forwarderConfig);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
generator = Generators[id];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!generator) {
|
||||||
|
throw new Error(`Invalid credential algorithm: ${JSON.stringify(id)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const info: AlgorithmInfo = {
|
||||||
|
id: generator.id,
|
||||||
|
category: generator.category,
|
||||||
|
name: integration ? integration.name : this.i18nService.t(generator.nameKey),
|
||||||
|
generate: this.i18nService.t(generator.generateKey),
|
||||||
|
copy: this.i18nService.t(generator.copyKey),
|
||||||
|
onlyOnRequest: generator.onlyOnRequest,
|
||||||
|
request: generator.request,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (generator.descriptionKey) {
|
||||||
|
info.description = this.i18nService.t(generator.descriptionKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
return info;
|
||||||
|
}
|
||||||
|
|
||||||
|
private encryptor$(userId: UserId) {
|
||||||
|
const packer = new PaddedDataPacker(OPTIONS_FRAME_SIZE);
|
||||||
|
const encryptor$ = this.cryptoService.userKey$(userId).pipe(
|
||||||
|
// complete when the account locks
|
||||||
|
takeWhile((key) => !!key),
|
||||||
|
map((key) => {
|
||||||
|
const encryptor = new UserKeyEncryptor(userId, this.encryptService, key, packer);
|
||||||
|
|
||||||
|
return { userId, encryptor } satisfies UserBound<"encryptor", UserEncryptor>;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
return encryptor$;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Get the settings for the provided configuration
|
/** Get the settings for the provided configuration
|
||||||
@ -208,27 +298,21 @@ export class CredentialGeneratorService {
|
|||||||
dependencies?: Settings$Dependencies,
|
dependencies?: Settings$Dependencies,
|
||||||
) {
|
) {
|
||||||
const userId$ = dependencies?.userId$ ?? this.stateProvider.activeUserId$;
|
const userId$ = dependencies?.userId$ ?? this.stateProvider.activeUserId$;
|
||||||
const completion$ = userId$.pipe(ignoreElements(), endWith(true));
|
const constraints$ = this.policy$(configuration, { userId$ });
|
||||||
|
|
||||||
const state$ = userId$.pipe(
|
const settings$ = userId$.pipe(
|
||||||
filter((userId) => !!userId),
|
filter((userId) => !!userId),
|
||||||
distinctUntilChanged(),
|
distinctUntilChanged(),
|
||||||
switchMap((userId) => {
|
switchMap((userId) => {
|
||||||
const state$ = this.stateProvider
|
const state$ = new UserStateSubject(
|
||||||
.getUserState$(configuration.settings.account, userId)
|
configuration.settings.account,
|
||||||
.pipe(takeUntil(completion$));
|
(key) => this.stateProvider.getUser(userId, key),
|
||||||
|
{ constraints$, singleUserEncryptor$: this.encryptor$(userId) },
|
||||||
|
);
|
||||||
return state$;
|
return state$;
|
||||||
}),
|
}),
|
||||||
map((settings) => settings ?? structuredClone(configuration.settings.initial)),
|
map((settings) => settings ?? structuredClone(configuration.settings.initial)),
|
||||||
);
|
takeUntil(anyComplete(userId$)),
|
||||||
|
|
||||||
const settings$ = combineLatest([state$, this.policy$(configuration, { userId$ })]).pipe(
|
|
||||||
map(([settings, policy]) => {
|
|
||||||
const calibration = isDynamic(policy) ? policy.calibrate(settings) : policy;
|
|
||||||
const adjusted = calibration.adjust(settings);
|
|
||||||
return adjusted;
|
|
||||||
}),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
return settings$;
|
return settings$;
|
||||||
@ -251,8 +335,11 @@ export class CredentialGeneratorService {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// FIXME: enforce policy
|
// FIXME: enforce policy
|
||||||
const state = this.stateProvider.getUser(userId, PREFERENCES);
|
const subject = new UserStateSubject(
|
||||||
const subject = new UserStateSubject(state, { ...dependencies });
|
PREFERENCES,
|
||||||
|
(key) => this.stateProvider.getUser(userId, key),
|
||||||
|
{ singleUserEncryptor$: this.encryptor$(userId) },
|
||||||
|
);
|
||||||
|
|
||||||
return subject;
|
return subject;
|
||||||
}
|
}
|
||||||
@ -271,10 +358,14 @@ export class CredentialGeneratorService {
|
|||||||
const userId = await firstValueFrom(
|
const userId = await firstValueFrom(
|
||||||
dependencies.singleUserId$.pipe(filter((userId) => !!userId)),
|
dependencies.singleUserId$.pipe(filter((userId) => !!userId)),
|
||||||
);
|
);
|
||||||
const state = this.stateProvider.getUser(userId, configuration.settings.account);
|
|
||||||
const constraints$ = this.policy$(configuration, { userId$: dependencies.singleUserId$ });
|
const constraints$ = this.policy$(configuration, { userId$: dependencies.singleUserId$ });
|
||||||
|
|
||||||
const subject = new UserStateSubject(state, { ...dependencies, constraints$ });
|
const subject = new UserStateSubject(
|
||||||
|
configuration.settings.account,
|
||||||
|
(key) => this.stateProvider.getUser(userId, key),
|
||||||
|
{ constraints$, singleUserEncryptor$: this.encryptor$(userId) },
|
||||||
|
);
|
||||||
|
|
||||||
return subject;
|
return subject;
|
||||||
}
|
}
|
||||||
|
@ -1,4 +1,7 @@
|
|||||||
|
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||||
import { UserKeyDefinition } from "@bitwarden/common/platform/state";
|
import { UserKeyDefinition } from "@bitwarden/common/platform/state";
|
||||||
|
import { RestClient } from "@bitwarden/common/tools/integration/rpc";
|
||||||
|
import { ObjectKey } from "@bitwarden/common/tools/state/object-key";
|
||||||
import { Constraints } from "@bitwarden/common/tools/types";
|
import { Constraints } from "@bitwarden/common/tools/types";
|
||||||
|
|
||||||
import { Randomizer } from "../abstractions";
|
import { Randomizer } from "../abstractions";
|
||||||
@ -6,9 +9,58 @@ import { CredentialAlgorithm, CredentialCategory, PolicyConfiguration } from "..
|
|||||||
|
|
||||||
import { CredentialGenerator } from "./credential-generator";
|
import { CredentialGenerator } from "./credential-generator";
|
||||||
|
|
||||||
|
export type GeneratorDependencyProvider = {
|
||||||
|
randomizer: Randomizer;
|
||||||
|
client: RestClient;
|
||||||
|
i18nService: I18nService;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AlgorithmInfo = {
|
||||||
|
/** Uniquely identifies the credential configuration
|
||||||
|
* @example
|
||||||
|
* // Use `isForwarderIntegration(algorithm: CredentialAlgorithm)`
|
||||||
|
* // to pattern test whether the credential describes a forwarder algorithm
|
||||||
|
* const meta : CredentialGeneratorInfo = // ...
|
||||||
|
* const { forwarder } = isForwarderIntegration(meta.id) ? credentialId : {};
|
||||||
|
*/
|
||||||
|
id: CredentialAlgorithm;
|
||||||
|
|
||||||
|
/** The kind of credential generated by this configuration */
|
||||||
|
category: CredentialCategory;
|
||||||
|
|
||||||
|
/** Localized algorithm name */
|
||||||
|
name: string;
|
||||||
|
|
||||||
|
/* Localized generate button label */
|
||||||
|
generate: string;
|
||||||
|
|
||||||
|
/* Localized copy button label */
|
||||||
|
copy: string;
|
||||||
|
|
||||||
|
/** Localized algorithm description */
|
||||||
|
description?: string;
|
||||||
|
|
||||||
|
/** When true, credential generation must be explicitly requested.
|
||||||
|
* @remarks this property is useful when credential generation
|
||||||
|
* carries side effects, such as configuring a service external
|
||||||
|
* to Bitwarden.
|
||||||
|
*/
|
||||||
|
onlyOnRequest: boolean;
|
||||||
|
|
||||||
|
/** Well-known fields to display on the options panel or collect from the environment.
|
||||||
|
* @remarks: at present, this is only used by forwarders
|
||||||
|
*/
|
||||||
|
request: readonly string[];
|
||||||
|
};
|
||||||
|
|
||||||
/** Credential generator metadata common across credential generators */
|
/** Credential generator metadata common across credential generators */
|
||||||
export type CredentialGeneratorInfo = {
|
export type CredentialGeneratorInfo = {
|
||||||
/** Uniquely identifies the credential configuration
|
/** Uniquely identifies the credential configuration
|
||||||
|
* @example
|
||||||
|
* // Use `isForwarderIntegration(algorithm: CredentialAlgorithm)`
|
||||||
|
* // to pattern test whether the credential describes a forwarder algorithm
|
||||||
|
* const meta : CredentialGeneratorInfo = // ...
|
||||||
|
* const { forwarder } = isForwarderIntegration(meta.id) ? credentialId : {};
|
||||||
*/
|
*/
|
||||||
id: CredentialAlgorithm;
|
id: CredentialAlgorithm;
|
||||||
|
|
||||||
@ -21,15 +73,32 @@ export type CredentialGeneratorInfo = {
|
|||||||
/** Key used to localize the credential description in the I18nService */
|
/** Key used to localize the credential description in the I18nService */
|
||||||
descriptionKey?: string;
|
descriptionKey?: string;
|
||||||
|
|
||||||
|
/* Localized generate button label */
|
||||||
|
generateKey: string;
|
||||||
|
|
||||||
|
/* Localized copy button label */
|
||||||
|
copyKey: string;
|
||||||
|
|
||||||
/** When true, credential generation must be explicitly requested.
|
/** When true, credential generation must be explicitly requested.
|
||||||
* @remarks this property is useful when credential generation
|
* @remarks this property is useful when credential generation
|
||||||
* carries side effects, such as configuring a service external
|
* carries side effects, such as configuring a service external
|
||||||
* to Bitwarden.
|
* to Bitwarden.
|
||||||
*/
|
*/
|
||||||
onlyOnRequest: boolean;
|
onlyOnRequest: boolean;
|
||||||
|
|
||||||
|
/** Well-known fields to display on the options panel or collect from the environment.
|
||||||
|
* @remarks: at present, this is only used by forwarders
|
||||||
|
*/
|
||||||
|
request: readonly string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Credential generator metadata that relies upon typed setting and policy definitions. */
|
/** Credential generator metadata that relies upon typed setting and policy definitions.
|
||||||
|
* @example
|
||||||
|
* // Use `isForwarderIntegration(algorithm: CredentialAlgorithm)`
|
||||||
|
* // to pattern test whether the credential describes a forwarder algorithm
|
||||||
|
* const meta : CredentialGeneratorInfo = // ...
|
||||||
|
* const { forwarder } = isForwarderIntegration(meta.id) ? credentialId : {};
|
||||||
|
*/
|
||||||
export type CredentialGeneratorConfiguration<Settings, Policy> = CredentialGeneratorInfo & {
|
export type CredentialGeneratorConfiguration<Settings, Policy> = CredentialGeneratorInfo & {
|
||||||
/** An algorithm that generates credentials when ran. */
|
/** An algorithm that generates credentials when ran. */
|
||||||
engine: {
|
engine: {
|
||||||
@ -40,7 +109,7 @@ export type CredentialGeneratorConfiguration<Settings, Policy> = CredentialGener
|
|||||||
// the credential generator, but engine configurations should return
|
// the credential generator, but engine configurations should return
|
||||||
// the underlying type. `create` may be able to do double-duty w/ an
|
// the underlying type. `create` may be able to do double-duty w/ an
|
||||||
// engine definition if `CredentialGenerator` can be made covariant.
|
// engine definition if `CredentialGenerator` can be made covariant.
|
||||||
create: (randomizer: Randomizer) => CredentialGenerator<Settings>;
|
create: (randomizer: GeneratorDependencyProvider) => CredentialGenerator<Settings>;
|
||||||
};
|
};
|
||||||
/** Defines the stored parameters for credential generation */
|
/** Defines the stored parameters for credential generation */
|
||||||
settings: {
|
settings: {
|
||||||
@ -51,7 +120,10 @@ export type CredentialGeneratorConfiguration<Settings, Policy> = CredentialGener
|
|||||||
constraints: Constraints<Settings>;
|
constraints: Constraints<Settings>;
|
||||||
|
|
||||||
/** storage location for account-global settings */
|
/** storage location for account-global settings */
|
||||||
account: UserKeyDefinition<Settings>;
|
account: UserKeyDefinition<Settings> | ObjectKey<Settings>;
|
||||||
|
|
||||||
|
/** storage location for *plaintext* settings imports */
|
||||||
|
import?: UserKeyDefinition<Settings> | ObjectKey<Settings, Record<string, never>, Settings>;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** defines how to construct policy for this settings instance */
|
/** defines how to construct policy for this settings instance */
|
||||||
|
@ -1,3 +1,5 @@
|
|||||||
|
import { IntegrationId } from "@bitwarden/common/tools/integration";
|
||||||
|
|
||||||
import { EmailAlgorithms, PasswordAlgorithms, UsernameAlgorithms } from "../data/generator-types";
|
import { EmailAlgorithms, PasswordAlgorithms, UsernameAlgorithms } from "../data/generator-types";
|
||||||
|
|
||||||
/** A type of password that may be generated by the credential generator. */
|
/** A type of password that may be generated by the credential generator. */
|
||||||
@ -9,8 +11,31 @@ export type UsernameAlgorithm = (typeof UsernameAlgorithms)[number];
|
|||||||
/** A type of email address that may be generated by the credential generator. */
|
/** A type of email address that may be generated by the credential generator. */
|
||||||
export type EmailAlgorithm = (typeof EmailAlgorithms)[number];
|
export type EmailAlgorithm = (typeof EmailAlgorithms)[number];
|
||||||
|
|
||||||
|
export type ForwarderIntegration = { forwarder: IntegrationId };
|
||||||
|
|
||||||
|
/** Returns true when the input algorithm is a forwarder integration. */
|
||||||
|
export function isForwarderIntegration(
|
||||||
|
algorithm: CredentialAlgorithm,
|
||||||
|
): algorithm is ForwarderIntegration {
|
||||||
|
return algorithm && typeof algorithm === "object" && "forwarder" in algorithm;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isSameAlgorithm(lhs: CredentialAlgorithm, rhs: CredentialAlgorithm) {
|
||||||
|
if (lhs === rhs) {
|
||||||
|
return true;
|
||||||
|
} else if (isForwarderIntegration(lhs) && isForwarderIntegration(rhs)) {
|
||||||
|
return lhs.forwarder === rhs.forwarder;
|
||||||
|
} else {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** A type of credential that may be generated by the credential generator. */
|
/** A type of credential that may be generated by the credential generator. */
|
||||||
export type CredentialAlgorithm = PasswordAlgorithm | UsernameAlgorithm | EmailAlgorithm;
|
export type CredentialAlgorithm =
|
||||||
|
| PasswordAlgorithm
|
||||||
|
| UsernameAlgorithm
|
||||||
|
| EmailAlgorithm
|
||||||
|
| ForwarderIntegration;
|
||||||
|
|
||||||
/** Compound credential types supported by the credential generator. */
|
/** Compound credential types supported by the credential generator. */
|
||||||
export const CredentialCategories = Object.freeze({
|
export const CredentialCategories = Object.freeze({
|
||||||
@ -21,7 +46,7 @@ export const CredentialCategories = Object.freeze({
|
|||||||
username: UsernameAlgorithms as Readonly<UsernameAlgorithm[]>,
|
username: UsernameAlgorithms as Readonly<UsernameAlgorithm[]>,
|
||||||
|
|
||||||
/** Lists algorithms in the "email" credential category */
|
/** Lists algorithms in the "email" credential category */
|
||||||
email: EmailAlgorithms as Readonly<EmailAlgorithm[]>,
|
email: EmailAlgorithms as Readonly<(EmailAlgorithm | ForwarderIntegration)[]>,
|
||||||
});
|
});
|
||||||
|
|
||||||
/** Returns true when the input algorithm is a password algorithm. */
|
/** Returns true when the input algorithm is a password algorithm. */
|
||||||
@ -40,7 +65,7 @@ export function isUsernameAlgorithm(
|
|||||||
|
|
||||||
/** Returns true when the input algorithm is an email algorithm. */
|
/** Returns true when the input algorithm is an email algorithm. */
|
||||||
export function isEmailAlgorithm(algorithm: CredentialAlgorithm): algorithm is EmailAlgorithm {
|
export function isEmailAlgorithm(algorithm: CredentialAlgorithm): algorithm is EmailAlgorithm {
|
||||||
return EmailAlgorithms.includes(algorithm as any);
|
return EmailAlgorithms.includes(algorithm as any) || isForwarderIntegration(algorithm);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A type of compound credential that may be generated by the credential generator. */
|
/** A type of compound credential that may be generated by the credential generator. */
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { CredentialAlgorithm, PasswordAlgorithm } from "./generator-type";
|
import { EmailAlgorithm, PasswordAlgorithm, UsernameAlgorithm } from "./generator-type";
|
||||||
|
|
||||||
export * from "./boundary";
|
export * from "./boundary";
|
||||||
export * from "./catchall-generator-options";
|
export * from "./catchall-generator-options";
|
||||||
@ -22,7 +22,7 @@ export * from "./word-options";
|
|||||||
/** Provided for backwards compatibility only.
|
/** Provided for backwards compatibility only.
|
||||||
* @deprecated Use one of the Algorithm types instead.
|
* @deprecated Use one of the Algorithm types instead.
|
||||||
*/
|
*/
|
||||||
export type GeneratorType = CredentialAlgorithm;
|
export type GeneratorType = PasswordAlgorithm | UsernameAlgorithm | EmailAlgorithm;
|
||||||
|
|
||||||
/** Provided for backwards compatibility only.
|
/** Provided for backwards compatibility only.
|
||||||
* @deprecated Use one of the Algorithm types instead.
|
* @deprecated Use one of the Algorithm types instead.
|
||||||
|
@ -2,8 +2,11 @@ import { NgModule } from "@angular/core";
|
|||||||
|
|
||||||
import { safeProvider } from "@bitwarden/angular/platform/utils/safe-provider";
|
import { safeProvider } from "@bitwarden/angular/platform/utils/safe-provider";
|
||||||
import { SafeInjectionToken } from "@bitwarden/angular/services/injection-tokens";
|
import { SafeInjectionToken } from "@bitwarden/angular/services/injection-tokens";
|
||||||
|
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||||
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
|
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
|
||||||
import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service";
|
import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service";
|
||||||
|
import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service";
|
||||||
|
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||||
import { StateProvider } from "@bitwarden/common/platform/state";
|
import { StateProvider } from "@bitwarden/common/platform/state";
|
||||||
import {
|
import {
|
||||||
createRandomizer,
|
createRandomizer,
|
||||||
@ -32,7 +35,15 @@ const RANDOMIZER = new SafeInjectionToken<Randomizer>("Randomizer");
|
|||||||
safeProvider({
|
safeProvider({
|
||||||
useClass: CredentialGeneratorService,
|
useClass: CredentialGeneratorService,
|
||||||
provide: CredentialGeneratorService,
|
provide: CredentialGeneratorService,
|
||||||
deps: [RANDOMIZER, StateProvider, PolicyService],
|
deps: [
|
||||||
|
RANDOMIZER,
|
||||||
|
StateProvider,
|
||||||
|
PolicyService,
|
||||||
|
ApiService,
|
||||||
|
I18nService,
|
||||||
|
EncryptService,
|
||||||
|
CryptoService,
|
||||||
|
],
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
exports: [SendFormComponent],
|
exports: [SendFormComponent],
|
||||||
|
Loading…
Reference in New Issue
Block a user