diff --git a/coldmfa/app/src/components/CodeSummaryLine.spec.ts b/coldmfa/app/src/components/CodeSummaryLine.spec.ts index edb6e20..a0478d0 100644 --- a/coldmfa/app/src/components/CodeSummaryLine.spec.ts +++ b/coldmfa/app/src/components/CodeSummaryLine.spec.ts @@ -15,6 +15,7 @@ describe('CodeSummaryLine', () => { let client: AxiosInstance let pinia: Pinia let groupId: string + let otherGroupId: string let codeId: string const Host = { @@ -67,6 +68,15 @@ describe('CodeSummaryLine', () => { const groupsStore = useGroupsStore() groupsStore.insertGroup(resp.data) + const otherGroupResp: AxiosResponse = await client.post( + 'http://127.0.0.1:3000/coldmfa/api/groups', + { + name: 'Other Test Group' + } + ) + otherGroupId = otherGroupResp.data.groupId + groupsStore.insertGroup(otherGroupResp.data) + const codeResp: AxiosResponse = await client.post( `http://127.0.0.1:3000/coldmfa/api/groups/${groupId}/codes`, { @@ -232,4 +242,49 @@ describe('CodeSummaryLine', () => { const code = groupsStore.codeById(groupId, codeId) expect(code?.deleted).toBe(true) }) + + it('move between groups', async () => { + const wrapper = mount(Host, { + props: { + groupId, + codeId + }, + global: { + provide: { + client + } + } + }) + + expect(wrapper.html()).toContain('EphyraSoftware:test-a') + + await wrapper.get('button[data-test-id="move"]').trigger('click') + + const otherGroups = wrapper.findAll('li') + expect(otherGroups.length).toBe(1) + expect(otherGroups[0].html()).toContain('Other Test Group') + expect(otherGroups[0].isVisible()).toBe(true) + + // Select the group to switch to and click it + // That triggers a move request to the backend and should update the store + await otherGroups[0].trigger('click') + await flushPromises() + + expect(wrapper.html()).not.toContain('EphyraSoftware:test-a') + + // Switch to the other group, where the code should have moved to + await wrapper.setProps({ + groupId: otherGroupId + }) + + // Now it's showing again + expect(wrapper.html()).toContain('EphyraSoftware:test-a') + + // Check the state was correctly updated in the store + const groupsStore = useGroupsStore() + const removedCode = groupsStore.codeById(groupId, codeId) + expect(removedCode).toBe(undefined) + const code = groupsStore.codeById(otherGroupId, codeId) + expect(code?.codeId).toBe(codeId) + }) }) diff --git a/coldmfa/app/src/components/CodeSummaryLine.vue b/coldmfa/app/src/components/CodeSummaryLine.vue index 90e6b8a..f448321 100644 --- a/coldmfa/app/src/components/CodeSummaryLine.vue +++ b/coldmfa/app/src/components/CodeSummaryLine.vue @@ -18,10 +18,12 @@ defineEmits<{ const client = inject('client') as AxiosInstance const groupsStore = useGroupsStore() const fetchedCode = ref() +const moveCodeModalOpen = ref(false) const codeName = useTemplateRef('codeName') const code = computed(() => groupsStore.codeById(props.groupId, props.codeId)) +const otherGroups = computed(() => groupsStore.groups.filter((g) => g.groupId !== props.groupId)) const deleteCounter = ref(5) @@ -110,6 +112,35 @@ const tryDelete = async () => { } } } + +const showMoveCode = () => { + moveCodeModalOpen.value = true +} + +const moveCode = async (groupId: string) => { + if (!code.value || !groupId) { + return + } + + try { + await client.post( + `api/groups/${props.groupId}/codes/${code.value.codeId}/move`, + { + toGroupId: groupId + }, + { + validateStatus: (status) => status === 204 + } + ) + + groupsStore.addCodeToGroup(groupId, code.value) + groupsStore.removeCodeFromGroup(props.groupId, code.value.codeId) + } catch (e) { + console.error(e) + } + + moveCodeModalOpen.value = false +} diff --git a/coldmfa/app/src/stores/groups.ts b/coldmfa/app/src/stores/groups.ts index af68ab7..7ee5d90 100644 --- a/coldmfa/app/src/stores/groups.ts +++ b/coldmfa/app/src/stores/groups.ts @@ -62,6 +62,16 @@ export const useGroupsStore = defineStore('groups', () => { return group && group.codes } + const removeCodeFromGroup = (groupId: string, codeId: string) => { + const group = groups.value.find((g) => g.groupId === groupId) + if (group && group.codes) { + const codeIndex = group.codes.findIndex((c) => c.codeId === codeId) + if (codeIndex !== -1) { + group.codes.splice(codeIndex, 1) + } + } + } + return { groups, insertGroup, @@ -71,7 +81,8 @@ export const useGroupsStore = defineStore('groups', () => { groupHasCodes, groupById, codeById, - groupCodes + groupCodes, + removeCodeFromGroup } }) diff --git a/coldmfa/app/src/support/httpMock.ts b/coldmfa/app/src/support/httpMock.ts index 4fb38ea..59b70fa 100644 --- a/coldmfa/app/src/support/httpMock.ts +++ b/coldmfa/app/src/support/httpMock.ts @@ -171,6 +171,39 @@ const handlers = [ } ), + http.post( + 'http://127.0.0.1:3000/coldmfa/api/groups/:groupId/codes/:codeId/move', + async ({ request, params }) => { + if (nextIsHttpErr) { + nextIsHttpErr = false + return HttpResponse.json({ error: 'A test error' }, { status: 500 }) + } + + const groupId = params['groupId'] as string + const codeId = params['codeId'] as string + + const req = (await request.json()) as Record + const toGroupId = req['toGroupId'] + + if (!data[groupId] || !data[toGroupId]) { + return HttpResponse.json({ error: 'Group not found' }, { status: 404 }) + } + + if (!data[groupId].codes) { + data[groupId].codes = [] + } + + if (!data[toGroupId].codes) { + data[toGroupId].codes = [] + } + + data[groupId].codes = data[groupId].codes!.filter((code) => code.codeId !== codeId) + data[toGroupId].codes!.push(data[groupId].codes!.find((code) => code.codeId === codeId)!) + + return new HttpResponse(null, { status: 204 }) + } + ), + http.delete( 'http://127.0.0.1:3000/coldmfa/api/groups/:groupId/codes/:codeId', async ({ params }) => { diff --git a/coldmfa/coldmfa.go b/coldmfa/coldmfa.go index 69e84a0..aa820e0 100644 --- a/coldmfa/coldmfa.go +++ b/coldmfa/coldmfa.go @@ -344,6 +344,51 @@ func (a *App) Prepare() { return c.SendStatus(http.StatusNoContent) }) + api.Post("/groups/:groupId/codes/:codeId/move", func(c *fiber.Ctx) error { + sessionId := auth.SessionId(c) + if sessionId == "" { + return c.SendStatus(http.StatusUnauthorized) + } + + currentGroupId := c.Params("groupId") + if currentGroupId == "" { + return c.Status(http.StatusBadRequest).JSON(ApiError{Error: "missing groupId"}) + } + + codeId := c.Params("codeId") + if codeId == "" { + return c.Status(http.StatusBadRequest).JSON(ApiError{Error: "missing codeId"}) + } + + moveCodeRequest := new(MoveCodeRequest) + if err := c.BodyParser(moveCodeRequest); err != nil { + return c.Status(http.StatusBadRequest).JSON(ApiError{Error: "invalid request"}) + } + + if moveCodeRequest.ToGroupId == "" { + return c.Status(http.StatusBadRequest).JSON(ApiError{Error: "missing toGroupId"}) + } + + result, err := db.ExecContext(c.Context(), "update code set code_group_id = (select id from code_group where owner_id = $1 and group_id = $2) where deleted = false and code_group_id = (select id from code_group where owner_id = $1 and group_id = $3) and code_id = $4", sessionId, moveCodeRequest.ToGroupId, currentGroupId, codeId) + + if err != nil { + log.Errorf("failed to move code: %s", err.Error()) + return c.Status(http.StatusInternalServerError).JSON(ApiError{Error: "database error"}) + } + + rowsAffected, err := result.RowsAffected() + if err != nil { + log.Errorf("failed to update code: %s", err.Error()) + return c.Status(http.StatusInternalServerError).JSON(ApiError{Error: "database error"}) + } + + if rowsAffected == 0 { + return c.Status(http.StatusNotFound).JSON(ApiError{Error: "code or target group not found"}) + } + + return c.SendStatus(http.StatusNoContent) + }) + api.Delete("/groups/:groupId/codes/:codeId", func(c *fiber.Ctx) error { sessionId := auth.SessionId(c) if sessionId == "" { @@ -712,8 +757,8 @@ func extractOtpAuthUrl(raw string) (*OtpConfig, error) { if err != nil || num < 0 { return nil, fmt.Errorf("invalid period") } - unsigned_num := uint(num) - out.Period = &unsigned_num + unsignedNum := uint(num) + out.Period = &unsignedNum } return &out, nil diff --git a/coldmfa/model.go b/coldmfa/model.go index ccc44a5..e11206f 100644 --- a/coldmfa/model.go +++ b/coldmfa/model.go @@ -60,3 +60,7 @@ type BackupWarning struct { LastBackupAt *time.Time `json:"lastBackupAt"` NumberNotBackedUp int `json:"numberNotBackedUp"` } + +type MoveCodeRequest struct { + ToGroupId string `json:"toGroupId"` +}