mirror of
https://github.com/bitwarden/browser
synced 2025-12-06 00:13:28 +00:00
[PM-23532] introduce send access guard (#15529)
* document expected auth flows * implement send access trampoline
This commit is contained in:
@@ -0,0 +1,75 @@
|
||||
# Send Authentication Flows
|
||||
|
||||
In the below diagrams, activations represent client control flow.
|
||||
|
||||
## Public Sends
|
||||
|
||||
Anyone can access a public send. The token endpoint automatically issues a token. It never issues a challenge.
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Visitor
|
||||
participant TryAccess as try-send-access.guard
|
||||
participant SendToken as send-token API
|
||||
participant ViewContent as view-content.component
|
||||
participant SendAccess as send-access API
|
||||
|
||||
Visitor->>TryAccess: Navigate to send URL
|
||||
activate TryAccess
|
||||
TryAccess->>SendToken: Request anonymous access token
|
||||
SendToken-->>TryAccess: OK + Security token
|
||||
TryAccess->>ViewContent: Redirect with token
|
||||
deactivate TryAccess
|
||||
activate ViewContent
|
||||
ViewContent->>SendAccess: Request send content (with token and key)
|
||||
SendAccess-->>ViewContent: Return send content
|
||||
ViewContent->>Visitor: Display send content
|
||||
deactivate ViewContent
|
||||
```
|
||||
|
||||
## Password Protected Sends
|
||||
|
||||
Password protected sends redirect to a password challenge prompt.
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Visitor
|
||||
participant TryAccess as try-send-access.guard
|
||||
participant PasswordAuth as password-authentication.component
|
||||
participant SendToken as send-token API
|
||||
participant ViewContent as view-content.component
|
||||
participant SendAccess as send-access API
|
||||
|
||||
Visitor->>TryAccess: Navigate to send URL
|
||||
activate TryAccess
|
||||
TryAccess->>SendToken: Request anonymous access token
|
||||
SendToken-->>TryAccess: Unauthorized + Password challenge
|
||||
TryAccess->>PasswordAuth: Redirect with send ID and key
|
||||
deactivate TryAccess
|
||||
activate PasswordAuth
|
||||
PasswordAuth->>Visitor: Request password
|
||||
Visitor-->>PasswordAuth: Enter password
|
||||
PasswordAuth->>SendToken: Request access token (with password)
|
||||
SendToken-->>PasswordAuth: OK + Security token
|
||||
deactivate PasswordAuth
|
||||
activate ViewContent
|
||||
PasswordAuth->>ViewContent: Redirect with token and send key
|
||||
ViewContent->>SendAccess: Request send content (with token)
|
||||
SendAccess-->>ViewContent: Return send content
|
||||
ViewContent->>Visitor: Display send content
|
||||
deactivate ViewContent
|
||||
```
|
||||
|
||||
## Send Access without token
|
||||
|
||||
Visiting the view page without a token redirects to a try-access flow, above.
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Visitor
|
||||
participant ViewContent as view-content.component
|
||||
participant TryAccess as try-send-access.guard
|
||||
|
||||
Visitor->>ViewContent: Navigate to send URL (with id and key)
|
||||
ViewContent->>TryAccess: Redirect to try-access (with id and key)
|
||||
```
|
||||
@@ -0,0 +1,175 @@
|
||||
import { TestBed, fakeAsync, tick } from "@angular/core/testing";
|
||||
import { Router, UrlTree } from "@angular/router";
|
||||
import { mock, MockProxy } from "jest-mock-extended";
|
||||
import { firstValueFrom, NEVER } from "rxjs";
|
||||
|
||||
import { ErrorResponse } from "@bitwarden/common/models/response/error.response";
|
||||
import { StateProvider } from "@bitwarden/common/platform/state";
|
||||
import { mockAccountServiceWith, FakeStateProvider } from "@bitwarden/common/spec";
|
||||
import { SemanticLogger } from "@bitwarden/common/tools/log";
|
||||
import { SystemServiceProvider } from "@bitwarden/common/tools/providers";
|
||||
import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
import { SYSTEM_SERVICE_PROVIDER } from "@bitwarden/generator-components";
|
||||
|
||||
import { DefaultSendAccessService } from "./default-send-access-service";
|
||||
import { SEND_RESPONSE_KEY, SEND_CONTEXT_KEY } from "./send-access-memory";
|
||||
|
||||
describe("DefaultSendAccessService", () => {
|
||||
let service: DefaultSendAccessService;
|
||||
let stateProvider: FakeStateProvider;
|
||||
let sendApiService: MockProxy<SendApiService>;
|
||||
let router: MockProxy<Router>;
|
||||
let logger: MockProxy<SemanticLogger>;
|
||||
let systemServiceProvider: MockProxy<SystemServiceProvider>;
|
||||
|
||||
beforeEach(() => {
|
||||
const accountService = mockAccountServiceWith("user-id" as UserId);
|
||||
stateProvider = new FakeStateProvider(accountService);
|
||||
sendApiService = mock<SendApiService>();
|
||||
router = mock<Router>();
|
||||
logger = mock<SemanticLogger>();
|
||||
systemServiceProvider = mock<SystemServiceProvider>();
|
||||
|
||||
systemServiceProvider.log.mockReturnValue(logger);
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
DefaultSendAccessService,
|
||||
{ provide: StateProvider, useValue: stateProvider },
|
||||
{ provide: SendApiService, useValue: sendApiService },
|
||||
{ provide: Router, useValue: router },
|
||||
{ provide: SYSTEM_SERVICE_PROVIDER, useValue: systemServiceProvider },
|
||||
],
|
||||
});
|
||||
|
||||
service = TestBed.inject(DefaultSendAccessService);
|
||||
});
|
||||
|
||||
describe("constructor", () => {
|
||||
it("creates logger with type 'SendAccessAuthenticationService' when initialized", () => {
|
||||
expect(systemServiceProvider.log).toHaveBeenCalledWith({
|
||||
type: "SendAccessAuthenticationService",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("redirect$", () => {
|
||||
const sendId = "test-send-id";
|
||||
|
||||
it("returns content page UrlTree and logs info when API returns success", async () => {
|
||||
const expectedUrlTree = { toString: () => "/send/content/test-send-id" } as UrlTree;
|
||||
sendApiService.postSendAccess.mockResolvedValue({} as any);
|
||||
router.createUrlTree.mockReturnValue(expectedUrlTree);
|
||||
|
||||
const result = await firstValueFrom(service.redirect$(sendId));
|
||||
|
||||
expect(result).toBe(expectedUrlTree);
|
||||
expect(logger.info).toHaveBeenCalledWith(
|
||||
"public send detected; redirecting to send access with token.",
|
||||
);
|
||||
});
|
||||
|
||||
describe("given error responses", () => {
|
||||
it("returns password flow UrlTree and logs debug when 401 received", async () => {
|
||||
const expectedUrlTree = { toString: () => "/send/test-send-id" } as UrlTree;
|
||||
const errorResponse = new ErrorResponse([], 401);
|
||||
sendApiService.postSendAccess.mockRejectedValue(errorResponse);
|
||||
router.createUrlTree.mockReturnValue(expectedUrlTree);
|
||||
|
||||
const result = await firstValueFrom(service.redirect$(sendId));
|
||||
|
||||
expect(result).toBe(expectedUrlTree);
|
||||
expect(logger.debug).toHaveBeenCalledWith(errorResponse, "redirecting to password flow");
|
||||
});
|
||||
|
||||
it("returns 404 page UrlTree and logs debug when 404 received", async () => {
|
||||
const expectedUrlTree = { toString: () => "/404.html" } as UrlTree;
|
||||
const errorResponse = new ErrorResponse([], 404);
|
||||
sendApiService.postSendAccess.mockRejectedValue(errorResponse);
|
||||
router.parseUrl.mockReturnValue(expectedUrlTree);
|
||||
|
||||
const result = await firstValueFrom(service.redirect$(sendId));
|
||||
|
||||
expect(result).toBe(expectedUrlTree);
|
||||
expect(logger.debug).toHaveBeenCalledWith(errorResponse, "redirecting to unavailable page");
|
||||
});
|
||||
|
||||
it("logs warning and throws error when 500 received", async () => {
|
||||
const errorResponse = new ErrorResponse([], 500);
|
||||
sendApiService.postSendAccess.mockRejectedValue(errorResponse);
|
||||
|
||||
await expect(firstValueFrom(service.redirect$(sendId))).rejects.toBe(errorResponse);
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
errorResponse,
|
||||
"received unexpected error response",
|
||||
);
|
||||
});
|
||||
|
||||
it("throws error when unexpected error code received", async () => {
|
||||
const errorResponse = new ErrorResponse([], 403);
|
||||
sendApiService.postSendAccess.mockRejectedValue(errorResponse);
|
||||
|
||||
await expect(firstValueFrom(service.redirect$(sendId))).rejects.toBe(errorResponse);
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
errorResponse,
|
||||
"received unexpected error response",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("throws error when non-ErrorResponse error occurs", async () => {
|
||||
const regularError = new Error("Network error");
|
||||
sendApiService.postSendAccess.mockRejectedValue(regularError);
|
||||
|
||||
await expect(firstValueFrom(service.redirect$(sendId))).rejects.toThrow("Network error");
|
||||
expect(logger.warn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("emits timeout error when API response exceeds 10 seconds", fakeAsync(() => {
|
||||
// Mock API to never resolve (simulating a hung request)
|
||||
sendApiService.postSendAccess.mockReturnValue(firstValueFrom(NEVER));
|
||||
|
||||
const result$ = service.redirect$(sendId);
|
||||
let error: any;
|
||||
|
||||
result$.subscribe({
|
||||
error: (err: unknown) => (error = err),
|
||||
});
|
||||
|
||||
// Advance time past 10 seconds
|
||||
tick(10001);
|
||||
|
||||
expect(error).toBeDefined();
|
||||
expect(error.name).toBe("TimeoutError");
|
||||
}));
|
||||
});
|
||||
|
||||
describe("setContext", () => {
|
||||
it("updates global state with send context when called with sendId and key", async () => {
|
||||
const sendId = "test-send-id";
|
||||
const key = "test-key";
|
||||
|
||||
await service.setContext(sendId, key);
|
||||
|
||||
const context = await firstValueFrom(stateProvider.getGlobal(SEND_CONTEXT_KEY).state$);
|
||||
expect(context).toEqual({ id: sendId, key });
|
||||
});
|
||||
});
|
||||
|
||||
describe("clear", () => {
|
||||
it("sets both SEND_RESPONSE_KEY and SEND_CONTEXT_KEY to null when called", async () => {
|
||||
// Set initial values
|
||||
await stateProvider.getGlobal(SEND_RESPONSE_KEY).update(() => ({ some: "response" }) as any);
|
||||
await stateProvider.getGlobal(SEND_CONTEXT_KEY).update(() => ({ id: "test", key: "test" }));
|
||||
|
||||
await service.clear();
|
||||
|
||||
const response = await firstValueFrom(stateProvider.getGlobal(SEND_RESPONSE_KEY).state$);
|
||||
const context = await firstValueFrom(stateProvider.getGlobal(SEND_CONTEXT_KEY).state$);
|
||||
|
||||
expect(response).toBeNull();
|
||||
expect(context).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,96 @@
|
||||
import { Injectable, Inject } from "@angular/core";
|
||||
import { Router, UrlTree } from "@angular/router";
|
||||
import { map, of, from, catchError, timeout } from "rxjs";
|
||||
|
||||
import { ErrorResponse } from "@bitwarden/common/models/response/error.response";
|
||||
import { StateProvider } from "@bitwarden/common/platform/state";
|
||||
import { SemanticLogger } from "@bitwarden/common/tools/log";
|
||||
import { SystemServiceProvider } from "@bitwarden/common/tools/providers";
|
||||
import { SendAccessRequest } from "@bitwarden/common/tools/send/models/request/send-access.request";
|
||||
import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction";
|
||||
import { SYSTEM_SERVICE_PROVIDER } from "@bitwarden/generator-components";
|
||||
|
||||
import { SEND_RESPONSE_KEY, SEND_CONTEXT_KEY } from "./send-access-memory";
|
||||
import { SendAccessService } from "./send-access-service.abstraction";
|
||||
import { isErrorResponse } from "./util";
|
||||
|
||||
const TEN_SECONDS = 10_000;
|
||||
|
||||
@Injectable({ providedIn: "root" })
|
||||
export class DefaultSendAccessService implements SendAccessService {
|
||||
private readonly logger: SemanticLogger;
|
||||
|
||||
constructor(
|
||||
private readonly state: StateProvider,
|
||||
private readonly api: SendApiService,
|
||||
private readonly router: Router,
|
||||
@Inject(SYSTEM_SERVICE_PROVIDER) system: SystemServiceProvider,
|
||||
) {
|
||||
this.logger = system.log({ type: "SendAccessAuthenticationService" });
|
||||
}
|
||||
|
||||
redirect$(sendId: string) {
|
||||
// FIXME: when the send authentication APIs become available, this method
|
||||
// should delegate to the API
|
||||
const response$ = from(this.api.postSendAccess(sendId, new SendAccessRequest()));
|
||||
|
||||
const redirect$ = response$.pipe(
|
||||
timeout({ first: TEN_SECONDS }),
|
||||
map((_response) => {
|
||||
this.logger.info("public send detected; redirecting to send access with token.");
|
||||
const url = this.toViewRedirect(sendId);
|
||||
|
||||
return url;
|
||||
}),
|
||||
catchError((error: unknown) => {
|
||||
let processed: UrlTree | undefined = undefined;
|
||||
|
||||
if (isErrorResponse(error)) {
|
||||
processed = this.toErrorRedirect(sendId, error);
|
||||
}
|
||||
|
||||
if (processed) {
|
||||
return of(processed);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}),
|
||||
);
|
||||
|
||||
return redirect$;
|
||||
}
|
||||
|
||||
private toViewRedirect(sendId: string) {
|
||||
return this.router.createUrlTree(["send", "content", sendId]);
|
||||
}
|
||||
|
||||
private toErrorRedirect(sendId: string, response: ErrorResponse) {
|
||||
let url: UrlTree | undefined = undefined;
|
||||
|
||||
switch (response.statusCode) {
|
||||
case 401:
|
||||
this.logger.debug(response, "redirecting to password flow");
|
||||
url = this.router.createUrlTree(["send/password", sendId]);
|
||||
break;
|
||||
|
||||
case 404:
|
||||
this.logger.debug(response, "redirecting to unavailable page");
|
||||
url = this.router.parseUrl("/404.html");
|
||||
break;
|
||||
|
||||
default:
|
||||
this.logger.warn(response, "received unexpected error response");
|
||||
}
|
||||
|
||||
return url;
|
||||
}
|
||||
|
||||
async setContext(sendId: string, key: string) {
|
||||
await this.state.getGlobal(SEND_CONTEXT_KEY).update(() => ({ id: sendId, key }));
|
||||
}
|
||||
|
||||
async clear(): Promise<void> {
|
||||
await this.state.getGlobal(SEND_RESPONSE_KEY).update(() => null);
|
||||
await this.state.getGlobal(SEND_CONTEXT_KEY).update(() => null);
|
||||
}
|
||||
}
|
||||
@@ -1,2 +1,4 @@
|
||||
export { AccessComponent } from "./access.component";
|
||||
export { SendAccessExplainerComponent } from "./send-access-explainer.component";
|
||||
|
||||
export { SendAccessRoutes } from "./routes";
|
||||
|
||||
60
apps/web/src/app/tools/send/send-access/routes.ts
Normal file
60
apps/web/src/app/tools/send/send-access/routes.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { Routes } from "@angular/router";
|
||||
|
||||
import { AnonLayoutWrapperData } from "@bitwarden/components";
|
||||
import { ActiveSendIcon } from "@bitwarden/send-ui";
|
||||
|
||||
import { RouteDataProperties } from "../../../core";
|
||||
|
||||
import { SendAccessExplainerComponent } from "./send-access-explainer.component";
|
||||
import { SendAccessPasswordComponent } from "./send-access-password.component";
|
||||
import { trySendAccess } from "./try-send-access.guard";
|
||||
|
||||
/** Routes to reach send access screens */
|
||||
export const SendAccessRoutes: Routes = [
|
||||
{
|
||||
path: "send/:sendId",
|
||||
// there are no child pages because `trySendAccess` always performs a redirect
|
||||
canActivate: [trySendAccess],
|
||||
},
|
||||
{
|
||||
path: "send/password/:sendId",
|
||||
data: {
|
||||
pageTitle: {
|
||||
key: "sendAccessPasswordTitle",
|
||||
},
|
||||
pageIcon: ActiveSendIcon,
|
||||
showReadonlyHostname: true,
|
||||
} satisfies RouteDataProperties & AnonLayoutWrapperData,
|
||||
children: [
|
||||
{
|
||||
path: "",
|
||||
component: SendAccessPasswordComponent,
|
||||
},
|
||||
{
|
||||
path: "",
|
||||
outlet: "secondary",
|
||||
component: SendAccessExplainerComponent,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: "send/content/:sendId",
|
||||
data: {
|
||||
pageTitle: {
|
||||
key: "sendAccessContentTitle",
|
||||
},
|
||||
pageIcon: ActiveSendIcon,
|
||||
showReadonlyHostname: true,
|
||||
} satisfies RouteDataProperties & AnonLayoutWrapperData,
|
||||
children: [
|
||||
{
|
||||
path: "send/password/:sendId",
|
||||
},
|
||||
{
|
||||
path: "",
|
||||
outlet: "secondary",
|
||||
component: SendAccessExplainerComponent,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,50 @@
|
||||
import { KeyDefinition, SEND_ACCESS_AUTH_MEMORY } from "@bitwarden/common/platform/state";
|
||||
import { SendAccessResponse } from "@bitwarden/common/tools/send/models/response/send-access.response";
|
||||
|
||||
import { SEND_CONTEXT_KEY, SEND_RESPONSE_KEY } from "./send-access-memory";
|
||||
import { SendContext } from "./types";
|
||||
|
||||
describe("send-access-memory", () => {
|
||||
describe("SEND_CONTEXT_KEY", () => {
|
||||
it("has correct state definition properties", () => {
|
||||
expect(SEND_CONTEXT_KEY).toBeInstanceOf(KeyDefinition);
|
||||
expect(SEND_CONTEXT_KEY.stateDefinition).toBe(SEND_ACCESS_AUTH_MEMORY);
|
||||
expect(SEND_CONTEXT_KEY.key).toBe("sendContext");
|
||||
});
|
||||
|
||||
it("deserializes data as-is", () => {
|
||||
const testContext: SendContext = { id: "test-id", key: "test-key" };
|
||||
const deserializer = SEND_CONTEXT_KEY.deserializer;
|
||||
expect(deserializer(testContext)).toBe(testContext);
|
||||
});
|
||||
|
||||
it("deserializes null as null", () => {
|
||||
const deserializer = SEND_CONTEXT_KEY.deserializer;
|
||||
expect(deserializer(null)).toBe(null);
|
||||
});
|
||||
});
|
||||
|
||||
describe("SEND_RESPONSE_KEY", () => {
|
||||
it("has correct state definition properties", () => {
|
||||
expect(SEND_RESPONSE_KEY).toBeInstanceOf(KeyDefinition);
|
||||
expect(SEND_RESPONSE_KEY.stateDefinition).toBe(SEND_ACCESS_AUTH_MEMORY);
|
||||
expect(SEND_RESPONSE_KEY.key).toBe("sendResponse");
|
||||
});
|
||||
|
||||
it("deserializes data into SendAccessResponse instance", () => {
|
||||
const mockData = { id: "test-id", name: "test-send" } as any;
|
||||
const deserializer = SEND_RESPONSE_KEY.deserializer;
|
||||
const result = deserializer(mockData);
|
||||
|
||||
expect(result).toBeInstanceOf(SendAccessResponse);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[null, "null"],
|
||||
[undefined, "undefined"],
|
||||
])("deserializes %s as null", (value, _) => {
|
||||
const deserializer = SEND_RESPONSE_KEY.deserializer;
|
||||
expect(deserializer(value!)).toBe(null);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
import { KeyDefinition, SEND_ACCESS_AUTH_MEMORY } from "@bitwarden/common/platform/state";
|
||||
import { SendAccessResponse } from "@bitwarden/common/tools/send/models/response/send-access.response";
|
||||
|
||||
import { SendContext } from "./types";
|
||||
|
||||
export const SEND_CONTEXT_KEY = new KeyDefinition<SendContext | null>(
|
||||
SEND_ACCESS_AUTH_MEMORY,
|
||||
"sendContext",
|
||||
{
|
||||
deserializer: (data) => data,
|
||||
},
|
||||
);
|
||||
|
||||
/** When send authentication succeeds, this stores the result so that
|
||||
* multiple access attempts don't accrue due to the send workflow.
|
||||
*/
|
||||
// FIXME: replace this with the send authentication token once it's
|
||||
// available
|
||||
export const SEND_RESPONSE_KEY = new KeyDefinition<SendAccessResponse | null>(
|
||||
SEND_ACCESS_AUTH_MEMORY,
|
||||
"sendResponse",
|
||||
{
|
||||
deserializer: (data) => (data ? new SendAccessResponse(data) : null),
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,10 @@
|
||||
import { UrlTree } from "@angular/router";
|
||||
import { Observable } from "rxjs";
|
||||
|
||||
export abstract class SendAccessService {
|
||||
abstract redirect$: (sendId: string) => Observable<UrlTree>;
|
||||
|
||||
abstract setContext: (sendId: string, key: string) => Promise<void>;
|
||||
|
||||
abstract clear: () => Promise<void>;
|
||||
}
|
||||
@@ -0,0 +1,426 @@
|
||||
import { TestBed } from "@angular/core/testing";
|
||||
import { ActivatedRouteSnapshot, RouterStateSnapshot, UrlTree } from "@angular/router";
|
||||
import { firstValueFrom, Observable, of } from "rxjs";
|
||||
|
||||
import { SemanticLogger } from "@bitwarden/common/tools/log";
|
||||
import { SystemServiceProvider } from "@bitwarden/common/tools/providers";
|
||||
import { SYSTEM_SERVICE_PROVIDER } from "@bitwarden/generator-components";
|
||||
|
||||
import { SendAccessService } from "./send-access-service.abstraction";
|
||||
import { trySendAccess } from "./try-send-access.guard";
|
||||
|
||||
function createMockRoute(params: Record<string, any>): ActivatedRouteSnapshot {
|
||||
return { params } as ActivatedRouteSnapshot;
|
||||
}
|
||||
|
||||
function createMockLogger(): SemanticLogger {
|
||||
return {
|
||||
warn: jest.fn(),
|
||||
panic: jest.fn().mockImplementation(() => {
|
||||
throw new Error("Logger panic called");
|
||||
}),
|
||||
} as any as SemanticLogger;
|
||||
}
|
||||
|
||||
function createMockSystemServiceProvider(): SystemServiceProvider {
|
||||
return {
|
||||
log: jest.fn().mockReturnValue(createMockLogger()),
|
||||
} as any as SystemServiceProvider;
|
||||
}
|
||||
|
||||
function createMockSendAccessService() {
|
||||
return {
|
||||
setContext: jest.fn().mockResolvedValue(undefined),
|
||||
redirect$: jest.fn().mockReturnValue(of({} as UrlTree)),
|
||||
clear: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
}
|
||||
|
||||
describe("trySendAccess", () => {
|
||||
let mockSendAccessService: ReturnType<typeof createMockSendAccessService>;
|
||||
let mockSystemServiceProvider: SystemServiceProvider;
|
||||
let mockRouterState: RouterStateSnapshot;
|
||||
|
||||
beforeEach(() => {
|
||||
mockSendAccessService = createMockSendAccessService();
|
||||
mockSystemServiceProvider = createMockSystemServiceProvider();
|
||||
mockRouterState = {} as RouterStateSnapshot;
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
{ provide: SendAccessService, useValue: mockSendAccessService },
|
||||
{ provide: SYSTEM_SERVICE_PROVIDER, useValue: mockSystemServiceProvider },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("canActivate", () => {
|
||||
describe("given valid route parameters", () => {
|
||||
it("extracts sendId and key from route params when both are valid strings", async () => {
|
||||
const sendId = "test-send-id";
|
||||
const key = "test-key";
|
||||
const mockRoute = createMockRoute({ sendId, key });
|
||||
const expectedUrlTree = { toString: () => "/test-url" } as UrlTree;
|
||||
mockSendAccessService.redirect$.mockReturnValue(of(expectedUrlTree));
|
||||
|
||||
// need to cast the result because `CanActivateFn` performs type erasure
|
||||
const result$ = TestBed.runInInjectionContext(() =>
|
||||
trySendAccess(mockRoute, mockRouterState),
|
||||
) as unknown as Observable<UrlTree>;
|
||||
|
||||
expect(mockSendAccessService.setContext).toHaveBeenCalledWith(sendId, key);
|
||||
expect(mockSendAccessService.setContext).toHaveBeenCalledTimes(1);
|
||||
await expect(firstValueFrom(result$)).resolves.toEqual(expectedUrlTree);
|
||||
});
|
||||
|
||||
it("does not throw validation errors when sendId and key are valid strings", async () => {
|
||||
const sendId = "valid-send-id";
|
||||
const key = "valid-key";
|
||||
const mockRoute = createMockRoute({ sendId, key });
|
||||
const expectedUrlTree = { toString: () => "/test-url" } as UrlTree;
|
||||
mockSendAccessService.redirect$.mockReturnValue(of(expectedUrlTree));
|
||||
|
||||
// Should not throw any errors during guard execution
|
||||
let guardResult: Observable<UrlTree> | undefined;
|
||||
expect(() => {
|
||||
guardResult = TestBed.runInInjectionContext(() =>
|
||||
trySendAccess(mockRoute, mockRouterState),
|
||||
) as unknown as Observable<UrlTree>;
|
||||
}).not.toThrow();
|
||||
|
||||
// Verify the observable can be subscribed to without errors
|
||||
expect(guardResult).toBeDefined();
|
||||
await expect(firstValueFrom(guardResult!)).resolves.toEqual(expectedUrlTree);
|
||||
|
||||
// Logger methods should not be called for warnings or panics
|
||||
const mockLogger = (mockSystemServiceProvider.log as jest.Mock).mock.results[0].value;
|
||||
expect(mockLogger.warn).not.toHaveBeenCalled();
|
||||
expect(mockLogger.panic).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("given invalid route parameters", () => {
|
||||
describe("given invalid sendId", () => {
|
||||
it.each([
|
||||
["undefined", undefined],
|
||||
["null", null],
|
||||
])(
|
||||
"logs warning with correct message when sendId is %s",
|
||||
async (description, sendIdValue) => {
|
||||
const key = "valid-key";
|
||||
const mockRoute = createMockRoute(
|
||||
sendIdValue === undefined ? { key } : { sendId: sendIdValue, key },
|
||||
);
|
||||
const mockLogger = createMockLogger();
|
||||
(mockSystemServiceProvider.log as jest.Mock).mockReturnValue(mockLogger);
|
||||
|
||||
await expect(async () => {
|
||||
const result$ = TestBed.runInInjectionContext(() =>
|
||||
trySendAccess(mockRoute, mockRouterState),
|
||||
) as unknown as Observable<UrlTree>;
|
||||
await firstValueFrom(result$);
|
||||
}).rejects.toThrow("Logger panic called");
|
||||
|
||||
expect(mockSystemServiceProvider.log).toHaveBeenCalledWith({
|
||||
function: "trySendAccess",
|
||||
});
|
||||
expect(mockLogger.warn).toHaveBeenCalledWith(
|
||||
"sendId missing from the route parameters; redirecting to 404",
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
["number", 123],
|
||||
["object", {}],
|
||||
["boolean", true],
|
||||
])("logs panic with expected/actual type info when sendId is %s", async (type, value) => {
|
||||
const key = "valid-key";
|
||||
const mockRoute = createMockRoute({ sendId: value, key });
|
||||
const mockLogger = createMockLogger();
|
||||
(mockSystemServiceProvider.log as jest.Mock).mockReturnValue(mockLogger);
|
||||
|
||||
await expect(async () => {
|
||||
const result$ = TestBed.runInInjectionContext(() =>
|
||||
trySendAccess(mockRoute, mockRouterState),
|
||||
) as unknown as Observable<UrlTree>;
|
||||
await firstValueFrom(result$);
|
||||
}).rejects.toThrow("Logger panic called");
|
||||
|
||||
expect(mockSystemServiceProvider.log).toHaveBeenCalledWith({ function: "trySendAccess" });
|
||||
expect(mockLogger.panic).toHaveBeenCalledWith(
|
||||
{ expected: "string", actual: type },
|
||||
"sendId has invalid type",
|
||||
);
|
||||
});
|
||||
|
||||
it("throws when sendId is not a string", async () => {
|
||||
const key = "valid-key";
|
||||
const invalidSendIdValues = [123, {}, true, null, undefined];
|
||||
|
||||
for (const invalidSendId of invalidSendIdValues) {
|
||||
const mockRoute = createMockRoute(
|
||||
invalidSendId === undefined ? { key } : { sendId: invalidSendId, key },
|
||||
);
|
||||
const mockLogger = createMockLogger();
|
||||
(mockSystemServiceProvider.log as jest.Mock).mockReturnValue(mockLogger);
|
||||
|
||||
await expect(async () => {
|
||||
const result$ = TestBed.runInInjectionContext(() =>
|
||||
trySendAccess(mockRoute, mockRouterState),
|
||||
) as unknown as Observable<UrlTree>;
|
||||
await firstValueFrom(result$);
|
||||
}).rejects.toThrow("Logger panic called");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("given invalid key", () => {
|
||||
it.each([
|
||||
["undefined", undefined],
|
||||
["null", null],
|
||||
])("logs panic with correct message when key is %s", async (description, keyValue) => {
|
||||
const sendId = "valid-send-id";
|
||||
const mockRoute = createMockRoute(
|
||||
keyValue === undefined ? { sendId } : { sendId, key: keyValue },
|
||||
);
|
||||
const mockLogger = createMockLogger();
|
||||
(mockSystemServiceProvider.log as jest.Mock).mockReturnValue(mockLogger);
|
||||
|
||||
await expect(async () => {
|
||||
const result$ = TestBed.runInInjectionContext(() =>
|
||||
trySendAccess(mockRoute, mockRouterState),
|
||||
) as unknown as Observable<UrlTree>;
|
||||
await firstValueFrom(result$);
|
||||
}).rejects.toThrow("Logger panic called");
|
||||
|
||||
expect(mockSystemServiceProvider.log).toHaveBeenCalledWith({ function: "trySendAccess" });
|
||||
expect(mockLogger.panic).toHaveBeenCalledWith("key missing from the route parameters");
|
||||
});
|
||||
|
||||
it.each([
|
||||
["number", 123],
|
||||
["object", {}],
|
||||
["boolean", true],
|
||||
])("logs panic with expected/actual type info when key is %s", async (type, value) => {
|
||||
const sendId = "valid-send-id";
|
||||
const mockRoute = createMockRoute({ sendId, key: value });
|
||||
const mockLogger = createMockLogger();
|
||||
(mockSystemServiceProvider.log as jest.Mock).mockReturnValue(mockLogger);
|
||||
|
||||
await expect(async () => {
|
||||
const result$ = TestBed.runInInjectionContext(() =>
|
||||
trySendAccess(mockRoute, mockRouterState),
|
||||
) as unknown as Observable<UrlTree>;
|
||||
await firstValueFrom(result$);
|
||||
}).rejects.toThrow("Logger panic called");
|
||||
|
||||
expect(mockSystemServiceProvider.log).toHaveBeenCalledWith({ function: "trySendAccess" });
|
||||
expect(mockLogger.panic).toHaveBeenCalledWith(
|
||||
{ expected: "string", actual: type },
|
||||
"key has invalid type",
|
||||
);
|
||||
});
|
||||
|
||||
it("throws when key is not a string", async () => {
|
||||
const sendId = "valid-send-id";
|
||||
const invalidKeyValues = [123, {}, true, null, undefined];
|
||||
|
||||
for (const invalidKey of invalidKeyValues) {
|
||||
const mockRoute = createMockRoute(
|
||||
invalidKey === undefined ? { sendId } : { sendId, key: invalidKey },
|
||||
);
|
||||
const mockLogger = createMockLogger();
|
||||
(mockSystemServiceProvider.log as jest.Mock).mockReturnValue(mockLogger);
|
||||
|
||||
await expect(async () => {
|
||||
const result$ = TestBed.runInInjectionContext(() =>
|
||||
trySendAccess(mockRoute, mockRouterState),
|
||||
) as unknown as Observable<UrlTree>;
|
||||
await firstValueFrom(result$);
|
||||
}).rejects.toThrow("Logger panic called");
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("given service interactions", () => {
|
||||
it("calls setContext with extracted sendId and key when parameters are valid", async () => {
|
||||
const sendId = "test-send-id";
|
||||
const key = "test-key";
|
||||
const mockRoute = createMockRoute({ sendId, key });
|
||||
const expectedUrlTree = { toString: () => "/test-url" } as UrlTree;
|
||||
mockSendAccessService.redirect$.mockReturnValue(of(expectedUrlTree));
|
||||
|
||||
const result$ = TestBed.runInInjectionContext(() =>
|
||||
trySendAccess(mockRoute, mockRouterState),
|
||||
) as unknown as Observable<UrlTree>;
|
||||
|
||||
await firstValueFrom(result$);
|
||||
|
||||
expect(mockSendAccessService.setContext).toHaveBeenCalledWith(sendId, key);
|
||||
expect(mockSendAccessService.setContext).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("calls redirect$ with extracted sendId when setContext completes", async () => {
|
||||
const sendId = "test-send-id";
|
||||
const key = "test-key";
|
||||
const mockRoute = createMockRoute({ sendId, key });
|
||||
const expectedUrlTree = { toString: () => "/test-url" } as UrlTree;
|
||||
mockSendAccessService.redirect$.mockReturnValue(of(expectedUrlTree));
|
||||
|
||||
const result$ = TestBed.runInInjectionContext(() =>
|
||||
trySendAccess(mockRoute, mockRouterState),
|
||||
) as unknown as Observable<UrlTree>;
|
||||
|
||||
await firstValueFrom(result$);
|
||||
|
||||
expect(mockSendAccessService.redirect$).toHaveBeenCalledWith(sendId);
|
||||
expect(mockSendAccessService.redirect$).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("given observable behavior", () => {
|
||||
it("returns redirect$ emissions when setContext completes successfully", async () => {
|
||||
const sendId = "test-send-id";
|
||||
const key = "test-key";
|
||||
const mockRoute = createMockRoute({ sendId, key });
|
||||
const expectedUrlTree = { toString: () => "/test-url" } as UrlTree;
|
||||
mockSendAccessService.redirect$.mockReturnValue(of(expectedUrlTree));
|
||||
|
||||
const result$ = TestBed.runInInjectionContext(() =>
|
||||
trySendAccess(mockRoute, mockRouterState),
|
||||
) as unknown as Observable<UrlTree>;
|
||||
|
||||
const actualResult = await firstValueFrom(result$);
|
||||
|
||||
expect(actualResult).toEqual(expectedUrlTree);
|
||||
expect(mockSendAccessService.redirect$).toHaveBeenCalledWith(sendId);
|
||||
});
|
||||
|
||||
it("does not emit setContext values when using ignoreElements", async () => {
|
||||
const sendId = "test-send-id";
|
||||
const key = "test-key";
|
||||
const mockRoute = createMockRoute({ sendId, key });
|
||||
const expectedUrlTree = { toString: () => "/test-url" } as UrlTree;
|
||||
const setContextValue = "should-not-be-emitted";
|
||||
|
||||
// Mock setContext to return a value
|
||||
mockSendAccessService.setContext.mockResolvedValue(setContextValue);
|
||||
mockSendAccessService.redirect$.mockReturnValue(of(expectedUrlTree));
|
||||
|
||||
const result$ = TestBed.runInInjectionContext(() =>
|
||||
trySendAccess(mockRoute, mockRouterState),
|
||||
) as unknown as Observable<UrlTree>;
|
||||
|
||||
const actualResult = await firstValueFrom(result$);
|
||||
|
||||
// Should only emit the redirect$ value, not the setContext value
|
||||
expect(actualResult).toEqual(expectedUrlTree);
|
||||
expect(actualResult).not.toEqual(setContextValue);
|
||||
});
|
||||
|
||||
it("ensures setContext completes before redirect$ executes (sequencing)", async () => {
|
||||
const sendId = "test-send-id";
|
||||
const key = "test-key";
|
||||
const mockRoute = createMockRoute({ sendId, key });
|
||||
const expectedUrlTree = { toString: () => "/test-url" } as UrlTree;
|
||||
|
||||
let setContextResolved = false;
|
||||
|
||||
// Mock setContext to track when it resolves
|
||||
mockSendAccessService.setContext.mockImplementation(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 10)); // Small delay
|
||||
setContextResolved = true;
|
||||
});
|
||||
|
||||
// Mock redirect$ to return a delayed observable and check if setContext resolved
|
||||
mockSendAccessService.redirect$.mockImplementation((id) => {
|
||||
return new Observable((subscriber) => {
|
||||
// Check if setContext has resolved when redirect$ subscription starts
|
||||
setTimeout(() => {
|
||||
expect(setContextResolved).toBe(true);
|
||||
subscriber.next(expectedUrlTree);
|
||||
subscriber.complete();
|
||||
}, 0);
|
||||
});
|
||||
});
|
||||
|
||||
const result$ = TestBed.runInInjectionContext(() =>
|
||||
trySendAccess(mockRoute, mockRouterState),
|
||||
) as unknown as Observable<UrlTree>;
|
||||
|
||||
await firstValueFrom(result$);
|
||||
});
|
||||
});
|
||||
|
||||
describe("given error scenarios", () => {
|
||||
it("does not call redirect$ when setContext rejects", async () => {
|
||||
const sendId = "test-send-id";
|
||||
const key = "test-key";
|
||||
const mockRoute = createMockRoute({ sendId, key });
|
||||
const setContextError = new Error("setContext failed");
|
||||
|
||||
// Reset mocks to ensure clean state
|
||||
jest.clearAllMocks();
|
||||
|
||||
// Mock setContext to reject
|
||||
mockSendAccessService.setContext.mockRejectedValue(setContextError);
|
||||
|
||||
// Create a mock observable that we can spy on subscription
|
||||
const mockRedirectObservable = of({} as UrlTree);
|
||||
const subscribeSpy = jest.spyOn(mockRedirectObservable, "subscribe");
|
||||
mockSendAccessService.redirect$.mockReturnValue(mockRedirectObservable);
|
||||
|
||||
const result$ = TestBed.runInInjectionContext(() =>
|
||||
trySendAccess(mockRoute, mockRouterState),
|
||||
) as unknown as Observable<UrlTree>;
|
||||
|
||||
// Expect the observable to reject when setContext fails
|
||||
await expect(firstValueFrom(result$)).rejects.toThrow("setContext failed");
|
||||
|
||||
// The redirect$ method will be called (since it's called synchronously)
|
||||
expect(mockSendAccessService.redirect$).toHaveBeenCalledWith(sendId);
|
||||
|
||||
// But the returned observable should not be subscribed to due to the error
|
||||
// Note: This test verifies the error propagation behavior
|
||||
expect(subscribeSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("propagates error to guard return value when redirect$ throws", async () => {
|
||||
const sendId = "test-send-id";
|
||||
const key = "test-key";
|
||||
const mockRoute = createMockRoute({ sendId, key });
|
||||
const redirectError = new Error("redirect$ failed");
|
||||
|
||||
// Reset mocks to ensure clean state
|
||||
jest.clearAllMocks();
|
||||
|
||||
// Mock setContext to succeed and redirect$ to throw
|
||||
mockSendAccessService.setContext.mockResolvedValue(undefined);
|
||||
mockSendAccessService.redirect$.mockReturnValue(
|
||||
new Observable((subscriber) => {
|
||||
subscriber.error(redirectError);
|
||||
}),
|
||||
);
|
||||
|
||||
const result$ = TestBed.runInInjectionContext(() =>
|
||||
trySendAccess(mockRoute, mockRouterState),
|
||||
) as unknown as Observable<UrlTree>;
|
||||
|
||||
// Expect the observable to propagate the redirect$ error
|
||||
await expect(firstValueFrom(result$)).rejects.toThrow("redirect$ failed");
|
||||
|
||||
// Verify that setContext was called (should succeed)
|
||||
expect(mockSendAccessService.setContext).toHaveBeenCalledWith(sendId, key);
|
||||
|
||||
// Verify that redirect$ was called (but it throws)
|
||||
expect(mockSendAccessService.redirect$).toHaveBeenCalledWith(sendId);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
import { inject } from "@angular/core";
|
||||
import { ActivatedRouteSnapshot, CanActivateFn, RouterStateSnapshot } from "@angular/router";
|
||||
import { from, ignoreElements, concat } from "rxjs";
|
||||
|
||||
import { SystemServiceProvider } from "@bitwarden/common/tools/providers";
|
||||
import { SYSTEM_SERVICE_PROVIDER } from "@bitwarden/generator-components";
|
||||
|
||||
import { SendAccessService } from "./send-access-service.abstraction";
|
||||
|
||||
export const trySendAccess: CanActivateFn = (
|
||||
route: ActivatedRouteSnapshot,
|
||||
_state: RouterStateSnapshot,
|
||||
) => {
|
||||
const sendAccess = inject(SendAccessService);
|
||||
const system = inject<SystemServiceProvider>(SYSTEM_SERVICE_PROVIDER);
|
||||
const logger = system.log({ function: "trySendAccess" });
|
||||
|
||||
const { sendId, key } = route.params;
|
||||
if (!sendId) {
|
||||
logger.warn("sendId missing from the route parameters; redirecting to 404");
|
||||
}
|
||||
if (typeof sendId !== "string") {
|
||||
logger.panic({ expected: "string", actual: typeof sendId }, "sendId has invalid type");
|
||||
}
|
||||
|
||||
if (!key) {
|
||||
logger.panic("key missing from the route parameters");
|
||||
}
|
||||
if (typeof key !== "string") {
|
||||
logger.panic({ expected: "string", actual: typeof key }, "key has invalid type");
|
||||
}
|
||||
|
||||
const contextUpdated$ = from(sendAccess.setContext(sendId, key)).pipe(ignoreElements());
|
||||
const redirect$ = sendAccess.redirect$(sendId);
|
||||
|
||||
// ensure the key has loaded before redirecting
|
||||
return concat(contextUpdated$, redirect$);
|
||||
};
|
||||
8
apps/web/src/app/tools/send/send-access/types.ts
Normal file
8
apps/web/src/app/tools/send/send-access/types.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
/** global contextual information for the current send access page. */
|
||||
export type SendContext = {
|
||||
/** identifies the send */
|
||||
id: string;
|
||||
|
||||
/** decrypts the send content */
|
||||
key: string;
|
||||
};
|
||||
69
apps/web/src/app/tools/send/send-access/util.spec.ts
Normal file
69
apps/web/src/app/tools/send/send-access/util.spec.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { ErrorResponse } from "@bitwarden/common/models/response/error.response";
|
||||
|
||||
import { isErrorResponse, isSendContext } from "./util";
|
||||
|
||||
describe("util", () => {
|
||||
describe("isErrorResponse", () => {
|
||||
it("returns true when value is an ErrorResponse instance", () => {
|
||||
const error = new ErrorResponse(["Error message"], 400);
|
||||
expect(isErrorResponse(error)).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[null, "null"],
|
||||
[undefined, "undefined"],
|
||||
])("returns false when value is %s", (value, description) => {
|
||||
expect(isErrorResponse(value)).toBe(false);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["string", "string"],
|
||||
[123, "number"],
|
||||
[true, "boolean"],
|
||||
[{}, "plain object"],
|
||||
[[], "array"],
|
||||
])("returns false when value is not an ErrorResponse (%s)", (value, description) => {
|
||||
expect(isErrorResponse(value)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when value is a different Error type", () => {
|
||||
const error = new Error("test");
|
||||
expect(isErrorResponse(error)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isSendContext", () => {
|
||||
it("returns true when value has id and key properties", () => {
|
||||
const validContext = { id: "test-id", key: "test-key" };
|
||||
expect(isSendContext(validContext)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true even with additional properties", () => {
|
||||
const contextWithExtras = { id: "test-id", key: "test-key", extra: "data" };
|
||||
expect(isSendContext(contextWithExtras)).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[null, "null"],
|
||||
[undefined, "undefined"],
|
||||
])("returns false when value is %s", (value, _) => {
|
||||
expect(isSendContext(value)).toBe(false);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["string", "string"],
|
||||
[123, "number"],
|
||||
[true, "boolean"],
|
||||
])("returns false when value is not an object (%s)", (value, _) => {
|
||||
expect(isSendContext(value)).toBe(false);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[{ key: "test-key" }, "missing id"],
|
||||
[{ id: "test-id" }, "missing key"],
|
||||
[{}, "empty object"],
|
||||
])("returns false when value is %s", (value, _) => {
|
||||
expect(isSendContext(value)).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
13
apps/web/src/app/tools/send/send-access/util.ts
Normal file
13
apps/web/src/app/tools/send/send-access/util.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { ErrorResponse } from "@bitwarden/common/models/response/error.response";
|
||||
|
||||
import { SendContext } from "./types";
|
||||
|
||||
/** narrows a type to an `ErrorResponse` */
|
||||
export function isErrorResponse(value: unknown): value is ErrorResponse {
|
||||
return value instanceof ErrorResponse;
|
||||
}
|
||||
|
||||
/** narrows a type to a `SendContext` */
|
||||
export function isSendContext(value: unknown): value is SendContext {
|
||||
return !!value && typeof value === "object" && "id" in value && "key" in value;
|
||||
}
|
||||
@@ -5269,6 +5269,14 @@
|
||||
"downloadAttachments": {
|
||||
"message": "Download attachments"
|
||||
},
|
||||
"sendAccessPasswordTitle": {
|
||||
"message": "Enter the password to view this Send",
|
||||
"description": "Title of the Send password authentication screen."
|
||||
},
|
||||
"sendAccessContentTitle": {
|
||||
"message": "View Send",
|
||||
"description": "Title of the Send view content screen."
|
||||
},
|
||||
"sendAccessUnavailable": {
|
||||
"message": "The Send you are trying to access does not exist or is no longer available.",
|
||||
"description": "'Send' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated."
|
||||
|
||||
@@ -43,7 +43,12 @@ export const RANDOMIZER = new SafeInjectionToken<Randomizer>("Randomizer");
|
||||
const GENERATOR_SERVICE_PROVIDER = new SafeInjectionToken<providers.CredentialGeneratorProviders>(
|
||||
"CredentialGeneratorProviders",
|
||||
);
|
||||
const SYSTEM_SERVICE_PROVIDER = new SafeInjectionToken<SystemServiceProvider>("SystemServices");
|
||||
|
||||
// FIXME: relocate the system service provider to a more general module once
|
||||
// NX migration is complete.
|
||||
export const SYSTEM_SERVICE_PROVIDER = new SafeInjectionToken<SystemServiceProvider>(
|
||||
"SystemServices",
|
||||
);
|
||||
|
||||
/** Shared module containing generator component dependencies */
|
||||
@NgModule({
|
||||
|
||||
@@ -2,4 +2,4 @@ export { CredentialGeneratorHistoryComponent } from "./credential-generator-hist
|
||||
export { CredentialGeneratorHistoryDialogComponent } from "./credential-generator-history-dialog.component";
|
||||
export { EmptyCredentialHistoryComponent } from "./empty-credential-history.component";
|
||||
export { GeneratorModule } from "./generator.module";
|
||||
export { GeneratorServicesModule } from "./generator-services.module";
|
||||
export { GeneratorServicesModule, SYSTEM_SERVICE_PROVIDER } from "./generator-services.module";
|
||||
|
||||
Reference in New Issue
Block a user