diff --git a/apps/api/src/app/invites/dto/redeem-invite.dto.ts b/apps/api/src/app/invites/dto/redeem-invite.dto.ts new file mode 100644 index 00000000..eebe6ba0 --- /dev/null +++ b/apps/api/src/app/invites/dto/redeem-invite.dto.ts @@ -0,0 +1,15 @@ +import { ApiProperty } from "@nestjs/swagger" +import { IsNumberString, IsString, Length } from "class-validator" + +export class RedeemInviteDto { + @IsString() + @Length(8) + @ApiProperty() + readonly inviteCode: string + + @IsString() + @Length(32) + @IsNumberString() + @ApiProperty() + readonly groupId: string +} diff --git a/apps/api/src/app/invites/invites.controller.ts b/apps/api/src/app/invites/invites.controller.ts index 65b883e0..41b0ed05 100644 --- a/apps/api/src/app/invites/invites.controller.ts +++ b/apps/api/src/app/invites/invites.controller.ts @@ -5,6 +5,7 @@ import { Headers, NotImplementedException, Param, + Patch, Post, Req } from "@nestjs/common" @@ -22,6 +23,7 @@ import { CreateInviteDto } from "./dto/create-invite.dto" import { Invite } from "./entities/invite.entity" import { InvitesService } from "./invites.service" import { mapEntity } from "../utils" +import { RedeemInviteDto } from "./dto/redeem-invite.dto" @ApiTags("invites") @Controller("invites") @@ -72,4 +74,37 @@ export class InvitesController { return mapEntity(invite) } + + @Patch("redeem") + @ApiBody({ type: RedeemInviteDto }) + @ApiHeader({ name: "x-api-key", required: true }) + @ApiOperation({ description: "Redeems a specific invite." }) + @ApiCreatedResponse({ type: InviteResponse }) + async redeemInvite( + @Headers() headers: Headers, + @Req() req: Request, + @Body() { inviteCode, groupId }: RedeemInviteDto + ): Promise { + let invite: Invite + + const apiKey = headers["x-api-key"] as string + + if (apiKey) { + invite = await this.invitesService.redeemInviteWithApiKey( + inviteCode, + groupId, + apiKey + ) + } else if (req.session.adminId) { + invite = await this.invitesService.redeemInviteKeyManually( + inviteCode, + groupId, + req.session.adminId + ) + } else { + throw new NotImplementedException() + } + + return mapEntity(invite) + } } diff --git a/apps/api/src/app/invites/invites.service.test.ts b/apps/api/src/app/invites/invites.service.test.ts index e3ac0a48..865b95e4 100644 --- a/apps/api/src/app/invites/invites.service.test.ts +++ b/apps/api/src/app/invites/invites.service.test.ts @@ -217,7 +217,7 @@ describe("InvitesService", () => { }) describe("# createInviteWithApiKey", () => { - it("Should create an invite manually", async () => { + it("Should create an invite with api key", async () => { const { group, code, @@ -402,6 +402,118 @@ describe("InvitesService", () => { }) }) + describe("# redeemInviteWithApiKey", () => { + it("Should redeem an invite with api key", async () => { + await adminsService.updateApiKey(admin.id, ApiKeyActions.Enable) + + const invite = await invitesService.createInvite( + { groupId }, + admin.id + ) + + const { isRedeemed: redeemed } = + await invitesService.redeemInviteWithApiKey( + invite.code, + groupId, + admin.apiKey + ) + + expect(redeemed).toBeTruthy() + }) + + it("Should not reddem an invite if the given api key is invalid", async () => { + const invite = await invitesService.createInvite( + { groupId }, + admin.id + ) + + const fun = invitesService.redeemInviteWithApiKey( + invite.code, + groupId, + "wrong-apikey" + ) + + await expect(fun).rejects.toThrow( + `Invalid API key or invalid admin for the group '${groupId}'` + ) + }) + + it("Should not redeem an invite if the given api key does not belong to an admin", async () => { + const invite = await invitesService.createInvite( + { groupId }, + admin.id + ) + + const oldApiKey = admin.apiKey + + await adminsService.updateApiKey(admin.id, ApiKeyActions.Generate) + + const fun = invitesService.redeemInviteWithApiKey( + invite.code, + groupId, + oldApiKey + ) + + await expect(fun).rejects.toThrow( + `Invalid API key or invalid admin for the group '${groupId}'` + ) + }) + + it("Should not redeem an invite if the given api key is disabled", async () => { + const invite = await invitesService.createInvite( + { groupId }, + admin.id + ) + + await adminsService.updateApiKey(admin.id, ApiKeyActions.Disable) + + admin = await adminsService.findOne({ id: admin.id }) + + const fun = invitesService.redeemInviteWithApiKey( + invite.code, + groupId, + admin.apiKey + ) + + await expect(fun).rejects.toThrow( + `Invalid API key or API access not enabled for admin '${admin.id}'` + ) + }) + }) + + describe("# redeemInviteKeyManually", () => { + it("Should redeem an invite manually", async () => { + const invite = await invitesService.createInvite( + { groupId }, + admin.id + ) + + const { isRedeemed: redeemed } = + await invitesService.redeemInviteKeyManually( + invite.code, + groupId, + admin.id + ) + + expect(redeemed).toBeTruthy() + }) + + it("Should not redeem an invite if the given identifier does not belong to an admin", async () => { + const invite = await invitesService.createInvite( + { groupId }, + admin.id + ) + + const fun = invitesService.redeemInviteKeyManually( + invite.code, + groupId, + "wrong-admin" + ) + + await expect(fun).rejects.toThrow("You are not an admin") + }) + }) + describe("# generateCode", () => { it("Should generate a random code with 8 characters", async () => { const result = (invitesService as any).generateCode() diff --git a/apps/api/src/app/invites/invites.service.ts b/apps/api/src/app/invites/invites.service.ts index 8f54ce86..589e080b 100644 --- a/apps/api/src/app/invites/invites.service.ts +++ b/apps/api/src/app/invites/invites.service.ts @@ -121,6 +121,7 @@ export class InvitesService { * Redeems an invite by consuming its code. Every invite * can be used only once. * @param inviteCode Invite code to be redeemed. + * @param groupId Group id. * @returns The updated invite. */ async redeemInvite(inviteCode: string, groupId: string): Promise { @@ -149,6 +150,42 @@ export class InvitesService { return this.inviteRepository.save(invite) } + /** + * Redeems an invite by using API Key. + * @param inviteCode Invite code to be redeemed. + * @param groupId Group id. + * @param apiKey The API Key. + * @returns The updated invite. + */ + async redeemInviteWithApiKey( + inviteCode: string, + groupId: string, + apiKey: string + ) { + await getAndCheckAdmin(this.adminsService, apiKey, groupId) + + return this.redeemInvite(inviteCode, groupId) + } + + /** + * Redeems an invite manually without using API Key. + * @param inviteCode Invite code to be redeemed. + * @param groupId Group id. + * @param adminId Group admin id. + * @returns The updated invite. + */ + async redeemInviteKeyManually( + inviteCode: string, + groupId: string, + adminId: string + ) { + const admin = await this.adminsService.findOne({ id: adminId }) + + if (!admin) throw new BadRequestException(`You are not an admin`) + + return this.redeemInvite(inviteCode, groupId) + } + /** * Generates a random code with a given number of characters. * The list of available characters have been chosen to be human readable. diff --git a/apps/client/src/api/bandadaAPI.ts b/apps/client/src/api/bandadaAPI.ts index 49aba4d2..6d629200 100644 --- a/apps/client/src/api/bandadaAPI.ts +++ b/apps/client/src/api/bandadaAPI.ts @@ -1,6 +1,8 @@ import { ApiSdk, Group, Invite } from "@bandada/api-sdk" +import { request } from "@bandada/utils" const api = new ApiSdk(import.meta.env.VITE_API_URL) +const API_URL = import.meta.env.VITE_API_URL export async function getInvite(inviteCode: string): Promise { try { @@ -72,3 +74,27 @@ export async function addMemberByInviteCode( return null } } + +export async function redeemInvite( + inviteCode: string, + groupId: string +): Promise { + try { + return await request( + `${API_URL}/invites/redeem/${inviteCode}/group/${groupId}`, + { + method: "post" + } + ) + } catch (error: any) { + console.error(error) + + if (error.response) { + alert(error.response.statusText) + } else { + alert("Some error occurred!") + } + + return null + } +} diff --git a/apps/client/src/pages/home.tsx b/apps/client/src/pages/home.tsx index 8305dcbc..f4f5dd19 100644 --- a/apps/client/src/pages/home.tsx +++ b/apps/client/src/pages/home.tsx @@ -118,6 +118,10 @@ export default function HomePage(): JSX.Element { ) await semaphore.addMember(group.id, identityCommitment) + await bandadaAPI.redeemInvite( + inviteCode, + invite.group.id + ) } catch (error) { alert( "Some error occurred! Check if you're on Sepolia network and the transaction is signed and completed" diff --git a/libs/api-sdk/README.md b/libs/api-sdk/README.md index 51ed17cf..035ce939 100644 --- a/libs/api-sdk/README.md +++ b/libs/api-sdk/README.md @@ -521,6 +521,19 @@ const apiKey = "70f07d0d-6aa2-4fe1-b4b9-06c271a641dc" const invite = await apiSdk.getInvite(inviteCode) ``` +## Redeem invite + +\# **redeemInvite**(): _Promise\_ + +Redeems a specific invite. + +```ts +const groupId = "10402173435763029700781503965100" +const inviteCode = "C5VAG4HD" + +const invite = await apiSdk.redeemInvite(inviteCode, groupId) +``` + ## Get credential group join URL \# **getCredentialGroupJoinUrl**(): _string_ diff --git a/libs/api-sdk/src/apiSdk.ts b/libs/api-sdk/src/apiSdk.ts index a18ba4ff..f0f6b672 100644 --- a/libs/api-sdk/src/apiSdk.ts +++ b/libs/api-sdk/src/apiSdk.ts @@ -31,7 +31,7 @@ import { getGroupsByType, createAssociatedGroup } from "./groups" -import { createInvite, getInvite } from "./invites" +import { createInvite, getInvite, redeemInvite } from "./invites" export default class ApiSdk { private _url: string @@ -419,6 +419,28 @@ export default class ApiSdk { return invite } + /** + * Redeems a specific invite. + * @param inviteCode Invite code. + * @param groupId Group id. + * @param apiKey The api key. + * @returns The updated invite. + */ + async redeemInvite( + inviteCode: string, + groupId: string, + apiKey: string + ): Promise { + const invite = await redeemInvite( + this._config, + inviteCode, + groupId, + apiKey + ) + + return invite + } + /** * Generate a custom url for joining a credential group. * @param dashboardUrl Dashboard base url. diff --git a/libs/api-sdk/src/index.test.ts b/libs/api-sdk/src/index.test.ts index f48173f3..200fcb42 100644 --- a/libs/api-sdk/src/index.test.ts +++ b/libs/api-sdk/src/index.test.ts @@ -1151,6 +1151,50 @@ describe("Bandada API SDK", () => { expect(invite.group).toStrictEqual(group) }) }) + + describe("# redeemInvite", () => { + it("Should redeem an invite", async () => { + const groupId = "95633257675970239314311768035433" + const groupName = "Group 1" + const group = { + id: groupId, + name: groupName, + description: "This is Group 1", + type: "off-chain", + adminId: + "0x63229164c457584616006e31d1e171e6cdd4163695bc9c4bf0227095998ffa4c", + treeDepth: 16, + fingerprintDuration: 3600, + credentials: null, + apiEnabled: false, + apiKey: null, + createdAt: "2023-08-09T18:09:53.000Z", + updatedAt: "2023-08-09T18:09:53.000Z" + } + const apiKey = "70f07d0d-6aa2-4fe1-b4b9-06c271a641dc" + const inviteCode = "C5VAG4HD" + const inviteCreatedAt = "2023-08-09T18:10:02.000Z" + + requestMocked.mockImplementationOnce(() => + Promise.resolve({ + code: inviteCode, + isRedeemed: true, + createdAt: inviteCreatedAt, + group + }) + ) + + const apiSdk: ApiSdk = new ApiSdk(SupportedUrl.DEV) + const invite = await apiSdk.redeemInvite( + inviteCode, + groupId, + apiKey + ) + + expect(invite.code).toBe(inviteCode) + expect(invite.isRedeemed).toBe(true) + }) + }) }) describe("Check Parameter", () => { describe("Should not throw an error if the parameter has the expected type", () => { diff --git a/libs/api-sdk/src/invites.ts b/libs/api-sdk/src/invites.ts index df4af2b7..70d1031d 100644 --- a/libs/api-sdk/src/invites.ts +++ b/libs/api-sdk/src/invites.ts @@ -46,3 +46,27 @@ export async function createInvite( return req } + +export async function redeemInvite( + config: object, + inviteCode: string, + groupId: string, + apiKey: string +): Promise { + const requestUrl = `${url}/redeem` + + const newConfig: any = { + method: "patch", + data: { + inviteCode, + groupId + }, + ...config + } + + newConfig.headers["x-api-key"] = apiKey + + const req = await request(requestUrl, newConfig) + + return req +}