Skip to content

Commit 0aaaddd

Browse files
committed
Warning when secure local storage is unavailable
Adds renderer SafeStorageService and IPC events
1 parent 8dc60d9 commit 0aaaddd

File tree

11 files changed

+176
-9
lines changed

11 files changed

+176
-9
lines changed

desktop/i18n/resources.resjson

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,8 @@
5151
"auth-service.tenant-error": "Cannot access resources for tenant {tenantName}",
5252
"auth-settings.title": "Authentication Settings",
5353
"auth.cancel-button.label": "Cancel",
54+
"auth.encryption-unavailable.message": "Secure credential storage is unavailable. You will need to sign in each time you start the application. On Linux, install libsecret to enable credential storage.",
55+
"auth.encryption-unavailable.title": "Secure Storage Unavailable",
5456
"auth.not-logged-in": "You are not logged in. Please log in to continue.",
5557
"auth.signin-button.label": "Sign in",
5658
"auth.tenant-sign-in-title": "Sign in to {tenant}",

desktop/src/@batch-flask/ui/banner/banner.component.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,8 @@ export class BannerComponent implements OnChanges {
5757

5858
@Input() public height: string = "standard";
5959

60+
@Input() public collapsible: boolean = true;
61+
6062
@ContentChildren(BannerOtherFixDirective)
6163
public otherFixes: QueryList<BannerOtherFixDirective>;
6264

desktop/src/@batch-flask/ui/banner/banner.html

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
<bl-card [class]="type">
22
<bl-clickable class="summary-container"
33
[ngClass]="height"
4-
[tabindex]="details.children.length !== 0 ? 0 : -1"
5-
(do)="showDetails = !showDetails && details.children.length !== 0"
6-
[class.expandable]="details.children.length !== 0"
4+
[tabindex]="collapsible && details.children.length !== 0 ? 0 : -1"
5+
(do)="showDetails = !showDetails && collapsible && details.children.length !== 0"
6+
[class.expandable]="collapsible && details.children.length !== 0"
77
[attr.aria-expanded]="showDetails">
88
<ng-content select="[code]"></ng-content>
99
<div class="message">
@@ -31,7 +31,7 @@
3131
</bl-clickable>
3232
</div>
3333
</div>
34-
<i class="caret fa" [class.fa-caret-left]="!showDetails" [class.fa-caret-down]="showDetails" *ngIf="details.children.length !== 0" aria-hidden="true"></i>
34+
<i class="caret fa" [class.fa-caret-left]="!showDetails" [class.fa-caret-down]="showDetails" *ngIf="collapsible && details.children.length !== 0" aria-hidden="true"></i>
3535
</bl-clickable>
3636
<div class="details-container" [hidden]="!showDetails || details.children.length === 0" #details>
3737
<ng-content select="[details]"></ng-content>

desktop/src/app/components/auth/auth.i18n.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,3 +6,6 @@ auth:
66
label: Sign in
77
cancel-button:
88
label: Cancel
9+
encryption-unavailable:
10+
title: Secure Storage Unavailable
11+
message: Secure credential storage is unavailable. You will need to sign in each time you start the application. On Linux, install libsecret to enable credential storage.

desktop/src/app/components/welcome/welcome.component.ts

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,35 @@
1-
import { Component } from "@angular/core";
2-
import { AuthService, NavigatorService } from "app/services";
1+
import { Component, OnInit } from "@angular/core";
2+
import { AuthService, NavigatorService, SafeStorageService } from "app/services";
33
import { autobind } from "@batch-flask/core";
44
import { first } from "rxjs/operators";
55

66
@Component({
77
selector: "be-welcome",
88
templateUrl: "./welcome.html",
99
})
10-
export class WelcomeComponent {
10+
export class WelcomeComponent implements OnInit {
11+
public encryptionUnavailable = false;
12+
1113
public static breadcrumb() {
1214
return { name: "Home" };
1315
}
1416

1517
constructor(
1618
private authService: AuthService,
17-
private navigationService: NavigatorService
19+
private navigationService: NavigatorService,
20+
private safeStorage: SafeStorageService
1821
) {}
1922

23+
public async ngOnInit() {
24+
// Check if encryption is available
25+
try {
26+
this.encryptionUnavailable = !(await this.safeStorage.isEncryptionAvailable());
27+
} catch (error) {
28+
console.warn("Failed to check encryption availability:", error);
29+
this.encryptionUnavailable = true;
30+
}
31+
}
32+
2033
@autobind()
2134
public async signIn() {
2235
await this.authService.login();

desktop/src/app/components/welcome/welcome.html

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
11
<div class="central-panel">
22
<div class="central-panel-content">
33
<h1>{{"common.welcome" | i18n}}</h1>
4+
5+
<bl-banner *ngIf="encryptionUnavailable" type="warning" [collapsible]="false">
6+
<div message>{{"auth.encryption-unavailable.title" | i18n}}</div>
7+
<div details>{{"auth.encryption-unavailable.message" | i18n}}</div>
8+
</bl-banner>
9+
410
<div class="central-button-group">
511
<bl-button type="wide" class="central-button"
612
[action]="signIn">

desktop/src/app/services/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,3 +33,4 @@ export * from "./themes";
3333
export * from "./network";
3434
export * from "./user-configuration";
3535
export * from "./version";
36+
export * from "./safe-storage.service";
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import { SafeStorageService } from "./safe-storage.service";
2+
import { IpcEvent } from "common/constants";
3+
4+
describe("SafeStorageService", () => {
5+
let service: SafeStorageService;
6+
let ipcSpy;
7+
8+
beforeEach(() => {
9+
ipcSpy = {
10+
send: jasmine.createSpy("send").and.returnValue(Promise.resolve())
11+
};
12+
13+
service = new SafeStorageService(ipcSpy);
14+
});
15+
16+
it("should call IPC send for setPassword", async () => {
17+
await service.setPassword("testService", "testAccount", "testPassword");
18+
19+
expect(ipcSpy.send).toHaveBeenCalledWith(IpcEvent.safeStorage.setPassword, {
20+
key: "testService:testAccount",
21+
password: "testPassword"
22+
});
23+
});
24+
25+
it("should call IPC send for getPassword", async () => {
26+
ipcSpy.send.and.returnValue(Promise.resolve("retrievedPassword"));
27+
28+
const result = await service.getPassword("testService", "testAccount");
29+
30+
expect(ipcSpy.send).toHaveBeenCalledWith(IpcEvent.safeStorage.getPassword, {
31+
key: "testService:testAccount"
32+
});
33+
expect(result).toBe("retrievedPassword");
34+
});
35+
36+
it("should call IPC send for deletePassword", async () => {
37+
ipcSpy.send.and.returnValue(Promise.resolve(true));
38+
39+
const result = await service.deletePassword("testService", "testAccount");
40+
41+
expect(ipcSpy.send).toHaveBeenCalledWith(IpcEvent.safeStorage.deletePassword, {
42+
key: "testService:testAccount"
43+
});
44+
expect(result).toBe(true);
45+
});
46+
47+
it("should call IPC send for isEncryptionAvailable", async () => {
48+
ipcSpy.send.and.returnValue(Promise.resolve(true));
49+
50+
const result = await service.isEncryptionAvailable();
51+
52+
expect(ipcSpy.send).toHaveBeenCalledWith(IpcEvent.safeStorage.isEncryptionAvailable);
53+
expect(result).toBe(true);
54+
});
55+
});
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import { Injectable } from "@angular/core";
2+
import { IpcService } from "@batch-flask/electron";
3+
import { IpcEvent } from "common/constants";
4+
5+
/**
6+
* Service wrapping Electron's safeStorage API for secure credential storage.
7+
* This service is for use in the renderer process and communicates with the main process via IPC.
8+
*/
9+
@Injectable({ providedIn: "root" })
10+
export class SafeStorageService {
11+
constructor(private ipc: IpcService) {}
12+
13+
/**
14+
* Store a password securely using Electron's safeStorage API
15+
* @param service Service name (equivalent to keytar service)
16+
* @param account Account name (equivalent to keytar account)
17+
* @param password Password to store
18+
*/
19+
public async setPassword(service: string, account: string, password: string): Promise<void> {
20+
const key = `${service}:${account}`;
21+
return this.ipc.send(IpcEvent.safeStorage.setPassword, { key, password });
22+
}
23+
24+
/**
25+
* Retrieve a password securely using Electron's safeStorage API
26+
* @param service Service name (equivalent to keytar service)
27+
* @param account Account name (equivalent to keytar account)
28+
* @returns Promise resolving to password or null if not found
29+
*/
30+
public async getPassword(service: string, account: string): Promise<string | null> {
31+
const key = `${service}:${account}`;
32+
return this.ipc.send(IpcEvent.safeStorage.getPassword, { key });
33+
}
34+
35+
/**
36+
* Delete a password from secure storage
37+
* @param service Service name
38+
* @param account Account name
39+
*/
40+
public async deletePassword(service: string, account: string): Promise<boolean> {
41+
const key = `${service}:${account}`;
42+
return this.ipc.send(IpcEvent.safeStorage.deletePassword, { key });
43+
}
44+
45+
/**
46+
* Check if encryption is available
47+
* Important on Linux where safeStorage may fall back to unencrypted storage
48+
*/
49+
public async isEncryptionAvailable(): Promise<boolean> {
50+
return this.ipc.send(IpcEvent.safeStorage.isEncryptionAvailable);
51+
}
52+
}

desktop/src/client/core/secure-data-store/safe-storage-main.service.ts

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import { Injectable } from "@angular/core";
2+
import { BlIpcMain } from "../bl-ipc-main";
23
import { safeStorage } from "electron";
34
import { GlobalStorage } from "@batch-flask/core";
5+
import { IpcEvent } from "common/constants";
46

57
/**
68
* Handles safeStorage operations using Electron's safeStorage API.
@@ -9,7 +11,32 @@ import { GlobalStorage } from "@batch-flask/core";
911
export class SafeStorageMainService {
1012
private _storageKey = "safeStorageData";
1113

12-
constructor(private _storage: GlobalStorage) {
14+
constructor(
15+
private ipcMain: BlIpcMain,
16+
private _storage: GlobalStorage
17+
) {
18+
}
19+
20+
public init() {
21+
this._setupIpcHandlers();
22+
}
23+
24+
private _setupIpcHandlers() {
25+
this.ipcMain.on(IpcEvent.safeStorage.setPassword, async (data) => {
26+
return this.setPassword(data.key, data.password);
27+
});
28+
29+
this.ipcMain.on(IpcEvent.safeStorage.getPassword, async (data) => {
30+
return this.getPassword(data.key);
31+
});
32+
33+
this.ipcMain.on(IpcEvent.safeStorage.deletePassword, async (data) => {
34+
return this.deletePassword(data.key);
35+
});
36+
37+
this.ipcMain.on(IpcEvent.safeStorage.isEncryptionAvailable, async () => {
38+
return safeStorage.isEncryptionAvailable();
39+
});
1340
}
1441

1542
public async setPassword(key: string, password: string): Promise<void> {

0 commit comments

Comments
 (0)