Compare commits

...

26 Commits

Author SHA1 Message Date
mirarifhasan
f5a654b27a test: added test coverage for infra-config 2024-03-07 11:40:49 +05:30
mirarifhasan
c2c5cf25b1 test: fix failed test cases 2024-03-07 11:40:49 +05:30
mirarifhasan
f92c70e1ff feat: remove InfraConfigEnumForClient enum and add exclude const variable 2024-03-07 11:40:49 +05:30
mirarifhasan
f0adc5b2e4 chore: rebased and resolve conflicts 2024-03-07 11:40:49 +05:30
mirarifhasan
fdcf55552a fix: add return statement 2024-03-07 11:40:49 +05:30
mirarifhasan
b0b7df0a3e feat: removed unnecessary checks 2024-03-07 11:40:49 +05:30
mirarifhasan
01fd27f81a feat: infra config key check added instead of count check 2024-03-07 11:40:49 +05:30
mirarifhasan
2d7fb8e23a fix: pnpm issue 2024-03-07 11:40:49 +05:30
mirarifhasan
600e0eea76 fix: checks added for infraConfig table containing missing value or not 2024-03-07 11:40:49 +05:30
mirarifhasan
9e9907b4be feat: feedback implemented 2024-03-07 11:40:49 +05:30
Mir Arif Hasan
2ba79a043b fix: url regex update 2024-03-07 11:40:49 +05:30
Mir Arif Hasan
1be466efcd fix: code scanning issue 2024-03-07 11:40:49 +05:30
Mir Arif Hasan
0c13ca7dca feat: update configService in strategy file 2024-03-07 11:40:49 +05:30
Mir Arif Hasan
b6b6acd2fa fix: validate url function 2024-03-07 11:40:49 +05:30
Mir Arif Hasan
9c00d6238e feat: sso callback url and scope added in infra-config 2024-03-07 11:40:49 +05:30
Joel Jacob Stephen
919579b1da feat(sh-admin): introducing data analytics and newsletter configurations (#3845)
Co-authored-by: jamesgeorge007 <jamesgeorge998001@gmail.com>
Co-authored-by: nivedin <nivedinp@gmail.com>
2024-03-06 20:06:48 +05:30
Nivedin
4798d7bbbd refactor: remove restore tab popup and its functionalities (#3867) 2024-03-05 18:14:41 +05:30
Balu Babu
a0c6b22641 feat: full text search for TeamCollections and TeamRequests (#3857)
Co-authored-by: mirarifhasan <arif.ishan05@gmail.com>
2024-03-05 18:05:58 +05:30
James George
de8929ab18 feat(common): support simultaneous imports of collections and environment files (#3719) 2024-03-05 17:49:01 +05:30
Andrew Bastin
55a94bdccc chore: merge hoppscotch/release/2023.12.6 into hoppscotch/release/2024.3.0 2024-02-27 13:35:20 +05:30
Balu Babu
cf039c482a feat: SH instance analytics data collection (#3838) 2024-02-22 00:35:12 +05:30
Mir Arif Hasan
ded2725116 feat: admin user management (backend) (#3786) 2024-02-21 21:35:08 +05:30
Balu Babu
9c6754c70f feat: inital setup info route (#3847) 2024-02-21 21:15:47 +05:30
Joel Jacob Stephen
b359650d96 refactor: updated teams nomenclature in admin dashboard to workspaces (#3770) 2024-02-08 22:17:42 +05:30
James George
3482743782 chore(cli): emit bundle in ESM format (#3777) 2024-02-05 22:55:05 +05:30
Joel Jacob Stephen
3d6adcc39d refactor: consolidated admin dashboard improvements (#3790)
Co-authored-by: jamesgeorge007 <jamesgeorge998001@gmail.com>
2024-02-02 15:17:25 +05:30
114 changed files with 3601 additions and 1824 deletions

View File

@@ -112,7 +112,7 @@ services:
build:
dockerfile: packages/hoppscotch-backend/Dockerfile
context: .
target: prod
target: dev
env_file:
- ./.env
restart: always
@@ -122,7 +122,7 @@ services:
- PORT=3000
volumes:
# Uncomment the line below when modifying code. Only applicable when using the "dev" target.
# - ./packages/hoppscotch-backend/:/usr/src/app
- ./packages/hoppscotch-backend/:/usr/src/app
- /usr/src/app/node_modules/
depends_on:
hoppscotch-db:

View File

@@ -34,12 +34,14 @@
"@nestjs/jwt": "^10.1.1",
"@nestjs/passport": "^10.0.2",
"@nestjs/platform-express": "^10.2.6",
"@nestjs/schedule": "^4.0.1",
"@nestjs/throttler": "^5.0.0",
"@prisma/client": "^5.8.0",
"argon2": "^0.30.3",
"bcrypt": "^5.1.0",
"cookie": "^0.5.0",
"cookie-parser": "^1.4.6",
"cron": "^3.1.6",
"express": "^4.17.1",
"express-session": "^1.17.3",
"fp-ts": "^2.13.1",
@@ -57,6 +59,7 @@
"passport-jwt": "^4.0.1",
"passport-local": "^1.0.0",
"passport-microsoft": "^1.0.0",
"posthog-node": "^3.6.3",
"prisma": "^5.8.0",
"reflect-metadata": "^0.1.13",
"rimraf": "^3.0.2",

View File

@@ -0,0 +1,17 @@
-- AlterTable
ALTER TABLE
"TeamCollection"
ADD
titleSearch tsvector GENERATED ALWAYS AS (to_tsvector('english', title)) STORED;
-- AlterTable
ALTER TABLE
"TeamRequest"
ADD
titleSearch tsvector GENERATED ALWAYS AS (to_tsvector('english', title)) STORED;
-- CreateIndex
CREATE INDEX "TeamCollection_textSearch_idx" ON "TeamCollection" USING GIN (titleSearch);
-- CreateIndex
CREATE INDEX "TeamRequest_textSearch_idx" ON "TeamRequest" USING GIN (titleSearch);

View File

@@ -41,31 +41,31 @@ model TeamInvitation {
}
model TeamCollection {
id String @id @default(cuid())
id String @id @default(cuid())
parentID String?
data Json?
parent TeamCollection? @relation("TeamCollectionChildParent", fields: [parentID], references: [id])
children TeamCollection[] @relation("TeamCollectionChildParent")
parent TeamCollection? @relation("TeamCollectionChildParent", fields: [parentID], references: [id])
children TeamCollection[] @relation("TeamCollectionChildParent")
requests TeamRequest[]
teamID String
team Team @relation(fields: [teamID], references: [id], onDelete: Cascade)
team Team @relation(fields: [teamID], references: [id], onDelete: Cascade)
title String
orderIndex Int
createdOn DateTime @default(now()) @db.Timestamp(3)
updatedOn DateTime @updatedAt @db.Timestamp(3)
createdOn DateTime @default(now()) @db.Timestamp(3)
updatedOn DateTime @updatedAt @db.Timestamp(3)
}
model TeamRequest {
id String @id @default(cuid())
id String @id @default(cuid())
collectionID String
collection TeamCollection @relation(fields: [collectionID], references: [id], onDelete: Cascade)
collection TeamCollection @relation(fields: [collectionID], references: [id], onDelete: Cascade)
teamID String
team Team @relation(fields: [teamID], references: [id], onDelete: Cascade)
team Team @relation(fields: [teamID], references: [id], onDelete: Cascade)
title String
request Json
orderIndex Int
createdOn DateTime @default(now()) @db.Timestamp(3)
updatedOn DateTime @updatedAt @db.Timestamp(3)
createdOn DateTime @default(now()) @db.Timestamp(3)
updatedOn DateTime @updatedAt @db.Timestamp(3)
}
model Shortcode {

View File

@@ -27,9 +27,7 @@ import {
} from './input-types.args';
import { GqlThrottlerGuard } from 'src/guards/gql-throttler.guard';
import { SkipThrottle } from '@nestjs/throttler';
import { User } from 'src/user/user.model';
import { PaginationArgs } from 'src/types/input-types.args';
import { TeamInvitation } from 'src/team-invitation/team-invitation.model';
import { UserDeletionResult } from 'src/user/user.model';
@UseGuards(GqlThrottlerGuard)
@Resolver(() => Admin)
@@ -49,203 +47,6 @@ export class AdminResolver {
return admin;
}
@ResolveField(() => [User], {
description: 'Returns a list of all admin users in infra',
deprecationReason: 'Use `infra` query instead',
})
@UseGuards(GqlAuthGuard, GqlAdminGuard)
async admins() {
const admins = await this.adminService.fetchAdmins();
return admins;
}
@ResolveField(() => User, {
description: 'Returns a user info by UID',
deprecationReason: 'Use `infra` query instead',
})
@UseGuards(GqlAuthGuard, GqlAdminGuard)
async userInfo(
@Args({
name: 'userUid',
type: () => ID,
description: 'The user UID',
})
userUid: string,
): Promise<AuthUser> {
const user = await this.adminService.fetchUserInfo(userUid);
if (E.isLeft(user)) throwErr(user.left);
return user.right;
}
@ResolveField(() => [User], {
description: 'Returns a list of all the users in infra',
deprecationReason: 'Use `infra` query instead',
})
@UseGuards(GqlAuthGuard, GqlAdminGuard)
async allUsers(
@Parent() admin: Admin,
@Args() args: PaginationArgs,
): Promise<AuthUser[]> {
const users = await this.adminService.fetchUsers(args.cursor, args.take);
return users;
}
@ResolveField(() => [InvitedUser], {
description: 'Returns a list of all the invited users',
deprecationReason: 'Use `infra` query instead',
})
async invitedUsers(@Parent() admin: Admin): Promise<InvitedUser[]> {
const users = await this.adminService.fetchInvitedUsers();
return users;
}
@ResolveField(() => [Team], {
description: 'Returns a list of all the teams in the infra',
deprecationReason: 'Use `infra` query instead',
})
async allTeams(
@Parent() admin: Admin,
@Args() args: PaginationArgs,
): Promise<Team[]> {
const teams = await this.adminService.fetchAllTeams(args.cursor, args.take);
return teams;
}
@ResolveField(() => Team, {
description: 'Returns a team info by ID when requested by Admin',
deprecationReason: 'Use `infra` query instead',
})
async teamInfo(
@Parent() admin: Admin,
@Args({
name: 'teamID',
type: () => ID,
description: 'Team ID for which info to fetch',
})
teamID: string,
): Promise<Team> {
const team = await this.adminService.getTeamInfo(teamID);
if (E.isLeft(team)) throwErr(team.left);
return team.right;
}
@ResolveField(() => Number, {
description: 'Return count of all the members in a team',
deprecationReason: 'Use `infra` query instead',
})
async membersCountInTeam(
@Parent() admin: Admin,
@Args({
name: 'teamID',
type: () => ID,
description: 'Team ID for which team members to fetch',
nullable: false,
})
teamID: string,
): Promise<number> {
const teamMembersCount = await this.adminService.membersCountInTeam(teamID);
return teamMembersCount;
}
@ResolveField(() => Number, {
description: 'Return count of all the stored collections in a team',
deprecationReason: 'Use `infra` query instead',
})
async collectionCountInTeam(
@Parent() admin: Admin,
@Args({
name: 'teamID',
type: () => ID,
description: 'Team ID for which team members to fetch',
})
teamID: string,
): Promise<number> {
const teamCollCount = await this.adminService.collectionCountInTeam(teamID);
return teamCollCount;
}
@ResolveField(() => Number, {
description: 'Return count of all the stored requests in a team',
deprecationReason: 'Use `infra` query instead',
})
async requestCountInTeam(
@Parent() admin: Admin,
@Args({
name: 'teamID',
type: () => ID,
description: 'Team ID for which team members to fetch',
})
teamID: string,
): Promise<number> {
const teamReqCount = await this.adminService.requestCountInTeam(teamID);
return teamReqCount;
}
@ResolveField(() => Number, {
description: 'Return count of all the stored environments in a team',
deprecationReason: 'Use `infra` query instead',
})
async environmentCountInTeam(
@Parent() admin: Admin,
@Args({
name: 'teamID',
type: () => ID,
description: 'Team ID for which team members to fetch',
})
teamID: string,
): Promise<number> {
const envsCount = await this.adminService.environmentCountInTeam(teamID);
return envsCount;
}
@ResolveField(() => [TeamInvitation], {
description: 'Return all the pending invitations in a team',
deprecationReason: 'Use `infra` query instead',
})
async pendingInvitationCountInTeam(
@Parent() admin: Admin,
@Args({
name: 'teamID',
type: () => ID,
description: 'Team ID for which team members to fetch',
})
teamID: string,
) {
const invitations = await this.adminService.pendingInvitationCountInTeam(
teamID,
);
return invitations;
}
@ResolveField(() => Number, {
description: 'Return total number of Users in organization',
deprecationReason: 'Use `infra` query instead',
})
async usersCount() {
return this.adminService.getUsersCount();
}
@ResolveField(() => Number, {
description: 'Return total number of Teams in organization',
deprecationReason: 'Use `infra` query instead',
})
async teamsCount() {
return this.adminService.getTeamsCount();
}
@ResolveField(() => Number, {
description: 'Return total number of Team Collections in organization',
deprecationReason: 'Use `infra` query instead',
})
async teamCollectionsCount() {
return this.adminService.getTeamCollectionsCount();
}
@ResolveField(() => Number, {
description: 'Return total number of Team Requests in organization',
deprecationReason: 'Use `infra` query instead',
})
async teamRequestsCount() {
return this.adminService.getTeamRequestsCount();
}
/* Mutations */
@Mutation(() => InvitedUser, {
@@ -269,8 +70,26 @@ export class AdminResolver {
return invitedUser.right;
}
@Mutation(() => Boolean, {
description: 'Revoke a user invites by invitee emails',
})
@UseGuards(GqlAuthGuard, GqlAdminGuard)
async revokeUserInvitationsByAdmin(
@Args({
name: 'inviteeEmails',
description: 'Invitee Emails',
type: () => [String],
})
inviteeEmails: string[],
): Promise<boolean> {
const invite = await this.adminService.revokeUserInvitations(inviteeEmails);
if (E.isLeft(invite)) throwErr(invite.left);
return invite.right;
}
@Mutation(() => Boolean, {
description: 'Delete an user account from infra',
deprecationReason: 'Use removeUsersByAdmin instead',
})
@UseGuards(GqlAuthGuard, GqlAdminGuard)
async removeUserByAdmin(
@@ -281,12 +100,33 @@ export class AdminResolver {
})
userUID: string,
): Promise<boolean> {
const invitedUser = await this.adminService.removeUserAccount(userUID);
if (E.isLeft(invitedUser)) throwErr(invitedUser.left);
return invitedUser.right;
const removedUser = await this.adminService.removeUserAccount(userUID);
if (E.isLeft(removedUser)) throwErr(removedUser.left);
return removedUser.right;
}
@Mutation(() => [UserDeletionResult], {
description: 'Delete user accounts from infra',
})
@UseGuards(GqlAuthGuard, GqlAdminGuard)
async removeUsersByAdmin(
@Args({
name: 'userUIDs',
description: 'users UID',
type: () => [ID],
})
userUIDs: string[],
): Promise<UserDeletionResult[]> {
const deletionResults = await this.adminService.removeUserAccounts(
userUIDs,
);
if (E.isLeft(deletionResults)) throwErr(deletionResults.left);
return deletionResults.right;
}
@Mutation(() => Boolean, {
description: 'Make user an admin',
deprecationReason: 'Use makeUsersAdmin instead',
})
@UseGuards(GqlAuthGuard, GqlAdminGuard)
async makeUserAdmin(
@@ -302,8 +142,51 @@ export class AdminResolver {
return admin.right;
}
@Mutation(() => Boolean, {
description: 'Make users an admin',
})
@UseGuards(GqlAuthGuard, GqlAdminGuard)
async makeUsersAdmin(
@Args({
name: 'userUIDs',
description: 'users UID',
type: () => [ID],
})
userUIDs: string[],
): Promise<boolean> {
const isUpdated = await this.adminService.makeUsersAdmin(userUIDs);
if (E.isLeft(isUpdated)) throwErr(isUpdated.left);
return isUpdated.right;
}
@Mutation(() => Boolean, {
description: 'Update user display name',
})
@UseGuards(GqlAuthGuard, GqlAdminGuard)
async updateUserDisplayNameByAdmin(
@Args({
name: 'userUID',
description: 'users UID',
type: () => ID,
})
userUID: string,
@Args({
name: 'displayName',
description: 'users display name',
})
displayName: string,
): Promise<boolean> {
const isUpdated = await this.adminService.updateUserDisplayName(
userUID,
displayName,
);
if (E.isLeft(isUpdated)) throwErr(isUpdated.left);
return isUpdated.right;
}
@Mutation(() => Boolean, {
description: 'Remove user as admin',
deprecationReason: 'Use demoteUsersByAdmin instead',
})
@UseGuards(GqlAuthGuard, GqlAdminGuard)
async removeUserAsAdmin(
@@ -319,6 +202,23 @@ export class AdminResolver {
return admin.right;
}
@Mutation(() => Boolean, {
description: 'Remove users as admin',
})
@UseGuards(GqlAuthGuard, GqlAdminGuard)
async demoteUsersByAdmin(
@Args({
name: 'userUIDs',
description: 'users UID',
type: () => [ID],
})
userUIDs: string[],
): Promise<boolean> {
const isUpdated = await this.adminService.demoteUsersByAdmin(userUIDs);
if (E.isLeft(isUpdated)) throwErr(isUpdated.left);
return isUpdated.right;
}
@Mutation(() => Team, {
description:
'Create a new team by providing the user uid to nominate as Team owner',

View File

@@ -1,7 +1,7 @@
import { AdminService } from './admin.service';
import { PubSubService } from '../pubsub/pubsub.service';
import { mockDeep } from 'jest-mock-extended';
import { InvitedUsers } from '@prisma/client';
import { InvitedUsers, User as DbUser } from '@prisma/client';
import { UserService } from '../user/user.service';
import { TeamService } from '../team/team.service';
import { TeamEnvironmentsService } from '../team-environments/team-environments.service';
@@ -13,10 +13,15 @@ import { PrismaService } from 'src/prisma/prisma.service';
import {
DUPLICATE_EMAIL,
INVALID_EMAIL,
ONLY_ONE_ADMIN_ACCOUNT,
USER_ALREADY_INVITED,
USER_INVITATION_DELETION_FAILED,
USER_NOT_FOUND,
} from '../errors';
import { ShortcodeService } from 'src/shortcode/shortcode.service';
import { ConfigService } from '@nestjs/config';
import { OffsetPaginationArgs } from 'src/types/input-types.args';
import * as E from 'fp-ts/Either';
const mockPrisma = mockDeep<PrismaService>();
const mockPubSub = mockDeep<PubSubService>();
@@ -58,20 +63,87 @@ const invitedUsers: InvitedUsers[] = [
invitedOn: new Date(),
},
];
const dbAdminUsers: DbUser[] = [
{
uid: 'uid 1',
displayName: 'displayName',
email: 'email@email.com',
photoURL: 'photoURL',
isAdmin: true,
refreshToken: 'refreshToken',
currentRESTSession: '',
currentGQLSession: '',
createdOn: new Date(),
},
{
uid: 'uid 2',
displayName: 'displayName',
email: 'email@email.com',
photoURL: 'photoURL',
isAdmin: true,
refreshToken: 'refreshToken',
currentRESTSession: '',
currentGQLSession: '',
createdOn: new Date(),
},
];
const dbNonAminUser: DbUser = {
uid: 'uid 3',
displayName: 'displayName',
email: 'email@email.com',
photoURL: 'photoURL',
isAdmin: false,
refreshToken: 'refreshToken',
currentRESTSession: '',
currentGQLSession: '',
createdOn: new Date(),
};
describe('AdminService', () => {
describe('fetchInvitedUsers', () => {
test('should resolve right and return an array of invited users', async () => {
test('should resolve right and apply pagination correctly', async () => {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
mockPrisma.user.findMany.mockResolvedValue([dbAdminUsers[0]]);
// @ts-ignore
mockPrisma.invitedUsers.findMany.mockResolvedValue(invitedUsers);
const results = await adminService.fetchInvitedUsers();
const paginationArgs: OffsetPaginationArgs = { take: 5, skip: 2 };
const results = await adminService.fetchInvitedUsers(paginationArgs);
expect(mockPrisma.invitedUsers.findMany).toHaveBeenCalledWith({
...paginationArgs,
orderBy: {
invitedOn: 'desc',
},
where: {
NOT: {
inviteeEmail: {
in: [dbAdminUsers[0].email],
},
},
},
});
});
test('should resolve right and return an array of invited users', async () => {
const paginationArgs: OffsetPaginationArgs = { take: 10, skip: 0 };
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
mockPrisma.user.findMany.mockResolvedValue([dbAdminUsers[0]]);
// @ts-ignore
mockPrisma.invitedUsers.findMany.mockResolvedValue(invitedUsers);
const results = await adminService.fetchInvitedUsers(paginationArgs);
expect(results).toEqual(invitedUsers);
});
test('should resolve left and return an empty array if invited users not found', async () => {
const paginationArgs: OffsetPaginationArgs = { take: 10, skip: 0 };
mockPrisma.invitedUsers.findMany.mockResolvedValue([]);
const results = await adminService.fetchInvitedUsers();
const results = await adminService.fetchInvitedUsers(paginationArgs);
expect(results).toEqual([]);
});
});
@@ -134,6 +206,58 @@ describe('AdminService', () => {
});
});
describe('revokeUserInvitations', () => {
test('should resolve left and return error if email not invited', async () => {
mockPrisma.invitedUsers.deleteMany.mockRejectedValueOnce(
'RecordNotFound',
);
const result = await adminService.revokeUserInvitations([
'test@gmail.com',
]);
expect(result).toEqualLeft(USER_INVITATION_DELETION_FAILED);
});
test('should resolve right and return deleted invitee email', async () => {
const adminUid = 'adminUid';
mockPrisma.invitedUsers.deleteMany.mockResolvedValueOnce({ count: 1 });
const result = await adminService.revokeUserInvitations([
invitedUsers[0].inviteeEmail,
]);
expect(mockPrisma.invitedUsers.deleteMany).toHaveBeenCalledWith({
where: {
inviteeEmail: { in: [invitedUsers[0].inviteeEmail] },
},
});
expect(result).toEqualRight(true);
});
});
describe('removeUsersAsAdmin', () => {
test('should resolve right and make admins to users', async () => {
mockUserService.fetchAdminUsers.mockResolvedValueOnce(dbAdminUsers);
mockUserService.removeUsersAsAdmin.mockResolvedValueOnce(E.right(true));
return expect(
await adminService.demoteUsersByAdmin([dbAdminUsers[0].uid]),
).toEqualRight(true);
});
test('should resolve left and return error if only one admin in the infra', async () => {
mockUserService.fetchAdminUsers.mockResolvedValueOnce(dbAdminUsers);
mockUserService.removeUsersAsAdmin.mockResolvedValueOnce(E.right(true));
return expect(
await adminService.demoteUsersByAdmin(
dbAdminUsers.map((user) => user.uid),
),
).toEqualLeft(ONLY_ONE_ADMIN_ACCOUNT);
});
});
describe('getUsersCount', () => {
test('should return count of all users in the organization', async () => {
mockUserService.getUsersCount.mockResolvedValueOnce(10);

View File

@@ -6,13 +6,16 @@ import * as E from 'fp-ts/Either';
import * as O from 'fp-ts/Option';
import { validateEmail } from '../utils';
import {
ADMIN_CAN_NOT_BE_DELETED,
DUPLICATE_EMAIL,
EMAIL_FAILED,
INVALID_EMAIL,
ONLY_ONE_ADMIN_ACCOUNT,
TEAM_INVITE_ALREADY_MEMBER,
TEAM_INVITE_NO_INVITE_FOUND,
USERS_NOT_FOUND,
USER_ALREADY_INVITED,
USER_INVITATION_DELETION_FAILED,
USER_IS_ADMIN,
USER_NOT_FOUND,
} from '../errors';
@@ -26,6 +29,8 @@ import { TeamInvitationService } from '../team-invitation/team-invitation.servic
import { TeamMemberRole } from '../team/team.model';
import { ShortcodeService } from 'src/shortcode/shortcode.service';
import { ConfigService } from '@nestjs/config';
import { OffsetPaginationArgs } from 'src/types/input-types.args';
import { UserDeletionResult } from 'src/user/user.model';
@Injectable()
export class AdminService {
@@ -48,12 +53,30 @@ export class AdminService {
* @param cursorID Users uid
* @param take number of users to fetch
* @returns an Either of array of user or error
* @deprecated use fetchUsersV2 instead
*/
async fetchUsers(cursorID: string, take: number) {
const allUsers = await this.userService.fetchAllUsers(cursorID, take);
return allUsers;
}
/**
* Fetch all the users in the infra.
* @param searchString search on users displayName or email
* @param paginationOption pagination options
* @returns an Either of array of user or error
*/
async fetchUsersV2(
searchString: string,
paginationOption: OffsetPaginationArgs,
) {
const allUsers = await this.userService.fetchAllUsersV2(
searchString,
paginationOption,
);
return allUsers;
}
/**
* Invite a user to join the infra.
* @param adminUID Admin's UID
@@ -110,14 +133,68 @@ export class AdminService {
return E.right(invitedUser);
}
/**
* Update the display name of a user
* @param userUid Who's display name is being updated
* @param displayName New display name of the user
* @returns an Either of boolean or error
*/
async updateUserDisplayName(userUid: string, displayName: string) {
const updatedUser = await this.userService.updateUserDisplayName(
userUid,
displayName,
);
if (E.isLeft(updatedUser)) return E.left(updatedUser.left);
return E.right(true);
}
/**
* Revoke infra level user invitations
* @param inviteeEmails Invitee's emails
* @param adminUid Admin Uid
* @returns an Either of boolean or error string
*/
async revokeUserInvitations(inviteeEmails: string[]) {
try {
await this.prisma.invitedUsers.deleteMany({
where: {
inviteeEmail: { in: inviteeEmails },
},
});
return E.right(true);
} catch (error) {
return E.left(USER_INVITATION_DELETION_FAILED);
}
}
/**
* Fetch the list of invited users by the admin.
* @returns an Either of array of `InvitedUser` object or error
*/
async fetchInvitedUsers() {
const invitedUsers = await this.prisma.invitedUsers.findMany();
async fetchInvitedUsers(paginationOption: OffsetPaginationArgs) {
const userEmailObjs = await this.prisma.user.findMany({
select: {
email: true,
},
});
const users: InvitedUser[] = invitedUsers.map(
const pendingInvitedUsers = await this.prisma.invitedUsers.findMany({
take: paginationOption.take,
skip: paginationOption.skip,
orderBy: {
invitedOn: 'desc',
},
where: {
NOT: {
inviteeEmail: {
in: userEmailObjs.map((user) => user.email),
},
},
},
});
const users: InvitedUser[] = pendingInvitedUsers.map(
(user) => <InvitedUser>{ ...user },
);
@@ -337,6 +414,7 @@ export class AdminService {
* Remove a user account by UID
* @param userUid User UID
* @returns an Either of boolean or error
* @deprecated use removeUserAccounts instead
*/
async removeUserAccount(userUid: string) {
const user = await this.userService.findUserById(userUid);
@@ -349,10 +427,73 @@ export class AdminService {
return E.right(delUser.right);
}
/**
* Remove user (not Admin) accounts by UIDs
* @param userUIDs User UIDs
* @returns an Either of boolean or error
*/
async removeUserAccounts(userUIDs: string[]) {
const userDeleteResult: UserDeletionResult[] = [];
// step 1: fetch all users
const allUsersList = await this.userService.findUsersByIds(userUIDs);
if (allUsersList.length === 0) return E.left(USERS_NOT_FOUND);
// step 2: admin user can not be deleted without removing admin status/role
allUsersList.forEach((user) => {
if (user.isAdmin) {
userDeleteResult.push({
userUID: user.uid,
isDeleted: false,
errorMessage: ADMIN_CAN_NOT_BE_DELETED,
});
}
});
const nonAdminUsers = allUsersList.filter((user) => !user.isAdmin);
let deletedUserEmails: string[] = [];
// step 3: delete non-admin users
const deletionPromises = nonAdminUsers.map((user) => {
return this.userService
.deleteUserByUID(user)()
.then((res) => {
if (E.isLeft(res)) {
return {
userUID: user.uid,
isDeleted: false,
errorMessage: res.left,
} as UserDeletionResult;
}
deletedUserEmails.push(user.email);
return {
userUID: user.uid,
isDeleted: true,
errorMessage: null,
} as UserDeletionResult;
});
});
const promiseResult = await Promise.allSettled(deletionPromises);
// step 4: revoke all the invites sent to the deleted users
await this.revokeUserInvitations(deletedUserEmails);
// step 5: return the result
promiseResult.forEach((result) => {
if (result.status === 'fulfilled') {
userDeleteResult.push(result.value);
}
});
return E.right(userDeleteResult);
}
/**
* Make a user an admin
* @param userUid User UID
* @returns an Either of boolean or error
* @deprecated use makeUsersAdmin instead
*/
async makeUserAdmin(userUID: string) {
const admin = await this.userService.makeAdmin(userUID);
@@ -360,10 +501,22 @@ export class AdminService {
return E.right(true);
}
/**
* Make users to admin
* @param userUid User UIDs
* @returns an Either of boolean or error
*/
async makeUsersAdmin(userUIDs: string[]) {
const isUpdated = await this.userService.makeAdmins(userUIDs);
if (E.isLeft(isUpdated)) return E.left(isUpdated.left);
return E.right(true);
}
/**
* Remove user as admin
* @param userUid User UID
* @returns an Either of boolean or error
* @deprecated use demoteUsersByAdmin instead
*/
async removeUserAsAdmin(userUID: string) {
const adminUsers = await this.userService.fetchAdminUsers();
@@ -374,6 +527,26 @@ export class AdminService {
return E.right(true);
}
/**
* Remove users as admin
* @param userUIDs User UIDs
* @returns an Either of boolean or error
*/
async demoteUsersByAdmin(userUIDs: string[]) {
const adminUsers = await this.userService.fetchAdminUsers();
const remainingAdmins = adminUsers.filter(
(adminUser) => !userUIDs.includes(adminUser.uid),
);
if (remainingAdmins.length < 1) {
return E.left(ONLY_ONE_ADMIN_ACCOUNT);
}
const isUpdated = await this.userService.removeUsersAsAdmin(userUIDs);
if (E.isLeft(isUpdated)) return E.left(isUpdated.left);
return E.right(isUpdated.right);
}
/**
* Fetch list of all the Users in org
* @returns number of users in the org

View File

@@ -0,0 +1,11 @@
import { Injectable, ExecutionContext, CanActivate } from '@nestjs/common';
@Injectable()
export class RESTAdminGuard implements CanActivate {
canActivate(context: ExecutionContext): boolean {
const request = context.switchToHttp().getRequest();
const user = request.user;
return user.isAdmin;
}
}

View File

@@ -17,7 +17,10 @@ import { AuthUser } from 'src/types/AuthUser';
import { throwErr } from 'src/utils';
import * as E from 'fp-ts/Either';
import { Admin } from './admin.model';
import { PaginationArgs } from 'src/types/input-types.args';
import {
OffsetPaginationArgs,
PaginationArgs,
} from 'src/types/input-types.args';
import { InvitedUser } from './invited-user.model';
import { Team } from 'src/team/team.model';
import { TeamInvitation } from 'src/team-invitation/team-invitation.model';
@@ -29,7 +32,8 @@ import {
EnableAndDisableSSOArgs,
InfraConfigArgs,
} from 'src/infra-config/input-args';
import { InfraConfigEnumForClient } from 'src/types/InfraConfig';
import { InfraConfigEnum } from 'src/types/InfraConfig';
import { ServiceStatus } from 'src/infra-config/helper';
@UseGuards(GqlThrottlerGuard)
@Resolver(() => Infra)
@@ -76,6 +80,7 @@ export class InfraResolver {
@ResolveField(() => [User], {
description: 'Returns a list of all the users in infra',
deprecationReason: 'Use allUsersV2 instead',
})
@UseGuards(GqlAuthGuard, GqlAdminGuard)
async allUsers(@Args() args: PaginationArgs): Promise<AuthUser[]> {
@@ -83,11 +88,33 @@ export class InfraResolver {
return users;
}
@ResolveField(() => [User], {
description: 'Returns a list of all the users in infra',
})
@UseGuards(GqlAuthGuard, GqlAdminGuard)
async allUsersV2(
@Args({
name: 'searchString',
nullable: true,
description: 'Search on users displayName or email',
})
searchString: string,
@Args() paginationOption: OffsetPaginationArgs,
): Promise<AuthUser[]> {
const users = await this.adminService.fetchUsersV2(
searchString,
paginationOption,
);
return users;
}
@ResolveField(() => [InvitedUser], {
description: 'Returns a list of all the invited users',
})
async invitedUsers(): Promise<InvitedUser[]> {
const users = await this.adminService.fetchInvitedUsers();
async invitedUsers(
@Args() args: OffsetPaginationArgs,
): Promise<InvitedUser[]> {
const users = await this.adminService.fetchInvitedUsers(args);
return users;
}
@@ -247,10 +274,10 @@ export class InfraResolver {
async infraConfigs(
@Args({
name: 'configNames',
type: () => [InfraConfigEnumForClient],
type: () => [InfraConfigEnum],
description: 'Configs to fetch',
})
names: InfraConfigEnumForClient[],
names: InfraConfigEnum[],
) {
const infraConfigs = await this.infraConfigService.getMany(names);
if (E.isLeft(infraConfigs)) throwErr(infraConfigs.left);
@@ -284,6 +311,25 @@ export class InfraResolver {
return updatedRes.right;
}
@Mutation(() => Boolean, {
description: 'Enable or disable analytics collection',
})
@UseGuards(GqlAuthGuard, GqlAdminGuard)
async toggleAnalyticsCollection(
@Args({
name: 'status',
type: () => ServiceStatus,
description: 'Toggle analytics collection',
})
analyticsCollectionStatus: ServiceStatus,
) {
const res = await this.infraConfigService.toggleAnalyticsCollection(
analyticsCollectionStatus,
);
if (E.isLeft(res)) throwErr(res.left);
return res.right;
}
@Mutation(() => Boolean, {
description: 'Reset Infra Configs with default values (.env)',
})
@@ -306,7 +352,9 @@ export class InfraResolver {
})
providerInfo: EnableAndDisableSSOArgs[],
) {
const isUpdated = await this.infraConfigService.enableAndDisableSSO(providerInfo);
const isUpdated = await this.infraConfigService.enableAndDisableSSO(
providerInfo,
);
if (E.isLeft(isUpdated)) throwErr(isUpdated.left);
return true;

View File

@@ -24,6 +24,8 @@ import { ConfigModule, ConfigService } from '@nestjs/config';
import { InfraConfigModule } from './infra-config/infra-config.module';
import { loadInfraConfiguration } from './infra-config/helper';
import { MailerModule } from './mailer/mailer.module';
import { PosthogModule } from './posthog/posthog.module';
import { ScheduleModule } from '@nestjs/schedule';
@Module({
imports: [
@@ -96,6 +98,8 @@ import { MailerModule } from './mailer/mailer.module';
UserCollectionModule,
ShortcodeModule,
InfraConfigModule,
PosthogModule,
ScheduleModule.forRoot(),
],
providers: [GQLComplexityPlugin],
controllers: [AppController],

View File

@@ -18,12 +18,7 @@ import { JwtAuthGuard } from './guards/jwt-auth.guard';
import { GqlUser } from 'src/decorators/gql-user.decorator';
import { AuthUser } from 'src/types/AuthUser';
import { RTCookie } from 'src/decorators/rt-cookie.decorator';
import {
AuthProvider,
authCookieHandler,
authProviderCheck,
throwHTTPErr,
} from './helper';
import { AuthProvider, authCookieHandler, authProviderCheck } from './helper';
import { GoogleSSOGuard } from './guards/google-sso.guard';
import { GithubSSOGuard } from './guards/github-sso.guard';
import { MicrosoftSSOGuard } from './guards/microsoft-sso-.guard';
@@ -31,6 +26,7 @@ import { ThrottlerBehindProxyGuard } from 'src/guards/throttler-behind-proxy.gua
import { SkipThrottle } from '@nestjs/throttler';
import { AUTH_PROVIDER_NOT_SPECIFIED } from 'src/errors';
import { ConfigService } from '@nestjs/config';
import { throwHTTPErr } from 'src/utils';
@UseGuards(ThrottlerBehindProxyGuard)
@Controller({ path: 'auth', version: '1' })

View File

@@ -12,7 +12,10 @@ import { GithubStrategy } from './strategies/github.strategy';
import { MicrosoftStrategy } from './strategies/microsoft.strategy';
import { AuthProvider, authProviderCheck } from './helper';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { loadInfraConfiguration } from 'src/infra-config/helper';
import {
isInfraConfigTablePopulated,
loadInfraConfiguration,
} from 'src/infra-config/helper';
import { InfraConfigModule } from 'src/infra-config/infra-config.module';
@Module({
@@ -34,6 +37,11 @@ import { InfraConfigModule } from 'src/infra-config/infra-config.module';
})
export class AuthModule {
static async register() {
const isInfraConfigPopulated = await isInfraConfigTablePopulated();
if (!isInfraConfigPopulated) {
return { module: AuthModule };
}
const env = await loadInfraConfiguration();
const allowedAuthProviders = env.INFRA.VITE_ALLOWED_AUTH_PROVIDERS;

View File

@@ -24,7 +24,7 @@ import {
RefreshTokenPayload,
} from 'src/types/AuthTokens';
import { JwtService } from '@nestjs/jwt';
import { AuthError } from 'src/types/AuthError';
import { RESTError } from 'src/types/RESTError';
import { AuthUser, IsAdmin } from 'src/types/AuthUser';
import { VerificationToken } from '@prisma/client';
import { Origin } from './helper';
@@ -117,7 +117,7 @@ export class AuthService {
userUid,
);
if (E.isLeft(updatedUser))
return E.left(<AuthError>{
return E.left(<RESTError>{
message: updatedUser.left,
statusCode: HttpStatus.NOT_FOUND,
});
@@ -255,7 +255,7 @@ export class AuthService {
*/
async verifyMagicLinkTokens(
magicLinkIDTokens: VerifyMagicDto,
): Promise<E.Right<AuthTokens> | E.Left<AuthError>> {
): Promise<E.Right<AuthTokens> | E.Left<RESTError>> {
const passwordlessTokens = await this.validatePasswordlessTokens(
magicLinkIDTokens,
);
@@ -373,7 +373,7 @@ export class AuthService {
if (usersCount === 1) {
const elevatedUser = await this.usersService.makeAdmin(user.uid);
if (E.isLeft(elevatedUser))
return E.left(<AuthError>{
return E.left(<RESTError>{
message: elevatedUser.left,
statusCode: HttpStatus.NOT_FOUND,
});

View File

@@ -1,9 +1,10 @@
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { AuthProvider, authProviderCheck, throwHTTPErr } from '../helper';
import { AuthProvider, authProviderCheck } from '../helper';
import { Observable } from 'rxjs';
import { AUTH_PROVIDER_NOT_SPECIFIED } from 'src/errors';
import { ConfigService } from '@nestjs/config';
import { throwHTTPErr } from 'src/utils';
@Injectable()
export class GithubSSOGuard extends AuthGuard('github') implements CanActivate {

View File

@@ -1,9 +1,10 @@
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { AuthProvider, authProviderCheck, throwHTTPErr } from '../helper';
import { AuthProvider, authProviderCheck } from '../helper';
import { Observable } from 'rxjs';
import { AUTH_PROVIDER_NOT_SPECIFIED } from 'src/errors';
import { ConfigService } from '@nestjs/config';
import { throwHTTPErr } from 'src/utils';
@Injectable()
export class GoogleSSOGuard extends AuthGuard('google') implements CanActivate {

View File

@@ -1,9 +1,10 @@
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { AuthProvider, authProviderCheck, throwHTTPErr } from '../helper';
import { AuthProvider, authProviderCheck } from '../helper';
import { Observable } from 'rxjs';
import { AUTH_PROVIDER_NOT_SPECIFIED } from 'src/errors';
import { ConfigService } from '@nestjs/config';
import { throwHTTPErr } from 'src/utils';
@Injectable()
export class MicrosoftSSOGuard

View File

@@ -1,6 +1,5 @@
import { HttpException, HttpStatus } from '@nestjs/common';
import { DateTime } from 'luxon';
import { AuthError } from 'src/types/AuthError';
import { AuthTokens } from 'src/types/AuthTokens';
import { Response } from 'express';
import * as cookie from 'cookie';
@@ -25,15 +24,6 @@ export enum AuthProvider {
EMAIL = 'EMAIL',
}
/**
* This function allows throw to be used as an expression
* @param errMessage Message present in the error message
*/
export function throwHTTPErr(errorData: AuthError): never {
const { message, statusCode } = errorData;
throw new HttpException(message, statusCode);
}
/**
* Sets and returns the cookies in the response object on successful authentication
* @param res Express Response Object

View File

@@ -17,8 +17,8 @@ export class GithubStrategy extends PassportStrategy(Strategy) {
super({
clientID: configService.get('INFRA.GITHUB_CLIENT_ID'),
clientSecret: configService.get('INFRA.GITHUB_CLIENT_SECRET'),
callbackURL: configService.get('GITHUB_CALLBACK_URL'),
scope: [configService.get('GITHUB_SCOPE')],
callbackURL: configService.get('INFRA.GITHUB_CALLBACK_URL'),
scope: [configService.get('INFRA.GITHUB_SCOPE')],
store: true,
});
}

View File

@@ -17,8 +17,8 @@ export class GoogleStrategy extends PassportStrategy(Strategy) {
super({
clientID: configService.get('INFRA.GOOGLE_CLIENT_ID'),
clientSecret: configService.get('INFRA.GOOGLE_CLIENT_SECRET'),
callbackURL: configService.get('GOOGLE_CALLBACK_URL'),
scope: configService.get('GOOGLE_SCOPE').split(','),
callbackURL: configService.get('INFRA.GOOGLE_CALLBACK_URL'),
scope: configService.get('INFRA.GOOGLE_SCOPE').split(','),
passReqToCallback: true,
store: true,
});

View File

@@ -17,9 +17,9 @@ export class MicrosoftStrategy extends PassportStrategy(Strategy) {
super({
clientID: configService.get('INFRA.MICROSOFT_CLIENT_ID'),
clientSecret: configService.get('INFRA.MICROSOFT_CLIENT_SECRET'),
callbackURL: configService.get('MICROSOFT_CALLBACK_URL'),
scope: [configService.get('MICROSOFT_SCOPE')],
tenant: configService.get('MICROSOFT_TENANT'),
callbackURL: configService.get('INFRA.MICROSOFT_CALLBACK_URL'),
scope: [configService.get('INFRA.MICROSOFT_SCOPE')],
tenant: configService.get('INFRA.MICROSOFT_TENANT'),
store: true,
});
}

View File

@@ -10,6 +10,14 @@ export const DUPLICATE_EMAIL = 'email/both_emails_cannot_be_same' as const;
export const ONLY_ONE_ADMIN_ACCOUNT =
'admin/only_one_admin_account_found' as const;
/**
* Admin user can not be deleted
* To delete the admin user, first make the Admin user a normal user
* (AdminService)
*/
export const ADMIN_CAN_NOT_BE_DELETED =
'admin/admin_can_not_be_deleted' as const;
/**
* Token Authorization failed (Check 'Authorization' Header)
* (GqlAuthGuard)
@@ -99,6 +107,13 @@ export const USER_IS_OWNER = 'user/is_owner' as const;
*/
export const USER_IS_ADMIN = 'user/is_admin' as const;
/**
* User invite deletion failure error due to invitation not found
* (AdminService)
*/
export const USER_INVITATION_DELETION_FAILED =
'user/invitation_deletion_failed' as const;
/**
* Teams not found
* (TeamsService)
@@ -213,6 +228,12 @@ export const TEAM_COL_NOT_SAME_PARENT =
export const TEAM_COL_SAME_NEXT_COLL =
'team_coll/collection_and_next_collection_are_same';
/**
* Team Collection search failed
* (TeamCollectionService)
*/
export const TEAM_COL_SEARCH_FAILED = 'team_coll/team_collection_search_failed';
/**
* Team Collection Re-Ordering Failed
* (TeamCollectionService)
@@ -268,6 +289,13 @@ export const TEAM_NOT_OWNER = 'team_coll/team_not_owner' as const;
export const TEAM_COLL_DATA_INVALID =
'team_coll/team_coll_data_invalid' as const;
/**
* Team Collection parent tree generation failed
* (TeamCollectionService)
*/
export const TEAM_COLL_PARENT_TREE_GEN_FAILED =
'team_coll/team_coll_parent_tree_generation_failed';
/**
* Tried to perform an action on a request that doesn't accept their member role level
* (GqlRequestTeamMemberGuard)
@@ -293,6 +321,19 @@ export const TEAM_REQ_INVALID_TARGET_COLL_ID =
*/
export const TEAM_REQ_REORDERING_FAILED = 'team_req/reordering_failed' as const;
/**
* Team Request search failed
* (TeamRequestService)
*/
export const TEAM_REQ_SEARCH_FAILED = 'team_req/team_request_search_failed';
/**
* Team Request parent tree generation failed
* (TeamRequestService)
*/
export const TEAM_REQ_PARENT_TREE_GEN_FAILED =
'team_req/team_req_parent_tree_generation_failed';
/**
* No Postmark Sender Email defined
* (AuthService)
@@ -690,9 +731,22 @@ export const INFRA_CONFIG_INVALID_INPUT = 'infra_config/invalid_input' as const;
export const INFRA_CONFIG_SERVICE_NOT_CONFIGURED =
'infra_config/service_not_configured' as const;
/**
* Infra Config update/fetch operation not allowed
* (InfraConfigService)
*/
export const INFRA_CONFIG_OPERATION_NOT_ALLOWED =
'infra_config/operation_not_allowed';
/**
* Error message for when the database table does not exist
* (InfraConfigService)
*/
export const DATABASE_TABLE_NOT_EXIST =
'Database migration not found. Please check the documentation for assistance: https://docs.hoppscotch.io/documentation/self-host/community-edition/install-and-build#running-migrations';
/**
* PostHog client is not initialized
* (InfraConfigService)
*/
export const POSTHOG_CLIENT_NOT_INITIALIZED = 'posthog/client_not_initialized';

View File

@@ -1,8 +1,12 @@
import { AuthProvider } from 'src/auth/helper';
import { AUTH_PROVIDER_NOT_CONFIGURED } from 'src/errors';
import {
AUTH_PROVIDER_NOT_CONFIGURED,
DATABASE_TABLE_NOT_EXIST,
} from 'src/errors';
import { PrismaService } from 'src/prisma/prisma.service';
import { InfraConfigEnum } from 'src/types/InfraConfig';
import { throwErr } from 'src/utils';
import { randomBytes } from 'crypto';
export enum ServiceStatus {
ENABLE = 'ENABLE',
@@ -13,14 +17,21 @@ const AuthProviderConfigurations = {
[AuthProvider.GOOGLE]: [
InfraConfigEnum.GOOGLE_CLIENT_ID,
InfraConfigEnum.GOOGLE_CLIENT_SECRET,
InfraConfigEnum.GOOGLE_CALLBACK_URL,
InfraConfigEnum.GOOGLE_SCOPE,
],
[AuthProvider.GITHUB]: [
InfraConfigEnum.GITHUB_CLIENT_ID,
InfraConfigEnum.GITHUB_CLIENT_SECRET,
InfraConfigEnum.GITHUB_CALLBACK_URL,
InfraConfigEnum.GITHUB_SCOPE,
],
[AuthProvider.MICROSOFT]: [
InfraConfigEnum.MICROSOFT_CLIENT_ID,
InfraConfigEnum.MICROSOFT_CLIENT_SECRET,
InfraConfigEnum.MICROSOFT_CALLBACK_URL,
InfraConfigEnum.MICROSOFT_SCOPE,
InfraConfigEnum.MICROSOFT_TENANT,
],
[AuthProvider.EMAIL]: [
InfraConfigEnum.MAILER_SMTP_URL,
@@ -53,6 +64,125 @@ export async function loadInfraConfiguration() {
}
}
/**
* Read the default values from .env file and return them as an array
* @returns Array of default infra configs
*/
export async function getDefaultInfraConfigs(): Promise<
{ name: InfraConfigEnum; value: string }[]
> {
const prisma = new PrismaService();
// Prepare rows for 'infra_config' table with default values (from .env) for each 'name'
const infraConfigDefaultObjs: { name: InfraConfigEnum; value: string }[] = [
{
name: InfraConfigEnum.MAILER_SMTP_URL,
value: process.env.MAILER_SMTP_URL,
},
{
name: InfraConfigEnum.MAILER_ADDRESS_FROM,
value: process.env.MAILER_ADDRESS_FROM,
},
{
name: InfraConfigEnum.GOOGLE_CLIENT_ID,
value: process.env.GOOGLE_CLIENT_ID,
},
{
name: InfraConfigEnum.GOOGLE_CLIENT_SECRET,
value: process.env.GOOGLE_CLIENT_SECRET,
},
{
name: InfraConfigEnum.GOOGLE_CALLBACK_URL,
value: process.env.GOOGLE_CALLBACK_URL,
},
{
name: InfraConfigEnum.GOOGLE_SCOPE,
value: process.env.GOOGLE_SCOPE,
},
{
name: InfraConfigEnum.GITHUB_CLIENT_ID,
value: process.env.GITHUB_CLIENT_ID,
},
{
name: InfraConfigEnum.GITHUB_CLIENT_SECRET,
value: process.env.GITHUB_CLIENT_SECRET,
},
{
name: InfraConfigEnum.GITHUB_CALLBACK_URL,
value: process.env.GITHUB_CALLBACK_URL,
},
{
name: InfraConfigEnum.GITHUB_SCOPE,
value: process.env.GITHUB_SCOPE,
},
{
name: InfraConfigEnum.MICROSOFT_CLIENT_ID,
value: process.env.MICROSOFT_CLIENT_ID,
},
{
name: InfraConfigEnum.MICROSOFT_CLIENT_SECRET,
value: process.env.MICROSOFT_CLIENT_SECRET,
},
{
name: InfraConfigEnum.MICROSOFT_CALLBACK_URL,
value: process.env.MICROSOFT_CALLBACK_URL,
},
{
name: InfraConfigEnum.MICROSOFT_SCOPE,
value: process.env.MICROSOFT_SCOPE,
},
{
name: InfraConfigEnum.MICROSOFT_TENANT,
value: process.env.MICROSOFT_TENANT,
},
{
name: InfraConfigEnum.VITE_ALLOWED_AUTH_PROVIDERS,
value: getConfiguredSSOProviders(),
},
{
name: InfraConfigEnum.ALLOW_ANALYTICS_COLLECTION,
value: false.toString(),
},
{
name: InfraConfigEnum.ANALYTICS_USER_ID,
value: generateAnalyticsUserId(),
},
{
name: InfraConfigEnum.IS_FIRST_TIME_INFRA_SETUP,
value: (await prisma.infraConfig.count()) === 0 ? 'true' : 'false',
},
];
return infraConfigDefaultObjs;
}
/**
* Verify if 'infra_config' table is loaded with all entries
* @returns boolean
*/
export async function isInfraConfigTablePopulated(): Promise<boolean> {
const prisma = new PrismaService();
try {
const dbInfraConfigs = await prisma.infraConfig.findMany();
const infraConfigDefaultObjs = await getDefaultInfraConfigs();
const propsRemainingToInsert = infraConfigDefaultObjs.filter(
(p) => !dbInfraConfigs.find((e) => e.name === p.name),
);
if (propsRemainingToInsert.length > 0) {
console.log(
'Infra Config table is not populated with all entries. Populating now...',
);
return false;
}
return true;
} catch (error) {
return false;
}
}
/**
* Stop the app after 5 seconds
* (Docker will re-start the app)
@@ -104,3 +234,12 @@ export function getConfiguredSSOProviders() {
return configuredAuthProviders.join(',');
}
/**
* Generate a hashed valued for analytics
* @returns Generated hashed value
*/
export function generateAnalyticsUserId() {
const hashedUserID = randomBytes(20).toString('hex');
return hashedUserID;
}

View File

@@ -0,0 +1,47 @@
import { Controller, Get, HttpStatus, Put, UseGuards } from '@nestjs/common';
import { ThrottlerBehindProxyGuard } from 'src/guards/throttler-behind-proxy.guard';
import { InfraConfigService } from './infra-config.service';
import * as E from 'fp-ts/Either';
import { JwtAuthGuard } from 'src/auth/guards/jwt-auth.guard';
import { RESTAdminGuard } from 'src/admin/guards/rest-admin.guard';
import { RESTError } from 'src/types/RESTError';
import { InfraConfigEnum } from 'src/types/InfraConfig';
import { throwHTTPErr } from 'src/utils';
@UseGuards(ThrottlerBehindProxyGuard)
@Controller({ path: 'site', version: '1' })
export class SiteController {
constructor(private infraConfigService: InfraConfigService) {}
@Get('setup')
@UseGuards(JwtAuthGuard, RESTAdminGuard)
async fetchSetupInfo() {
const status = await this.infraConfigService.get(
InfraConfigEnum.IS_FIRST_TIME_INFRA_SETUP,
);
if (E.isLeft(status))
throwHTTPErr(<RESTError>{
message: status.left,
statusCode: HttpStatus.NOT_FOUND,
});
return status.right;
}
@Put('setup')
@UseGuards(JwtAuthGuard, RESTAdminGuard)
async setSetupAsComplete() {
const res = await this.infraConfigService.update(
InfraConfigEnum.IS_FIRST_TIME_INFRA_SETUP,
false.toString(),
false,
);
if (E.isLeft(res))
throwHTTPErr(<RESTError>{
message: res.left,
statusCode: HttpStatus.FORBIDDEN,
});
return res.right;
}
}

View File

@@ -1,6 +1,6 @@
import { Field, ObjectType, registerEnumType } from '@nestjs/graphql';
import { AuthProvider } from 'src/auth/helper';
import { InfraConfigEnumForClient } from 'src/types/InfraConfig';
import { InfraConfigEnum } from 'src/types/InfraConfig';
import { ServiceStatus } from './helper';
@ObjectType()
@@ -8,7 +8,7 @@ export class InfraConfig {
@Field({
description: 'Infra Config Name',
})
name: InfraConfigEnumForClient;
name: InfraConfigEnum;
@Field({
description: 'Infra Config Value',
@@ -16,7 +16,7 @@ export class InfraConfig {
value: string;
}
registerEnumType(InfraConfigEnumForClient, {
registerEnumType(InfraConfigEnum, {
name: 'InfraConfigEnum',
});

View File

@@ -1,10 +1,12 @@
import { Module } from '@nestjs/common';
import { InfraConfigService } from './infra-config.service';
import { PrismaModule } from 'src/prisma/prisma.module';
import { SiteController } from './infra-config.controller';
@Module({
imports: [PrismaModule],
providers: [InfraConfigService],
exports: [InfraConfigService],
controllers: [SiteController],
})
export class InfraConfigModule {}

View File

@@ -1,13 +1,16 @@
import { mockDeep, mockReset } from 'jest-mock-extended';
import { PrismaService } from 'src/prisma/prisma.service';
import { InfraConfigService } from './infra-config.service';
import { InfraConfigEnum } from 'src/types/InfraConfig';
import {
InfraConfigEnum,
InfraConfigEnumForClient,
} from 'src/types/InfraConfig';
import { INFRA_CONFIG_NOT_FOUND, INFRA_CONFIG_UPDATE_FAILED } from 'src/errors';
INFRA_CONFIG_NOT_FOUND,
INFRA_CONFIG_OPERATION_NOT_ALLOWED,
INFRA_CONFIG_UPDATE_FAILED,
} from 'src/errors';
import { ConfigService } from '@nestjs/config';
import * as helper from './helper';
import { InfraConfig as dbInfraConfig } from '@prisma/client';
import { InfraConfig } from './infra-config.model';
const mockPrisma = mockDeep<PrismaService>();
const mockConfigService = mockDeep<ConfigService>();
@@ -19,12 +22,82 @@ const infraConfigService = new InfraConfigService(
mockConfigService,
);
const INITIALIZED_DATE_CONST = new Date();
const dbInfraConfigs: dbInfraConfig[] = [
{
id: '3',
name: InfraConfigEnum.GOOGLE_CLIENT_ID,
value: 'abcdefghijkl',
active: true,
createdOn: INITIALIZED_DATE_CONST,
updatedOn: INITIALIZED_DATE_CONST,
},
{
id: '4',
name: InfraConfigEnum.VITE_ALLOWED_AUTH_PROVIDERS,
value: 'google',
active: true,
createdOn: INITIALIZED_DATE_CONST,
updatedOn: INITIALIZED_DATE_CONST,
},
];
const infraConfigs: InfraConfig[] = [
{
name: dbInfraConfigs[0].name as InfraConfigEnum,
value: dbInfraConfigs[0].value,
},
{
name: dbInfraConfigs[1].name as InfraConfigEnum,
value: dbInfraConfigs[1].value,
},
];
beforeEach(() => {
mockReset(mockPrisma);
});
describe('InfraConfigService', () => {
describe('update', () => {
it('should update the infra config without backend server restart', async () => {
const name = InfraConfigEnum.GOOGLE_CLIENT_ID;
const value = 'true';
mockPrisma.infraConfig.update.mockResolvedValueOnce({
id: '',
name,
value,
active: true,
createdOn: new Date(),
updatedOn: new Date(),
});
jest.spyOn(helper, 'stopApp').mockReturnValueOnce();
const result = await infraConfigService.update(name, value);
expect(helper.stopApp).not.toHaveBeenCalled();
expect(result).toEqualRight({ name, value });
});
it('should update the infra config with backend server restart', async () => {
const name = InfraConfigEnum.GOOGLE_CLIENT_ID;
const value = 'true';
mockPrisma.infraConfig.update.mockResolvedValueOnce({
id: '',
name,
value,
active: true,
createdOn: new Date(),
updatedOn: new Date(),
});
jest.spyOn(helper, 'stopApp').mockReturnValueOnce();
const result = await infraConfigService.update(name, value, true);
expect(helper.stopApp).toHaveBeenCalledTimes(1);
expect(result).toEqualRight({ name, value });
});
it('should update the infra config', async () => {
const name = InfraConfigEnum.GOOGLE_CLIENT_ID;
const value = 'true';
@@ -71,7 +144,7 @@ describe('InfraConfigService', () => {
describe('get', () => {
it('should get the infra config', async () => {
const name = InfraConfigEnumForClient.GOOGLE_CLIENT_ID;
const name = InfraConfigEnum.GOOGLE_CLIENT_ID;
const value = 'true';
mockPrisma.infraConfig.findUniqueOrThrow.mockResolvedValueOnce({
@@ -87,7 +160,7 @@ describe('InfraConfigService', () => {
});
it('should pass correct params to prisma findUnique', async () => {
const name = InfraConfigEnumForClient.GOOGLE_CLIENT_ID;
const name = InfraConfigEnum.GOOGLE_CLIENT_ID;
await infraConfigService.get(name);
@@ -98,7 +171,7 @@ describe('InfraConfigService', () => {
});
it('should throw an error if the infra config does not exist', async () => {
const name = InfraConfigEnumForClient.GOOGLE_CLIENT_ID;
const name = InfraConfigEnum.GOOGLE_CLIENT_ID;
mockPrisma.infraConfig.findUniqueOrThrow.mockRejectedValueOnce('null');
@@ -106,4 +179,45 @@ describe('InfraConfigService', () => {
expect(result).toEqualLeft(INFRA_CONFIG_NOT_FOUND);
});
});
describe('getMany', () => {
it('should throw error if any disallowed names are provided', async () => {
const disallowedNames = [InfraConfigEnum.VITE_ALLOWED_AUTH_PROVIDERS];
const result = await infraConfigService.getMany(disallowedNames);
expect(result).toEqualLeft(INFRA_CONFIG_OPERATION_NOT_ALLOWED);
});
it('should resolve right with disallowed names if `checkDisallowed` parameter passed', async () => {
const disallowedNames = [InfraConfigEnum.VITE_ALLOWED_AUTH_PROVIDERS];
const dbInfraConfigResponses = dbInfraConfigs.filter((dbConfig) =>
disallowedNames.includes(dbConfig.name as InfraConfigEnum),
);
mockPrisma.infraConfig.findMany.mockResolvedValueOnce(
dbInfraConfigResponses,
);
const result = await infraConfigService.getMany(disallowedNames, false);
expect(result).toEqualRight(
infraConfigs.filter((i) => disallowedNames.includes(i.name)),
);
});
it('should return right with infraConfigs if Prisma query succeeds', async () => {
const allowedNames = [InfraConfigEnum.GOOGLE_CLIENT_ID];
const dbInfraConfigResponses = dbInfraConfigs.filter((dbConfig) =>
allowedNames.includes(dbConfig.name as InfraConfigEnum),
);
mockPrisma.infraConfig.findMany.mockResolvedValueOnce(
dbInfraConfigResponses,
);
const result = await infraConfigService.getMany(allowedNames);
expect(result).toEqualRight(
infraConfigs.filter((i) => allowedNames.includes(i.name)),
);
});
});
});

View File

@@ -3,23 +3,25 @@ import { InfraConfig } from './infra-config.model';
import { PrismaService } from 'src/prisma/prisma.service';
import { InfraConfig as DBInfraConfig } from '@prisma/client';
import * as E from 'fp-ts/Either';
import {
InfraConfigEnum,
InfraConfigEnumForClient,
} from 'src/types/InfraConfig';
import { InfraConfigEnum } from 'src/types/InfraConfig';
import {
AUTH_PROVIDER_NOT_SPECIFIED,
DATABASE_TABLE_NOT_EXIST,
INFRA_CONFIG_INVALID_INPUT,
INFRA_CONFIG_NOT_FOUND,
INFRA_CONFIG_NOT_LISTED,
INFRA_CONFIG_RESET_FAILED,
INFRA_CONFIG_UPDATE_FAILED,
INFRA_CONFIG_SERVICE_NOT_CONFIGURED,
INFRA_CONFIG_OPERATION_NOT_ALLOWED,
} from 'src/errors';
import { throwErr, validateSMTPEmail, validateSMTPUrl } from 'src/utils';
import {
throwErr,
validateSMTPEmail,
validateSMTPUrl,
validateUrl,
} from 'src/utils';
import { ConfigService } from '@nestjs/config';
import { ServiceStatus, getConfiguredSSOProviders, stopApp } from './helper';
import { ServiceStatus, getDefaultInfraConfigs, stopApp } from './helper';
import { EnableAndDisableSSOArgs, InfraConfigArgs } from './input-args';
import { AuthProvider } from 'src/auth/helper';
@@ -30,70 +32,32 @@ export class InfraConfigService implements OnModuleInit {
private readonly configService: ConfigService,
) {}
// Following fields are not updatable by `infraConfigs` Mutation. Use dedicated mutations for these fields instead.
EXCLUDE_FROM_UPDATE_CONFIGS = [
InfraConfigEnum.VITE_ALLOWED_AUTH_PROVIDERS,
InfraConfigEnum.ALLOW_ANALYTICS_COLLECTION,
InfraConfigEnum.ANALYTICS_USER_ID,
InfraConfigEnum.IS_FIRST_TIME_INFRA_SETUP,
];
// Following fields can not be fetched by `infraConfigs` Query. Use dedicated queries for these fields instead.
EXCLUDE_FROM_FETCH_CONFIGS = [
InfraConfigEnum.VITE_ALLOWED_AUTH_PROVIDERS,
InfraConfigEnum.ANALYTICS_USER_ID,
InfraConfigEnum.IS_FIRST_TIME_INFRA_SETUP,
];
async onModuleInit() {
await this.initializeInfraConfigTable();
}
getDefaultInfraConfigs(): { name: InfraConfigEnum; value: string }[] {
// Prepare rows for 'infra_config' table with default values (from .env) for each 'name'
const infraConfigDefaultObjs: { name: InfraConfigEnum; value: string }[] = [
{
name: InfraConfigEnum.MAILER_SMTP_URL,
value: process.env.MAILER_SMTP_URL,
},
{
name: InfraConfigEnum.MAILER_ADDRESS_FROM,
value: process.env.MAILER_ADDRESS_FROM,
},
{
name: InfraConfigEnum.GOOGLE_CLIENT_ID,
value: process.env.GOOGLE_CLIENT_ID,
},
{
name: InfraConfigEnum.GOOGLE_CLIENT_SECRET,
value: process.env.GOOGLE_CLIENT_SECRET,
},
{
name: InfraConfigEnum.GITHUB_CLIENT_ID,
value: process.env.GITHUB_CLIENT_ID,
},
{
name: InfraConfigEnum.GITHUB_CLIENT_SECRET,
value: process.env.GITHUB_CLIENT_SECRET,
},
{
name: InfraConfigEnum.MICROSOFT_CLIENT_ID,
value: process.env.MICROSOFT_CLIENT_ID,
},
{
name: InfraConfigEnum.MICROSOFT_CLIENT_SECRET,
value: process.env.MICROSOFT_CLIENT_SECRET,
},
{
name: InfraConfigEnum.VITE_ALLOWED_AUTH_PROVIDERS,
value: getConfiguredSSOProviders(),
},
];
return infraConfigDefaultObjs;
}
/**
* Initialize the 'infra_config' table with values from .env
* @description This function create rows 'infra_config' in very first time (only once)
*/
async initializeInfraConfigTable() {
try {
// Get all the 'names' of the properties to be saved in the 'infra_config' table
const enumValues = Object.values(InfraConfigEnum);
// Fetch the default values (value in .env) for configs to be saved in 'infra_config' table
const infraConfigDefaultObjs = this.getDefaultInfraConfigs();
// Check if all the 'names' are listed in the default values
if (enumValues.length !== infraConfigDefaultObjs.length) {
throw new Error(INFRA_CONFIG_NOT_LISTED);
}
const infraConfigDefaultObjs = await getDefaultInfraConfigs();
// Eliminate the rows (from 'infraConfigDefaultObjs') that are already present in the database table
const dbInfraConfigs = await this.prisma.infraConfig.findMany();
@@ -147,12 +111,10 @@ export class InfraConfigService implements OnModuleInit {
* Update InfraConfig by name
* @param name Name of the InfraConfig
* @param value Value of the InfraConfig
* @param restartEnabled If true, restart the app after updating the InfraConfig
* @returns InfraConfig model
*/
async update(
name: InfraConfigEnumForClient | InfraConfigEnum,
value: string,
) {
async update(name: InfraConfigEnum, value: string, restartEnabled = false) {
const isValidate = this.validateEnvValues([{ name, value }]);
if (E.isLeft(isValidate)) return E.left(isValidate.left);
@@ -162,7 +124,7 @@ export class InfraConfigService implements OnModuleInit {
data: { value },
});
stopApp();
if (restartEnabled) stopApp();
return E.right(this.cast(infraConfig));
} catch (e) {
@@ -176,6 +138,11 @@ export class InfraConfigService implements OnModuleInit {
* @returns InfraConfig model
*/
async updateMany(infraConfigs: InfraConfigArgs[]) {
for (let i = 0; i < infraConfigs.length; i++) {
if (this.EXCLUDE_FROM_UPDATE_CONFIGS.includes(infraConfigs[i].name))
return E.left(INFRA_CONFIG_OPERATION_NOT_ALLOWED);
}
const isValidate = this.validateEnvValues(infraConfigs);
if (E.isLeft(isValidate)) return E.left(isValidate.left);
@@ -209,12 +176,26 @@ export class InfraConfigService implements OnModuleInit {
) {
switch (service) {
case AuthProvider.GOOGLE:
return configMap.GOOGLE_CLIENT_ID && configMap.GOOGLE_CLIENT_SECRET;
return (
configMap.GOOGLE_CLIENT_ID &&
configMap.GOOGLE_CLIENT_SECRET &&
configMap.GOOGLE_CALLBACK_URL &&
configMap.GOOGLE_SCOPE
);
case AuthProvider.GITHUB:
return configMap.GITHUB_CLIENT_ID && configMap.GITHUB_CLIENT_SECRET;
return (
configMap.GITHUB_CLIENT_ID &&
configMap.GITHUB_CLIENT_SECRET &&
configMap.GITHUB_CALLBACK_URL &&
configMap.GITHUB_SCOPE
);
case AuthProvider.MICROSOFT:
return (
configMap.MICROSOFT_CLIENT_ID && configMap.MICROSOFT_CLIENT_SECRET
configMap.MICROSOFT_CLIENT_ID &&
configMap.MICROSOFT_CLIENT_SECRET &&
configMap.MICROSOFT_CALLBACK_URL &&
configMap.MICROSOFT_SCOPE &&
configMap.MICROSOFT_TENANT
);
case AuthProvider.EMAIL:
return configMap.MAILER_SMTP_URL && configMap.MAILER_ADDRESS_FROM;
@@ -223,6 +204,22 @@ export class InfraConfigService implements OnModuleInit {
}
}
/**
* Enable or Disable Analytics Collection
*
* @param status Status to enable or disable
* @returns Boolean of status of analytics collection
*/
async toggleAnalyticsCollection(status: ServiceStatus) {
const isUpdated = await this.update(
InfraConfigEnum.ALLOW_ANALYTICS_COLLECTION,
status === ServiceStatus.ENABLE ? 'true' : 'false',
);
if (E.isLeft(isUpdated)) return E.left(isUpdated.left);
return E.right(isUpdated.right.value === 'true');
}
/**
* Enable or Disable SSO for login/signup
* @param provider Auth Provider to enable or disable
@@ -261,6 +258,7 @@ export class InfraConfigService implements OnModuleInit {
const isUpdated = await this.update(
InfraConfigEnum.VITE_ALLOWED_AUTH_PROVIDERS,
updatedAuthProviders.join(','),
true,
);
if (E.isLeft(isUpdated)) return E.left(isUpdated.left);
@@ -272,7 +270,7 @@ export class InfraConfigService implements OnModuleInit {
* @param name Name of the InfraConfig
* @returns InfraConfig model
*/
async get(name: InfraConfigEnumForClient) {
async get(name: InfraConfigEnum) {
try {
const infraConfig = await this.prisma.infraConfig.findUniqueOrThrow({
where: { name },
@@ -289,7 +287,15 @@ export class InfraConfigService implements OnModuleInit {
* @param names Names of the InfraConfigs
* @returns InfraConfig model
*/
async getMany(names: InfraConfigEnumForClient[]) {
async getMany(names: InfraConfigEnum[], checkDisallowedKeys: boolean = true) {
if (checkDisallowedKeys) {
// Check if the names are allowed to fetch by client
for (let i = 0; i < names.length; i++) {
if (this.EXCLUDE_FROM_FETCH_CONFIGS.includes(names[i]))
return E.left(INFRA_CONFIG_OPERATION_NOT_ALLOWED);
}
}
try {
const infraConfigs = await this.prisma.infraConfig.findMany({
where: { name: { in: names } },
@@ -316,13 +322,24 @@ export class InfraConfigService implements OnModuleInit {
*/
async reset() {
try {
const infraConfigDefaultObjs = this.getDefaultInfraConfigs();
const infraConfigDefaultObjs = await getDefaultInfraConfigs();
await this.prisma.infraConfig.deleteMany({
where: { name: { in: infraConfigDefaultObjs.map((p) => p.name) } },
});
// Hardcode t
const updatedInfraConfigDefaultObjs = infraConfigDefaultObjs.filter(
(obj) => obj.name !== InfraConfigEnum.IS_FIRST_TIME_INFRA_SETUP,
);
await this.prisma.infraConfig.createMany({
data: infraConfigDefaultObjs,
data: [
...updatedInfraConfigDefaultObjs,
{
name: InfraConfigEnum.IS_FIRST_TIME_INFRA_SETUP,
value: 'true',
},
],
});
stopApp();
@@ -338,36 +355,60 @@ export class InfraConfigService implements OnModuleInit {
*/
validateEnvValues(
infraConfigs: {
name: InfraConfigEnumForClient | InfraConfigEnum;
name: InfraConfigEnum;
value: string;
}[],
) {
for (let i = 0; i < infraConfigs.length; i++) {
switch (infraConfigs[i].name) {
case InfraConfigEnumForClient.MAILER_SMTP_URL:
case InfraConfigEnum.MAILER_SMTP_URL:
const isValidUrl = validateSMTPUrl(infraConfigs[i].value);
if (!isValidUrl) return E.left(INFRA_CONFIG_INVALID_INPUT);
break;
case InfraConfigEnumForClient.MAILER_ADDRESS_FROM:
case InfraConfigEnum.MAILER_ADDRESS_FROM:
const isValidEmail = validateSMTPEmail(infraConfigs[i].value);
if (!isValidEmail) return E.left(INFRA_CONFIG_INVALID_INPUT);
break;
case InfraConfigEnumForClient.GOOGLE_CLIENT_ID:
case InfraConfigEnum.GOOGLE_CLIENT_ID:
if (!infraConfigs[i].value) return E.left(INFRA_CONFIG_INVALID_INPUT);
break;
case InfraConfigEnumForClient.GOOGLE_CLIENT_SECRET:
case InfraConfigEnum.GOOGLE_CLIENT_SECRET:
if (!infraConfigs[i].value) return E.left(INFRA_CONFIG_INVALID_INPUT);
break;
case InfraConfigEnumForClient.GITHUB_CLIENT_ID:
case InfraConfigEnum.GOOGLE_CALLBACK_URL:
if (!validateUrl(infraConfigs[i].value))
return E.left(INFRA_CONFIG_INVALID_INPUT);
break;
case InfraConfigEnum.GOOGLE_SCOPE:
if (!infraConfigs[i].value) return E.left(INFRA_CONFIG_INVALID_INPUT);
break;
case InfraConfigEnumForClient.GITHUB_CLIENT_SECRET:
case InfraConfigEnum.GITHUB_CLIENT_ID:
if (!infraConfigs[i].value) return E.left(INFRA_CONFIG_INVALID_INPUT);
break;
case InfraConfigEnumForClient.MICROSOFT_CLIENT_ID:
case InfraConfigEnum.GITHUB_CLIENT_SECRET:
if (!infraConfigs[i].value) return E.left(INFRA_CONFIG_INVALID_INPUT);
break;
case InfraConfigEnumForClient.MICROSOFT_CLIENT_SECRET:
case InfraConfigEnum.GITHUB_CALLBACK_URL:
if (!validateUrl(infraConfigs[i].value))
return E.left(INFRA_CONFIG_INVALID_INPUT);
break;
case InfraConfigEnum.GITHUB_SCOPE:
if (!infraConfigs[i].value) return E.left(INFRA_CONFIG_INVALID_INPUT);
break;
case InfraConfigEnum.MICROSOFT_CLIENT_ID:
if (!infraConfigs[i].value) return E.left(INFRA_CONFIG_INVALID_INPUT);
break;
case InfraConfigEnum.MICROSOFT_CLIENT_SECRET:
if (!infraConfigs[i].value) return E.left(INFRA_CONFIG_INVALID_INPUT);
break;
case InfraConfigEnum.MICROSOFT_CALLBACK_URL:
if (!validateUrl(infraConfigs[i].value))
return E.left(INFRA_CONFIG_INVALID_INPUT);
break;
case InfraConfigEnum.MICROSOFT_SCOPE:
if (!infraConfigs[i].value) return E.left(INFRA_CONFIG_INVALID_INPUT);
break;
case InfraConfigEnum.MICROSOFT_TENANT:
if (!infraConfigs[i].value) return E.left(INFRA_CONFIG_INVALID_INPUT);
break;
default:

View File

@@ -1,14 +1,14 @@
import { Field, InputType } from '@nestjs/graphql';
import { InfraConfigEnumForClient } from 'src/types/InfraConfig';
import { InfraConfigEnum } from 'src/types/InfraConfig';
import { ServiceStatus } from './helper';
import { AuthProvider } from 'src/auth/helper';
@InputType()
export class InfraConfigArgs {
@Field(() => InfraConfigEnumForClient, {
@Field(() => InfraConfigEnum, {
description: 'Infra Config Name',
})
name: InfraConfigEnumForClient;
name: InfraConfigEnum;
@Field({
description: 'Infra Config Value',

View File

@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { PosthogService } from './posthog.service';
import { PrismaModule } from 'src/prisma/prisma.module';
@Module({
imports: [PrismaModule],
providers: [PosthogService],
})
export class PosthogModule {}

View File

@@ -0,0 +1,58 @@
import { Injectable } from '@nestjs/common';
import { PostHog } from 'posthog-node';
import { Cron, CronExpression, SchedulerRegistry } from '@nestjs/schedule';
import { ConfigService } from '@nestjs/config';
import { PrismaService } from 'src/prisma/prisma.service';
import { CronJob } from 'cron';
import { POSTHOG_CLIENT_NOT_INITIALIZED } from 'src/errors';
import { throwErr } from 'src/utils';
@Injectable()
export class PosthogService {
private postHogClient: PostHog;
private POSTHOG_API_KEY = 'phc_9CipPajQC22mSkk2wxe2TXsUA0Ysyupe8dt5KQQELqx';
constructor(
private readonly configService: ConfigService,
private readonly prismaService: PrismaService,
private schedulerRegistry: SchedulerRegistry,
) {}
async onModuleInit() {
if (this.configService.get('INFRA.ALLOW_ANALYTICS_COLLECTION') === 'true') {
console.log('Initializing PostHog');
this.postHogClient = new PostHog(this.POSTHOG_API_KEY, {
host: 'https://eu.posthog.com',
});
// Schedule the cron job only if analytics collection is allowed
this.scheduleCronJob();
}
}
private scheduleCronJob() {
const job = new CronJob(CronExpression.EVERY_WEEK, async () => {
await this.capture();
});
this.schedulerRegistry.addCronJob('captureAnalytics', job);
job.start();
}
async capture() {
if (!this.postHogClient) {
throwErr(POSTHOG_CLIENT_NOT_INITIALIZED);
}
this.postHogClient.capture({
distinctId: this.configService.get('INFRA.ANALYTICS_USER_ID'),
event: 'sh_instance',
properties: {
type: 'COMMUNITY',
total_user_count: await this.prismaService.user.count(),
total_workspace_count: await this.prismaService.team.count(),
version: this.configService.get('npm_package_version'),
},
});
console.log('Sent event to PostHog');
}
}

View File

@@ -0,0 +1,14 @@
// Type of data returned from the query to obtain all search results
export type SearchQueryReturnType = {
id: string;
title: string;
type: 'collection' | 'request';
method?: string;
};
// Type of data returned from the query to obtain all parents
export type ParentTreeQueryReturnType = {
id: string;
parentID: string;
title: string;
};

View File

@@ -0,0 +1,38 @@
import { Controller, Get, Param, Query, UseGuards } from '@nestjs/common';
import { TeamCollectionService } from './team-collection.service';
import * as E from 'fp-ts/Either';
import { ThrottlerBehindProxyGuard } from 'src/guards/throttler-behind-proxy.guard';
import { JwtAuthGuard } from 'src/auth/guards/jwt-auth.guard';
import { RequiresTeamRole } from 'src/team/decorators/requires-team-role.decorator';
import { TeamMemberRole } from '@prisma/client';
import { RESTTeamMemberGuard } from 'src/team/guards/rest-team-member.guard';
import { throwHTTPErr } from 'src/utils';
@UseGuards(ThrottlerBehindProxyGuard)
@Controller({ path: 'team-collection', version: '1' })
export class TeamCollectionController {
constructor(private readonly teamCollectionService: TeamCollectionService) {}
@Get('search/:teamID/:searchQuery')
@RequiresTeamRole(
TeamMemberRole.VIEWER,
TeamMemberRole.EDITOR,
TeamMemberRole.OWNER,
)
@UseGuards(JwtAuthGuard, RESTTeamMemberGuard)
async searchByTitle(
@Param('searchQuery') searchQuery: string,
@Param('teamID') teamID: string,
@Query('take') take: string,
@Query('skip') skip: string,
) {
const res = await this.teamCollectionService.searchByTitle(
searchQuery,
teamID,
parseInt(take),
parseInt(skip),
);
if (E.isLeft(res)) throwHTTPErr(res.left);
return res.right;
}
}

View File

@@ -6,6 +6,7 @@ import { GqlCollectionTeamMemberGuard } from './guards/gql-collection-team-membe
import { TeamModule } from '../team/team.module';
import { UserModule } from '../user/user.module';
import { PubSubModule } from '../pubsub/pubsub.module';
import { TeamCollectionController } from './team-collection.controller';
@Module({
imports: [PrismaModule, TeamModule, UserModule, PubSubModule],
@@ -15,5 +16,6 @@ import { PubSubModule } from '../pubsub/pubsub.module';
GqlCollectionTeamMemberGuard,
],
exports: [TeamCollectionService, GqlCollectionTeamMemberGuard],
controllers: [TeamCollectionController],
})
export class TeamCollectionModule {}

View File

@@ -1,4 +1,4 @@
import { Injectable } from '@nestjs/common';
import { HttpStatus, Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { TeamCollection } from './team-collection.model';
import {
@@ -14,6 +14,10 @@ import {
TEAM_COL_SAME_NEXT_COLL,
TEAM_COL_REORDERING_FAILED,
TEAM_COLL_DATA_INVALID,
TEAM_REQ_SEARCH_FAILED,
TEAM_COL_SEARCH_FAILED,
TEAM_REQ_PARENT_TREE_GEN_FAILED,
TEAM_COLL_PARENT_TREE_GEN_FAILED,
} from '../errors';
import { PubSubService } from '../pubsub/pubsub.service';
import { isValidLength } from 'src/utils';
@@ -22,6 +26,9 @@ import * as O from 'fp-ts/Option';
import { Prisma, TeamCollection as DBTeamCollection } from '@prisma/client';
import { CollectionFolder } from 'src/types/CollectionFolder';
import { stringToJson } from 'src/utils';
import { CollectionSearchNode } from 'src/types/CollectionSearchNode';
import { ParentTreeQueryReturnType, SearchQueryReturnType } from './helper';
import { RESTError } from 'src/types/RESTError';
@Injectable()
export class TeamCollectionService {
@@ -1056,4 +1063,266 @@ export class TeamCollectionService {
return E.left(TEAM_COLL_NOT_FOUND);
}
}
/**
* Search for TeamCollections and TeamRequests by title
*
* @param searchQuery The search query
* @param teamID The Team ID
* @param take Number of items we want returned
* @param skip Number of items we want to skip
* @returns An Either of the search results
*/
async searchByTitle(
searchQuery: string,
teamID: string,
take = 10,
skip = 0,
) {
// Fetch all collections and requests that match the search query
const searchResults: SearchQueryReturnType[] = [];
const matchedCollections = await this.searchCollections(
searchQuery,
teamID,
take,
skip,
);
if (E.isLeft(matchedCollections))
return E.left(<RESTError>{
message: matchedCollections.left,
statusCode: HttpStatus.NOT_FOUND,
});
searchResults.push(...matchedCollections.right);
const matchedRequests = await this.searchRequests(
searchQuery,
teamID,
take,
skip,
);
if (E.isLeft(matchedRequests))
return E.left(<RESTError>{
message: matchedRequests.left,
statusCode: HttpStatus.NOT_FOUND,
});
searchResults.push(...matchedRequests.right);
// Generate the parent tree for searchResults
const searchResultsWithTree: CollectionSearchNode[] = [];
for (let i = 0; i < searchResults.length; i++) {
const fetchedParentTree = await this.fetchParentTree(searchResults[i]);
if (E.isLeft(fetchedParentTree))
return E.left(<RESTError>{
message: fetchedParentTree.left,
statusCode: HttpStatus.NOT_FOUND,
});
searchResultsWithTree.push({
type: searchResults[i].type,
title: searchResults[i].title,
method: searchResults[i].method,
id: searchResults[i].id,
path: !fetchedParentTree
? []
: ([fetchedParentTree.right] as CollectionSearchNode[]),
});
}
return E.right({ data: searchResultsWithTree });
}
/**
* Search for TeamCollections by title
*
* @param searchQuery The search query
* @param teamID The Team ID
* @param take Number of items we want returned
* @param skip Number of items we want to skip
* @returns An Either of the search results
*/
private async searchCollections(
searchQuery: string,
teamID: string,
take: number,
skip: number,
) {
const query = Prisma.sql`
select id,title,'collection' AS type
from "TeamCollection"
where "TeamCollection"."teamID"=${teamID}
and titlesearch @@ to_tsquery(${searchQuery})
order by ts_rank(titlesearch,to_tsquery(${searchQuery}))
limit ${take}
OFFSET ${skip === 0 ? 0 : (skip - 1) * take};
`;
try {
const res = await this.prisma.$queryRaw<SearchQueryReturnType[]>(query);
return E.right(res);
} catch (error) {
return E.left(TEAM_COL_SEARCH_FAILED);
}
}
/**
* Search for TeamRequests by title
*
* @param searchQuery The search query
* @param teamID The Team ID
* @param take Number of items we want returned
* @param skip Number of items we want to skip
* @returns An Either of the search results
*/
private async searchRequests(
searchQuery: string,
teamID: string,
take: number,
skip: number,
) {
const query = Prisma.sql`
select id,title,request->>'method' as method,'request' AS type
from "TeamRequest"
where "TeamRequest"."teamID"=${teamID}
and titlesearch @@ to_tsquery(${searchQuery})
order by ts_rank(titlesearch,to_tsquery(${searchQuery}))
limit ${take}
OFFSET ${skip === 0 ? 0 : (skip - 1) * take};
`;
try {
const res = await this.prisma.$queryRaw<SearchQueryReturnType[]>(query);
return E.right(res);
} catch (error) {
return E.left(TEAM_REQ_SEARCH_FAILED);
}
}
/**
* Generate the parent tree of a search result
*
* @param searchResult The search result for which we want to generate the parent tree
* @returns The parent tree of the search result
*/
private async fetchParentTree(searchResult: SearchQueryReturnType) {
return searchResult.type === 'collection'
? await this.fetchCollectionParentTree(searchResult.id)
: await this.fetchRequestParentTree(searchResult.id);
}
/**
* Generate the parent tree of a collection
*
* @param id The ID of the collection
* @returns The parent tree of the collection
*/
private async fetchCollectionParentTree(id: string) {
try {
const query = Prisma.sql`
WITH RECURSIVE collection_tree AS (
SELECT tc.id, tc."parentID", tc.title
FROM "TeamCollection" AS tc
JOIN "TeamCollection" AS tr ON tc.id = tr."parentID"
WHERE tr.id = ${id}
UNION ALL
SELECT parent.id, parent."parentID", parent.title
FROM "TeamCollection" AS parent
JOIN collection_tree AS ct ON parent.id = ct."parentID"
)
SELECT * FROM collection_tree;
`;
const res = await this.prisma.$queryRaw<ParentTreeQueryReturnType[]>(
query,
);
const collectionParentTree = this.generateParentTree(res);
return E.right(collectionParentTree);
} catch (error) {
E.left(TEAM_COLL_PARENT_TREE_GEN_FAILED);
}
}
/**
* Generate the parent tree from the collections
*
* @param parentCollections The parent collections
* @returns The parent tree of the parent collections
*/
private generateParentTree(parentCollections: ParentTreeQueryReturnType[]) {
function findChildren(id) {
const collection = parentCollections.filter((item) => item.id === id)[0];
if (collection.parentID == null) {
return {
id: collection.id,
title: collection.title,
type: 'collection',
path: [],
};
}
const res = {
id: collection.id,
title: collection.title,
type: 'collection',
path: findChildren(collection.parentID),
};
return res;
}
if (parentCollections.length > 0) {
if (parentCollections[0].parentID == null) {
return {
id: parentCollections[0].id,
title: parentCollections[0].title,
type: 'collection',
path: [],
};
}
return {
id: parentCollections[0].id,
title: parentCollections[0].title,
type: 'collection',
path: findChildren(parentCollections[0].parentID),
};
}
return null;
}
/**
* Generate the parent tree of a request
*
* @param id The ID of the request
* @returns The parent tree of the request
*/
private async fetchRequestParentTree(id: string) {
try {
const query = Prisma.sql`
WITH RECURSIVE request_collection_tree AS (
SELECT tc.id, tc."parentID", tc.title
FROM "TeamCollection" AS tc
JOIN "TeamRequest" AS tr ON tc.id = tr."collectionID"
WHERE tr.id = ${id}
UNION ALL
SELECT parent.id, parent."parentID", parent.title
FROM "TeamCollection" AS parent
JOIN request_collection_tree AS ct ON parent.id = ct."parentID"
)
SELECT * FROM request_collection_tree;
`;
const res = await this.prisma.$queryRaw<ParentTreeQueryReturnType[]>(
query,
);
const requestParentTree = this.generateParentTree(res);
return E.right(requestParentTree);
} catch (error) {
return E.left(TEAM_REQ_PARENT_TREE_GEN_FAILED);
}
}
}

View File

@@ -0,0 +1,47 @@
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { TeamService } from '../../team/team.service';
import { TeamMemberRole } from '../../team/team.model';
import {
BUG_TEAM_NO_REQUIRE_TEAM_ROLE,
BUG_AUTH_NO_USER_CTX,
BUG_TEAM_NO_TEAM_ID,
TEAM_MEMBER_NOT_FOUND,
TEAM_NOT_REQUIRED_ROLE,
} from 'src/errors';
import { throwHTTPErr } from 'src/utils';
@Injectable()
export class RESTTeamMemberGuard implements CanActivate {
constructor(
private readonly reflector: Reflector,
private readonly teamService: TeamService,
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const requireRoles = this.reflector.get<TeamMemberRole[]>(
'requiresTeamRole',
context.getHandler(),
);
if (!requireRoles)
throwHTTPErr({ message: BUG_TEAM_NO_REQUIRE_TEAM_ROLE, statusCode: 400 });
const request = context.switchToHttp().getRequest();
const { user } = request;
if (user == undefined)
throwHTTPErr({ message: BUG_AUTH_NO_USER_CTX, statusCode: 400 });
const teamID = request.params.teamID;
if (!teamID)
throwHTTPErr({ message: BUG_TEAM_NO_TEAM_ID, statusCode: 400 });
const teamMember = await this.teamService.getTeamMember(teamID, user.uid);
if (!teamMember)
throwHTTPErr({ message: TEAM_MEMBER_NOT_FOUND, statusCode: 404 });
if (requireRoles.includes(teamMember.role)) return true;
throwHTTPErr({ message: TEAM_NOT_REQUIRED_ROLE, statusCode: 403 });
}
}

View File

@@ -0,0 +1,17 @@
// Response type of results from the search query
export type CollectionSearchNode = {
/** Encodes the hierarchy of where the node is **/
path: CollectionSearchNode[];
} & (
| {
type: 'request';
title: string;
method: string;
id: string;
}
| {
type: 'collection';
title: string;
id: string;
}
);

View File

@@ -4,26 +4,23 @@ export enum InfraConfigEnum {
GOOGLE_CLIENT_ID = 'GOOGLE_CLIENT_ID',
GOOGLE_CLIENT_SECRET = 'GOOGLE_CLIENT_SECRET',
GOOGLE_CALLBACK_URL = 'GOOGLE_CALLBACK_URL',
GOOGLE_SCOPE = 'GOOGLE_SCOPE',
GITHUB_CLIENT_ID = 'GITHUB_CLIENT_ID',
GITHUB_CLIENT_SECRET = 'GITHUB_CLIENT_SECRET',
GITHUB_CALLBACK_URL = 'GITHUB_CALLBACK_URL',
GITHUB_SCOPE = 'GITHUB_SCOPE',
MICROSOFT_CLIENT_ID = 'MICROSOFT_CLIENT_ID',
MICROSOFT_CLIENT_SECRET = 'MICROSOFT_CLIENT_SECRET',
MICROSOFT_CALLBACK_URL = 'MICROSOFT_CALLBACK_URL',
MICROSOFT_SCOPE = 'MICROSOFT_SCOPE',
MICROSOFT_TENANT = 'MICROSOFT_TENANT',
VITE_ALLOWED_AUTH_PROVIDERS = 'VITE_ALLOWED_AUTH_PROVIDERS',
}
export enum InfraConfigEnumForClient {
MAILER_SMTP_URL = 'MAILER_SMTP_URL',
MAILER_ADDRESS_FROM = 'MAILER_ADDRESS_FROM',
GOOGLE_CLIENT_ID = 'GOOGLE_CLIENT_ID',
GOOGLE_CLIENT_SECRET = 'GOOGLE_CLIENT_SECRET',
GITHUB_CLIENT_ID = 'GITHUB_CLIENT_ID',
GITHUB_CLIENT_SECRET = 'GITHUB_CLIENT_SECRET',
MICROSOFT_CLIENT_ID = 'MICROSOFT_CLIENT_ID',
MICROSOFT_CLIENT_SECRET = 'MICROSOFT_CLIENT_SECRET',
ALLOW_ANALYTICS_COLLECTION = 'ALLOW_ANALYTICS_COLLECTION',
ANALYTICS_USER_ID = 'ANALYTICS_USER_ID',
IS_FIRST_TIME_INFRA_SETUP = 'IS_FIRST_TIME_INFRA_SETUP',
}

View File

@@ -1,10 +1,10 @@
import { HttpStatus } from '@nestjs/common';
/**
** Custom interface to handle errors specific to Auth module
** Custom interface to handle errors for REST modules such as Auth, Admin modules
** Since its REST we need to return the HTTP status code along with the error message
*/
export type AuthError = {
export type RESTError = {
message: string;
statusCode: HttpStatus;
};

View File

@@ -17,3 +17,21 @@ export class PaginationArgs {
})
take: number;
}
@ArgsType()
@InputType()
export class OffsetPaginationArgs {
@Field({
nullable: true,
defaultValue: 0,
description: 'Number of items to skip',
})
skip: number;
@Field({
nullable: true,
defaultValue: 10,
description: 'Number of items to fetch',
})
take: number;
}

View File

@@ -56,3 +56,22 @@ export enum SessionType {
registerEnumType(SessionType, {
name: 'SessionType',
});
@ObjectType()
export class UserDeletionResult {
@Field(() => ID, {
description: 'UID of the user',
})
userUID: string;
@Field(() => Boolean, {
description: 'Flag to determine if user deletion was successful or not',
})
isDeleted: Boolean;
@Field({
nullable: true,
description: 'Error message if user deletion was not successful',
})
errorMessage: String;
}

View File

@@ -1,4 +1,4 @@
import { JSON_INVALID, USER_NOT_FOUND } from 'src/errors';
import { JSON_INVALID, USERS_NOT_FOUND, USER_NOT_FOUND } from 'src/errors';
import { mockDeep, mockReset } from 'jest-mock-extended';
import { PrismaService } from 'src/prisma/prisma.service';
import { AuthUser } from 'src/types/AuthUser';
@@ -176,6 +176,26 @@ describe('UserService', () => {
});
});
describe('findUsersByIds', () => {
test('should successfully return users given valid user UIDs', async () => {
mockPrisma.user.findMany.mockResolvedValueOnce(users);
const result = await userService.findUsersByIds([
'123344',
'5555',
'6666',
]);
expect(result).toEqual(users);
});
test('should return empty array of users given a invalid user UIDs', async () => {
mockPrisma.user.findMany.mockResolvedValueOnce([]);
const result = await userService.findUsersByIds(['sdcvbdbr']);
expect(result).toEqual([]);
});
});
describe('createUserViaMagicLink', () => {
test('should successfully create user and account for magic-link given valid inputs', async () => {
mockPrisma.user.create.mockResolvedValueOnce(user);
@@ -414,6 +434,54 @@ describe('UserService', () => {
});
});
describe('updateUserDisplayName', () => {
test('should resolve right and update user display name', async () => {
const newDisplayName = 'New Name';
mockPrisma.user.update.mockResolvedValueOnce({
...user,
displayName: newDisplayName,
});
const result = await userService.updateUserDisplayName(
user.uid,
newDisplayName,
);
expect(result).toEqualRight({
...user,
displayName: newDisplayName,
currentGQLSession: JSON.stringify(user.currentGQLSession),
currentRESTSession: JSON.stringify(user.currentRESTSession),
});
});
test('should resolve right and publish user updated subscription', async () => {
const newDisplayName = 'New Name';
mockPrisma.user.update.mockResolvedValueOnce({
...user,
displayName: newDisplayName,
});
await userService.updateUserDisplayName(user.uid, user.displayName);
expect(mockPubSub.publish).toHaveBeenCalledWith(
`user/${user.uid}/updated`,
{
...user,
displayName: newDisplayName,
currentGQLSession: JSON.stringify(user.currentGQLSession),
currentRESTSession: JSON.stringify(user.currentRESTSession),
},
);
});
test('should resolve left and error when invalid user uid is passed', async () => {
mockPrisma.user.update.mockRejectedValueOnce('NotFoundError');
const result = await userService.updateUserDisplayName(
'invalidUserUid',
user.displayName,
);
expect(result).toEqualLeft(USER_NOT_FOUND);
});
});
describe('fetchAllUsers', () => {
test('should resolve right and return 20 users when cursor is null', async () => {
mockPrisma.user.findMany.mockResolvedValueOnce(users);
@@ -435,6 +503,36 @@ describe('UserService', () => {
});
});
describe('fetchAllUsersV2', () => {
test('should resolve right and return first 20 users when searchString is null', async () => {
mockPrisma.user.findMany.mockResolvedValueOnce(users);
const result = await userService.fetchAllUsersV2(null, {
take: 20,
skip: 0,
});
expect(result).toEqual(users);
});
test('should resolve right and return next 20 users when searchString is provided', async () => {
mockPrisma.user.findMany.mockResolvedValueOnce(users);
const result = await userService.fetchAllUsersV2('.com', {
take: 20,
skip: 0,
});
expect(result).toEqual(users);
});
test('should resolve left and return an empty array when users not found', async () => {
mockPrisma.user.findMany.mockResolvedValueOnce([]);
const result = await userService.fetchAllUsersV2('Unknown entry', {
take: 20,
skip: 0,
});
expect(result).toEqual([]);
});
});
describe('fetchAdminUsers', () => {
test('should return a list of admin users', async () => {
mockPrisma.user.findMany.mockResolvedValueOnce(adminUsers);
@@ -556,4 +654,17 @@ describe('UserService', () => {
expect(result).toEqual(10);
});
});
describe('removeUsersAsAdmin', () => {
test('should resolve right and return true for valid user UIDs', async () => {
mockPrisma.user.updateMany.mockResolvedValueOnce({ count: 1 });
const result = await userService.removeUsersAsAdmin(['123344']);
expect(result).toEqualRight(true);
});
test('should resolve right and return false for invalid user UIDs', async () => {
mockPrisma.user.updateMany.mockResolvedValueOnce({ count: 0 });
const result = await userService.removeUsersAsAdmin(['123344']);
expect(result).toEqualLeft(USERS_NOT_FOUND);
});
});
});

View File

@@ -8,13 +8,14 @@ import * as T from 'fp-ts/Task';
import * as A from 'fp-ts/Array';
import { pipe, constVoid } from 'fp-ts/function';
import { AuthUser } from 'src/types/AuthUser';
import { USER_NOT_FOUND } from 'src/errors';
import { USERS_NOT_FOUND, USER_NOT_FOUND } from 'src/errors';
import { SessionType, User } from './user.model';
import { USER_UPDATE_FAILED } from 'src/errors';
import { PubSubService } from 'src/pubsub/pubsub.service';
import { stringToJson, taskEitherValidateArraySeq } from 'src/utils';
import { UserDataHandler } from './user.data.handler';
import { User as DbUser } from '@prisma/client';
import { OffsetPaginationArgs } from 'src/types/input-types.args';
@Injectable()
export class UserService {
@@ -88,6 +89,20 @@ export class UserService {
}
}
/**
* Find users with given IDs
* @param userUIDs User IDs
* @returns Array of found Users
*/
async findUsersByIds(userUIDs: string[]): Promise<AuthUser[]> {
const users = await this.prisma.user.findMany({
where: {
uid: { in: userUIDs },
},
});
return users;
}
/**
* Update User with new generated hashed refresh token
*
@@ -269,6 +284,30 @@ export class UserService {
}
}
/**
* Update a user's data
* @param userUID User UID
* @param displayName User's displayName
* @returns a Either of User or error
*/
async updateUserDisplayName(userUID: string, displayName: string) {
try {
const dbUpdatedUser = await this.prisma.user.update({
where: { uid: userUID },
data: { displayName },
});
const updatedUser = this.convertDbUserToUser(dbUpdatedUser);
// Publish subscription for user updates
await this.pubsub.publish(`user/${updatedUser.uid}/updated`, updatedUser);
return E.right(updatedUser);
} catch (error) {
return E.left(USER_NOT_FOUND);
}
}
/**
* Validate and parse currentRESTSession and currentGQLSession
* @param sessionData string of the session
@@ -286,6 +325,7 @@ export class UserService {
* @param cursorID string of userUID or null
* @param take number of users to query
* @returns an array of `User` object
* @deprecated use fetchAllUsersV2 instead
*/
async fetchAllUsers(cursorID: string, take: number) {
const fetchedUsers = await this.prisma.user.findMany({
@@ -296,6 +336,43 @@ export class UserService {
return fetchedUsers;
}
/**
* Fetch all the users in the `User` table based on cursor
* @param searchString search on user's displayName or email
* @param paginationOption pagination options
* @returns an array of `User` object
*/
async fetchAllUsersV2(
searchString: string,
paginationOption: OffsetPaginationArgs,
) {
const fetchedUsers = await this.prisma.user.findMany({
skip: paginationOption.skip,
take: paginationOption.take,
where: searchString
? {
OR: [
{
displayName: {
contains: searchString,
mode: 'insensitive',
},
},
{
email: {
contains: searchString,
mode: 'insensitive',
},
},
],
}
: undefined,
orderBy: [{ isAdmin: 'desc' }, { displayName: 'asc' }],
});
return fetchedUsers;
}
/**
* Fetch the number of users in db
* @returns a count (Int) of user records in DB
@@ -326,6 +403,23 @@ export class UserService {
}
}
/**
* Change users to admins by toggling isAdmin param to true
* @param userUID user UIDs
* @returns a Either of true or error
*/
async makeAdmins(userUIDs: string[]) {
try {
await this.prisma.user.updateMany({
where: { uid: { in: userUIDs } },
data: { isAdmin: true },
});
return E.right(true);
} catch (error) {
return E.left(USER_UPDATE_FAILED);
}
}
/**
* Fetch all the admin users
* @returns an array of admin users
@@ -444,4 +538,22 @@ export class UserService {
return E.left(USER_NOT_FOUND);
}
}
/**
* Change users from an admin by toggling isAdmin param to false
* @param userUIDs user UIDs
* @returns a Either of true or error
*/
async removeUsersAsAdmin(userUIDs: string[]) {
const data = await this.prisma.user.updateMany({
where: { uid: { in: userUIDs } },
data: { isAdmin: false },
});
if (data.count === 0) {
return E.left(USERS_NOT_FOUND);
}
return E.right(true);
}
}

View File

@@ -1,4 +1,4 @@
import { ExecutionContext } from '@nestjs/common';
import { ExecutionContext, HttpException } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { GqlExecutionContext } from '@nestjs/graphql';
import { pipe } from 'fp-ts/lib/function';
@@ -16,6 +16,7 @@ import {
JSON_INVALID,
} from './errors';
import { AuthProvider } from './auth/helper';
import { RESTError } from './types/RESTError';
/**
* A workaround to throw an exception in an expression.
@@ -27,6 +28,15 @@ export function throwErr(errMessage: string): never {
throw new Error(errMessage);
}
/**
* This function allows throw to be used as an expression
* @param errMessage Message present in the error message
*/
export function throwHTTPErr(errorData: RESTError): never {
const { message, statusCode } = errorData;
throw new HttpException(message, statusCode);
}
/**
* Prints the given value to log and returns the same value.
* Used for debugging functional pipelines.
@@ -173,6 +183,16 @@ export const validateSMTPUrl = (url: string) => {
return false;
};
/**
* Checks to see if the URL is valid or not
* @param url The URL to validate
* @returns boolean
*/
export const validateUrl = (url: string) => {
const urlRegex = /^(http|https):\/\/[^ "]+$/;
return urlRegex.test(url);
};
/**
* String to JSON parser
* @param {str} str The string to parse

View File

@@ -1,3 +0,0 @@
#!/usr/bin/env node
// * The entry point of the CLI
require("../dist").cli(process.argv);

View File

@@ -0,0 +1,6 @@
#!/usr/bin/env node
// * The entry point of the CLI
import { cli } from "../dist/index.js";
cli(process.argv);

View File

@@ -3,9 +3,10 @@
"version": "0.6.0",
"description": "A CLI to run Hoppscotch test scripts in CI environments.",
"homepage": "https://hoppscotch.io",
"type": "module",
"main": "dist/index.js",
"bin": {
"hopp": "bin/hopp"
"hopp": "bin/hopp.js"
},
"publishConfig": {
"access": "public"
@@ -39,27 +40,30 @@
},
"license": "MIT",
"private": false,
"dependencies": {
"axios": "^1.6.6",
"chalk": "^5.3.0",
"commander": "^11.1.0",
"lodash-es": "^4.17.21",
"qs": "^6.11.2",
"zod": "^3.22.4"
},
"devDependencies": {
"@hoppscotch/data": "workspace:^",
"@hoppscotch/js-sandbox": "workspace:^",
"@relmify/jest-fp-ts": "^2.1.1",
"@swc/core": "^1.3.92",
"@types/jest": "^29.5.5",
"@types/lodash": "^4.14.199",
"@types/qs": "^6.9.8",
"axios": "^0.21.4",
"chalk": "^4.1.2",
"commander": "^11.0.0",
"esm": "^3.2.25",
"fp-ts": "^2.16.1",
"io-ts": "^2.2.20",
"@swc/core": "^1.3.105",
"@types/jest": "^29.5.11",
"@types/lodash-es": "^4.17.12",
"@types/qs": "^6.9.11",
"fp-ts": "^2.16.2",
"jest": "^29.7.0",
"lodash": "^4.17.21",
"prettier": "^3.0.3",
"prettier": "^3.2.4",
"qs": "^6.11.2",
"ts-jest": "^29.1.1",
"tsup": "^7.2.0",
"typescript": "^5.2.2",
"ts-jest": "^29.1.2",
"tsup": "^8.0.1",
"typescript": "^5.3.3",
"verzod": "^0.2.2",
"zod": "^3.22.4"
}

View File

@@ -1,5 +1,5 @@
import chalk from "chalk";
import { program } from "commander";
import { Command } from "commander";
import * as E from "fp-ts/Either";
import { version } from "../package.json";
import { test } from "./commands/test";
@@ -20,6 +20,8 @@ const CLI_AFTER_ALL_TXT = `\nFor more help, head on to ${accent(
"https://docs.hoppscotch.io/documentation/clients/cli"
)}`;
const program = new Command()
program
.name("hopp")
.version(version, "-v, --ver", "see the current version of hopp-cli")

View File

@@ -1,9 +1,9 @@
import { HoppCollection, HoppRESTRequest } from "@hoppscotch/data";
import { bold } from "chalk";
import chalk from "chalk";
import { log } from "console";
import * as A from "fp-ts/Array";
import { pipe } from "fp-ts/function";
import round from "lodash/round";
import { round } from "lodash-es";
import { CollectionRunnerParam } from "../types/collections";
import {
@@ -68,7 +68,7 @@ export const collectionsRunner = async (
};
// Request processing initiated message.
log(WARN(`\nRunning: ${bold(requestPath)}`));
log(WARN(`\nRunning: ${chalk.bold(requestPath)}`));
// Processing current request.
const result = await processRequest(processRequestParams)();

View File

@@ -1,4 +1,4 @@
import { bold } from "chalk";
import chalk from "chalk";
import { groupEnd, group, log } from "console";
import { handleError } from "../handlers/error";
import { RequestConfig } from "../interfaces/request";
@@ -120,7 +120,7 @@ export const printErrorsReport = (
errorsReport: HoppCLIError[]
) => {
if (errorsReport.length > 0) {
const REPORTED_ERRORS_TITLE = FAIL(`\n${bold(path)} reported errors:`);
const REPORTED_ERRORS_TITLE = FAIL(`\n${chalk.bold(path)} reported errors:`);
group(REPORTED_ERRORS_TITLE);
for (const errorReport of errorsReport) {
@@ -143,7 +143,7 @@ export const printFailedTestsReport = (
// Only printing test-reports with failed test-cases.
if (failedTestsReport.length > 0) {
const FAILED_TESTS_PATH = FAIL(`\n${bold(path)} failed tests:`);
const FAILED_TESTS_PATH = FAIL(`\n${chalk.bold(path)} failed tests:`);
group(FAILED_TESTS_PATH);
for (const failedTestReport of failedTestsReport) {

View File

@@ -1,4 +1,4 @@
import { clone } from "lodash";
import { clone } from "lodash-es";
/**
* Sorts the array based on the sort func.

View File

@@ -11,7 +11,7 @@ import * as E from "fp-ts/Either";
import * as S from "fp-ts/string";
import * as O from "fp-ts/Option";
import { error } from "../types/errors";
import round from "lodash/round";
import { round } from "lodash-es";
import { DEFAULT_DURATION_PRECISION } from "./constants";
/**

View File

@@ -1,7 +1,7 @@
{
"compilerOptions": {
"target": "ES6",
"module": "commonjs",
"target": "ESNext",
"module": "ESNext",
"outDir": ".",
"rootDir": ".",
"strict": true,

View File

@@ -3,17 +3,14 @@ import { defineConfig } from "tsup";
export default defineConfig({
entry: [ "./src/index.ts" ],
outDir: "./dist/",
format: ["cjs"],
format: ["esm"],
platform: "node",
sourcemap: true,
bundle: true,
target: "node12",
target: "esnext",
skipNodeModulesBundle: false,
esbuildOptions(options) {
options.bundle = true
},
noExternal: [
/\w+/
],
clean: true,
});

View File

@@ -315,7 +315,8 @@
"proxy_error": "Proxy error",
"script_fail": "Could not execute pre-request script",
"something_went_wrong": "Something went wrong",
"test_script_fail": "Could not execute post-request script"
"test_script_fail": "Could not execute post-request script",
"reading_files": "Error while reading one or more files."
},
"export": {
"as_json": "Export as JSON",
@@ -413,7 +414,10 @@
"json_description": "Import collections from a Hoppscotch Collections JSON file",
"postman_environment": "Postman Environment",
"postman_environment_description": "Import Postman Environment from a JSON file",
"title": "Import"
"title": "Import",
"file_size_limit_exceeded_warning_multiple_files": "Chosen files exceed the recommended limit of 10MB. Only the first {files} selected will be imported",
"file_size_limit_exceeded_warning_single_file": "The currently chosen file exceeds the recommended limit of 10MB. Please select another file.",
"success": "Successfully imported"
},
"inspections": {
"description": "Inspect possible errors",

View File

@@ -263,7 +263,7 @@ const HoppOpenAPIImporter: ImporterOrExporter = {
step: UrlSource({
caption: "import.from_url",
onImportFromURL: async (content) => {
const res = await hoppOpenAPIImporter(content)()
const res = await hoppOpenAPIImporter([content])()
if (E.isRight(res)) {
handleImportToStore(res.right)

View File

@@ -694,7 +694,7 @@ class MyCollectionsAdapter implements SmartTreeAdapter<MyCollectionNode> {
let target = collections[indexPaths.shift() as number]
while (indexPaths.length > 0)
target = target.folders[indexPaths.shift() as number]
target = target?.folders[indexPaths.shift() as number]
return target !== undefined ? target : null
}

View File

@@ -133,7 +133,7 @@ const PostmanEnvironmentsImport: ImporterOrExporter = {
return
}
handleImportToStore([res.right])
handleImportToStore(res.right)
platform.analytics?.logEvent({
type: "HOPP_IMPORT_ENVIRONMENT",
@@ -166,19 +166,14 @@ const insomniaEnvironmentsImport: ImporterOrExporter = {
return
}
const globalEnvIndex = res.right.findIndex(
const globalEnvs = res.right.filter(
(env) => env.name === "Base Environment"
)
const otherEnvs = res.right.filter(
(env) => env.name !== "Base Environment"
)
const globalEnv =
globalEnvIndex !== -1 ? res.right[globalEnvIndex] : undefined
// remove the global env from the environments array to prevent it from being imported twice
if (globalEnvIndex !== -1) {
res.right.splice(globalEnvIndex, 1)
}
handleImportToStore(res.right, globalEnv)
handleImportToStore(otherEnvs, globalEnvs)
platform.analytics?.logEvent({
type: "HOPP_IMPORT_ENVIRONMENT",
@@ -340,14 +335,14 @@ const showImportFailedError = () => {
const handleImportToStore = async (
environments: Environment[],
globalEnv?: NonSecretEnvironment
globalEnvs: NonSecretEnvironment[] = []
) => {
// if there's a global env, add them to the store
if (globalEnv) {
globalEnv.variables.forEach(({ key, value, secret }) =>
// Add global envs to the store
globalEnvs.forEach(({ variables }) => {
variables.forEach(({ key, value, secret }) => {
addGlobalEnvVariable({ key, value, secret })
)
}
})
})
if (props.environmentType === "MY_ENV") {
appendEnvironments(environments)

View File

@@ -13,6 +13,7 @@
{{ t(`${caption}`) }}
</span>
</p>
<div
class="flex flex-col ml-10 border border-dashed rounded border-dividerDark"
>
@@ -23,15 +24,30 @@
type="file"
class="p-4 cursor-pointer transition file:transition file:cursor-pointer text-secondary hover:text-secondaryDark file:mr-2 file:py-2 file:px-4 file:rounded file:border-0 file:text-secondary hover:file:text-secondaryDark file:bg-primaryLight hover:file:bg-primaryDark"
:accept="acceptedFileTypes"
multiple
@change="onFileChange"
/>
</div>
<p v-if="showFileSizeLimitExceededWarning" class="text-red-500 ml-10">
<template v-if="importFilesCount">
{{
t("import.file_size_limit_exceeded_warning_multiple_files", {
files:
importFilesCount === 1 ? "file" : `${importFilesCount} files`,
})
}}
</template>
<template v-else>
{{ t("import.file_size_limit_exceeded_warning_single_file") }}
</template>
</p>
<div>
<HoppButtonPrimary
class="w-full"
:label="t('import.title')"
:disabled="!hasFile"
:disabled="!hasFile || showFileSizeLimitExceededWarning"
@click="emit('importFromFile', fileContent)"
/>
</div>
@@ -51,16 +67,30 @@ defineProps<{
const t = useI18n()
const toast = useToast()
const ALLOWED_FILE_SIZE_LIMIT = 10 // 10 MB
const importFilesCount = ref(0)
const hasFile = ref(false)
const fileContent = ref("")
const showFileSizeLimitExceededWarning = ref(false)
const fileContent = ref<string[]>([])
const inputChooseFileToImportFrom = ref<HTMLInputElement | any>()
const emit = defineEmits<{
(e: "importFromFile", content: string): void
(e: "importFromFile", content: string[]): void
}>()
const onFileChange = () => {
const onFileChange = async () => {
// Reset the state on entering the handler to avoid any stale state
if (showFileSizeLimitExceededWarning.value) {
showFileSizeLimitExceededWarning.value = false
}
if (importFilesCount.value) {
importFilesCount.value = 0
}
const inputFileToImport = inputChooseFileToImportFrom.value
if (!inputFileToImport) {
@@ -69,27 +99,52 @@ const onFileChange = () => {
}
if (!inputFileToImport.files || inputFileToImport.files.length === 0) {
inputChooseFileToImportFrom.value[0].value = ""
inputChooseFileToImportFrom.value = ""
hasFile.value = false
toast.show(t("action.choose_file").toString())
return
}
const reader = new FileReader()
const readerPromises: Promise<string | null>[] = []
reader.onload = ({ target }) => {
const content = target!.result as string | null
if (!content) {
hasFile.value = false
toast.show(t("action.choose_file").toString())
return
let totalFileSize = 0
for (let i = 0; i < inputFileToImport.files.length; i++) {
const file = inputFileToImport.files[i]
totalFileSize += file.size / 1024 / 1024
if (totalFileSize > ALLOWED_FILE_SIZE_LIMIT) {
showFileSizeLimitExceededWarning.value = true
break
}
fileContent.value = content
const reader = new FileReader()
hasFile.value = !!content?.length
readerPromises.push(
new Promise((resolve, reject) => {
reader.onload = () => resolve(reader.result as string | null)
reader.onerror = reject
reader.readAsText(file)
})
)
}
reader.readAsText(inputFileToImport.files[0])
importFilesCount.value = readerPromises.length
const results = await Promise.allSettled(readerPromises)
const contentsArr = results
.filter((result) => result.status === "fulfilled")
.map((result) => (result as { value: string | null }).value)
.filter(Boolean) as string[]
const errors = results.filter((result) => result.status === "rejected")
if (errors.length) {
toast.error(t("error.reading_files"))
}
fileContent.value = contentsArr
hasFile.value = contentsArr.length > 0
}
</script>

View File

@@ -2,6 +2,7 @@ import { pipe, flow } from "fp-ts/function"
import * as TE from "fp-ts/TaskEither"
import * as O from "fp-ts/Option"
import * as RA from "fp-ts/ReadonlyArray"
import * as A from "fp-ts/Array"
import { translateToNewRESTCollection, HoppCollection } from "@hoppscotch/data"
import { isPlainObject as _isPlainObject } from "lodash-es"
@@ -9,11 +10,13 @@ import { IMPORTER_INVALID_FILE_FORMAT } from "."
import { safeParseJSON } from "~/helpers/functional/json"
import { translateToNewGQLCollection } from "@hoppscotch/data"
export const hoppRESTImporter = (content: string) =>
export const hoppRESTImporter = (content: string[]) =>
pipe(
safeParseJSON(content),
content,
A.traverse(O.Applicative)((str) => safeParseJSON(str, true)),
O.chain(
flow(
A.flatten,
makeCollectionsArray,
RA.map(validateCollection),
O.sequenceArray,

View File

@@ -8,17 +8,35 @@ import { IMPORTER_INVALID_FILE_FORMAT } from "."
import { Environment } from "@hoppscotch/data"
import { z } from "zod"
export const hoppEnvImporter = (content: string) => {
const parsedContent = safeParseJSON(content, true)
export const hoppEnvImporter = (contents: string[]) => {
const parsedContents = contents.map((str) => safeParseJSON(str, true))
// parse json from the environments string
if (O.isNone(parsedContent)) {
if (parsedContents.some((parsed) => O.isNone(parsed))) {
return TE.left(IMPORTER_INVALID_FILE_FORMAT)
}
const parsedValues = parsedContents.flatMap((content) => {
const unwrappedContent = O.toNullable(content) as Environment[] | null
if (unwrappedContent) {
return unwrappedContent.map((contentEntry) => {
return {
...contentEntry,
variables: contentEntry.variables?.map((valueEntry) => ({
...valueEntry,
...("value" in valueEntry
? { value: String(valueEntry.value) }
: {}),
})),
}
})
}
return null
})
const validationResult = z
.array(entityReference(Environment))
.safeParse(parsedContent.value)
.safeParse(parsedValues)
if (!validationResult.success) {
return TE.left(IMPORTER_INVALID_FILE_FORMAT)

View File

@@ -3,10 +3,10 @@ import * as E from "fp-ts/Either"
// TODO: add zod validation
export const hoppGqlCollectionsImporter = (
content: string
contents: string[]
): E.Either<"INVALID_JSON", HoppCollection[]> => {
return E.tryCatch(
() => JSON.parse(content) as HoppCollection[],
() => contents.flatMap((content) => JSON.parse(content)),
() => "INVALID_JSON"
)
}

View File

@@ -6,7 +6,7 @@ import { v4 as uuidv4 } from "uuid"
export function FileSource(metadata: {
acceptedFileTypes: string
caption: string
onImportFromFile: (content: string) => any | Promise<any>
onImportFromFile: (content: string[]) => any | Promise<any>
}) {
const stepID = uuidv4()

View File

@@ -10,14 +10,14 @@ import { v4 as uuidv4 } from "uuid"
export function GistSource(metadata: {
caption: string
onImportFromGist: (
importResult: E.Either<string, string>
importResult: E.Either<string, string[]>
) => any | Promise<any>
}) {
const stepID = uuidv4()
return defineStep(stepID, UrlImport, () => ({
caption: metadata.caption,
onImportFromURL: (gistResponse) => {
onImportFromURL: (gistResponse: Record<string, unknown>) => {
const fileSchema = z.object({
files: z.record(z.object({ content: z.string() })),
})
@@ -29,9 +29,11 @@ export function GistSource(metadata: {
return
}
const content = Object.values(parseResult.data.files)[0].content
const contents = Object.values(parseResult.data.files).map(
({ content }) => content
)
metadata.onImportFromGist(E.right(content))
metadata.onImportFromGist(E.right(contents))
},
fetchLogic: fetchGistFromUrl,
}))

View File

@@ -1,19 +1,21 @@
import { convert, ImportRequest } from "insomnia-importers"
import { pipe } from "fp-ts/function"
import {
HoppCollection,
HoppRESTAuth,
HoppRESTHeader,
HoppRESTParam,
HoppRESTReqBody,
HoppRESTRequest,
knownContentTypes,
makeRESTRequest,
HoppCollection,
makeCollection,
makeRESTRequest,
} from "@hoppscotch/data"
import * as A from "fp-ts/Array"
import * as TO from "fp-ts/TaskOption"
import * as TE from "fp-ts/TaskEither"
import * as TO from "fp-ts/TaskOption"
import { pipe } from "fp-ts/function"
import { ImportRequest, convert } from "insomnia-importers"
import { IMPORTER_INVALID_FILE_FORMAT } from "."
import { replaceInsomniaTemplating } from "./insomniaEnv"
@@ -203,15 +205,18 @@ const getHoppFolder = (
headers: [],
})
const getHoppCollections = (doc: InsomniaDoc) =>
getFoldersIn(null, doc.data.resources).map((f) =>
getHoppFolder(f, doc.data.resources)
)
const getHoppCollections = (docs: InsomniaDoc[]) => {
return docs.flatMap((doc) => {
return getFoldersIn(null, doc.data.resources).map((f) =>
getHoppFolder(f, doc.data.resources)
)
})
}
export const hoppInsomniaImporter = (fileContent: string) =>
export const hoppInsomniaImporter = (fileContents: string[]) =>
pipe(
fileContent,
parseInsomniaDoc,
fileContents,
A.traverse(TO.ApplicativeSeq)(parseInsomniaDoc),
TO.map(getHoppCollections),
TE.fromTaskOption(() => IMPORTER_INVALID_FILE_FORMAT)
)

View File

@@ -29,33 +29,36 @@ export const replaceInsomniaTemplating = (expression: string) => {
return expression.replaceAll(regex, "<<$1>>")
}
export const insomniaEnvImporter = (content: string) => {
const parsedContent = safeParseJSONOrYAML(content)
if (O.isNone(parsedContent)) {
export const insomniaEnvImporter = (contents: string[]) => {
const parsedContents = contents.map((str) => safeParseJSONOrYAML(str))
if (parsedContents.some((parsed) => O.isNone(parsed))) {
return TE.left(IMPORTER_INVALID_FILE_FORMAT)
}
const validationResult = insomniaResourcesSchema.safeParse(
parsedContent.value
)
const parsedValues = parsedContents.map((parsed) => O.toNullable(parsed))
const validationResult = z
.array(insomniaResourcesSchema)
.safeParse(parsedValues)
if (!validationResult.success) {
return TE.left(IMPORTER_INVALID_FILE_FORMAT)
}
const insomniaEnvs = validationResult.data.resources
.filter((resource) => resource._type === "environment")
.map((envResource) => {
const envResourceData = envResource.data as Record<string, unknown>
const stringifiedData: Record<string, string> = {}
const insomniaEnvs = validationResult.data.flatMap(({ resources }) => {
return resources
.filter((resource) => resource._type === "environment")
.map((envResource) => {
const envResourceData = envResource.data as Record<string, unknown>
const stringifiedData: Record<string, string> = {}
Object.keys(envResourceData).forEach((key) => {
stringifiedData[key] = String(envResourceData[key])
Object.keys(envResourceData).forEach((key) => {
stringifiedData[key] = String(envResourceData[key])
})
return { ...envResource, data: stringifiedData }
})
return { ...envResource, data: stringifiedData }
})
})
const environments: NonSecretEnvironment[] = []

View File

@@ -584,24 +584,28 @@ const convertPathToHoppReqs = (
RA.toArray
)
const convertOpenApiDocToHopp = (
doc: OpenAPI.Document
const convertOpenApiDocsToHopp = (
docs: OpenAPI.Document[]
): TE.TaskEither<never, HoppCollection[]> => {
const name = doc.info.title
const collections = docs.map((doc) => {
const name = doc.info.title
const paths = Object.entries(doc.paths ?? {})
.map(([pathName, pathObj]) => convertPathToHoppReqs(doc, pathName, pathObj))
.flat()
const paths = Object.entries(doc.paths ?? {})
.map(([pathName, pathObj]) =>
convertPathToHoppReqs(doc, pathName, pathObj)
)
.flat()
return TE.of([
makeCollection({
return makeCollection({
name,
folders: [],
requests: paths,
auth: { authType: "inherit", authActive: true },
headers: [],
}),
])
})
})
return TE.of(collections)
}
const parseOpenAPIDocContent = (str: string) =>
@@ -614,29 +618,49 @@ const parseOpenAPIDocContent = (str: string) =>
)
)
export const hoppOpenAPIImporter = (fileContent: string) =>
export const hoppOpenAPIImporter = (fileContents: string[]) =>
pipe(
// See if we can parse JSON properly
fileContent,
parseOpenAPIDocContent,
TE.fromOption(() => IMPORTER_INVALID_FILE_FORMAT),
fileContents,
A.traverse(O.Applicative)(parseOpenAPIDocContent),
TE.fromOption(() => {
return IMPORTER_INVALID_FILE_FORMAT
}),
// Try validating, else the importer is invalid file format
TE.chainW((obj) =>
pipe(
TE.chainW((docArr) => {
return pipe(
TE.tryCatch(
() => SwaggerParser.validate(obj),
async () => {
const resultDoc = []
for (const docObj of docArr) {
const validatedDoc = await SwaggerParser.validate(docObj)
resultDoc.push(validatedDoc)
}
return resultDoc
},
() => IMPORTER_INVALID_FILE_FORMAT
)
)
),
}),
// Deference the references
TE.chainW((obj) =>
TE.chainW((docArr) =>
pipe(
TE.tryCatch(
() => SwaggerParser.dereference(obj),
async () => {
const resultDoc = []
for (const docObj of docArr) {
const validatedDoc = await SwaggerParser.dereference(docObj)
resultDoc.push(validatedDoc)
}
return resultDoc
},
() => OPENAPI_DEREF_ERROR
)
)
),
TE.chainW(convertOpenApiDocToHopp)
TE.chainW(convertOpenApiDocsToHopp)
)

View File

@@ -55,7 +55,11 @@ const readPMCollection = (def: string) =>
pipe(
def,
safeParseJSON,
O.chain((data) => O.tryCatch(() => new PMCollection(data)))
O.chain((data) =>
O.tryCatch(() => {
return new PMCollection(data)
})
)
)
const getHoppReqHeaders = (item: Item): HoppRESTHeader[] =>
@@ -296,15 +300,17 @@ const getHoppFolder = (ig: ItemGroup<Item>): HoppCollection =>
headers: [],
})
export const getHoppCollection = (coll: PMCollection) => getHoppFolder(coll)
export const getHoppCollections = (collections: PMCollection[]) => {
return collections.map(getHoppFolder)
}
export const hoppPostmanImporter = (fileContent: string) =>
export const hoppPostmanImporter = (fileContents: string[]) =>
pipe(
// Try reading
fileContent,
readPMCollection,
fileContents,
A.traverse(O.Applicative)(readPMCollection),
O.map(flow(getHoppCollection, A.of)),
O.map(flow(getHoppCollections)),
TE.fromOption(() => IMPORTER_INVALID_FILE_FORMAT)
)

View File

@@ -1,12 +1,11 @@
import * as TE from "fp-ts/TaskEither"
import * as O from "fp-ts/Option"
import { IMPORTER_INVALID_FILE_FORMAT } from "."
import { safeParseJSON } from "~/helpers/functional/json"
import { z } from "zod"
import { Environment } from "@hoppscotch/data"
import * as O from "fp-ts/Option"
import * as TE from "fp-ts/TaskEither"
import { uniqueId } from "lodash-es"
import { z } from "zod"
import { safeParseJSON } from "~/helpers/functional/json"
import { IMPORTER_INVALID_FILE_FORMAT } from "."
const postmanEnvSchema = z.object({
name: z.string(),
@@ -18,32 +17,44 @@ const postmanEnvSchema = z.object({
),
})
export const postmanEnvImporter = (content: string) => {
const parsedContent = safeParseJSON(content)
type PostmanEnv = z.infer<typeof postmanEnvSchema>
// parse json from the environments string
if (O.isNone(parsedContent)) {
export const postmanEnvImporter = (contents: string[]) => {
const parsedContents = contents.map((str) => safeParseJSON(str, true))
if (parsedContents.some((parsed) => O.isNone(parsed))) {
return TE.left(IMPORTER_INVALID_FILE_FORMAT)
}
const validationResult = postmanEnvSchema.safeParse(parsedContent.value)
const parsedValues = parsedContents.flatMap((parsed) => {
const unwrappedEntry = O.toNullable(parsed) as PostmanEnv[] | null
if (unwrappedEntry) {
return unwrappedEntry.map((entry) => ({
...entry,
values: entry.values?.map((valueEntry) => ({
...valueEntry,
value: String(valueEntry.value),
})),
}))
}
return null
})
const validationResult = z.array(postmanEnvSchema).safeParse(parsedValues)
if (!validationResult.success) {
return TE.left(IMPORTER_INVALID_FILE_FORMAT)
}
const postmanEnv = validationResult.data
const environment: Environment = {
id: uniqueId(),
v: 1,
name: postmanEnv.name,
variables: [],
}
postmanEnv.values.forEach(({ key, value }) =>
environment.variables.push({ key, value, secret: false })
// Convert `values` to `variables` to match the format expected by the system
const environments: Environment[] = validationResult.data.map(
({ name, values }) => ({
id: uniqueId(),
v: 1,
name,
variables: values.map((entires) => ({ ...entires, secret: false })),
})
)
return TE.right(environment)
return TE.right(environments)
}

View File

@@ -61,7 +61,7 @@ export function navigateToFolderWithIndexPath(
let target = collections[indexPaths.shift() as number]
while (indexPaths.length > 0)
while (indexPaths.length > 0 && target)
target = target.folders[indexPaths.shift() as number]
return target !== undefined ? target : null

View File

@@ -95,31 +95,15 @@
</template>
<script lang="ts" setup>
import { ref, onMounted, onBeforeUnmount } from "vue"
import { ref, onMounted } from "vue"
import { safelyExtractRESTRequest } from "@hoppscotch/data"
import { translateExtURLParams } from "~/helpers/RESTExtURLParams"
import { useRoute } from "vue-router"
import { useI18n } from "@composables/i18n"
import { getDefaultRESTRequest } from "~/helpers/rest/default"
import { defineActionHandler, invokeAction } from "~/helpers/actions"
import { onLoggedIn } from "~/composables/auth"
import { platform } from "~/platform"
import {
audit,
BehaviorSubject,
combineLatest,
EMPTY,
from,
map,
Subscription,
} from "rxjs"
import { useToast } from "~/composables/toast"
import { watchDebounced } from "@vueuse/core"
import { useReadonlyStream } from "~/composables/stream"
import {
changeCurrentSyncStatus,
currentSyncingStatus$,
} from "~/newstore/syncing"
import { useService } from "dioc/vue"
import { InspectionService } from "~/services/inspection"
import { HeaderInspectorService } from "~/services/inspection/inspectors/header.inspector"
@@ -127,7 +111,7 @@ import { EnvironmentInspectorService } from "~/services/inspection/inspectors/en
import { ResponseInspectorService } from "~/services/inspection/inspectors/response.inspector"
import { cloneDeep } from "lodash-es"
import { RESTTabService } from "~/services/tab/rest"
import { HoppTab, PersistableTabState } from "~/services/tab"
import { HoppTab } from "~/services/tab"
import { HoppRESTDocument } from "~/helpers/rest/document"
const savingRequest = ref(false)
@@ -140,7 +124,6 @@ const exceptedTabID = ref<string | null>(null)
const renameTabID = ref<string | null>(null)
const t = useI18n()
const toast = useToast()
const tabs = useService(RESTTabService)
@@ -171,12 +154,6 @@ const contextMenu = ref<PopupDetails>({
const activeTabs = tabs.getActiveTabs()
const confirmSync = useReadonlyStream(currentSyncingStatus$, {
isInitialSync: false,
shouldSync: true,
})
const tabStateForSync = ref<PersistableTabState<HoppRESTDocument> | null>(null)
function bindRequestToURLParams() {
const route = useRoute()
// Get URL parameters and set that as the request
@@ -327,111 +304,6 @@ const shareTabRequest = (tabID: string) => {
}
}
const syncTabState = () => {
if (tabStateForSync.value)
tabs.loadTabsFromPersistedState(tabStateForSync.value)
}
/**
* Performs sync of the REST Tab session with Firestore.
*
* @returns A subscription to the sync observable stream.
* Unsubscribe to stop syncing.
*/
function startTabStateSync(): Subscription {
const currentUser$ = platform.auth.getCurrentUserStream()
const tabState$ =
new BehaviorSubject<PersistableTabState<HoppRESTDocument> | null>(null)
watchDebounced(
tabs.persistableTabState,
(state) => {
tabState$.next(state)
},
{ debounce: 500, deep: true }
)
const sub = combineLatest([currentUser$, tabState$])
.pipe(
map(([user, tabState]) =>
user && tabState
? from(platform.sync.tabState.writeCurrentTabState(user, tabState))
: EMPTY
),
audit((x) => x)
)
.subscribe(() => {
// NOTE: This subscription should be kept
})
return sub
}
const showSyncToast = () => {
toast.show(t("confirm.sync"), {
duration: 0,
action: [
{
text: `${t("action.yes")}`,
onClick: (_, toastObject) => {
syncTabState()
changeCurrentSyncStatus({
isInitialSync: true,
shouldSync: true,
})
toastObject.goAway(0)
},
},
{
text: `${t("action.no")}`,
onClick: (_, toastObject) => {
changeCurrentSyncStatus({
isInitialSync: true,
shouldSync: false,
})
toastObject.goAway(0)
},
},
],
})
}
function setupTabStateSync() {
const route = useRoute()
// Subscription to request sync
let sub: Subscription | null = null
// Load request on login resolve and start sync
onLoggedIn(async () => {
if (
Object.keys(route.query).length === 0 &&
!(route.query.code || route.query.error)
) {
const tabStateFromSync =
await platform.sync.tabState.loadTabStateFromSync()
if (tabStateFromSync && !confirmSync.value.isInitialSync) {
tabStateForSync.value = tabStateFromSync
showSyncToast()
// Have to set isInitialSync to true here because the toast is shown
// and the user does not click on any of the actions
changeCurrentSyncStatus({
isInitialSync: true,
shouldSync: false,
})
}
}
sub = startTabStateSync()
})
// Stop subscription to stop syncing
onBeforeUnmount(() => {
sub?.unsubscribe()
})
}
defineActionHandler("contextmenu.open", ({ position, text }) => {
if (text) {
contextMenu.value = {
@@ -448,7 +320,6 @@ defineActionHandler("contextmenu.open", ({ position, text }) => {
}
})
setupTabStateSync()
bindRequestToURLParams()
defineActionHandler("rest.request.open", ({ doc }) => {

View File

@@ -4,7 +4,6 @@ import { EnvironmentsPlatformDef } from "./environments"
import { CollectionsPlatformDef } from "./collections"
import { SettingsPlatformDef } from "./settings"
import { HistoryPlatformDef } from "./history"
import { TabStatePlatformDef } from "./tab"
import { AnalyticsPlatformDef } from "./analytics"
import { InterceptorsPlatformDef } from "./interceptors"
import { HoppModule } from "~/modules"
@@ -25,7 +24,6 @@ export type PlatformDef = {
collections: CollectionsPlatformDef
settings: SettingsPlatformDef
history: HistoryPlatformDef
tabState: TabStatePlatformDef
}
interceptors: InterceptorsPlatformDef
additionalInspectors?: InspectorsPlatformDef

View File

@@ -1,11 +0,0 @@
import { PersistableTabState } from "~/services/tab"
import { HoppUser } from "./auth"
import { HoppRESTDocument } from "~/helpers/rest/document"
export type TabStatePlatformDef = {
loadTabStateFromSync: () => Promise<PersistableTabState<HoppRESTDocument> | null>
writeCurrentTabState: (
user: HoppUser,
persistableTabState: PersistableTabState<HoppRESTDocument>
) => Promise<void>
}

View File

@@ -1,11 +0,0 @@
mutation UpdateUserSession(
$currentSession: String!
$sessionType: SessionType!
) {
updateUserSessions(
currentSession: $currentSession
sessionType: $sessionType
) {
currentRESTSession
}
}

View File

@@ -1,5 +0,0 @@
query GetCurrentRESTSession {
me {
currentRESTSession
}
}

View File

@@ -4,7 +4,6 @@ import { def as environmentsDef } from "./platform/environments/environments.pla
import { def as collectionsDef } from "./platform/collections/collections.platform"
import { def as settingsDef } from "./platform/settings/settings.platform"
import { def as historyDef } from "./platform/history/history.platform"
import { def as tabStateDef } from "./platform/tabState/tabState.platform"
import { proxyInterceptor } from "@hoppscotch/common/platform/std/interceptors/proxy"
import { ExtensionInspectorService } from "@hoppscotch/common/platform/std/inspections/extension.inspector"
import { NativeInterceptorService } from "./platform/interceptors/native"
@@ -46,7 +45,6 @@ const headerPaddingTop = ref("0px")
collections: collectionsDef,
settings: settingsDef,
history: historyDef,
tabState: tabStateDef,
},
interceptors: {
default: "native",
@@ -97,17 +95,18 @@ const headerPaddingTop = ref("0px")
}
})()
function isTextInput(target: EventTarget | null) {
if (target instanceof HTMLInputElement) {
return target.type === 'text'
|| target.type === 'email'
|| target.type === 'password'
|| target.type === 'number'
|| target.type === 'search'
|| target.type === 'tel'
|| target.type === 'url'
|| target.type === 'textarea'
return (
target.type === "text" ||
target.type === "email" ||
target.type === "password" ||
target.type === "number" ||
target.type === "search" ||
target.type === "tel" ||
target.type === "url" ||
target.type === "textarea"
)
} else if (target instanceof HTMLTextAreaElement) {
return true
} else if (target instanceof HTMLElement && target.isContentEditable) {
@@ -117,8 +116,12 @@ function isTextInput(target: EventTarget | null) {
return false
}
window.addEventListener('keydown',function(e){
if (e.key === "Backspace" && !isTextInput(e.target)) {
e.preventDefault()
}
},true);
window.addEventListener(
"keydown",
function (e) {
if (e.key === "Backspace" && !isTextInput(e.target)) {
e.preventDefault()
}
},
true
)

View File

@@ -1,36 +0,0 @@
import {
runMutation,
runGQLQuery,
} from "@hoppscotch/common/helpers/backend/GQLClient"
import {
GetCurrentRestSessionDocument,
GetCurrentRestSessionQuery,
GetCurrentRestSessionQueryVariables,
SessionType,
UpdateUserSessionDocument,
UpdateUserSessionMutation,
UpdateUserSessionMutationVariables,
} from "../../api/generated/graphql"
export const updateUserSession = (
currentSession: string,
sessionType: SessionType
) =>
runMutation<
UpdateUserSessionMutation,
UpdateUserSessionMutationVariables,
""
>(UpdateUserSessionDocument, {
sessionType,
currentSession,
})()
export const getCurrentRestSession = () =>
runGQLQuery<
GetCurrentRestSessionQuery,
GetCurrentRestSessionQueryVariables,
""
>({
query: GetCurrentRestSessionDocument,
variables: {},
})

View File

@@ -1,37 +0,0 @@
import { PersistableRESTTabState } from "@hoppscotch/common/helpers/rest/tab"
import { HoppUser } from "@hoppscotch/common/platform/auth"
import { TabStatePlatformDef } from "@hoppscotch/common/platform/tab"
import { def as platformAuth } from "@platform/auth"
import { getCurrentRestSession, updateUserSession } from "./tabState.api"
import { SessionType } from "../../api/generated/graphql"
import * as E from "fp-ts/Either"
async function writeCurrentTabState(
_: HoppUser,
persistableTabState: PersistableRESTTabState
) {
await updateUserSession(JSON.stringify(persistableTabState), SessionType.Rest)
}
async function loadTabStateFromSync(): Promise<PersistableRESTTabState | null> {
const currentUser = platformAuth.getCurrentUser()
if (!currentUser)
throw new Error("Cannot load request from sync without login")
const res = await getCurrentRestSession()
if (E.isRight(res)) {
const currentRESTSession = res.right.me.currentRESTSession
return currentRESTSession ? JSON.parse(currentRESTSession) : null
} else {
}
return null
}
export const def: TabStatePlatformDef = {
loadTabStateFromSync,
writeCurrentTabState,
}

View File

@@ -1,11 +0,0 @@
mutation UpdateUserSession(
$currentSession: String!
$sessionType: SessionType!
) {
updateUserSessions(
currentSession: $currentSession
sessionType: $sessionType
) {
currentRESTSession
}
}

View File

@@ -1,5 +0,0 @@
query GetCurrentRESTSession {
me {
currentRESTSession
}
}

View File

@@ -4,7 +4,6 @@ import { def as environmentsDef } from "./platform/environments/environments.pla
import { def as collectionsDef } from "./platform/collections/collections.platform"
import { def as settingsDef } from "./platform/settings/settings.platform"
import { def as historyDef } from "./platform/history/history.platform"
import { def as tabStateDef } from "./platform/tabState/tabState.platform"
import { browserInterceptor } from "@hoppscotch/common/platform/std/interceptors/browser"
import { proxyInterceptor } from "@hoppscotch/common/platform/std/interceptors/proxy"
import { ExtensionInspectorService } from "@hoppscotch/common/platform/std/inspections/extension.inspector"
@@ -25,7 +24,6 @@ createHoppApp("#app", {
collections: collectionsDef,
settings: settingsDef,
history: historyDef,
tabState: tabStateDef,
},
interceptors: {
default: "browser",

View File

@@ -1,36 +0,0 @@
import {
runMutation,
runGQLQuery,
} from "@hoppscotch/common/helpers/backend/GQLClient"
import {
GetCurrentRestSessionDocument,
GetCurrentRestSessionQuery,
GetCurrentRestSessionQueryVariables,
SessionType,
UpdateUserSessionDocument,
UpdateUserSessionMutation,
UpdateUserSessionMutationVariables,
} from "../../api/generated/graphql"
export const updateUserSession = (
currentSession: string,
sessionType: SessionType
) =>
runMutation<
UpdateUserSessionMutation,
UpdateUserSessionMutationVariables,
""
>(UpdateUserSessionDocument, {
sessionType,
currentSession,
})()
export const getCurrentRestSession = () =>
runGQLQuery<
GetCurrentRestSessionQuery,
GetCurrentRestSessionQueryVariables,
""
>({
query: GetCurrentRestSessionDocument,
variables: {},
})

View File

@@ -1,38 +0,0 @@
import { PersistableTabState } from "@hoppscotch/common/services/tab"
import { HoppRESTDocument } from "@hoppscotch/common/helpers/rest/document"
import { HoppUser } from "@hoppscotch/common/platform/auth"
import { TabStatePlatformDef } from "@hoppscotch/common/platform/tab"
import { def as platformAuth } from "@platform/auth/auth.platform"
import { getCurrentRestSession, updateUserSession } from "./tabState.api"
import { SessionType } from "../../api/generated/graphql"
import * as E from "fp-ts/Either"
async function writeCurrentTabState(
_: HoppUser,
persistableTabState: PersistableTabState<HoppRESTDocument>
) {
await updateUserSession(JSON.stringify(persistableTabState), SessionType.Rest)
}
async function loadTabStateFromSync(): Promise<PersistableTabState<HoppRESTDocument> | null> {
const currentUser = platformAuth.getCurrentUser()
if (!currentUser)
throw new Error("Cannot load request from sync without login")
const res = await getCurrentRestSession()
if (E.isRight(res)) {
const currentRESTSession = res.right.me.currentRESTSession
return currentRESTSession ? JSON.parse(currentRESTSession) : null
} else {
}
return null
}
export const def: TabStatePlatformDef = {
loadTabStateFromSync,
writeCurrentTabState,
}

View File

@@ -0,0 +1,37 @@
<svg width="117" height="120" viewBox="0 0 117 120" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M104.634 68.6831C104.634 80.1972 100.53 90.6702 93.6709 98.8771C85.0353 109.228 72.0513 115.781 57.4749 115.781C43.511 115.781 31.0169 109.717 22.3813 100.102C14.9706 91.7726 10.4385 80.7484 10.4385 68.6831C10.4385 42.6538 31.5069 21.5854 57.5361 21.5854C83.5654 21.5854 104.634 42.6538 104.634 68.6831Z" stroke="#737373" stroke-width="2" stroke-miterlimit="10" stroke-dasharray="4 4"/>
<path d="M74.8781 71.7725C74.5226 73.2834 73.9893 74.8833 73.3671 76.2164C71.6785 79.5049 69.0121 82.0824 65.7236 83.7711C62.3462 85.4598 58.3467 86.1708 54.3471 85.282C44.926 83.3267 38.8823 74.0833 40.8376 64.6622C42.7929 55.2411 51.9474 49.1085 61.3685 51.1527C64.7459 51.8637 67.6789 53.5524 70.1675 55.8632C74.3448 60.0405 76.1224 66.0843 74.8781 71.7725Z" fill="#737373" stroke="#a3a3a3" stroke-width="2" stroke-miterlimit="10"/>
<path d="M63.4127 66.7063H60.4132C59.8609 66.7063 59.4132 66.2586 59.4132 65.7063V62.7067C59.4132 61.9068 58.791 61.1958 57.9023 61.1958C57.1024 61.1958 56.3913 61.818 56.3913 62.7067V65.7063C56.3913 66.2586 55.9436 66.7063 55.3913 66.7063H52.3918C51.5919 66.7063 50.8809 67.3284 50.8809 68.2172C50.8809 69.106 51.503 69.7281 52.3918 69.7281H55.3913C55.9436 69.7281 56.3913 70.1759 56.3913 70.7281V73.7277C56.3913 74.5276 57.0135 75.2386 57.9023 75.2386C58.7022 75.2386 59.4132 74.6165 59.4132 73.7277V70.7281C59.4132 70.1759 59.8609 69.7281 60.4132 69.7281H63.4127C64.2126 69.7281 64.9237 69.106 64.9237 68.2172C64.9237 67.3284 64.2126 66.7063 63.4127 66.7063Z" fill="#525252"/>
<path d="M78.3516 21.6057C78.3516 27.1603 76.1318 32.0976 72.6787 35.8007C72.4321 36.171 72.0621 36.4178 71.6921 36.6647C67.9924 40.1209 63.0594 42.2192 57.6332 42.2192C53.3169 42.2192 49.2472 40.8615 45.9174 38.5162C44.9308 37.899 44.0675 37.035 43.2043 36.2944C39.3812 32.5914 37.0381 27.4071 37.0381 21.6057C37.0381 10.2498 46.2874 0.992188 57.6332 0.992188C69.1023 0.992188 78.3516 10.2498 78.3516 21.6057Z" fill="black"/>
<path d="M78.3516 21.6043C78.3516 27.6526 75.7618 32.9603 71.6921 36.7868C67.9924 40.2429 63.0594 42.3413 57.6332 42.3413C53.3169 42.3413 49.2472 40.9835 45.9174 38.6383C40.4912 34.9353 37.0381 28.7635 37.0381 21.7278C37.0381 10.3718 46.2874 1.11426 57.6332 1.11426C68.979 1.11426 78.3516 10.2484 78.3516 21.6043Z" fill="#1f1f1f" stroke="#737373" stroke-width="2" stroke-miterlimit="10"/>
<path d="M72.679 35.8005C72.4324 36.1708 72.0624 36.4176 71.6924 36.6645C67.9927 40.1207 63.0597 42.219 57.6335 42.219C53.3172 42.219 49.2475 40.8613 45.9177 38.516C44.9311 37.8988 44.0679 37.0348 43.2046 36.2942C43.6979 35.677 44.4378 35.3067 46.041 34.6895L46.6577 34.4427C47.8909 33.9489 49.6174 33.3318 51.8373 32.3443C52.2072 32.2209 52.4539 31.974 52.7005 31.7271C52.8239 31.6037 52.9472 31.4803 52.9472 31.2334C53.0705 30.9865 53.1938 30.6162 53.1938 30.3693V26.1726C53.0705 26.0492 53.0705 26.0492 52.9472 25.9257C52.5772 25.432 52.3306 24.8148 52.3306 24.0742L52.0839 23.9508C50.974 24.1976 51.0973 23.0867 50.8507 20.8649C50.7274 20.0009 50.8507 19.754 51.344 19.6306L51.7139 19.1368C50.974 17.4088 50.604 15.8041 50.604 14.5698C50.604 12.4714 51.4673 11.1136 52.7005 10.4964C51.9606 9.01522 51.9606 8.52148 51.9606 8.52148C51.9606 8.52148 56.2769 9.26209 57.7568 9.01522C59.6067 8.64492 62.5664 9.13866 63.6764 11.6073C65.5262 12.3479 66.1428 13.4589 66.3895 14.6932C66.6361 16.6681 65.5262 18.7665 65.2796 19.6306V19.754C65.5262 19.8774 65.6495 20.1243 65.5262 20.9883C65.2796 23.0867 65.2796 24.3211 64.293 24.0742L63.3064 25.8023C63.3064 26.0492 63.3064 26.0492 63.1831 26.1726C63.1831 26.5429 63.1831 27.1601 63.1831 30.4928C63.1831 30.8631 63.3064 31.3568 63.553 31.6037C63.6764 31.7271 63.6764 31.8506 63.7997 31.8506C64.0463 32.0974 64.293 32.3443 64.5396 32.3443C67.0061 33.3318 68.7326 34.0724 70.0892 34.5661C71.3224 35.0599 72.1857 35.4302 72.679 35.8005Z" fill="#1f1f1f"/>
<path d="M72.679 35.8004C72.4324 36.1707 72.0624 36.4176 71.6924 36.6644C67.9927 40.1206 63.0597 42.219 57.6335 42.219C53.3172 42.219 49.2475 40.8612 45.9177 38.5159C44.9311 37.8988 44.0679 37.0347 43.2046 36.2941C43.6979 35.6769 44.4378 35.3066 46.041 34.6895L46.6577 34.4426C47.8909 33.9489 49.6174 33.3317 51.8373 32.3442C52.2072 32.2208 52.4539 31.9739 52.7005 31.7271C53.9338 33.4551 55.907 34.566 58.2501 34.566C60.4699 34.566 62.4431 33.4551 63.6764 31.8505C63.923 32.0974 64.1697 32.3442 64.4163 32.3442C66.8828 33.3317 68.6093 34.0723 69.9659 34.566C71.3224 35.0598 72.1857 35.4301 72.679 35.8004Z" fill="#737373"/>
<path d="M65.1564 19.5071C65.2797 19.0134 65.0331 18.2728 64.7864 17.9025C64.7864 17.7791 64.6631 17.7791 64.6631 17.6556C63.7999 15.9275 61.95 15.3104 60.2235 15.1869C55.6605 14.9401 55.2905 15.8041 53.9339 14.5698C54.4272 15.1869 54.4272 16.2978 53.6873 17.5322C53.194 18.3962 52.3307 18.89 51.4675 19.1368C49.371 14.4463 50.4809 11.4839 52.454 10.4964C51.7141 9.01522 51.7141 8.52148 51.7141 8.52148C51.7141 8.52148 56.0304 9.26209 57.5103 9.01522C59.3602 8.64492 62.32 9.13866 63.4299 11.6073C65.2797 12.3479 65.8964 13.4589 66.143 14.6932C66.513 16.5447 65.4031 18.6431 65.1564 19.5071Z" fill="#a3a3a3"/>
<path d="M53.317 30.3692V26.1724C53.1936 26.049 53.1936 26.049 53.0703 25.9256V25.6787C53.317 26.049 53.5636 26.4193 53.9336 26.6662L57.2633 29.0114C58.0033 29.6286 59.1132 29.6286 59.8531 29.0114L62.9362 26.2959C63.0595 26.1724 63.1829 26.1724 63.3062 26.049C63.3062 26.4193 63.3062 27.0365 63.3062 30.3692C63.3062 30.6161 63.3062 30.7395 63.4295 30.9864H53.317C53.1936 30.7395 53.317 30.6161 53.317 30.3692Z" fill="url(#paint0_linear)"/>
<path d="M115.285 97.8074C115.285 103.362 113.065 108.299 109.612 112.002C109.365 112.373 108.995 112.619 108.625 112.866C104.925 116.323 99.9925 118.421 94.5663 118.421C90.25 118.421 86.1803 117.063 82.8505 114.718C81.8639 114.101 81.0007 113.237 80.1374 112.496C76.3143 108.793 73.9712 103.609 73.9712 97.8074C73.9712 86.4514 83.2205 77.1938 94.5663 77.1938C106.035 77.1938 115.285 86.4514 115.285 97.8074Z" fill="black"/>
<path d="M115.285 97.8065C115.285 103.855 112.695 109.162 108.625 112.989C104.925 116.445 99.9925 118.543 94.5663 118.543C90.25 118.543 86.1803 117.186 82.8505 114.84C77.4243 111.137 73.9712 104.966 73.9712 97.9299C73.9712 86.574 83.2205 77.3164 94.5663 77.3164C105.912 77.3164 115.285 86.4505 115.285 97.8065Z" fill="#1f1f1f" stroke="#737373" stroke-width="2" stroke-miterlimit="10"/>
<path d="M109.613 112.003C109.366 112.373 108.996 112.62 108.626 112.867C104.927 116.323 99.9938 118.421 94.5676 118.421C90.2512 118.421 86.1815 117.063 82.8518 114.718C81.8652 114.101 81.0019 113.237 80.1387 112.496C80.632 111.879 81.3719 111.509 82.9751 110.892L83.5917 110.645C84.825 110.151 86.5515 109.534 88.7713 108.546C89.1413 108.423 89.388 108.176 89.6346 107.929C89.7579 107.806 89.8813 107.682 89.8813 107.436C90.0046 107.189 90.1279 106.818 90.1279 106.571V102.375C90.0046 102.251 90.0046 102.251 89.8813 102.128C89.5113 101.634 89.2646 101.017 89.2646 100.276L89.018 100.153C87.9081 100.4 88.0314 99.2889 87.7848 97.0671C87.6614 96.203 87.7848 95.9562 88.278 95.8327L88.648 95.339C87.9081 93.6109 87.5381 92.0063 87.5381 90.7719C87.5381 88.6735 88.4014 87.3158 89.6346 86.6986C88.8947 85.2174 88.8947 84.7236 88.8947 84.7236C88.8947 84.7236 93.211 85.4642 94.6909 85.2174C96.5408 84.8471 99.5005 85.3408 100.61 87.8095C102.46 88.5501 103.077 89.661 103.324 90.8953C103.57 92.8703 102.46 94.9687 102.214 95.8327V95.9562C102.46 96.0796 102.584 96.3265 102.46 97.1905C102.214 99.2889 102.214 100.523 101.227 100.276L100.24 102.004C100.24 102.251 100.24 102.251 100.117 102.375C100.117 102.745 100.117 103.362 100.117 106.695C100.117 107.065 100.24 107.559 100.487 107.806C100.61 107.929 100.61 108.053 100.734 108.053C100.98 108.3 101.227 108.546 101.474 108.546C103.94 109.534 105.667 110.275 107.023 110.768C108.257 111.262 109.12 111.632 109.613 112.003Z" fill="#1f1f1f"/>
<path d="M109.612 112.003C109.365 112.373 108.995 112.62 108.626 112.867C104.926 116.323 99.9928 118.421 94.5666 118.421C90.2503 118.421 86.1806 117.063 82.8508 114.718C81.8642 114.101 81.001 113.237 80.1377 112.496C80.631 111.879 81.3709 111.509 82.9741 110.892L83.5908 110.645C84.824 110.151 86.5505 109.534 88.7704 108.546C89.1403 108.423 89.387 108.176 89.6336 107.929C90.8669 109.657 92.8401 110.768 95.1832 110.768C97.403 110.768 99.3762 109.657 100.609 108.053C100.856 108.3 101.103 108.546 101.349 108.546C103.816 109.534 105.542 110.274 106.899 110.768C108.256 111.262 109.119 111.632 109.612 112.003Z" fill="#737373"/>
<path d="M102.09 95.7093C102.213 95.2155 101.966 94.4749 101.72 94.1046C101.72 93.9812 101.596 93.9812 101.596 93.8578C100.733 92.1297 98.8831 91.5125 97.1566 91.3891C92.5936 91.1422 92.2236 92.0063 90.867 90.7719C91.3603 91.3891 91.3603 92.5 90.6204 93.7343C90.1271 94.5984 89.2638 95.0921 88.4006 95.339C86.3041 90.6485 87.414 87.6861 89.3872 86.6986C88.6472 85.2174 88.6472 84.7236 88.6472 84.7236C88.6472 84.7236 92.9635 85.4642 94.4434 85.2174C96.2933 84.8471 99.2531 85.3408 100.363 87.8095C102.213 88.5501 102.829 89.661 103.076 90.8953C103.446 92.7469 102.336 94.8452 102.09 95.7093Z" fill="#a3a3a3"/>
<path d="M90.2501 106.571V102.375C90.1267 102.251 90.1267 102.251 90.0034 102.128V101.881C90.2501 102.251 90.4967 102.621 90.8667 102.868L94.1964 105.214C94.9364 105.831 96.0463 105.831 96.7862 105.214L99.8693 102.498C99.9927 102.375 100.116 102.375 100.239 102.251C100.239 102.621 100.239 103.239 100.239 106.571C100.239 106.818 100.239 106.942 100.363 107.189H90.2501C90.1267 106.942 90.2501 106.818 90.2501 106.571Z" fill="url(#paint1_linear)"/>
<path d="M41.2036 98.1168C41.2036 103.918 38.7371 109.102 34.7908 112.805C33.6808 113.793 32.5709 114.657 31.2144 115.398C28.2546 117.126 24.8015 118.113 21.1018 118.113C17.4021 118.113 13.949 117.126 10.9892 115.398C10.4959 115.151 10.126 114.904 9.63268 114.534C4.45307 110.954 1 104.906 1 98.1168C1 87.0077 10.0026 78.1204 20.9785 78.1204C32.201 77.997 41.2036 87.0077 41.2036 98.1168Z" fill="#1f1f1f" stroke="#737373" stroke-width="2" stroke-miterlimit="10"/>
<path d="M17.0323 102.56C17.279 102.806 17.5256 103.177 17.8956 103.424C18.1422 103.67 18.3889 103.794 18.6355 104.041C18.7589 104.164 19.0055 104.288 19.1288 104.411C19.1288 104.411 19.2522 104.411 19.2522 104.534L19.3755 104.658V106.386C19.3755 106.386 19.3755 106.386 19.2522 106.263C19.1288 106.139 18.8822 106.016 18.7589 105.892C18.5122 105.769 18.2656 105.522 18.0189 105.399C17.8956 105.399 17.8956 105.275 17.7723 105.275C16.909 104.781 16.1691 104.288 16.1691 103.67C16.2924 103.424 16.539 103.053 17.0323 102.56ZM34.1878 112.107C33.4478 110.502 32.4478 108.978 30.7213 108.114C29.858 107.744 28.8714 107.373 27.8848 107.373C27.6382 107.373 27.2682 107.373 27.0216 107.373C26.8982 107.373 26.7749 107.373 26.6516 107.373C25.295 107.25 25.1717 107.003 25.1717 107.003V104.164C26.035 103.424 26.8982 102.56 27.6382 101.695C28.2548 100.831 28.7481 99.844 28.9947 98.6096C30.1047 98.3628 30.8446 97.3753 30.7213 96.1409C30.7213 95.6472 30.3513 95.1535 30.3513 94.6597C30.3513 94.4129 30.3513 94.166 30.3513 93.9191C30.3513 93.7957 30.3513 93.5488 30.3513 93.4254C30.3513 93.3019 30.3513 93.0551 30.3513 92.9316C30.228 92.0676 29.9813 91.2036 29.488 90.2161C28.0082 87.5005 25.295 85.7725 22.0886 85.7725C21.472 85.7725 20.8554 85.8959 20.2387 86.0193C19.1288 86.2662 18.0189 86.7599 17.1556 87.5005C17.0323 87.624 16.7857 87.7474 16.6624 87.9943L16.539 88.1177C15.5524 89.1052 14.6892 90.2161 14.3192 91.5739C13.8259 92.9316 13.8259 94.2894 13.9492 95.6472C13.9492 95.6472 13.9492 95.6472 13.9492 95.7706V95.8941C13.9492 96.1409 14.0726 96.1409 13.9492 96.2644C13.9492 96.3878 13.8259 96.3878 13.8259 96.5112C13.5793 96.8815 13.4559 97.3753 13.7026 98.1159C14.1959 99.3502 14.9358 99.2268 15.7991 99.844C15.7991 99.844 15.6758 99.844 15.6758 99.9674L14.8125 100.214C10.8661 101.449 9.50957 104.781 11.2361 106.88C11.8527 107.62 12.8393 108.238 14.3192 108.608C13.9492 108.608 13.5793 108.855 13.3326 109.102C11.6061 110.459 10.4962 112.558 10.2495 114.533C10.2495 114.656 10.2495 114.78 10.2495 114.903C10.7428 115.15 11.1128 115.52 11.6061 115.767L30.9175 115.315C32.1507 114.575 32.6963 114.003 33.8062 113.016C33.6829 112.399 34.3111 112.23 34.1878 112.107Z" fill="#1f1f1f"/>
<path d="M34.7909 112.805C33.681 113.792 32.5711 114.656 31.2145 115.397C28.2547 117.125 24.8017 118.113 21.1019 118.113C17.4022 118.113 13.9491 117.125 10.9894 115.397C10.4961 115.15 10.1261 114.903 9.63281 114.533C9.63281 114.41 9.63281 114.286 9.63281 114.163C9.87946 112.188 10.9894 110.089 12.7159 108.732C12.9626 108.485 13.3325 108.361 13.7025 108.238C12.2226 107.991 11.236 107.374 10.6194 106.51H15.3057C16.6623 108.361 18.7588 109.472 21.2253 109.472C23.3218 109.472 25.1716 108.608 26.5282 107.25C26.6515 107.25 26.7748 107.25 26.8982 107.25C27.1448 107.25 27.3915 107.25 27.7614 107.25C28.748 107.25 29.7346 107.497 30.5979 107.991C32.3244 108.855 33.5577 110.336 34.4209 112.064C34.6676 112.311 34.6676 112.558 34.7909 112.805Z" fill="#737373"/>
<path d="M25.2953 104.165V106.757L17.5259 107.004L17.8958 105.275C18.0192 105.275 18.0192 105.399 18.1425 105.399C18.3891 105.522 18.6358 105.769 18.8824 105.893C19.0058 106.016 19.1291 106.139 19.3757 106.263C19.3757 106.263 19.4991 106.263 19.4991 106.386V104.658L19.3757 104.535C20.7323 105.275 22.5822 105.769 25.2953 104.165Z" fill="url(#paint2_linear)"/>
<path d="M30.351 93.4261C28.8711 93.9198 27.1446 94.1667 25.5414 94.0432C22.9516 93.7964 20.4851 92.8089 18.5119 91.0808C17.8953 92.9323 16.2921 94.2901 14.4422 95.1541C14.1956 95.2776 13.949 95.401 13.7023 95.401C13.7023 95.401 13.7023 95.401 13.7023 95.2776C13.579 93.9198 13.579 92.562 14.0723 91.2042C14.4422 89.8465 15.3055 88.7356 16.2921 87.7481L16.4154 87.6247C16.5388 87.5012 16.7854 87.3778 16.9087 87.1309C17.772 86.3903 18.8819 85.8966 19.9918 85.6497C20.6084 85.5263 21.2251 85.4028 21.8417 85.4028C25.0481 85.4028 27.8846 87.1309 29.2411 89.8465C29.7344 90.8339 29.9811 91.8214 30.1044 92.562C30.351 93.0558 30.351 93.3026 30.351 93.4261Z" fill="#a3a3a3"/>
<path d="M20.4853 111.694C19.7453 112.558 18.5121 112.558 17.4022 112.558C18.5121 111.447 17.8955 107.868 13.9491 108.238C8.52286 107.251 9.01616 101.573 14.4424 99.8445L15.3057 99.5977L15.429 99.7211C15.799 100.832 16.4156 101.819 17.0322 102.56C14.8124 104.412 17.8955 104.905 19.3754 106.387C20.6086 107.127 21.7185 110.213 20.4853 111.694Z" fill="#a3a3a3"/>
<defs>
<linearGradient id="paint0_linear" x1="58.2299" y1="30.8211" x2="58.2299" y2="28.0409" gradientUnits="userSpaceOnUse">
<stop stop-color="#1f1f1f"/>
<stop offset="0.9913" stop-color="#222427"/>
</linearGradient>
<linearGradient id="paint1_linear" x1="95.163" y1="107.023" x2="95.163" y2="104.243" gradientUnits="userSpaceOnUse">
<stop stop-color="#1f1f1f"/>
<stop offset="0.9913" stop-color="#222427"/>
</linearGradient>
<linearGradient id="paint2_linear" x1="21.3956" y1="106.915" x2="21.3956" y2="105.428" gradientUnits="userSpaceOnUse">
<stop stop-color="#1f1f1f"/>
<stop offset="0.9913" stop-color="#222427"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -0,0 +1,16 @@
<svg width="331" height="45" viewBox="0 0 331 45" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M33.6246 26.1371C33.0006 27.5354 31.0809 28.041 29.3368 27.2663C27.5927 26.4916 26.6848 24.73 27.3088 23.3317C27.9328 21.9334 29.8525 21.4279 31.5966 22.2026C33.3406 22.9773 34.2486 24.7388 33.6246 26.1371Z" fill="white"/>
<path d="M44.0676 32.1669C45.8936 32.6891 47.7149 31.9259 48.1357 30.4624C48.5565 28.9989 47.4174 27.3892 45.5915 26.867C43.7656 26.3449 41.9442 27.108 41.5234 28.5715C41.1026 30.0351 42.2417 31.6448 44.0676 32.1669Z" fill="white"/>
<path d="M18.7488 14.9439C20.3596 15.9482 21.0098 17.8092 20.2012 19.1005C19.3927 20.3918 17.4314 20.6244 15.8207 19.62C14.2099 18.6156 13.5597 16.7547 14.3683 15.4634C15.1769 14.1721 17.1381 13.9395 18.7488 14.9439Z" fill="white"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M40.6046 2.01061C48.845 5.6709 54.1347 13.6568 54.3457 22.5803C59.1966 26.3892 64.0172 31.7206 61.7753 36.7443C59.2609 42.3784 50.306 42.1466 40.1275 39.2956C35.2246 42.0037 29.1423 42.4667 23.6406 40.0229C18.139 37.5792 14.4154 32.7638 13.1505 27.3128C4.21605 21.6741 -1.94858 15.19 0.565742 9.55601C2.8077 4.5323 10.0047 4.5389 16.0878 5.58671C22.8759 -0.227821 32.3641 -1.64967 40.6046 2.01061ZM4.83679 11.4531C3.61531 14.1902 10.1761 21.9584 28.1412 29.9382C46.1063 37.918 56.2827 37.5843 57.5042 34.8472C58.5152 32.5819 54.3556 28.2274 49.5922 24.8534C45.8871 22.2113 40.8119 19.2992 34.1918 16.3586C32.39 15.5583 31.6863 13.4223 32.444 11.7246C33.2016 10.027 34.8661 8.89177 38.3702 8.73021C40.0197 8.72521 40.2362 8.2689 40.3116 7.87768C40.405 7.27411 39.5665 6.65894 38.7016 6.27477C31.8252 3.22038 23.4074 4.50137 17.5726 10.6308C11.872 9.35686 5.84776 9.18781 4.83679 11.4531Z" fill="white"/>
<path d="M79.2855 35.9435V4.86794H85.7377V17.387H100.066V4.86794H106.518V35.9435H100.066V23.2025H85.7377V35.9435H79.2855Z" fill="white"/>
<path d="M123.083 36.3875C120.769 36.3875 118.678 35.8547 116.809 34.7893C114.94 33.7238 113.457 32.2884 112.359 30.4831C111.261 28.6777 110.713 26.6504 110.713 24.4012C110.713 22.1519 111.247 20.1246 112.314 18.3192C113.412 16.4843 114.895 15.0341 116.764 13.9686C118.663 12.9032 120.769 12.3705 123.083 12.3705C125.397 12.3705 127.488 12.9032 129.357 13.9686C131.256 15.0341 132.739 16.4843 133.807 18.3192C134.905 20.1246 135.453 22.1519 135.453 24.4012C135.453 26.6504 134.905 28.6777 133.807 30.4831C132.739 32.2884 131.271 33.7238 129.402 34.7893C127.533 35.8547 125.427 36.3875 123.083 36.3875ZM123.083 31.1046C124.299 31.1046 125.382 30.8086 126.331 30.2167C127.31 29.6248 128.082 28.8257 128.645 27.8195C129.239 26.7836 129.535 25.6442 129.535 24.4012C129.535 23.1285 129.239 21.9891 128.645 20.9828C128.082 19.9766 127.31 19.1775 126.331 18.5856C125.382 17.9641 124.299 17.6533 123.083 17.6533C121.896 17.6533 120.814 17.9641 119.835 18.5856C118.856 19.1775 118.084 19.9766 117.521 20.9828C116.957 21.9891 116.675 23.1285 116.675 24.4012C116.675 25.6442 116.957 26.7836 117.521 27.8195C118.084 28.8257 118.856 29.6248 119.835 30.2167C120.814 30.8086 121.896 31.1046 123.083 31.1046Z" fill="white"/>
<path d="M139.201 44.9998V12.8144H145.208V14.7233C147.106 13.2435 149.302 12.5037 151.794 12.5037C153.989 12.5037 155.976 13.0364 157.756 14.1018C159.536 15.1673 160.945 16.6027 161.984 18.408C163.052 20.1838 163.586 22.1815 163.586 24.4012C163.586 26.6208 163.052 28.6334 161.984 30.4387C160.916 32.2144 159.477 33.635 157.667 34.7005C155.858 35.7363 153.841 36.2543 151.616 36.2543C150.459 36.2543 149.346 36.1063 148.278 35.8103C147.21 35.4848 146.217 35.026 145.297 34.4341V44.9998H139.201ZM150.815 31.1046C152.09 31.1046 153.232 30.8086 154.241 30.2167C155.279 29.6248 156.095 28.8257 156.688 27.8195C157.282 26.8132 157.578 25.6738 157.578 24.4012C157.578 23.1285 157.282 21.9891 156.688 20.9828C156.095 19.947 155.279 19.1479 154.241 18.5856C153.232 17.9937 152.09 17.6977 150.815 17.6977C149.687 17.6977 148.649 17.8901 147.7 18.2748C146.78 18.63 145.979 19.1775 145.297 19.9174V28.9293C145.95 29.61 146.751 30.1427 147.7 30.5275C148.679 30.9122 149.717 31.1046 150.815 31.1046Z" fill="white"/>
<path d="M167.273 44.9998V12.8144H173.28V14.7233C175.178 13.2435 177.374 12.5037 179.866 12.5037C182.061 12.5037 184.048 13.0364 185.828 14.1018C187.608 15.1673 189.017 16.6027 190.056 18.408C191.124 20.1838 191.658 22.1815 191.658 24.4012C191.658 26.6208 191.124 28.6334 190.056 30.4387C188.988 32.2144 187.549 33.635 185.739 34.7005C183.93 35.7363 181.913 36.2543 179.688 36.2543C178.531 36.2543 177.418 36.1063 176.35 35.8103C175.282 35.4848 174.289 35.026 173.369 34.4341V44.9998H167.273ZM178.887 31.1046C180.162 31.1046 181.304 30.8086 182.313 30.2167C183.351 29.6248 184.167 28.8257 184.76 27.8195C185.354 26.8132 185.65 25.6738 185.65 24.4012C185.65 23.1285 185.354 21.9891 184.76 20.9828C184.167 19.947 183.351 19.1479 182.313 18.5856C181.304 17.9937 180.162 17.6977 178.887 17.6977C177.759 17.6977 176.721 17.8901 175.772 18.2748C174.852 18.63 174.051 19.1775 173.369 19.9174V28.9293C174.022 29.61 174.822 30.1427 175.772 30.5275C176.751 30.9122 177.789 31.1046 178.887 31.1046Z" fill="white"/>
<path d="M204.022 36.3875C201.886 36.3875 199.913 36.1211 198.104 35.5884C196.294 35.026 194.722 34.227 193.387 33.1911L196.413 29.1513C197.748 30.0392 199.023 30.7051 200.239 31.149C201.485 31.5929 202.716 31.8149 203.933 31.8149C205.297 31.8149 206.38 31.5929 207.181 31.149C208.012 30.6755 208.427 30.0688 208.427 29.3289C208.427 28.7369 208.19 28.2634 207.715 27.9083C207.27 27.5531 206.543 27.3015 205.535 27.1536L201.085 26.4877C198.771 26.1325 197.021 25.3926 195.834 24.268C194.648 23.1137 194.054 21.6044 194.054 19.7398C194.054 18.2304 194.44 16.943 195.211 15.8776C196.012 14.7825 197.125 13.939 198.549 13.3471C200.002 12.7256 201.723 12.4149 203.71 12.4149C205.401 12.4149 207.048 12.6516 208.65 13.1252C210.281 13.5987 211.809 14.3238 213.233 15.3005L210.296 19.2515C209.02 18.4524 207.804 17.8753 206.647 17.5201C205.49 17.165 204.318 16.9874 203.132 16.9874C202.034 16.9874 201.144 17.1946 200.462 17.6089C199.809 18.0233 199.483 18.5708 199.483 19.2515C199.483 19.873 199.72 20.3613 200.195 20.7165C200.67 21.0716 201.485 21.3232 202.642 21.4712L207.048 22.1371C209.362 22.4626 211.127 23.2025 212.343 24.3568C213.559 25.4814 214.167 26.9464 214.167 28.7517C214.167 30.2315 213.722 31.5485 212.832 32.7028C211.942 33.8274 210.741 34.7301 209.228 35.4108C207.715 36.0619 205.98 36.3875 204.022 36.3875Z" fill="white"/>
<path d="M228.5 36.3875C226.216 36.3875 224.154 35.8695 222.315 34.8337C220.475 33.7682 219.022 32.3328 217.954 30.5275C216.886 28.6925 216.352 26.6504 216.352 24.4012C216.352 22.1223 216.886 20.0802 217.954 18.2748C219.022 16.4695 220.475 15.0341 222.315 13.9686C224.154 12.9032 226.216 12.3705 228.5 12.3705C230.339 12.3705 232.089 12.7256 233.751 13.4359C235.442 14.1462 236.895 15.1673 238.111 16.4991L234.374 20.4501C233.513 19.5031 232.608 18.8076 231.659 18.3636C230.74 17.8901 229.731 17.6533 228.633 17.6533C227.447 17.6533 226.379 17.9493 225.429 18.5412C224.51 19.1331 223.768 19.9322 223.205 20.9385C222.671 21.9447 222.404 23.0989 222.404 24.4012C222.404 25.6442 222.671 26.7836 223.205 27.8195C223.768 28.8257 224.54 29.6248 225.518 30.2167C226.497 30.779 227.58 31.0602 228.767 31.0602C229.805 31.0602 230.769 30.853 231.659 30.4387C232.579 29.9948 233.454 29.3437 234.285 28.4854L237.933 32.3032C236.747 33.5759 235.323 34.5821 233.662 35.322C232 36.0323 230.28 36.3875 228.5 36.3875Z" fill="white"/>
<path d="M251.754 36.3875C249.44 36.3875 247.348 35.8547 245.479 34.7893C243.61 33.7238 242.127 32.2884 241.029 30.4831C239.932 28.6777 239.383 26.6504 239.383 24.4012C239.383 22.1519 239.917 20.1246 240.985 18.3192C242.083 16.4843 243.566 15.0341 245.435 13.9686C247.333 12.9032 249.44 12.3705 251.754 12.3705C254.067 12.3705 256.159 12.9032 258.028 13.9686C259.926 15.0341 261.41 16.4843 262.478 18.3192C263.575 20.1246 264.124 22.1519 264.124 24.4012C264.124 26.6504 263.575 28.6777 262.478 30.4831C261.41 32.2884 259.941 33.7238 258.072 34.7893C256.203 35.8547 254.097 36.3875 251.754 36.3875ZM251.754 31.1046C252.97 31.1046 254.053 30.8086 255.002 30.2167C255.981 29.6248 256.752 28.8257 257.316 27.8195C257.909 26.7836 258.206 25.6442 258.206 24.4012C258.206 23.1285 257.909 21.9891 257.316 20.9828C256.752 19.9766 255.981 19.1775 255.002 18.5856C254.053 17.9641 252.97 17.6533 251.754 17.6533C250.567 17.6533 249.484 17.9641 248.505 18.5856C247.526 19.1775 246.755 19.9766 246.191 20.9828C245.628 21.9891 245.346 23.1285 245.346 24.4012C245.346 25.6442 245.628 26.7836 246.191 27.8195C246.755 28.8257 247.526 29.6248 248.505 30.2167C249.484 30.8086 250.567 31.1046 251.754 31.1046Z" fill="white"/>
<path d="M277.95 36.2987C275.369 36.2987 273.411 35.7215 272.076 34.5673C270.771 33.3835 270.118 31.6669 270.118 29.4176V17.8753H265.357V12.8144H270.118V6.91005L276.214 5.53385V12.8144H282.844V17.8753H276.214V28.1746C276.214 29.2401 276.452 30.0096 276.926 30.4831C277.401 30.927 278.231 31.149 279.418 31.149C280.011 31.149 280.545 31.1194 281.02 31.0602C281.524 30.9714 282.073 30.8234 282.666 30.6163V35.6328C282.043 35.8399 281.257 36.0027 280.308 36.1211C279.388 36.2395 278.602 36.2987 277.95 36.2987Z" fill="white"/>
<path d="M296.637 36.3875C294.353 36.3875 292.291 35.8695 290.452 34.8337C288.613 33.7682 287.159 32.3328 286.091 30.5275C285.023 28.6925 284.489 26.6504 284.489 24.4012C284.489 22.1223 285.023 20.0802 286.091 18.2748C287.159 16.4695 288.613 15.0341 290.452 13.9686C292.291 12.9032 294.353 12.3705 296.637 12.3705C298.477 12.3705 300.227 12.7256 301.888 13.4359C303.579 14.1462 305.033 15.1673 306.249 16.4991L302.511 20.4501C301.651 19.5031 300.746 18.8076 299.797 18.3636C298.877 17.8901 297.869 17.6533 296.771 17.6533C295.584 17.6533 294.516 17.9493 293.567 18.5412C292.647 19.1331 291.906 19.9322 291.342 20.9385C290.808 21.9447 290.541 23.0989 290.541 24.4012C290.541 25.6442 290.808 26.7836 291.342 27.8195C291.906 28.8257 292.677 29.6248 293.656 30.2167C294.635 30.779 295.718 31.0602 296.904 31.0602C297.943 31.0602 298.907 30.853 299.797 30.4387C300.716 29.9948 301.592 29.3437 302.422 28.4854L306.071 32.3032C304.884 33.5759 303.46 34.5821 301.799 35.322C300.138 36.0323 298.417 36.3875 296.637 36.3875Z" fill="white"/>
<path d="M309.24 35.9435V4.86794L315.337 3.53613V14.7233C317.087 13.1548 319.252 12.3705 321.833 12.3705C323.643 12.3705 325.23 12.77 326.595 13.5691C327.989 14.3386 329.072 15.4188 329.843 16.8098C330.614 18.1712 331 19.7694 331 21.6044V35.9435H324.904V22.4922C324.904 20.9828 324.474 19.799 323.613 18.9407C322.753 18.0529 321.581 17.6089 320.098 17.6089C319.06 17.6089 318.14 17.8161 317.339 18.2304C316.538 18.6152 315.871 19.1775 315.337 19.9174V35.9435H309.24Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 10 KiB

View File

@@ -1,10 +1,12 @@
{
"app": {
"collapse_sidebar": "Collapse Sidebar",
"continue_to_dashboard": "Continue to Dashboard",
"expand_sidebar": "Expand Sidebar",
"name": "HOPPSCOTCH",
"no_name": "No name",
"open_navigation": "Open Navigation"
"open_navigation": "Open Navigation",
"read_documentation": "Read Documentation"
},
"configs": {
"auth_providers": {
@@ -15,6 +17,16 @@
"update_failure": "Failed to update authentication provider configurations!!"
},
"confirm_changes": "Hoppscotch server must restart to reflect the new changes. Confirm changes made to the server configurations?",
"input_empty": "Please fill all the fields before updating the configurations",
"data_sharing": {
"title": "Data Sharing",
"description": "Help improve Hoppscotch by sharing anonymous data",
"enable": "Enable Data Sharing",
"secondary_title": "Data Sharing Configurations",
"see_shared": "See what is shared",
"toggle_description": "Share anonymous data",
"update_failure": "Failed to update data sharing configurations!!"
},
"load_error": "Unable to load server configurations",
"mail_configs": {
"description": " Configure the smtp configurations",
@@ -39,23 +51,34 @@
"save_changes": "Save Changes",
"title": "Configurations"
},
"data_sharing": {
"description": "Share anonymous data usage to improve Hoppscotch",
"enable": "Enable Data Sharing",
"see_shared": "See what is shared",
"toggle_description": "Share data and make Hoppscotch better",
"title": "Make Hoppscotch Better",
"welcome": "Welcome to"
},
"metrics": {
"dashboard": "Dashboard",
"no_metrics": "No metrics found",
"total_collections": "Total Collections",
"total_requests": "Total Requests",
"total_teams": "Total Teams",
"total_teams": "Total Workspaces",
"total_users": "Total Users"
},
"role": {
"editor": "EDITOR",
"owner": "OWNER",
"viewer": "VIEWER"
"newsletter": {
"description": "Get updates about our latest news",
"subscribe": "Subscribe",
"title": "Stay in Touch",
"toggle_description": "Get updates about the latest at Hoppscotch",
"unsubscribe": "Unsubscribe"
},
"settings": {
"settings": "Settings"
},
"shared_requests": {
"action": "Action",
"clear_filter": "Clear Filter",
"confirm_request_deletion": "Confirm deletion of the selected shared request?",
"copy": "Copy",
@@ -75,8 +98,8 @@
"url": "URL"
},
"state": {
"add_user_failure": "Failed to add user to the team!!",
"add_user_success": "User is now a member of the team!!",
"add_user_failure": "Failed to add user to the workspace!!",
"add_user_success": "User is now a member of the workspace!!",
"admin_failure": "Failed to make user an admin!!",
"admin_success": "User is now an admin!!",
"and": "and",
@@ -87,19 +110,20 @@
"continue_google": "Continue with Google",
"continue_microsoft": "Continue with Microsoft",
"copied_to_clipboard": "Copied to clipboard",
"create_team_failure": "Failed to create team!!",
"create_team_success": "Team created successfully!!",
"create_team_failure": "Failed to create workspace!!",
"create_team_success": "Workspace created successfully!!",
"data_sharing_failure": "Failed to update data sharing settings",
"delete_request_failure": "Shared Request deletion failed!!",
"delete_request_success": "Shared Request deleted successfully!!",
"delete_team_failure": "Team deletion failed!!",
"delete_team_success": "Team deleted successfully!!",
"delete_team_failure": "Workspace deletion failed!!",
"delete_team_success": "Workspace deleted successfully!!",
"delete_user_failure": "User deletion failed!!",
"delete_user_success": "User deleted successfully!!",
"email": "Email",
"email_failure": "Failed to send invitation",
"email_signin_failure": "Failed to login with Email",
"email_success": "Email invitation sent successfully",
"enter_team_email": "Please enter email of team owner!!",
"enter_team_email": "Please enter email of workspace owner!!",
"error": "Something went wrong",
"error_auth_providers": "Unable to load auth providers",
"github_signin_failure": "Failed to login with Github",
@@ -111,8 +135,10 @@
"magic_link_sign_in": "Click on the link to sign in.",
"magic_link_success": "We sent a magic link to",
"microsoft_signin_failure": "Failed to login with Microsoft",
"newsletter_failure": "Unable to update newsletter settings",
"non_admin_logged_in": "Logged in as non admin user.",
"non_admin_login": "You are logged in. But you're not an admin",
"owner_not_present": "Atleast one owner should be present in the team!!",
"privacy_policy": "Privacy Policy",
"reenter_email": "Re-enter email",
"remove_admin_failure": "Failed to remove admin status!!",
@@ -122,58 +148,75 @@
"remove_invitee_success": "Removal of invitee is successfull!!",
"remove_member_failure": "Member couldn't be removed!!",
"remove_member_success": "Member removed successfully!!",
"rename_team_failure": "Failed to rename team!!",
"rename_team_success": "Team renamed successfully!",
"rename_team_failure": "Failed to rename workspace!!",
"rename_team_success": "Workspace renamed successfully!",
"require_auth_provider": "You need to set atleast one authentication provider to log in.",
"role_update_failed": "Roles updation has failed!!",
"role_update_success": "Roles updated successfully!!",
"self_host_docs": "Self Host Documentation",
"send_magic_link": "Send magic link",
"setup_failure": "Setup has failed!!",
"setup_success": "Setup completed successfully!!",
"sign_in_agreement": "By signing in, you are agreeing to our",
"sign_in_options": "All sign in option",
"sign_out": "Sign out",
"team_name_long": "Team name should be atleast 6 characters long!!",
"team_name_too_short": "Workspace name should be atleast 6 characters long!!",
"user_not_found": "User not found in the infra!!"
},
"teams": {
"add_member": "Add Member",
"add_members": "Add Members",
"add_new": "Add New",
"admin": "Admin",
"admin_Email": "Admin Email",
"admin_id": "Admin ID",
"cancel": "Cancel",
"confirm_team_deletion": "Confirm Deletion of the team?",
"create_team": "Create team",
"confirm_team_deletion": "Confirm deletion of the workspace?",
"create_team": "Create Workspace",
"date": "Date",
"delete_team": "Delete Team",
"delete_team": "Delete Workspace",
"details": "Details",
"edit": "Edit",
"email": "Team owner email",
"editor": "EDITOR",
"editor_description": "Editors can add, edit, and delete requests and collections.",
"email": "Workspace owner email",
"email_address": "Email Address",
"email_title": "Email",
"empty_name": "Team name cannot be empty!!",
"error": "Something went wrong. Please try again later.",
"id": "Team ID",
"id": "Workspace ID",
"invited_email": "Invitee Email",
"invited_on": "Invited On",
"invites": "Invites",
"load_info_error": "Unable to load team info",
"load_list_error": "Unable to Load Teams List",
"load_info_error": "Unable to load Workspace info",
"load_list_error": "Unable to Load Workspace List",
"members": "Number of members",
"name": "Team Name",
"no_members": "No members in this team. Add members to this team to collaborate",
"no_invite": "No invites",
"owner": "OWNER",
"owner_description": " Owners can add, edit, and delete requests, collections and workspace members.",
"permissions": "Permissions",
"name": "Workspace Name",
"no_members": "No members in this workspace. Add members to this workspace to collaborate",
"no_pending_invites": "No pending invites",
"no_teams": "No teams found",
"no_teams": "No workspaces found",
"pending_invites": "Pending invites",
"roles": "Roles",
"roles_description": "Roles are used to control access to the shared collections.",
"remove": "Remove",
"rename": "Rename",
"save": "Save",
"save_changes": "Save Changes",
"send_invite": "Send Invite",
"show_more": "Show more",
"team_details": "Team details",
"team_details": "Workspace details",
"team_members": "Members",
"team_members_tab": "Team members",
"teams": "Teams",
"team_members_tab": "Workspace members",
"teams": "Workspace",
"uid": "UID",
"unnamed": "(Unnamed Team)",
"valid_name": "Please enter a valid team name",
"unnamed": "(Unnamed Workspace)",
"viewer": "VIEWER",
"viewer_description": "Viewers can only view and use requests",
"valid_name": "Please enter a valid workspace name",
"valid_owner_email": "Please enter a valid owner email"
},
"users": {

View File

@@ -17,25 +17,21 @@ declare module '@vue/runtime-core' {
HoppButtonPrimary: typeof import('@hoppscotch/ui')['HoppButtonPrimary']
HoppButtonSecondary: typeof import('@hoppscotch/ui')['HoppButtonSecondary']
HoppSmartAnchor: typeof import('@hoppscotch/ui')['HoppSmartAnchor']
HoppSmartAutoComplete: typeof import('@hoppscotch/ui')['HoppSmartAutoComplete']
HoppSmartConfirmModal: typeof import('@hoppscotch/ui')['HoppSmartConfirmModal']
HoppSmartInput: typeof import('@hoppscotch/ui')['HoppSmartInput']
HoppSmartItem: typeof import('@hoppscotch/ui')['HoppSmartItem']
HoppSmartLink: typeof import('@hoppscotch/ui')['HoppSmartLink']
HoppSmartModal: typeof import('@hoppscotch/ui')['HoppSmartModal']
HoppSmartPicture: typeof import('@hoppscotch/ui')['HoppSmartPicture']
HoppSmartSpinner: typeof import('@hoppscotch/ui')['HoppSmartSpinner']
HoppSmartTab: typeof import('@hoppscotch/ui')['HoppSmartTab']
HoppSmartTable: typeof import('@hoppscotch/ui')['HoppSmartTable']
HoppSmartTabs: typeof import('@hoppscotch/ui')['HoppSmartTabs']
HoppSmartToggle: typeof import('@hoppscotch/ui')['HoppSmartToggle']
IconLucideChevronDown: typeof import('~icons/lucide/chevron-down')['default']
IconLucideInbox: typeof import('~icons/lucide/inbox')['default']
SettingsAuthProvider: typeof import('./components/settings/AuthProvider.vue')['default']
SettingsConfigurations: typeof import('./components/settings/Configurations.vue')['default']
SettingsDataSharing: typeof import('./components/settings/DataSharing.vue')['default']
SettingsReset: typeof import('./components/settings/Reset.vue')['default']
SettingsServerRestart: typeof import('./components/settings/ServerRestart.vue')['default']
SettingsSmtpConfiguration: typeof import('./components/settings/SmtpConfiguration.vue')['default']
SetupDataSharingAndNewsletter: typeof import('./components/setup/DataSharingAndNewsletter.vue')['default']
TeamsAdd: typeof import('./components/teams/Add.vue')['default']
TeamsDetails: typeof import('./components/teams/Details.vue')['default']
TeamsInvite: typeof import('./components/teams/Invite.vue')['default']

View File

@@ -2,6 +2,7 @@
<div>
<SettingsAuthProvider v-model:config="workingConfigs" />
<SettingsSmtpConfiguration v-model:config="workingConfigs" />
<SettingsDataSharing v-model:config="workingConfigs" />
<SettingsReset />
</div>
</template>

View File

@@ -0,0 +1,66 @@
<template>
<div class="md:grid md:grid-cols-3 md:gap-4 border-divider border-b py-8">
<div class="px-8 md:col-span-1">
<h3 class="heading">{{ t('configs.data_sharing.title') }}</h3>
<p class="my-1 text-secondaryLight">
{{ t('configs.data_sharing.description') }}
</p>
</div>
<div class="mx-8 md:col-span-2">
<h4 class="font-semibold text-secondaryDark">
{{ t('configs.data_sharing.title') }}
</h4>
<div class="flex items-center space-y-4 py-4">
<HoppSmartToggle
:on="dataSharingConfigs.enabled"
@change="dataSharingConfigs.enabled = !dataSharingConfigs.enabled"
>
{{ t('configs.data_sharing.toggle_description') }}
</HoppSmartToggle>
</div>
<!-- TODO: Update the link below -->
<HoppButtonSecondary
outline
filled
:icon="IconShieldQuestion"
:label="t('configs.data_sharing.see_shared')"
to="http://docs.hoppscotch.io"
blank
class="w-min my-2"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { useVModel } from '@vueuse/core';
import { computed } from 'vue';
import { useI18n } from '~/composables/i18n';
import { Config } from '~/composables/useConfigHandler';
import IconShieldQuestion from '~icons/lucide/shield-question';
const t = useI18n();
const props = defineProps<{
config: Config;
}>();
const emit = defineEmits<{
(e: 'update:config', v: Config): void;
}>();
const workingConfigs = useVModel(props, 'config', emit);
// Data Sharing Configs
const dataSharingConfigs = computed({
get() {
return workingConfigs.value?.dataSharingConfigs;
},
set(value) {
workingConfigs.value.dataSharingConfigs = value;
},
});
</script>

View File

@@ -22,6 +22,7 @@ import {
EnableAndDisableSsoDocument,
ResetInfraConfigsDocument,
UpdateInfraConfigsDocument,
ToggleAnalyticsCollectionDocument,
} from '~/helpers/backend/graphql';
const t = useI18n();
@@ -43,10 +44,17 @@ const updateInfraConfigsMutation = useMutation(UpdateInfraConfigsDocument);
const updateAllowedAuthProviderMutation = useMutation(
EnableAndDisableSsoDocument
);
const toggleDataSharingMutation = useMutation(
ToggleAnalyticsCollectionDocument
);
// Mutation handlers
const { updateInfraConfigs, updateAuthProvider, resetInfraConfigs } =
useConfigHandler(props.workingConfigs);
const {
updateInfraConfigs,
updateAuthProvider,
resetInfraConfigs,
updateDataSharingConfigs,
} = useConfigHandler(props.workingConfigs);
// Call relevant mutations on component mount and initiate server restart
const duration = ref(30);
@@ -72,13 +80,20 @@ onMounted(async () => {
success = await resetInfraConfigs(resetInfraConfigsMutation);
if (!success) return;
} else {
const infraResult = await updateInfraConfigs(updateInfraConfigsMutation);
if (!infraResult) return;
const authResult = await updateAuthProvider(
updateAllowedAuthProviderMutation
);
const infraResult = await updateInfraConfigs(updateInfraConfigsMutation);
if (!authResult) return;
success = authResult && infraResult;
if (!success) return;
const dataSharingResult = await updateDataSharingConfigs(
toggleDataSharingMutation
);
if (!dataSharingResult) return;
}
restart.value = true;

View File

@@ -0,0 +1,141 @@
<template>
<div
class="flex flex-col items-center justify-center min-h-screen space-y-10"
>
<div class="flex items-center justify-center flex-col space-y-2">
<h2 class="text-lg">{{ t('data_sharing.welcome') }}</h2>
<img
src="/assets/images/hoppscotch-title.svg"
alt="hoppscotch-title"
class="w-52"
/>
</div>
<div
class="bg-primaryLight p-10 border-2 border-dividerLight rounded-lg flex flex-col space-y-8"
>
<div class="flex flex-col space-y-5 items-start">
<div>
<p class="text-lg font-bold text-white">
{{ t('data_sharing.title') }}
</p>
<p class="font-light">
{{ t('data_sharing.description') }}
</p>
</div>
<HoppSmartToggle
:on="dataSharingToggle"
@change="dataSharingToggle = !dataSharingToggle"
>
{{ t('data_sharing.toggle_description') }}
</HoppSmartToggle>
<!-- TODO: Update link -->
<HoppSmartAnchor
blank
to="http://docs.hoppscotch.io"
:label="t('data_sharing.see_shared')"
class="underline"
/>
</div>
<div class="flex flex-col items-start space-y-5">
<div>
<p class="text-lg font-bold text-white">
{{ t('newsletter.title') }}
</p>
<p class="font-light">{{ t('newsletter.description') }}</p>
</div>
<HoppSmartToggle
:on="newsletterToggle"
@change="newsletterToggle = !newsletterToggle"
>
{{ t('newsletter.toggle_description') }}
</HoppSmartToggle>
</div>
<div class="flex flex-col items-center space-y-5">
<HoppButtonPrimary
:icon="IconLogIn"
:label="t('app.continue_to_dashboard')"
class="mx-10"
@click="submitSelection"
/>
<HoppSmartAnchor
blank
to="http://docs.hoppscotch.io"
:icon="IconBookOpenText"
:label="t('app.read_documentation')"
/>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { useMutation } from '@urql/vue';
import { ref } from 'vue';
import { useI18n } from '~/composables/i18n';
import { useToast } from '~/composables/toast';
import { auth } from '~/helpers/auth';
import { listmonkApi } from '~/helpers/axiosConfig';
import {
ServiceStatus,
ToggleAnalyticsCollectionDocument,
} from '~/helpers/backend/graphql';
import IconBookOpenText from '~icons/lucide/book-open-text';
import IconLogIn from '~icons/lucide/log-in';
const t = useI18n();
const toast = useToast();
const user = auth.getCurrentUser();
const emit = defineEmits<{
(event: 'setupComplete', status: boolean): void;
}>();
const dataSharingToggle = ref(true);
const newsletterToggle = ref(true);
// Toggle data sharing
const dataSharingMutation = useMutation(ToggleAnalyticsCollectionDocument);
const toggleDataSharing = async () => {
const status = dataSharingToggle.value
? ServiceStatus.Enable
: ServiceStatus.Disable;
const result = await dataSharingMutation.executeMutation({ status });
if (result.error) {
toast.error(t('state.data_sharing_failure'));
return false;
}
return true;
};
// Toggle subscription to newsletter
const toggleNewsletter = async () => {
try {
await listmonkApi.post('/subscription', {
email: user?.email,
name: user?.displayName,
list_uuids: ['f5f0b457-44d0-4aa1-b6f9-165dc1efa56a'],
});
return true;
} catch (e) {
console.error(e);
toast.error(t('state.newsletter_failure'));
return false;
}
};
// Submit selections made
const submitSelection = async () => {
const dataSharingResult =
dataSharingToggle.value && (await toggleDataSharing());
const newsletterResult = newsletterToggle.value && (await toggleNewsletter());
const setupDataComplete = !dataSharingToggle.value || dataSharingResult;
const setupNewsletterComplete = !newsletterToggle.value || newsletterResult;
emit('setupComplete', setupDataComplete && setupNewsletterComplete);
};
</script>

View File

@@ -3,7 +3,7 @@
v-if="show"
dialog
:title="t('teams.create_team')"
@close="$emit('hide-modal')"
@close="emit('hide-modal')"
>
<template #body>
<div class="flex flex-col space-y-4 relative">
@@ -16,12 +16,16 @@
class="flex-1 !flex"
:source="allUsersEmail"
:spellcheck="true"
placeholder=""
:placeholder="t('teams.email')"
@input="(email: string) => getOwnerEmail(email)"
/>
</div>
<label for="teamName"> {{ t('teams.name') }} </label>
<HoppSmartInput v-model="teamName" placeholder="" class="!my-2" />
<HoppSmartInput
v-model="teamName"
:placeholder="t('teams.name')"
class="!my-2"
/>
</div>
</template>
<template #footer>
@@ -44,11 +48,10 @@
<script setup lang="ts">
import { ref, watchEffect } from 'vue';
import { useToast } from '~/composables/toast';
import { useI18n } from '~/composables/i18n';
import { useToast } from '~/composables/toast';
const t = useI18n();
const toast = useToast();
const props = withDefaults(
@@ -82,11 +85,11 @@ const getOwnerEmail = (email: string) => (ownerEmail.value = email);
const createTeam = () => {
if (teamName.value.trim() === '') {
toast.error(`${t('teams.valid_name')}`);
toast.error(t('teams.valid_name'));
return;
}
if (ownerEmail.value.trim() === '') {
toast.error(`${t('teams.valid_owner_email')}`);
toast.error(t('teams.valid_owner_email'));
return;
}
emit('create-team', teamName.value, ownerEmail.value);

View File

@@ -1,7 +1,7 @@
<template>
<div class="flex flex-col">
<div v-if="team" class="flex flex-col">
<div class="flex flex-col space-y-8">
<div v-if="team.id" class="flex flex-col space-y-3">
<div class="flex flex-col space-y-3">
<label class="text-accentContrast" for="username"
>{{ t('teams.id') }}
</label>
@@ -10,33 +10,33 @@
</div>
</div>
<div v-if="teamName" class="flex flex-col space-y-3">
<div class="flex flex-col space-y-3">
<label class="text-accentContrast" for="teamname"
>{{ t('teams.name') }}
</label>
<div
class="flex bg-divider rounded-md items-stretch flex-1 border border-divider"
:class="{
'!border-accent': showRenameInput,
'!border-accent': isTeamNameBeingEdited,
}"
>
<HoppSmartInput
v-model="newTeamName"
v-model="updatedTeamName"
styles="bg-transparent flex-1 rounded-md !rounded-r-none disabled:select-none border-r-0 disabled:cursor-default disabled:opacity-50"
placeholder="Team Name"
:disabled="!showRenameInput"
:disabled="!isTeamNameBeingEdited"
>
<template #button>
<HoppButtonPrimary
class="!rounded-l-none"
filled
:icon="showRenameInput ? IconSave : IconEdit"
:icon="isTeamNameBeingEdited ? IconSave : IconEdit"
:label="
showRenameInput
isTeamNameBeingEdited
? `${t('teams.rename')}`
: `${t('teams.edit')}`
"
@click="handleNameEdit()"
@click="handleTeamNameEdit"
/>
</template>
</HoppSmartInput>
@@ -58,7 +58,7 @@
class="!bg-red-600 !hover:opacity-80"
filled
:label="t('teams.delete_team')"
@click="team && $emit('delete-team', team.id)"
@click="emit('delete-team', team.id)"
:icon="IconTrash"
/>
</div>
@@ -66,45 +66,89 @@
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { useMutation } from '@urql/vue';
import { useVModel } from '@vueuse/core';
import { computed, onMounted, ref } from 'vue';
import { useI18n } from '~/composables/i18n';
import { useToast } from '~/composables/toast';
import { TeamInfoQuery } from '~/helpers/backend/graphql';
import { RenameTeamDocument, TeamInfoQuery } from '~/helpers/backend/graphql';
import IconEdit from '~icons/lucide/edit';
import IconSave from '~icons/lucide/save';
import IconTrash from '~icons/lucide/trash-2';
import { useI18n } from '~/composables/i18n';
const t = useI18n();
const toast = useToast();
const props = defineProps<{
team: TeamInfoQuery['infra']['teamInfo'];
teamName: string;
showRenameInput: boolean;
}>();
const emit = defineEmits<{
(event: 'delete-team', teamID: string): void;
(event: 'rename-team', teamName: string): void;
(event: 'update:showRenameInput', showRenameInput: boolean): void;
(event: 'update:team', team: TeamInfoQuery['infra']['teamInfo']): void;
(event: 'delete-team', teamId: string): void;
}>();
const newTeamName = ref(props.teamName);
const team = useVModel(props, 'team', emit);
const handleNameEdit = () => {
if (props.showRenameInput) {
renameTeam();
// Contains the actual team name
const teamName = computed({
get: () => team.value.name,
set: (value) => {
team.value.name = value;
},
});
// Contains the stored team name from the actual team name before being edited
const currentTeamName = ref('');
// Contains the team name that is being edited
const updatedTeamName = computed({
get: () => currentTeamName.value,
set: (value) => {
currentTeamName.value = value;
},
});
// Set the current team name to the actual team name
onMounted(() => {
currentTeamName.value = teamName.value;
});
// Rename the team name
const isTeamNameBeingEdited = ref(false);
const teamRename = useMutation(RenameTeamDocument);
const handleTeamNameEdit = () => {
if (isTeamNameBeingEdited.value) {
// If the team name is not changed, then return control
if (teamName.value !== updatedTeamName.value) {
renameTeamName();
} else isTeamNameBeingEdited.value = false;
} else {
emit('update:showRenameInput', true);
isTeamNameBeingEdited.value = true;
}
};
const renameTeam = () => {
if (newTeamName.value.trim() === '') {
toast.error(`${t('teams.empty_name')}`);
const renameTeamName = async () => {
if (updatedTeamName.value.trim() === '') {
toast.error(t('teams.empty_name'));
return;
}
emit('rename-team', newTeamName.value);
if (updatedTeamName.value.length < 6) {
toast.error(t('state.team_name_too_short'));
return;
}
const variables = { uid: team.value.id, name: updatedTeamName.value };
const result = await teamRename.executeMutation(variables);
if (result.error) {
toast.error(t('state.rename_team_failure'));
} else {
isTeamNameBeingEdited.value = false;
toast.success(t('state.rename_team_success'));
teamName.value = updatedTeamName.value;
}
};
</script>

View File

@@ -1,16 +1,23 @@
<template>
<HoppSmartModal v-if="show" dialog title="Add Member" @close="hideModal">
<HoppSmartModal
v-if="show"
dialog
:title="t('teams.add_member')"
@close="hideModal"
>
<template #body>
<div v-if="addingUserToTeam" class="flex items-center justify-center p-4">
<HoppSmartSpinner />
</div>
<div v-else class="flex flex-col">
<div class="flex items-center justify-between flex-1 pt-4">
<label for="memberList" class="p-4"> Add members </label>
<label for="memberList" class="p-4">
{{ t('teams.add_members') }}
</label>
<div class="flex">
<HoppButtonSecondary
:icon="IconPlus"
label="Add new"
:label="t('teams.add_new')"
filled
@click="addNewMember"
/>
@@ -23,8 +30,8 @@
class="flex divide-x divide-dividerLight"
>
<HoppSmartAutoComplete
v-model="member.key"
placeholder="Email"
:value="member.key"
:placeholder="t('state.email')"
:source="allUsersEmail"
:name="'member' + index"
:spellcheck="true"
@@ -44,7 +51,7 @@
<HoppSmartSelectWrapper>
<input
class="flex flex-1 px-4 py-2 bg-transparent cursor-pointer"
placeholder="Permissions"
:placeholder="t('teams.permissions')"
:name="'value' + index"
:value="member.value"
readonly
@@ -58,7 +65,7 @@
@keyup.escape="hide()"
>
<HoppSmartItem
label="OWNER"
:label="t('teams.owner')"
:icon="
member.value === 'OWNER' ? IconCircleDot : IconCircle
"
@@ -71,7 +78,7 @@
"
/>
<HoppSmartItem
label="EDITOR"
:label="t('teams.editor')"
:icon="
member.value === 'EDITOR' ? IconCircleDot : IconCircle
"
@@ -84,7 +91,7 @@
"
/>
<HoppSmartItem
label="VIEWER"
:label="t('teams.viewer')"
:icon="
member.value === 'VIEWER' ? IconCircleDot : IconCircle
"
@@ -104,7 +111,7 @@
<HoppButtonSecondary
id="member"
v-tippy="{ theme: 'tooltip' }"
title="Remove"
:title="t('teams.remove')"
:icon="IconTrash"
color="red"
@click="removeNewMember(index)"
@@ -113,13 +120,13 @@
</div>
<HoppSmartPlaceholder
v-if="newMembersList.length === 0"
:src="`/images/states/dark/add_group.svg`"
alt="No invites"
text="No invites"
:src="addGroupImagePath"
:alt="t('teams.no_members')"
:text="t('teams.no_members')"
>
<template #body>
<HoppButtonSecondary
label="Add new"
:label="t('teams.add_new')"
filled
@click="addNewMember"
/>
@@ -136,11 +143,12 @@
<icon-lucide-help-circle
class="mr-2 text-secondaryLight svg-icons"
/>
Roles
{{ t('teams.roles') }}
</span>
<p>
<span class="text-secondaryLight">
Roles are used to control access to the shared collections.
{{ t('teams.roles_description') }}
</span>
</p>
<ul class="mt-4 space-y-4">
@@ -148,31 +156,30 @@
<span
class="w-1/4 font-semibold uppercase truncate text-secondaryDark max-w-[4rem]"
>
Owner
{{ t('teams.owner') }}
</span>
<span class="flex flex-1">
Owners can add, edit, and delete requests, collections and team
members.
{{ t('teams.owner_description') }}
</span>
</li>
<li class="flex">
<span
class="w-1/4 font-semibold uppercase truncate text-secondaryDark max-w-[4rem]"
>
Editor
{{ t('teams.editor') }}
</span>
<span class="flex flex-1">
Editors can add, edit, and delete requests.
{{ t('teams.editor_description') }}
</span>
</li>
<li class="flex">
<span
class="w-1/4 font-semibold uppercase truncate text-secondaryDark max-w-[4rem]"
>
Viewer
{{ t('teams.viewer') }}
</span>
<span class="flex flex-1">
Viewers can only view and use requests.
{{ t('teams.viewer_description') }}
</span>
</li>
</ul>
@@ -183,41 +190,61 @@
<template #footer>
<span class="flex space-x-2">
<HoppButtonPrimary
label="Add Member"
:label="t('teams.add_member')"
outline
@click="addUserasTeamMember"
/>
<HoppButtonSecondary label="Cancel" outline filled @click="hideModal" />
<HoppButtonSecondary
:label="t('teams.cancel')"
outline
filled
@click="hideModal"
/>
</span>
</template>
</HoppSmartModal>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { useMutation, useQuery } from '@urql/vue';
import * as A from 'fp-ts/Array';
import * as O from 'fp-ts/Option';
import { pipe } from 'fp-ts/function';
import { computed, ref } from 'vue';
import { useI18n } from '~/composables/i18n';
import { useToast } from '~/composables/toast';
import { usePagedQuery } from '~/composables/usePagedQuery';
import { Email, EmailCodec } from '~/helpers/Email';
import IconCircle from '~icons/lucide/circle';
import IconCircleDot from '~icons/lucide/circle-dot';
import IconPlus from '~icons/lucide/plus';
import IconTrash from '~icons/lucide/trash';
import {
AddUserToTeamByAdminDocument,
TeamMemberRole,
MetricsDocument,
TeamMemberRole,
UsersListDocument,
} from '../../helpers/backend/graphql';
import { useToast } from '~/composables/toast';
import { useMutation, useQuery } from '@urql/vue';
import { Email, EmailCodec } from '~/helpers/Email';
import IconTrash from '~icons/lucide/trash';
import IconPlus from '~icons/lucide/plus';
import IconCircleDot from '~icons/lucide/circle-dot';
import IconCircle from '~icons/lucide/circle';
import { computed } from 'vue';
import { usePagedQuery } from '~/composables/usePagedQuery';
import { useI18n } from '~/composables/i18n';
const t = useI18n();
const toast = useToast();
const tippyActions = ref<any | null>(null);
// Get Users List
const props = defineProps<{
show: boolean;
editingTeamID: string;
}>();
const emit = defineEmits<{
(e: 'hide-modal'): void;
(e: 'member'): void;
}>();
const addGroupImagePath = `${
import.meta.env.VITE_ADMIN_URL
}/assets/images/add_group.svg`;
// Get Users List to extract email ids of all users
const { data } = useQuery({ query: MetricsDocument });
const usersPerPage = computed(() => data.value?.infra.usersCount || 10000);
@@ -231,21 +258,6 @@ const { list: usersList } = usePagedQuery(
const allUsersEmail = computed(() => usersList.value.map((user) => user.email));
const toast = useToast();
// Template refs
const tippyActions = ref<any | null>(null);
const props = defineProps({
show: Boolean,
editingTeamID: { type: String, default: null },
});
const emit = defineEmits<{
(e: 'hide-modal'): void;
(e: 'member'): void;
}>();
const newMembersList = ref<Array<{ key: string; value: TeamMemberRole }>>([
{
key: '',
@@ -264,12 +276,13 @@ const updateNewMemberRole = (index: number, role: TeamMemberRole) => {
newMembersList.value[index].value = role;
};
const removeNewMember = (id: number) => {
newMembersList.value.splice(id, 1);
const removeNewMember = (index: number) => {
newMembersList.value.splice(index, 1);
};
const addingUserToTeam = ref<boolean>(false);
// Checks if the member invites are of valid email format and then adds the users to the team
const addUserasTeamMember = async () => {
addingUserToTeam.value = true;
@@ -293,7 +306,7 @@ const addUserasTeamMember = async () => {
if (O.isNone(validationResult)) {
// Error handling for no validation
toast.error(`${t('users.invalid_user')}`);
toast.error(t('users.invalid_user'));
addingUserToTeam.value = false;
return;
}
@@ -320,20 +333,18 @@ const addUserToTeam = async (
) => {
const variables = { userEmail: email, role: userRole, teamID: teamID };
const res = await addUserToTeamMutation
.executeMutation(variables)
.then((result) => {
if (result.error) {
if (result.error.toString() == '[GraphQL] user/not_found') {
toast.error(`${t('state.user_not_found')}`);
} else {
toast.error(`${t('state.add_user_failure')}`);
}
} else {
toast.success(`${t('state.add_user_success')}`);
emit('member');
}
});
return res;
const result = await addUserToTeamMutation.executeMutation(variables);
if (result.error) {
if (result.error.toString() == '[GraphQL] user/not_found') {
toast.error(t('state.user_not_found'));
} else {
toast.error(t('state.add_user_failure'));
}
} else {
toast.success(t('state.add_user_success'));
emit('member');
}
return result;
};
</script>

View File

@@ -13,7 +13,7 @@
<div class="border rounded border-divider my-8">
<HoppSmartPlaceholder
v-if="team?.teamMembers?.length === 0"
text="No members in this team. Add members to this team to collaborate"
:text="t('teams.no_members')"
>
<template #body>
<HoppButtonSecondary
@@ -35,7 +35,7 @@
>
<input
class="flex flex-1 px-4 py-3 bg-transparent"
placeholder="Email"
:placeholder="t('teams.email_title')"
:name="'param' + index"
:value="member.email"
readonly
@@ -50,7 +50,7 @@
<span class="relative">
<input
class="flex flex-1 px-4 py-3 bg-transparent cursor-pointer"
placeholder="Permissions"
:placeholder="t('teams.permissions')"
:name="'value' + index"
:value="member.role"
readonly
@@ -69,7 +69,7 @@
@keyup.escape="hide()"
>
<HoppSmartItem
label="OWNER"
:label="t('teams.owner')"
:icon="
member.role === 'OWNER' ? IconCircleDot : IconCircle
"
@@ -82,7 +82,7 @@
"
/>
<HoppSmartItem
label="EDITOR"
:label="t('teams.editor')"
:icon="
member.role === 'EDITOR' ? IconCircleDot : IconCircle
"
@@ -98,7 +98,7 @@
"
/>
<HoppSmartItem
label="VIEWER"
:label="t('teams.viewer')"
:icon="
member.role === 'VIEWER' ? IconCircleDot : IconCircle
"
@@ -131,7 +131,7 @@
</div>
</div>
</div>
<div v-if="!fetching && !team" class="flex flex-col items-center">
<div v-if="!team" class="flex flex-col items-center">
<icon-lucide-help-circle class="mb-4 svg-icons" />
{{ t('teams.error') }}
</div>
@@ -139,13 +139,15 @@
<div class="flex">
<HoppButtonPrimary
:label="t('teams.save')"
v-if="areRolesUpdated"
:label="t('teams.save_changes')"
outline
@click="saveUpdatedTeam"
/>
</div>
<TeamsInvite
:show="showInvite"
:team="team"
:editingTeamID="route.params.id.toString()"
@member="updateMembers"
@hide-modal="
@@ -158,102 +160,101 @@
</template>
<script setup lang="ts">
import IconCircleDot from '~icons/lucide/circle-dot';
import IconCircle from '~icons/lucide/circle';
import IconUserPlus from '~icons/lucide/user-plus';
import IconUserMinus from '~icons/lucide/user-minus';
import IconChevronDown from '~icons/lucide/chevron-down';
import { useClientHandle, useMutation } from '@urql/vue';
import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
import { useMutation } from '@urql/vue';
import { useVModel } from '@vueuse/core';
import { cloneDeep, isEqual } from 'lodash-es';
import { computed, onMounted, ref } from 'vue';
import { useRoute } from 'vue-router';
import { useI18n } from '~/composables/i18n';
import { useToast } from '~/composables/toast';
import { useClientHandler } from '~/composables/useClientHandler';
import IconChevronDown from '~icons/lucide/chevron-down';
import IconCircle from '~icons/lucide/circle';
import IconCircleDot from '~icons/lucide/circle-dot';
import IconUserMinus from '~icons/lucide/user-minus';
import IconUserPlus from '~icons/lucide/user-plus';
import {
ChangeUserRoleInTeamByAdminDocument,
TeamInfoDocument,
TeamMemberRole,
RemoveUserFromTeamByAdminDocument,
TeamInfoDocument,
TeamInfoQuery,
TeamMemberRole,
} from '../../helpers/backend/graphql';
import { HoppButtonPrimary, HoppButtonSecondary } from '@hoppscotch/ui';
import { useI18n } from '~/composables/i18n';
const t = useI18n();
const toast = useToast();
const route = useRoute();
const props = defineProps<{
team: TeamInfoQuery['infra']['teamInfo'];
}>();
const emit = defineEmits<{
(e: 'update-team'): void;
(event: 'update:team', team: TeamInfoQuery['infra']['teamInfo']): void;
}>();
const teamDetails = useVModel(props, 'team', emit);
// Used to Invoke the Invite Members Modal
const showInvite = ref(false);
// Get Team Details
const team = ref<TeamInfoQuery['infra']['teamInfo'] | undefined>();
const fetching = ref(true);
const route = useRoute();
const { client } = useClientHandle();
const getTeamInfo = async () => {
fetching.value = true;
const result = await client
.query(TeamInfoDocument, { teamID: route.params.id.toString() })
.toPromise();
if (result.error) {
return toast.error(`${t('teams.load_info_error')}`);
const { fetchData: getTeamInfo, data: teamInfo } = useClientHandler(
TeamInfoDocument,
{
teamID: route.params.id.toString(),
}
if (result.data?.infra.teamInfo) {
team.value = result.data.infra.teamInfo;
}
fetching.value = false;
};
);
onMounted(async () => await getTeamInfo());
onUnmounted(() => emit('update-team'));
onMounted(async () => {
await getTeamInfo();
});
const team = computed(() => teamInfo.value?.infra.teamInfo);
// Update members tab after a change in the members list or member roles
const updateMembers = () => {
getTeamInfo();
emit('update-team');
const updateMembers = async () => {
if (!team.value) return;
await getTeamInfo();
teamDetails.value = team.value;
};
// Template refs
const tippyActions = ref<any | null>(null);
const roleUpdates = ref<
// Roles of the members in the team
const currentMemberRoles = ref<
{
userID: string;
role: TeamMemberRole;
}[]
>([]);
watch(
() => team.value,
(teamDetails) => {
const members = teamDetails?.teamMembers ?? [];
// Roles of the members in the team after the updates but before saving
const updatedMemberRoles = ref<
{
userID: string;
role: TeamMemberRole;
}[]
>(cloneDeep(currentMemberRoles.value));
// Remove deleted members
roleUpdates.value = roleUpdates.value.filter(
(update) =>
members.findIndex(
(y: { user: { uid: string } }) => y.user.uid === update.userID
) !== -1
);
}
// Check if the roles of the members have been updated
const areRolesUpdated = computed(() =>
currentMemberRoles.value && updatedMemberRoles.value
? !isEqual(currentMemberRoles.value, updatedMemberRoles.value)
: false
);
// Update the role of the member selected in the UI
const updateMemberRole = (userID: string, role: TeamMemberRole) => {
const updateIndex = roleUpdates.value.findIndex(
const updateIndex = updatedMemberRoles.value.findIndex(
(item) => item.userID === userID
);
if (updateIndex !== -1) {
// Role Update exists
roleUpdates.value[updateIndex].role = role;
updatedMemberRoles.value[updateIndex].role = role;
} else {
// Role Update does not exist
roleUpdates.value.push({
updatedMemberRoles.value.push({
userID,
role,
});
@@ -264,7 +265,7 @@ const updateMemberRole = (userID: string, role: TeamMemberRole) => {
const membersList = computed(() => {
if (!team.value) return [];
const members = (team.value.teamMembers ?? []).map((member) => {
const updatedRole = roleUpdates.value.find(
const updatedRole = updatedMemberRoles.value.find(
(update) => update.userID === member.user.uid
);
@@ -299,19 +300,31 @@ const isLoading = ref(false);
const saveUpdatedTeam = async () => {
isLoading.value = true;
roleUpdates.value.forEach(async (update) => {
const isOwnerPresent = membersList.value.some(
(member) => member.role === TeamMemberRole.Owner
);
if (!isOwnerPresent) {
toast.error(t('state.owner_not_present'));
isLoading.value = false;
return;
}
updatedMemberRoles.value.forEach(async (update) => {
if (!team.value) return;
const updateMemberRoleResult = await changeUserRoleInTeam(
update.userID,
team.value.id,
update.role
);
if (updateMemberRoleResult.error) {
toast.error(`${t('state.role_update_failed')}`);
roleUpdates.value = [];
toast.error(t('state.role_update_failed'));
} else {
toast.success(`${t('state.role_update_success')}`);
roleUpdates.value = [];
toast.success(t('state.role_update_success'));
currentMemberRoles.value = updatedMemberRoles.value;
updatedMemberRoles.value = cloneDeep(currentMemberRoles.value);
}
isLoading.value = false;
});
@@ -340,14 +353,14 @@ const removeExistingTeamMember = async (userID: string, index: number) => {
team.value.id
)();
if (removeTeamMemberResult.error) {
toast.error(`${t('state.remove_member_failure')}`);
toast.error(t('state.remove_member_failure'));
} else {
team.value.teamMembers = team.value.teamMembers?.filter(
(member: any) => member.user.uid !== userID
);
toast.success(`${t('state.remove_member_success')}`);
teamDetails.value = team.value;
toast.success(t('state.remove_member_success'));
}
isLoadingIndex.value = null;
emit('update-team');
};
</script>

View File

@@ -1,103 +1,76 @@
<template>
<div class="border rounded divide-y divide-dividerLight border-divider my-8">
<div v-if="fetching" class="flex items-center justify-center p-4">
<HoppSmartSpinner />
</div>
<div v-else>
<div v-if="team" class="divide-y divide-dividerLight">
<div
v-for="(invitee, index) in pendingInvites"
:key="`invitee-${index}`"
class="flex divide-x divide-dividerLight"
>
<input
v-if="invitee"
class="flex flex-1 px-4 py-2 bg-transparent text-secondaryLight"
placeholder="Email"
:name="'param' + index"
:value="invitee.inviteeEmail"
readonly
<HoppSmartPlaceholder
v-if="team && pendingInvites?.length === 0"
text="No pending invites"
/>
<div v-else class="divide-y divide-dividerLight">
<div
v-for="(invitee, index) in pendingInvites"
:key="`invitee-${index}`"
class="flex divide-x divide-dividerLight"
>
<input
v-if="invitee"
class="flex flex-1 px-4 py-2 bg-transparent text-secondaryLight"
:placeholder="t('teams.email_title')"
:name="'param' + index"
:value="invitee.inviteeEmail"
readonly
/>
<input
class="flex flex-1 px-4 py-2 bg-transparent text-secondaryLight"
:placeholder="t('teams.permission')"
:name="'value' + index"
:value="invitee.inviteeRole"
readonly
/>
<div class="flex">
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:title="t('teams.remove')"
:icon="IconTrash"
color="red"
:loading="isLoadingIndex === index"
@click="removeInvitee(invitee.id, index)"
/>
<input
class="flex flex-1 px-4 py-2 bg-transparent text-secondaryLight"
placeholder="Permissions"
:name="'value' + index"
:value="invitee.inviteeRole"
readonly
/>
<div class="flex">
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:title="t('teams.remove')"
:icon="IconTrash"
color="red"
:loading="isLoadingIndex === index"
@click="removeInvitee(invitee.id, index)"
/>
</div>
</div>
</div>
<HoppSmartPlaceholder
v-if="team && pendingInvites?.length === 0"
text="No pending invites"
>
<template #body>
<div v-if="!fetching && error" class="flex flex-col items-center p-4">
<icon-lucide-help-circle class="mb-4 svg-icons" />
{{ t('error.something_went_wrong') }}
</div>
</template>
</HoppSmartPlaceholder>
</div>
</div>
</template>
<script setup lang="ts">
import IconTrash from '~icons/lucide/trash';
import { useMutation, useClientHandle } from '@urql/vue';
import { ref, onMounted } from 'vue';
import { useMutation } from '@urql/vue';
import { useVModel } from '@vueuse/core';
import { computed, ref } from 'vue';
import { useI18n } from '~/composables/i18n';
import { useToast } from '~/composables/toast';
import {
RevokeTeamInvitationDocument,
TeamInfoDocument,
TeamInfoQuery,
} from '~/helpers/backend/graphql';
import { useToast } from '~/composables/toast';
import { useRoute } from 'vue-router';
import { useI18n } from '~/composables/i18n';
import IconTrash from '~icons/lucide/trash';
const t = useI18n();
const toast = useToast();
// Get details of the team
const fetching = ref(true);
const error = ref(false);
const { client } = useClientHandle();
const route = useRoute();
const team = ref<TeamInfoQuery['infra']['teamInfo'] | undefined>();
const pendingInvites = ref<
TeamInfoQuery['infra']['teamInfo']['teamInvitations'] | undefined
>();
const props = defineProps<{
team: TeamInfoQuery['infra']['teamInfo'];
}>();
const getTeamInfo = async () => {
fetching.value = true;
const result = await client
.query(TeamInfoDocument, { teamID: route.params.id.toString() })
.toPromise();
const emit = defineEmits<{
(event: 'update:team', team: TeamInfoQuery['infra']['teamInfo']): void;
}>();
if (result.error) {
error.value = true;
return toast.error(`${t('teams.load_info_error')}`);
}
if (result.data?.infra.teamInfo) {
team.value = result.data.infra.teamInfo;
pendingInvites.value = team.value.teamInvitations;
}
fetching.value = false;
};
onMounted(async () => await getTeamInfo());
const team = useVModel(props, 'team', emit);
const pendingInvites = computed({
get: () => team.value?.teamInvitations,
set: (value) => {
team.value.teamInvitations = value;
},
});
// Remove Invitation
const isLoadingIndex = ref<null | number>(null);
@@ -110,7 +83,7 @@ const removeInvitee = async (id: string, index: number) => {
isLoadingIndex.value = index;
const result = await revokeTeamInvitation(id);
if (result.error) {
toast.error(`${t('state.remove_invitee_failure')}`);
toast.error(t('state.remove_invitee_failure'));
} else {
if (pendingInvites.value) {
pendingInvites.value = pendingInvites.value.filter(
@@ -118,7 +91,7 @@ const removeInvitee = async (id: string, index: number) => {
return invite.id !== id;
}
);
toast.success(`${t('state.remove_invitee_success')}`);
toast.success(t('state.remove_invitee_success'));
}
}
isLoadingIndex.value = null;

View File

@@ -1,6 +1,6 @@
<template>
<div class="rounded-md">
<div class="grid gap-6 mt-4">
<div class="grid gap-6">
<div
class="relative"
:class="
@@ -71,18 +71,14 @@
<script setup lang="ts">
import { format } from 'date-fns';
import { useI18n } from '~/composables/i18n';
import { useToast } from '~/composables/toast';
import { UserInfoQuery } from '~/helpers/backend/graphql';
import IconTrash from '~icons/lucide/trash';
import IconUserCheck from '~icons/lucide/user-check';
import IconUserMinus from '~icons/lucide/user-minus';
import { UserInfoQuery } from '~/helpers/backend/graphql';
const t = useI18n();
const toast = useToast();
const props = defineProps<{

View File

@@ -3,7 +3,7 @@
v-if="show"
dialog
:title="t('users.invite_user')"
@close="$emit('hide-modal')"
@close="emit('hide-modal')"
>
<template #body>
<HoppSmartInput
@@ -16,7 +16,6 @@
<span class="flex space-x-2">
<HoppButtonPrimary
:label="t('users.send_invite')"
:loading="loadingState"
@click="sendInvite"
/>
<HoppButtonSecondary label="Cancel" outline filled @click="hideModal" />
@@ -27,21 +26,18 @@
<script setup lang="ts">
import { ref } from 'vue';
import { useToast } from '~/composables/toast';
import { useI18n } from '~/composables/i18n';
import { useToast } from '~/composables/toast';
const t = useI18n();
const toast = useToast();
withDefaults(
defineProps<{
show: boolean;
loadingState: boolean;
}>(),
{
show: false,
loadingState: false,
}
);
@@ -54,7 +50,7 @@ const email = ref('');
const sendInvite = () => {
if (email.value.trim() === '') {
toast.error(`${t('users.valid_email')}`);
toast.error(t('users.valid_email'));
return;
}
emit('send-invite', email.value);

View File

@@ -1,116 +1,116 @@
<template>
<div>
<div class="px-4">
<div v-if="fetching" class="flex justify-center">
<HoppSmartSpinner />
</div>
<div v-else-if="error">{{ t('shared_requests.load_list_error') }}</div>
<div v-else-if="sharedRequests.length === 0" class="ml-3 mt-5 text-lg">
<div v-else-if="sharedRequests.length === 0" class="mt-5">
{{ t('users.no_shared_requests') }}
</div>
<div v-else class="mt-10">
<HoppSmartTable :list="sharedRequests">
<template #head>
<tr
class="text-secondary border-b border-dividerDark text-sm text-left bg-primaryLight"
>
<th class="px-6 py-2">{{ t('shared_requests.id') }}</th>
<th class="px-6 py-2 w-30">{{ t('shared_requests.url') }}</th>
<th class="px-6 py-2">{{ t('shared_requests.created_on') }}</th>
<!-- Empty Heading for the Action Button -->
<th class="px-6 py-2 text-center">Actions</th>
</tr>
</template>
<template #body="{ list: sharedRequests }">
<tr
v-for="request in sharedRequests"
:key="request.id"
class="text-secondaryDark hover:bg-divider hover:cursor-pointer rounded-xl"
>
<td class="flex py-4 px-7 max-w-50">
<span class="truncate">
{{ request.id }}
</span>
</td>
<HoppSmartTable v-else class="mt-8" :list="sharedRequests">
<template #head>
<tr
class="text-secondary border-b border-dividerDark text-sm text-left bg-primaryLight"
>
<th class="px-6 py-2">{{ t('shared_requests.id') }}</th>
<th class="px-6 py-2 w-30">{{ t('shared_requests.url') }}</th>
<th class="px-6 py-2">{{ t('shared_requests.created_on') }}</th>
<!-- Empty Heading for the Action Button -->
<th class="px-6 py-2 text-center">
{{ t('shared_requests.action') }}
</th>
</tr>
</template>
<template #body="{ list: sharedRequests }">
<tr
v-for="request in sharedRequests"
:key="request.id"
class="text-secondaryDark hover:bg-divider hover:cursor-pointer rounded-xl"
>
<td class="flex py-4 px-7 max-w-50">
<span class="truncate">
{{ request.id }}
</span>
</td>
<td class="py-4 px-7 w-96">
{{ sharedRequestURL(request.request) }}
</td>
<td class="py-4 px-7 w-96">
{{ sharedRequestURL(request.request) }}
</td>
<td class="py-2 px-7">
{{ getCreatedDate(request.createdOn) }}
<div class="text-gray-400 text-tiny">
{{ getCreatedTime(request.createdOn) }}
</div>
</td>
<td class="py-2 px-7">
{{ getCreatedDate(request.createdOn) }}
<div class="text-gray-400 text-tiny">
{{ getCreatedTime(request.createdOn) }}
</div>
</td>
<td class="flex justify-center">
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:title="t('shared_requests.open_request')"
:to="`${shortcodeBaseURL}/r/${request.id}`"
:blank="true"
:icon="IconExternalLink"
class="px-3 text-emerald-500 hover:text-accent"
/>
<td class="flex justify-center">
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:title="t('shared_requests.open_request')"
:to="`${shortcodeBaseURL}/r/${request.id}`"
:blank="true"
:icon="IconExternalLink"
class="px-3 text-emerald-500 hover:text-accent"
/>
<UiAutoResetIcon
:title="t('shared_requests.copy')"
:icon="{ default: IconCopy, temporary: IconCheck }"
@click="copySharedRequest(request.id)"
/>
<UiAutoResetIcon
:title="t('shared_requests.copy')"
:icon="{ default: IconCopy, temporary: IconCheck }"
@click="copySharedRequest(request.id)"
/>
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:title="t('shared_requests.delete')"
:icon="IconTrash"
color="red"
class="px-3"
@click="deleteSharedRequest(request.id)"
/>
</td>
</tr>
</template>
</HoppSmartTable>
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:title="t('shared_requests.delete')"
:icon="IconTrash"
color="red"
class="px-3"
@click="deleteSharedRequest(request.id)"
/>
</td>
</tr>
</template>
</HoppSmartTable>
<!-- Pagination -->
<div
v-if="hasNextPage && sharedRequests.length >= sharedRequestsPerPage"
class="flex items-center w-28 px-3 py-2 mt-5 mx-auto font-semibold text-secondaryDark bg-divider hover:bg-dividerDark rounded-3xl cursor-pointer"
@click="fetchNextSharedRequests"
>
<span class="mr-2">{{ t('shared_requests.show_more') }}</span>
<icon-lucide-chevron-down />
</div>
<!-- Pagination -->
<div
v-if="hasNextPage && sharedRequests.length >= sharedRequestsPerPage"
class="flex items-center w-28 px-3 py-2 mt-5 mx-auto font-semibold text-secondaryDark bg-divider hover:bg-dividerDark rounded-3xl cursor-pointer"
@click="fetchNextSharedRequests"
>
<span class="mr-2">{{ t('shared_requests.show_more') }}</span>
<icon-lucide-chevron-down />
</div>
</div>
<HoppSmartConfirmModal
:show="confirmDeletion"
:title="t('shared_requests.confirm_request_deletion')"
@hide-modal="confirmDeletion = false"
@resolve="deleteSharedRequestMutation(deleteSharedRequestID)"
/>
<HoppSmartConfirmModal
:show="confirmDeletion"
:title="t('shared_requests.confirm_request_deletion')"
@hide-modal="confirmDeletion = false"
@resolve="deleteSharedRequestMutation(deleteSharedRequestID)"
/>
</div>
</template>
<script setup lang="ts">
import { useMutation } from '@urql/vue';
import { format } from 'date-fns';
import { ref } from 'vue';
import { useMutation } from '@urql/vue';
import {
SharedRequestsDocument,
RevokeShortcodeByAdminDocument,
} from '../../helpers/backend/graphql';
import { usePagedQuery } from '~/composables/usePagedQuery';
import { useToast } from '~/composables/toast';
import { useI18n } from '~/composables/i18n';
import { useToast } from '~/composables/toast';
import { usePagedQuery } from '~/composables/usePagedQuery';
import { copyToClipboard } from '~/helpers/utils/clipboard';
import IconTrash from '~icons/lucide/trash';
import IconCopy from '~icons/lucide/copy';
import IconCheck from '~icons/lucide/check';
import IconCopy from '~icons/lucide/copy';
import IconExternalLink from '~icons/lucide/external-link';
import IconTrash from '~icons/lucide/trash';
import {
RevokeShortcodeByAdminDocument,
SharedRequestsDocument,
} from '~/helpers/backend/graphql';
const t = useI18n();
const toast = useToast();
@@ -154,7 +154,7 @@ const shortcodeBaseURL =
// Copy Shared Request to Clipboard
const copySharedRequest = (requestID: string) => {
copyToClipboard(`${shortcodeBaseURL}/r/${requestID}`);
toast.success(`${t('state.copied_to_clipboard')}`);
toast.success(t('state.copied_to_clipboard'));
};
// Shared Request Deletion
@@ -170,19 +170,19 @@ const deleteSharedRequest = (id: string) => {
const deleteSharedRequestMutation = async (id: string | null) => {
if (!id) {
confirmDeletion.value = false;
toast.error(`${t('state.delete_request_failure')}`);
toast.error(t('state.delete_request_failure'));
return;
}
const variables = { codeID: id };
await sharedRequestDeletion.executeMutation(variables).then((result) => {
if (result.error) {
toast.error(`${t('state.delete_request_failure')}`);
toast.error(t('state.delete_request_failure'));
} else {
sharedRequests.value = sharedRequests.value.filter(
(request) => request.id !== id
);
refetch();
toast.success(`${t('state.delete_request_success')}`);
toast.success(t('state.delete_request_success'));
}
});
confirmDeletion.value = false;

View File

@@ -1,6 +1,6 @@
import { TypedDocumentNode, useClientHandle } from '@urql/vue';
import { DocumentNode } from 'graphql';
import { ref } from 'vue';
import { Ref, ref } from 'vue';
/** A composable function to handle grapqhl requests
* using urql's useClientHandle
@@ -14,36 +14,45 @@ export function useClientHandler<
ListItem
>(
query: string | TypedDocumentNode<Result, Vars> | DocumentNode,
getList: (result: Result) => ListItem[],
variables: Vars
variables: Vars,
getList?: (result: Result) => ListItem[]
) {
const { client } = useClientHandle();
const fetching = ref(true);
const error = ref(false);
const list = ref<ListItem[]>([]);
const data = ref<Result>();
const dataAsList: Ref<ListItem[]> = ref([]);
const fetchList = async () => {
const fetchData = async () => {
fetching.value = true;
try {
const result = await client
.query(query, {
...variables,
})
.toPromise();
const resultList = getList(result.data!);
const result = await client
.query(query, {
...variables,
})
.toPromise();
list.value.push(...resultList);
} catch (e) {
if (result.error) {
error.value = true;
fetching.value = false;
return;
}
if (getList) {
const resultList = getList(result.data!);
dataAsList.value.push(...resultList);
} else {
data.value = result.data;
}
fetching.value = false;
};
return {
fetching,
error,
list,
fetchList,
data,
dataAsList,
fetchData,
};
}

View File

@@ -1,19 +1,20 @@
import { computed, onMounted, ref } from 'vue';
import { AnyVariables, UseMutationResponse } from '@urql/vue';
import { cloneDeep } from 'lodash-es';
import { UseMutationResponse } from '@urql/vue';
import { useClientHandler } from './useClientHandler';
import { useToast } from './toast';
import { computed, onMounted, ref } from 'vue';
import { useI18n } from '~/composables/i18n';
import {
AllowedAuthProvidersDocument,
EnableAndDisableSsoArgs,
EnableAndDisableSsoMutation,
InfraConfigArgs,
InfraConfigEnum,
InfraConfigsDocument,
AllowedAuthProvidersDocument,
EnableAndDisableSsoMutation,
UpdateInfraConfigsMutation,
ResetInfraConfigsMutation,
EnableAndDisableSsoArgs,
InfraConfigArgs,
ToggleAnalyticsCollectionMutation,
UpdateInfraConfigsMutation,
} from '~/helpers/backend/graphql';
import { useToast } from './toast';
import { useClientHandler } from './useClientHandler';
// Types
export type SsoAuthProviders = 'google' | 'microsoft' | 'github';
@@ -54,6 +55,11 @@ export type Config = {
mailer_from_address: string;
};
};
dataSharingConfigs: {
name: string;
enabled: boolean;
};
};
type UpdatedConfigs = {
@@ -72,31 +78,36 @@ export function useConfigHandler(updatedConfigs?: Config) {
const {
fetching: fetchingInfraConfigs,
error: infraConfigsError,
list: infraConfigs,
fetchList: fetchInfraConfigs,
} = useClientHandler(InfraConfigsDocument, (x) => x.infraConfigs, {
configNames: [
'GOOGLE_CLIENT_ID',
'GOOGLE_CLIENT_SECRET',
'MICROSOFT_CLIENT_ID',
'MICROSOFT_CLIENT_SECRET',
'GITHUB_CLIENT_ID',
'GITHUB_CLIENT_SECRET',
'MAILER_SMTP_URL',
'MAILER_ADDRESS_FROM',
] as InfraConfigEnum[],
});
dataAsList: infraConfigs,
fetchData: fetchInfraConfigs,
} = useClientHandler(
InfraConfigsDocument,
{
configNames: [
'GOOGLE_CLIENT_ID',
'GOOGLE_CLIENT_SECRET',
'MICROSOFT_CLIENT_ID',
'MICROSOFT_CLIENT_SECRET',
'GITHUB_CLIENT_ID',
'GITHUB_CLIENT_SECRET',
'MAILER_SMTP_URL',
'MAILER_ADDRESS_FROM',
'ALLOW_ANALYTICS_COLLECTION',
] as InfraConfigEnum[],
},
(x) => x.infraConfigs
);
// Fetching allowed auth providers
const {
fetching: fetchingAllowedAuthProviders,
error: allowedAuthProvidersError,
list: allowedAuthProviders,
fetchList: fetchAllowedAuthProviders,
dataAsList: allowedAuthProviders,
fetchData: fetchAllowedAuthProviders,
} = useClientHandler(
AllowedAuthProvidersDocument,
(x) => x.allowedAuthProviders,
{}
{},
(x) => x.allowedAuthProviders
);
// Current and working configs
@@ -160,6 +171,12 @@ export function useConfigHandler(updatedConfigs?: Config) {
?.value ?? '',
},
},
dataSharingConfigs: {
name: 'data_sharing',
enabled: !!infraConfigs.value.find(
(x) => x.name === 'ALLOW_ANALYTICS_COLLECTION' && x.value === 'true'
),
},
};
// Cloning the current configs to working configs
@@ -255,7 +272,29 @@ export function useConfigHandler(updatedConfigs?: Config) {
return config;
});
// Trasforming the working configs back into the format required by the mutations
// Checking if any of the config fields are empty
const isFieldEmpty = (field: string) => field.trim() === '';
type ConfigSection = {
enabled: boolean;
fields: Record<string, string>;
};
const AreAnyConfigFieldsEmpty = (config: Config): boolean => {
const sections: Array<ConfigSection> = [
config.providers.github,
config.providers.google,
config.providers.microsoft,
config.mailConfigs,
];
return sections.some(
(section) =>
section.enabled && Object.values(section.fields).some(isFieldEmpty)
);
};
// Transforming the working configs back into the format required by the mutations
const updatedAllowedAuthProviders = computed(() => {
return [
{
@@ -279,55 +318,70 @@ export function useConfigHandler(updatedConfigs?: Config) {
];
});
// Updating the auth provider configurations
const updateAuthProvider = async (
updateProviderStatus: UseMutationResponse<EnableAndDisableSsoMutation>
) => {
const variables = {
providerInfo:
updatedAllowedAuthProviders.value as EnableAndDisableSsoArgs[],
};
const result = await updateProviderStatus.executeMutation(variables);
// Generic function to handle mutation execution and error handling
const executeMutation = async <T, V>(
mutation: UseMutationResponse<T>,
variables: AnyVariables = undefined,
errorMessage: string
): Promise<boolean> => {
const result = await mutation.executeMutation(variables);
if (result.error) {
toast.error(t('configs.auth_providers.update_failure'));
toast.error(t(errorMessage));
return false;
}
return true;
};
// Updating the auth provider configurations
const updateAuthProvider = (
updateProviderStatus: UseMutationResponse<EnableAndDisableSsoMutation>
) =>
executeMutation(
updateProviderStatus,
{
providerInfo:
updatedAllowedAuthProviders.value as EnableAndDisableSsoArgs[],
},
'configs.auth_providers.update_failure'
);
// Updating the infra configurations
const updateInfraConfigs = async (
const updateInfraConfigs = (
updateInfraConfigsMutation: UseMutationResponse<UpdateInfraConfigsMutation>
) => {
const variables = {
infraConfigs: updatedInfraConfigs.value as InfraConfigArgs[],
};
const result = await updateInfraConfigsMutation.executeMutation(variables);
if (result.error) {
toast.error(t('configs.mail_configs.update_failure'));
return false;
}
return true;
};
) =>
executeMutation(
updateInfraConfigsMutation,
{
infraConfigs: updatedInfraConfigs.value as InfraConfigArgs[],
},
'configs.mail_configs.update_failure'
);
// Resetting the infra configurations
const resetInfraConfigs = async (
const resetInfraConfigs = (
resetInfraConfigsMutation: UseMutationResponse<ResetInfraConfigsMutation>
) => {
const result = await resetInfraConfigsMutation.executeMutation();
) =>
executeMutation(
resetInfraConfigsMutation,
undefined,
'configs.reset.failure'
);
if (result.error) {
toast.error(t('configs.reset.failure'));
return false;
}
return true;
};
// Updating the data sharing configurations
const updateDataSharingConfigs = (
toggleDataSharingMutation: UseMutationResponse<ToggleAnalyticsCollectionMutation>
) =>
executeMutation(
toggleDataSharingMutation,
{
status: updatedConfigs?.dataSharingConfigs.enabled
? 'ENABLE'
: 'DISABLE',
},
'configs.data_sharing.update_failure'
);
return {
currentConfigs,
@@ -335,11 +389,13 @@ export function useConfigHandler(updatedConfigs?: Config) {
updatedInfraConfigs,
updatedAllowedAuthProviders,
updateAuthProvider,
updateDataSharingConfigs,
updateInfraConfigs,
resetInfraConfigs,
fetchingInfraConfigs,
fetchingAllowedAuthProviders,
infraConfigsError,
allowedAuthProvidersError,
AreAnyConfigFieldsEmpty,
};
}

Some files were not shown because too many files have changed in this diff Show More