Skip to content

Commit 9592462

Browse files
committed
frontend: encrypt grouping names
1 parent 991abd3 commit 9592462

File tree

3 files changed

+120
-66
lines changed

3 files changed

+120
-66
lines changed

frontend/src/components/device/GroupingModal.tsx

Lines changed: 32 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -12,15 +12,17 @@ interface GroupingModalProps {
1212
devices: StateDevice[];
1313
groupings: Grouping[];
1414
onClose: () => void;
15-
onGroupingsUpdated: (groupings: Grouping[]) => void;
15+
encryptGroupingName: (name: string) => Promise<string | undefined>;
16+
loadGroupings: () => Promise<void>;
1617
}
1718

1819
export function GroupingModal({
1920
show,
2021
devices,
2122
groupings,
2223
onClose,
23-
onGroupingsUpdated
24+
encryptGroupingName,
25+
loadGroupings: loadGroupingsFromParent
2426
}: GroupingModalProps) {
2527
const { t } = useTranslation("", { useSuspense: false, keyPrefix: "chargers" });
2628
const [editingGrouping, setEditingGrouping] = useState<Grouping | null>(null);
@@ -86,7 +88,7 @@ export function GroupingModal({
8688
}
8789

8890
// Reload groupings
89-
await loadGroupings();
91+
await loadGroupingsFromParent();
9092
handleCancel();
9193
} catch (error) {
9294
console.error("Error saving grouping:", error);
@@ -105,7 +107,7 @@ export function GroupingModal({
105107
});
106108

107109
if (response.status === 200) {
108-
await loadGroupings();
110+
await loadGroupingsFromParent();
109111
} else {
110112
showAlert(t("delete_grouping_failed", { error: error || response.status }), "danger");
111113
}
@@ -115,8 +117,14 @@ export function GroupingModal({
115117
};
116118

117119
const createGrouping = async (name: string, deviceIds: Set<string>) => {
120+
const encryptedName = await encryptGroupingName(name);
121+
if (!encryptedName) {
122+
showAlert(t("create_grouping_failed", { error: "Failed to encrypt name" }), "danger");
123+
throw new Error("Failed to encrypt grouping name");
124+
}
125+
118126
const { data, response, error } = await fetchClient.POST("/grouping/create", {
119-
body: { name },
127+
body: { name: encryptedName },
120128
credentials: "same-origin"
121129
});
122130

@@ -140,6 +148,25 @@ export function GroupingModal({
140148
const existingGrouping = groupings.find(g => g.id === groupingId);
141149
if (!existingGrouping) return;
142150

151+
// Update name if changed
152+
if (name !== existingGrouping.name) {
153+
const encryptedName = await encryptGroupingName(name);
154+
if (!encryptedName) {
155+
showAlert(t("update_grouping_failed", { error: "Failed to encrypt name" }), "danger");
156+
throw new Error("Failed to encrypt grouping name");
157+
}
158+
159+
const { response, error } = await fetchClient.PUT("/grouping/edit", {
160+
body: { grouping_id: groupingId, name: encryptedName },
161+
credentials: "same-origin"
162+
});
163+
164+
if (response.status !== 200 || error) {
165+
showAlert(t("update_grouping_failed", { error: error || response.status }), "danger");
166+
throw new Error("Failed to update grouping name");
167+
}
168+
}
169+
143170
// Devices to add
144171
const devicesToAdd = Array.from(deviceIds).filter(id => !existingGrouping.device_ids.includes(id));
145172
// Devices to remove
@@ -162,24 +189,6 @@ export function GroupingModal({
162189
}
163190
};
164191

165-
const loadGroupings = async () => {
166-
try {
167-
const { data, error, response } = await fetchClient.GET("/grouping/list", {
168-
credentials: "same-origin"
169-
});
170-
171-
if (error || !data) {
172-
const errorMsg = error ? String(error) : response.status || "Unknown error";
173-
showAlert(t("load_groupings_failed", { error: errorMsg }), "danger");
174-
return;
175-
}
176-
177-
onGroupingsUpdated(data.groupings);
178-
} catch (error) {
179-
showAlert(t("load_groupings_failed", { error: String(error) }), "danger");
180-
}
181-
};
182-
183192
const filterDevices = (devices: StateDevice[]): StateDevice[] => {
184193
if (!deviceSearchQuery.trim()) {
185194
return devices;

frontend/src/components/device/__tests__/GroupingModal.test.tsx

Lines changed: 40 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ vi.mock('../../../utils', () => ({
1010
POST: vi.fn(),
1111
DELETE: vi.fn(),
1212
GET: vi.fn(),
13+
PUT: vi.fn(),
1314
},
1415
}));
1516

@@ -72,6 +73,8 @@ const defaultProps = {
7273
groupings: mockGroupings,
7374
onClose: vi.fn(),
7475
onGroupingsUpdated: vi.fn(),
76+
encryptGroupingName: vi.fn((name: string) => Promise.resolve(`encrypted_${name}`)),
77+
loadGroupings: vi.fn(),
7578
};
7679

7780
describe('GroupingModal', () => {
@@ -178,50 +181,52 @@ describe('GroupingModal', () => {
178181
error: undefined,
179182
});
180183

181-
const mockGet = vi.mocked(fetchClient.GET);
182-
mockGet.mockResolvedValue({
183-
data: { groupings: [...mockGroupings, { id: 'new-group-id', name: 'New Group', device_ids: ['device1'] }] },
184-
response: { status: 200 } as Response,
185-
error: undefined,
186-
});
184+
const mockLoadGroupings = vi.fn();
187185

188-
render(<GroupingModal {...defaultProps} />);
186+
render(<GroupingModal {...defaultProps} loadGroupings={mockLoadGroupings} />);
189187

190188
const createButton = screen.getByRole('button', { name: /create/i });
191189
fireEvent.click(createButton);
192190

193-
await waitFor(async () => {
194-
const nameInput = screen.getByPlaceholderText('grouping_name_placeholder');
195-
fireEvent.change(nameInput, { target: { value: 'New Group' } });
191+
await waitFor(() => {
192+
expect(screen.getByPlaceholderText('grouping_name_placeholder')).toBeInTheDocument();
193+
});
196194

197-
const checkboxes = screen.getAllByRole('checkbox');
198-
fireEvent.click(checkboxes[0]);
195+
const nameInput = screen.getByPlaceholderText('grouping_name_placeholder');
196+
fireEvent.change(nameInput, { target: { value: 'New Group' } });
199197

200-
const saveButton = screen.getByRole('button', { name: 'save' });
201-
fireEvent.click(saveButton);
198+
const checkboxes = screen.getAllByRole('checkbox');
199+
fireEvent.click(checkboxes[0]);
202200

203-
await waitFor(() => {
204-
expect(mockPost).toHaveBeenCalledWith('/grouping/create', expect.any(Object));
205-
});
201+
const saveButton = screen.getByRole('button', { name: 'save' });
202+
fireEvent.click(saveButton);
203+
204+
await waitFor(() => {
205+
expect(mockPost).toHaveBeenCalledWith('/grouping/create', expect.objectContaining({
206+
body: expect.objectContaining({ name: 'encrypted_New Group' })
207+
}));
208+
expect(mockLoadGroupings).toHaveBeenCalled();
206209
});
207210
});
208211

209212
it('updates existing grouping when save is clicked after editing', async () => {
210213
const mockPost = vi.mocked(fetchClient.POST);
211214
mockPost.mockResolvedValue({
212-
data: null,
215+
data: undefined,
213216
response: { status: 200 } as Response,
214217
error: undefined,
215218
});
216219

217-
const mockGet = vi.mocked(fetchClient.GET);
218-
mockGet.mockResolvedValue({
219-
data: { groupings: mockGroupings },
220+
const mockPut = vi.mocked(fetchClient.PUT);
221+
mockPut.mockResolvedValue({
222+
data: undefined,
220223
response: { status: 200 } as Response,
221224
error: undefined,
222225
});
223226

224-
render(<GroupingModal {...defaultProps} />);
227+
const mockLoadGroupings = vi.fn();
228+
229+
render(<GroupingModal {...defaultProps} loadGroupings={mockLoadGroupings} />);
225230

226231
// Click edit on first grouping
227232
const editButtons = screen.getAllByRole('button', { name: '' });
@@ -238,8 +243,11 @@ describe('GroupingModal', () => {
238243
fireEvent.click(saveButton);
239244

240245
await waitFor(() => {
241-
// Should call POST to add/remove devices
242-
expect(mockPost).toHaveBeenCalled();
246+
// Should call PUT to update name
247+
expect(mockPut).toHaveBeenCalledWith('/grouping/edit', expect.objectContaining({
248+
body: expect.objectContaining({ name: 'encrypted_Updated Group Name' })
249+
}));
250+
expect(mockLoadGroupings).toHaveBeenCalled();
243251
});
244252
});
245253
}
@@ -252,19 +260,14 @@ describe('GroupingModal', () => {
252260

253261
const mockDelete = vi.mocked(fetchClient.DELETE);
254262
mockDelete.mockResolvedValue({
255-
data: null,
263+
data: undefined,
256264
response: { status: 200 } as Response,
257265
error: undefined,
258266
});
259267

260-
const mockGet = vi.mocked(fetchClient.GET);
261-
mockGet.mockResolvedValue({
262-
data: { groupings: [mockGroupings[1]] },
263-
response: { status: 200 } as Response,
264-
error: undefined,
265-
});
268+
const mockLoadGroupings = vi.fn();
266269

267-
render(<GroupingModal {...defaultProps} />);
270+
render(<GroupingModal {...defaultProps} loadGroupings={mockLoadGroupings} />);
268271

269272
const deleteButtons = screen.getAllByRole('button', { name: '' });
270273
// Find delete button (should be the second icon button)
@@ -275,6 +278,7 @@ describe('GroupingModal', () => {
275278
await waitFor(() => {
276279
expect(window.confirm).toHaveBeenCalled();
277280
expect(mockDelete).toHaveBeenCalledWith('/grouping/delete', expect.any(Object));
281+
expect(mockLoadGroupings).toHaveBeenCalled();
278282
});
279283

280284
// Restore original confirm
@@ -394,32 +398,27 @@ describe('GroupingModal', () => {
394398
});
395399

396400
it('calls onGroupingsUpdated after successful operations', async () => {
397-
const mockGet = vi.mocked(fetchClient.GET);
398-
mockGet.mockResolvedValue({
399-
data: { groupings: mockGroupings },
400-
response: { status: 200 } as Response,
401-
error: undefined,
402-
});
401+
const mockLoadGroupings = vi.fn();
403402

404403
const originalConfirm = window.confirm;
405404
window.confirm = vi.fn(() => true);
406405

407406
const mockDelete = vi.mocked(fetchClient.DELETE);
408407
mockDelete.mockResolvedValue({
409-
data: null,
408+
data: undefined,
410409
response: { status: 200 } as Response,
411410
error: undefined,
412411
});
413412

414-
render(<GroupingModal {...defaultProps} />);
413+
render(<GroupingModal {...defaultProps} loadGroupings={mockLoadGroupings} />);
415414

416415
const deleteButtons = screen.getAllByRole('button', { name: '' });
417416
const deleteButton = deleteButtons[deleteButtons.length - 1];
418417

419418
fireEvent.click(deleteButton);
420419

421420
await waitFor(() => {
422-
expect(defaultProps.onGroupingsUpdated).toHaveBeenCalledWith(mockGroupings);
421+
expect(mockLoadGroupings).toHaveBeenCalled();
423422
});
424423

425424
window.confirm = originalConfirm;

frontend/src/pages/Devices.tsx

Lines changed: 48 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,42 @@ export class DeviceList extends Component<{}, DeviceListState> {
9191
}
9292
}
9393

94+
async decryptGroupingName(name: string) {
95+
if (!pub_key || !secret) {
96+
await get_decrypted_secret();
97+
}
98+
99+
if (!name) {
100+
return "";
101+
}
102+
const nameBytes = Base64.toUint8Array(name);
103+
try {
104+
// pub_key and secret are null-checked before this function is called
105+
const decryptedName = sodium.crypto_box_seal_open(nameBytes, pub_key as Uint8Array, secret as Uint8Array);
106+
const decoder = new TextDecoder();
107+
return decoder.decode(decryptedName);
108+
} catch {
109+
return undefined;
110+
}
111+
}
112+
113+
async encryptGroupingName(name: string) {
114+
if (!pub_key || !secret) {
115+
await get_decrypted_secret();
116+
}
117+
118+
if (!name) {
119+
return "";
120+
}
121+
try {
122+
// pub_key and secret are null-checked before this function is called
123+
const encryptedName = sodium.crypto_box_seal(name, pub_key as Uint8Array);
124+
return Base64.fromUint8Array(encryptedName);
125+
} catch {
126+
return undefined;
127+
}
128+
}
129+
94130
async updateChargers() {
95131
if (!secret) {
96132
await get_decrypted_secret();
@@ -176,7 +212,16 @@ export class DeviceList extends Component<{}, DeviceListState> {
176212
return;
177213
}
178214

179-
this.setState({ groupings: data.groupings });
215+
// Decrypt grouping names
216+
const decryptedGroupings = await Promise.all(data.groupings.map(async (grouping) => {
217+
const decryptedName = await this.decryptGroupingName(grouping.name);
218+
return {
219+
...grouping,
220+
name: decryptedName !== undefined ? decryptedName : i18n.t("chargers.invalid_key")
221+
};
222+
}));
223+
224+
this.setState({ groupings: decryptedGroupings });
180225
} catch (error) {
181226
console.error("Failed to load groupings:", error);
182227
}
@@ -456,7 +501,8 @@ export class DeviceList extends Component<{}, DeviceListState> {
456501
devices={this.state.devices}
457502
groupings={this.state.groupings}
458503
onClose={() => this.setState({ showGroupingModal: false })}
459-
onGroupingsUpdated={this.handleGroupingsUpdated}
504+
encryptGroupingName={async (name: string) => this.encryptGroupingName(name)}
505+
loadGroupings={async () => this.loadGroupings()}
460506
/>
461507

462508
<Container fluid>

0 commit comments

Comments
 (0)