Compare commits

..

37 Commits

Author SHA1 Message Date
Mir Arif Hasan
089f6823e6 chore: removed stated return type 2023-07-13 11:55:32 +05:30
Mir Arif Hasan
1d93745a4e chore: improved code readability 2023-07-13 11:55:32 +05:30
Mir Arif Hasan
e0cc143436 chore: input-arg file added 2023-07-13 11:55:32 +05:30
Mir Arif Hasan
b58acfe8dc feat: added all feedback 2023-07-13 11:55:32 +05:30
Mir Arif Hasan
54bef30cf8 refactor: team invitation module 2023-07-13 11:55:28 +05:30
Balu Babu
6bc748a267 refactor: introduce team-environments into self-host refactored to pseudo-fp format (#3177) 2023-07-13 11:52:19 +05:30
Andrew Bastin
b29c04c28d fix: email not being checked case insensitive on team invitation acceptance (#3174) 2023-07-11 20:03:08 +05:30
Liyas Thomas
b2af353941 chore: new filled star icon to toggle favorite history entry (#3164) 2023-07-06 13:30:38 +05:30
Andrew Bastin
2ec29c47ad chore: merge release/2023.4.7 into main 2023-06-27 14:17:26 +05:30
Andrew Bastin
399a238bf4 chore: bump version to 2023.4.7 2023-06-27 14:15:12 +05:30
Liyas Thomas
b20ab72298 fix: explicitly added background color 2023-06-26 19:57:43 +05:30
Liyas Thomas
f723e6496a fix: text overflow on details summary label (#3160)
Co-authored-by: Nivedin <nivedinp@gmail.com>
2023-06-26 18:30:25 +05:30
Andrew Bastin
8c0aff8863 feat: introduce more events into the analytics pipeline (#3156) 2023-06-24 10:18:35 +05:30
James Butler
64c5077506 fix: self-host unable to use Azure oauth (#3138) 2023-06-22 23:43:05 +05:30
Akash K
2afc87847d fix: use --location param for url when parsing curl (#3152) 2023-06-22 23:40:09 +05:30
Ankit Sridhar
878ec833ce fix: remove existing team invitation for an invitee when adding invitee to team by admin (HBE-229) (#3157) 2023-06-22 23:38:02 +05:30
Anwarul Islam
039de8015f fix: graphql authorization headers (#3136) 2023-06-22 23:32:23 +05:30
Nivedin
f67b366b90 fix: unified bg color in collection tree (#3155) 2023-06-22 00:38:28 +05:30
Omer Baflah
77e8a36ab0 fix: correct typos (#3153) 2023-06-22 00:35:57 +05:30
Webysther Sperandio
d7cc9f5dbc feat: custom location on admin redirect to base (#3103) 2023-06-21 00:13:40 +05:30
Balázs Úr
4ba135f3b9 chore(i18n): updated hungarian translation (#3151) 2023-06-20 14:28:53 +05:30
Nivedin
24894e05dc fix: shortcode resolution screen is stuck on invalid shortcodes (#3142)
Co-authored-by: Andrew Bastin <andrewbastin.k@gmail.com>
2023-06-19 14:04:07 +05:30
Andrew Bastin
e2b668bee2 chore(ci): add manual workflow dispatch for hoppscotch-ui deploy script 2023-06-19 12:33:52 +05:30
Andrew Bastin
f112c46bb4 chore(ci): re-introduce hoppscotch-ui deploy script 2023-06-19 11:51:14 +05:30
Balu Babu
84b0c30d64 fix: fixed issue with team-invitations and new user accounts (#3137) 2023-06-15 17:15:06 +05:30
Andrew Bastin
e3dd9e99a1 chore: bump version to 2023.4.6 2023-06-12 10:43:44 +05:30
Hoai-Thu Vuong
e3091cb6db chore(i18n): fix typo in translation of clear_all (#3133) 2023-06-12 10:31:58 +05:30
Akash K
270f796683 fix: fix url getting overridden when query params are present (#3130) 2023-06-09 21:53:55 +05:30
Anwarul Islam
24c6bce02d fix: failed to execute 'observe' on 'IntersectionObserver' (#3122) 2023-06-09 09:40:09 +05:30
Anwarul Islam
2db567589f fix: collection request name edit issue (#3115)
Co-authored-by: Liyas Thomas <liyascthomas@gmail.com>
Co-authored-by: Nivedin <nivedinp@gmail.com>
2023-06-09 09:36:41 +05:30
Liyas Thomas
1fe83ebdc8 chore: updated i18n strings (#3106) 2023-06-07 23:59:04 +05:30
islamzeki
8320d4f222 chore(i18n): update tr.json 2023-06-07 23:56:49 +05:30
Liyas Thomas
e76c1bc64c fix: stack order of tab inside environment selector (#3108) 2023-06-07 23:47:24 +05:30
Nivedin
1f3f8464ea fix: team environment lost when route changes (#3113)
Co-authored-by: Liyas Thomas <liyascthomas@gmail.com>
2023-06-07 23:46:09 +05:30
Liyas Thomas
e75391cdf1 chore: updated icon with correct size (#3105) 2023-06-04 23:46:47 -04:00
Andrew Bastin
a213c0c26c chore: bump version to 2023.4.5 2023-06-04 23:41:01 -04:00
Andrew Bastin
15424903ed fix: stop logging DATABASE_URL in logs 2023-06-04 23:33:32 -04:00
103 changed files with 1519 additions and 1162 deletions

View File

@@ -31,6 +31,7 @@ MICROSOFT_CLIENT_ID="************************************************"
MICROSOFT_CLIENT_SECRET="************************************************" MICROSOFT_CLIENT_SECRET="************************************************"
MICROSOFT_CALLBACK_URL="http://localhost:3170/v1/auth/microsoft/callback" MICROSOFT_CALLBACK_URL="http://localhost:3170/v1/auth/microsoft/callback"
MICROSOFT_SCOPE="user.read" MICROSOFT_SCOPE="user.read"
MICROSOFT_TENANT="common"
# Mailer config # Mailer config
MAILER_SMTP_URL="smtps://user@domain.com:pass@smtp.domain.com" MAILER_SMTP_URL="smtps://user@domain.com:pass@smtp.domain.com"

42
.github/workflows/ui.yml vendored Normal file
View File

@@ -0,0 +1,42 @@
name: Deploy to Netlify (ui)
on:
push:
branches: [main]
# run this workflow only if an update is made to the ui package
paths:
- "packages/hoppscotch-ui/**"
workflow_dispatch:
jobs:
deploy:
name: Deploy
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v3
- name: Setup environment
run: mv .env.example .env
- name: Setup pnpm
uses: pnpm/action-setup@v2.2.4
with:
version: 8
run_install: true
- name: Setup node
uses: actions/setup-node@v3
with:
node-version: ${{ matrix.node }}
cache: pnpm
- name: Build site
run: pnpm run generate-ui
# Deploy the ui site with netlify-cli
- name: Deploy to Netlify (ui)
run: npx netlify-cli deploy --dir=packages/hoppscotch-ui/.histoire/dist --prod
env:
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_UI_SITE_ID }}
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}

View File

@@ -1,6 +1,6 @@
{ {
"name": "hoppscotch-backend", "name": "hoppscotch-backend",
"version": "2023.4.4", "version": "2023.4.7",
"description": "", "description": "",
"author": "", "author": "",
"private": true, "private": true,

View File

@@ -181,7 +181,7 @@ export class AdminService {
* @returns an array team invitations * @returns an array team invitations
*/ */
async pendingInvitationCountInTeam(teamID: string) { async pendingInvitationCountInTeam(teamID: string) {
const invitations = await this.teamInvitationService.getAllTeamInvitations( const invitations = await this.teamInvitationService.getTeamInvitations(
teamID, teamID,
); );
@@ -236,11 +236,11 @@ export class AdminService {
const user = await this.userService.findUserByEmail(userEmail); const user = await this.userService.findUserByEmail(userEmail);
if (O.isNone(user)) return E.left(USER_NOT_FOUND); if (O.isNone(user)) return E.left(USER_NOT_FOUND);
const isUserAlreadyMember = await this.teamService.getTeamMemberTE( const teamMember = await this.teamService.getTeamMemberTE(
teamID, teamID,
user.value.uid, user.value.uid,
)(); )();
if (E.left(isUserAlreadyMember)) { if (E.isLeft(teamMember)) {
const addedUser = await this.teamService.addMemberToTeamWithEmail( const addedUser = await this.teamService.addMemberToTeamWithEmail(
teamID, teamID,
userEmail, userEmail,
@@ -248,6 +248,18 @@ export class AdminService {
); );
if (E.isLeft(addedUser)) return E.left(addedUser.left); if (E.isLeft(addedUser)) return E.left(addedUser.left);
const userInvitation =
await this.teamInvitationService.getTeamInviteByEmailAndTeamID(
userEmail,
teamID,
);
if (E.isRight(userInvitation)) {
await this.teamInvitationService.revokeInvitation(
userInvitation.right.id,
);
}
return E.right(addedUser.right); return E.right(addedUser.right);
} }

View File

@@ -228,7 +228,7 @@ export class AuthService {
url = process.env.VITE_BASE_URL; url = process.env.VITE_BASE_URL;
} }
await this.mailerService.sendAuthEmail(email, { await this.mailerService.sendEmail(email, {
template: 'code-your-own', template: 'code-your-own',
variables: { variables: {
inviteeEmail: email, inviteeEmail: email,

View File

@@ -17,7 +17,7 @@ export class MicrosoftStrategy extends PassportStrategy(Strategy) {
clientSecret: process.env.MICROSOFT_CLIENT_SECRET, clientSecret: process.env.MICROSOFT_CLIENT_SECRET,
callbackURL: process.env.MICROSOFT_CALLBACK_URL, callbackURL: process.env.MICROSOFT_CALLBACK_URL,
scope: [process.env.MICROSOFT_SCOPE], scope: [process.env.MICROSOFT_SCOPE],
passReqToCallback: true, tenant: process.env.MICROSOFT_TENANT,
store: true, store: true,
}); });
} }

View File

@@ -23,7 +23,7 @@ export const AUTH_FAIL = 'auth/fail';
export const JSON_INVALID = 'json_invalid'; export const JSON_INVALID = 'json_invalid';
/** /**
* Tried to delete an user data document from fb firestore but failed. * Tried to delete a user data document from fb firestore but failed.
* (FirebaseService) * (FirebaseService)
*/ */
export const USER_FB_DOCUMENT_DELETION_FAILED = export const USER_FB_DOCUMENT_DELETION_FAILED =
@@ -231,7 +231,7 @@ export const TEAM_COLL_INVALID_JSON = 'team_coll/invalid_json';
export const TEAM_NOT_OWNER = 'team_coll/team_not_owner' as const; export const TEAM_NOT_OWNER = 'team_coll/team_not_owner' as const;
/** /**
* Tried to perform action on a request that doesn't accept their member role level * Tried to perform an action on a request that doesn't accept their member role level
* (GqlRequestTeamMemberGuard) * (GqlRequestTeamMemberGuard)
*/ */
export const TEAM_REQ_NOT_REQUIRED_ROLE = 'team_req/not_required_role'; export const TEAM_REQ_NOT_REQUIRED_ROLE = 'team_req/not_required_role';
@@ -262,7 +262,7 @@ export const TEAM_REQ_REORDERING_FAILED = 'team_req/reordering_failed' as const;
export const SENDER_EMAIL_INVALID = 'mailer/sender_email_invalid' as const; export const SENDER_EMAIL_INVALID = 'mailer/sender_email_invalid' as const;
/** /**
* Tried to perform action on a request when the user is not even member of the team * Tried to perform an action on a request when the user is not even a member of the team
* (GqlRequestTeamMemberGuard, GqlCollectionTeamMemberGuard) * (GqlRequestTeamMemberGuard, GqlCollectionTeamMemberGuard)
*/ */
export const TEAM_REQ_NOT_MEMBER = 'team_req/not_member'; export const TEAM_REQ_NOT_MEMBER = 'team_req/not_member';
@@ -307,11 +307,18 @@ export const SHORTCODE_INVALID_JSON = 'shortcode/invalid_json' as const;
export const SHORTCODE_ALREADY_EXISTS = 'shortcode/already_exists' as const; export const SHORTCODE_ALREADY_EXISTS = 'shortcode/already_exists' as const;
/** /**
* Invalid or non-existent TEAM ENVIRONMMENT ID * Invalid or non-existent TEAM ENVIRONMENT ID
* (TeamEnvironmentsService) * (TeamEnvironmentsService)
*/ */
export const TEAM_ENVIRONMENT_NOT_FOUND = 'team_environment/not_found' as const; export const TEAM_ENVIRONMENT_NOT_FOUND = 'team_environment/not_found' as const;
/**
* Invalid TEAM ENVIRONMENT name
* (TeamEnvironmentsService)
*/
export const TEAM_ENVIRONMENT_SHORT_NAME =
'team_environment/short_name' as const;
/** /**
* The user is not a member of the team of the given environment * The user is not a member of the team of the given environment
* (GqlTeamEnvTeamGuard) * (GqlTeamEnvTeamGuard)
@@ -340,7 +347,7 @@ export const USER_SETTINGS_NULL_SETTINGS =
'user_settings/null_settings' as const; 'user_settings/null_settings' as const;
/* /*
* Global environment doesnt exists for the user * Global environment doesn't exist for the user
* (UserEnvironmentsService) * (UserEnvironmentsService)
*/ */
export const USER_ENVIRONMENT_GLOBAL_ENV_DOES_NOT_EXISTS = export const USER_ENVIRONMENT_GLOBAL_ENV_DOES_NOT_EXISTS =

View File

@@ -5,7 +5,6 @@ import {
UserMagicLinkMailDescription, UserMagicLinkMailDescription,
} from './MailDescriptions'; } from './MailDescriptions';
import { throwErr } from 'src/utils'; import { throwErr } from 'src/utils';
import * as TE from 'fp-ts/TaskEither';
import { EMAIL_FAILED } from 'src/errors'; import { EMAIL_FAILED } from 'src/errors';
import { MailerService as NestMailerService } from '@nestjs-modules/mailer'; import { MailerService as NestMailerService } from '@nestjs-modules/mailer';
@@ -35,33 +34,14 @@ export class MailerService {
/** /**
* Sends an email to the given email address given a mail description * Sends an email to the given email address given a mail description
* @param to The email address to be sent to (NOTE: this is not validated) * @param to Receiver's email id
* @param mailDesc Definition of what email to be sent * @param mailDesc Definition of what email to be sent
* @returns Response if email was send successfully or not
*/ */
sendMail( async sendEmail(
to: string, to: string,
mailDesc: MailDescription | UserMagicLinkMailDescription, mailDesc: MailDescription | UserMagicLinkMailDescription,
) { ) {
return TE.tryCatch(
async () => {
await this.nestMailerService.sendMail({
to,
template: mailDesc.template,
subject: this.resolveSubjectForMailDesc(mailDesc),
context: mailDesc.variables,
});
},
() => EMAIL_FAILED,
);
}
/**
*
* @param to Receiver's email id
* @param mailDesc Details of email to be sent for Magic-Link auth
* @returns Response if email was send successfully or not
*/
async sendAuthEmail(to: string, mailDesc: UserMagicLinkMailDescription) {
try { try {
await this.nestMailerService.sendMail({ await this.nestMailerService.sendMail({
to, to,

View File

@@ -1,15 +1,5 @@
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common'; import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
import { Reflector } from '@nestjs/core'; import { Reflector } from '@nestjs/core';
import * as TE from 'fp-ts/TaskEither';
import * as O from 'fp-ts/Option';
import * as S from 'fp-ts/string';
import { pipe } from 'fp-ts/function';
import {
getAnnotatedRequiredRoles,
getGqlArg,
getUserFromGQLContext,
throwErr,
} from 'src/utils';
import { TeamEnvironmentsService } from './team-environments.service'; import { TeamEnvironmentsService } from './team-environments.service';
import { import {
BUG_AUTH_NO_USER_CTX, BUG_AUTH_NO_USER_CTX,
@@ -19,6 +9,10 @@ import {
TEAM_ENVIRONMENT_NOT_FOUND, TEAM_ENVIRONMENT_NOT_FOUND,
} from 'src/errors'; } from 'src/errors';
import { TeamService } from 'src/team/team.service'; import { TeamService } from 'src/team/team.service';
import { GqlExecutionContext } from '@nestjs/graphql';
import * as E from 'fp-ts/Either';
import { TeamMemberRole } from '@prisma/client';
import { throwErr } from 'src/utils';
/** /**
* A guard which checks whether the caller of a GQL Operation * A guard which checks whether the caller of a GQL Operation
@@ -33,50 +27,31 @@ export class GqlTeamEnvTeamGuard implements CanActivate {
private readonly teamService: TeamService, private readonly teamService: TeamService,
) {} ) {}
canActivate(context: ExecutionContext): Promise<boolean> { async canActivate(context: ExecutionContext): Promise<boolean> {
return pipe( const requireRoles = this.reflector.get<TeamMemberRole[]>(
TE.Do, 'requiresTeamRole',
context.getHandler(),
);
if (!requireRoles) throw new Error(BUG_TEAM_ENV_GUARD_NO_REQUIRE_ROLES);
TE.bindW('requiredRoles', () => const gqlExecCtx = GqlExecutionContext.create(context);
pipe(
getAnnotatedRequiredRoles(this.reflector, context),
TE.fromOption(() => BUG_TEAM_ENV_GUARD_NO_REQUIRE_ROLES),
),
),
TE.bindW('user', () => const { user } = gqlExecCtx.getContext().req;
pipe( if (user == undefined) throw new Error(BUG_AUTH_NO_USER_CTX);
getUserFromGQLContext(context),
TE.fromOption(() => BUG_AUTH_NO_USER_CTX),
),
),
TE.bindW('envID', () => const { id } = gqlExecCtx.getArgs<{ id: string }>();
pipe( if (!id) throwErr(BUG_TEAM_ENV_GUARD_NO_ENV_ID);
getGqlArg('id', context),
O.fromPredicate(S.isString),
TE.fromOption(() => BUG_TEAM_ENV_GUARD_NO_ENV_ID),
),
),
TE.bindW('membership', ({ envID, user }) => const teamEnvironment =
pipe( await this.teamEnvironmentService.getTeamEnvironment(id);
this.teamEnvironmentService.getTeamEnvironment(envID), if (E.isLeft(teamEnvironment)) throwErr(TEAM_ENVIRONMENT_NOT_FOUND);
TE.fromTaskOption(() => TEAM_ENVIRONMENT_NOT_FOUND),
TE.chainW((env) =>
pipe(
this.teamService.getTeamMemberTE(env.teamID, user.uid),
TE.mapLeft(() => TEAM_ENVIRONMENT_NOT_TEAM_MEMBER),
),
),
),
),
TE.map(({ membership, requiredRoles }) => const member = await this.teamService.getTeamMember(
requiredRoles.includes(membership.role), teamEnvironment.right.teamID,
), user.uid,
);
if (!member) throwErr(TEAM_ENVIRONMENT_NOT_TEAM_MEMBER);
TE.getOrElse(throwErr), return requireRoles.includes(member.role);
)();
} }
} }

View File

@@ -0,0 +1,41 @@
import { ArgsType, Field, ID } from '@nestjs/graphql';
@ArgsType()
export class CreateTeamEnvironmentArgs {
@Field({
name: 'name',
description: 'Name of the Team Environment',
})
name: string;
@Field(() => ID, {
name: 'teamID',
description: 'ID of the Team',
})
teamID: string;
@Field({
name: 'variables',
description: 'JSON string of the variables object',
})
variables: string;
}
@ArgsType()
export class UpdateTeamEnvironmentArgs {
@Field(() => ID, {
name: 'id',
description: 'ID of the Team Environment',
})
id: string;
@Field({
name: 'name',
description: 'Name of the Team Environment',
})
name: string;
@Field({
name: 'variables',
description: 'JSON string of the variables object',
})
variables: string;
}

View File

@@ -13,6 +13,11 @@ import { throwErr } from 'src/utils';
import { GqlTeamEnvTeamGuard } from './gql-team-env-team.guard'; import { GqlTeamEnvTeamGuard } from './gql-team-env-team.guard';
import { TeamEnvironment } from './team-environments.model'; import { TeamEnvironment } from './team-environments.model';
import { TeamEnvironmentsService } from './team-environments.service'; import { TeamEnvironmentsService } from './team-environments.service';
import * as E from 'fp-ts/Either';
import {
CreateTeamEnvironmentArgs,
UpdateTeamEnvironmentArgs,
} from './input-type.args';
@UseGuards(GqlThrottlerGuard) @UseGuards(GqlThrottlerGuard)
@Resolver(() => 'TeamEnvironment') @Resolver(() => 'TeamEnvironment')
@@ -29,29 +34,18 @@ export class TeamEnvironmentsResolver {
}) })
@UseGuards(GqlAuthGuard, GqlTeamMemberGuard) @UseGuards(GqlAuthGuard, GqlTeamMemberGuard)
@RequiresTeamRole(TeamMemberRole.OWNER, TeamMemberRole.EDITOR) @RequiresTeamRole(TeamMemberRole.OWNER, TeamMemberRole.EDITOR)
createTeamEnvironment( async createTeamEnvironment(
@Args({ @Args() args: CreateTeamEnvironmentArgs,
name: 'name',
description: 'Name of the Team Environment',
})
name: string,
@Args({
name: 'teamID',
description: 'ID of the Team',
type: () => ID,
})
teamID: string,
@Args({
name: 'variables',
description: 'JSON string of the variables object',
})
variables: string,
): Promise<TeamEnvironment> { ): Promise<TeamEnvironment> {
return this.teamEnvironmentsService.createTeamEnvironment( const teamEnvironment =
name, await this.teamEnvironmentsService.createTeamEnvironment(
teamID, args.name,
variables, args.teamID,
)(); args.variables,
);
if (E.isLeft(teamEnvironment)) throwErr(teamEnvironment.left);
return teamEnvironment.right;
} }
@Mutation(() => Boolean, { @Mutation(() => Boolean, {
@@ -59,7 +53,7 @@ export class TeamEnvironmentsResolver {
}) })
@UseGuards(GqlAuthGuard, GqlTeamEnvTeamGuard) @UseGuards(GqlAuthGuard, GqlTeamEnvTeamGuard)
@RequiresTeamRole(TeamMemberRole.OWNER, TeamMemberRole.EDITOR) @RequiresTeamRole(TeamMemberRole.OWNER, TeamMemberRole.EDITOR)
deleteTeamEnvironment( async deleteTeamEnvironment(
@Args({ @Args({
name: 'id', name: 'id',
description: 'ID of the Team Environment', description: 'ID of the Team Environment',
@@ -67,10 +61,12 @@ export class TeamEnvironmentsResolver {
}) })
id: string, id: string,
): Promise<boolean> { ): Promise<boolean> {
return pipe( const isDeleted = await this.teamEnvironmentsService.deleteTeamEnvironment(
this.teamEnvironmentsService.deleteTeamEnvironment(id), id,
TE.getOrElse(throwErr), );
)();
if (E.isLeft(isDeleted)) throwErr(isDeleted.left);
return isDeleted.right;
} }
@Mutation(() => TeamEnvironment, { @Mutation(() => TeamEnvironment, {
@@ -79,28 +75,19 @@ export class TeamEnvironmentsResolver {
}) })
@UseGuards(GqlAuthGuard, GqlTeamEnvTeamGuard) @UseGuards(GqlAuthGuard, GqlTeamEnvTeamGuard)
@RequiresTeamRole(TeamMemberRole.OWNER, TeamMemberRole.EDITOR) @RequiresTeamRole(TeamMemberRole.OWNER, TeamMemberRole.EDITOR)
updateTeamEnvironment( async updateTeamEnvironment(
@Args({ @Args()
name: 'id', args: UpdateTeamEnvironmentArgs,
description: 'ID of the Team Environment',
type: () => ID,
})
id: string,
@Args({
name: 'name',
description: 'Name of the Team Environment',
})
name: string,
@Args({
name: 'variables',
description: 'JSON string of the variables object',
})
variables: string,
): Promise<TeamEnvironment> { ): Promise<TeamEnvironment> {
return pipe( const updatedTeamEnvironment =
this.teamEnvironmentsService.updateTeamEnvironment(id, name, variables), await this.teamEnvironmentsService.updateTeamEnvironment(
TE.getOrElse(throwErr), args.id,
)(); args.name,
args.variables,
);
if (E.isLeft(updatedTeamEnvironment)) throwErr(updatedTeamEnvironment.left);
return updatedTeamEnvironment.right;
} }
@Mutation(() => TeamEnvironment, { @Mutation(() => TeamEnvironment, {
@@ -108,7 +95,7 @@ export class TeamEnvironmentsResolver {
}) })
@UseGuards(GqlAuthGuard, GqlTeamEnvTeamGuard) @UseGuards(GqlAuthGuard, GqlTeamEnvTeamGuard)
@RequiresTeamRole(TeamMemberRole.OWNER, TeamMemberRole.EDITOR) @RequiresTeamRole(TeamMemberRole.OWNER, TeamMemberRole.EDITOR)
deleteAllVariablesFromTeamEnvironment( async deleteAllVariablesFromTeamEnvironment(
@Args({ @Args({
name: 'id', name: 'id',
description: 'ID of the Team Environment', description: 'ID of the Team Environment',
@@ -116,10 +103,13 @@ export class TeamEnvironmentsResolver {
}) })
id: string, id: string,
): Promise<TeamEnvironment> { ): Promise<TeamEnvironment> {
return pipe( const teamEnvironment =
this.teamEnvironmentsService.deleteAllVariablesFromTeamEnvironment(id), await this.teamEnvironmentsService.deleteAllVariablesFromTeamEnvironment(
TE.getOrElse(throwErr), id,
)(); );
if (E.isLeft(teamEnvironment)) throwErr(teamEnvironment.left);
return teamEnvironment.right;
} }
@Mutation(() => TeamEnvironment, { @Mutation(() => TeamEnvironment, {
@@ -127,7 +117,7 @@ export class TeamEnvironmentsResolver {
}) })
@UseGuards(GqlAuthGuard, GqlTeamEnvTeamGuard) @UseGuards(GqlAuthGuard, GqlTeamEnvTeamGuard)
@RequiresTeamRole(TeamMemberRole.OWNER, TeamMemberRole.EDITOR) @RequiresTeamRole(TeamMemberRole.OWNER, TeamMemberRole.EDITOR)
createDuplicateEnvironment( async createDuplicateEnvironment(
@Args({ @Args({
name: 'id', name: 'id',
description: 'ID of the Team Environment', description: 'ID of the Team Environment',
@@ -135,10 +125,12 @@ export class TeamEnvironmentsResolver {
}) })
id: string, id: string,
): Promise<TeamEnvironment> { ): Promise<TeamEnvironment> {
return pipe( const res = await this.teamEnvironmentsService.createDuplicateEnvironment(
this.teamEnvironmentsService.createDuplicateEnvironment(id), id,
TE.getOrElse(throwErr), );
)();
if (E.isLeft(res)) throwErr(res.left);
return res.right;
} }
/* Subscriptions */ /* Subscriptions */

View File

@@ -2,7 +2,11 @@ import { mockDeep, mockReset } from 'jest-mock-extended';
import { PrismaService } from 'src/prisma/prisma.service'; import { PrismaService } from 'src/prisma/prisma.service';
import { TeamEnvironment } from './team-environments.model'; import { TeamEnvironment } from './team-environments.model';
import { TeamEnvironmentsService } from './team-environments.service'; import { TeamEnvironmentsService } from './team-environments.service';
import { TEAM_ENVIRONMENT_NOT_FOUND } from 'src/errors'; import {
JSON_INVALID,
TEAM_ENVIRONMENT_NOT_FOUND,
TEAM_ENVIRONMENT_SHORT_NAME,
} from 'src/errors';
const mockPrisma = mockDeep<PrismaService>(); const mockPrisma = mockDeep<PrismaService>();
@@ -31,125 +35,81 @@ beforeEach(() => {
describe('TeamEnvironmentsService', () => { describe('TeamEnvironmentsService', () => {
describe('getTeamEnvironment', () => { describe('getTeamEnvironment', () => {
test('queries the db with the id', async () => { test('should successfully return a TeamEnvironment with valid ID', async () => {
mockPrisma.teamEnvironment.findFirst.mockResolvedValue(teamEnvironment); mockPrisma.teamEnvironment.findFirstOrThrow.mockResolvedValueOnce(
teamEnvironment,
await teamEnvironmentsService.getTeamEnvironment('123')();
expect(mockPrisma.teamEnvironment.findFirst).toHaveBeenCalledWith(
expect.objectContaining({
where: {
id: '123',
},
}),
); );
});
test('requests prisma to reject the query promise if not found', async () => { const result = await teamEnvironmentsService.getTeamEnvironment(
mockPrisma.teamEnvironment.findFirst.mockResolvedValue(teamEnvironment); teamEnvironment.id,
await teamEnvironmentsService.getTeamEnvironment('123')();
expect(mockPrisma.teamEnvironment.findFirst).toHaveBeenCalledWith(
expect.objectContaining({
rejectOnNotFound: true,
}),
); );
expect(result).toEqualRight(teamEnvironment);
}); });
test('should return a Some of the correct environment if exists', async () => { test('should throw TEAM_ENVIRONMENT_NOT_FOUND with invalid ID', async () => {
mockPrisma.teamEnvironment.findFirst.mockResolvedValue(teamEnvironment); mockPrisma.teamEnvironment.findFirstOrThrow.mockRejectedValueOnce(
'RejectOnNotFound',
);
const result = await teamEnvironmentsService.getTeamEnvironment('123')(); const result = await teamEnvironmentsService.getTeamEnvironment(
teamEnvironment.id,
expect(result).toEqualSome(teamEnvironment); );
}); expect(result).toEqualLeft(TEAM_ENVIRONMENT_NOT_FOUND);
test('should return a None if the environment does not exist', async () => {
mockPrisma.teamEnvironment.findFirst.mockRejectedValue('NotFoundError');
const result = await teamEnvironmentsService.getTeamEnvironment('123')();
expect(result).toBeNone();
}); });
}); });
describe('createTeamEnvironment', () => { describe('createTeamEnvironment', () => {
test('should create and return a new team environment given a valid name,variable and team ID', async () => { test('should successfully create and return a new team environment given valid inputs', async () => {
mockPrisma.teamEnvironment.create.mockResolvedValue(teamEnvironment); mockPrisma.teamEnvironment.create.mockResolvedValue(teamEnvironment);
const result = await teamEnvironmentsService.createTeamEnvironment( const result = await teamEnvironmentsService.createTeamEnvironment(
teamEnvironment.name, teamEnvironment.name,
teamEnvironment.teamID, teamEnvironment.teamID,
JSON.stringify(teamEnvironment.variables), JSON.stringify(teamEnvironment.variables),
)(); );
expect(result).toEqual(<TeamEnvironment>{ expect(result).toEqualRight({
id: teamEnvironment.id, ...teamEnvironment,
name: teamEnvironment.name,
teamID: teamEnvironment.teamID,
variables: JSON.stringify(teamEnvironment.variables), variables: JSON.stringify(teamEnvironment.variables),
}); });
}); });
test('should reject if given team ID is invalid', async () => { test('should throw TEAM_ENVIRONMENT_SHORT_NAME if input TeamEnvironment name is invalid', async () => {
mockPrisma.teamEnvironment.create.mockRejectedValue(null as any); const result = await teamEnvironmentsService.createTeamEnvironment(
'12',
teamEnvironment.teamID,
JSON.stringify(teamEnvironment.variables),
);
await expect( expect(result).toEqualLeft(TEAM_ENVIRONMENT_SHORT_NAME);
teamEnvironmentsService.createTeamEnvironment(
teamEnvironment.name,
'invalidteamid',
JSON.stringify(teamEnvironment.variables),
),
).rejects.toBeDefined();
});
test('should reject if provided team environment name is not a string', async () => {
mockPrisma.teamEnvironment.create.mockRejectedValue(null as any);
await expect(
teamEnvironmentsService.createTeamEnvironment(
null as any,
teamEnvironment.teamID,
JSON.stringify(teamEnvironment.variables),
),
).rejects.toBeDefined();
});
test('should reject if provided variable is not a string', async () => {
mockPrisma.teamEnvironment.create.mockRejectedValue(null as any);
await expect(
teamEnvironmentsService.createTeamEnvironment(
teamEnvironment.name,
teamEnvironment.teamID,
null as any,
),
).rejects.toBeDefined();
}); });
test('should send pubsub message to "team_environment/<teamID>/created" if team environment is created successfully', async () => { test('should send pubsub message to "team_environment/<teamID>/created" if team environment is created successfully', async () => {
mockPrisma.teamEnvironment.create.mockResolvedValueOnce(teamEnvironment); mockPrisma.teamEnvironment.create.mockResolvedValue(teamEnvironment);
const result = await teamEnvironmentsService.createTeamEnvironment( const result = await teamEnvironmentsService.createTeamEnvironment(
teamEnvironment.name, teamEnvironment.name,
teamEnvironment.teamID, teamEnvironment.teamID,
JSON.stringify(teamEnvironment.variables), JSON.stringify(teamEnvironment.variables),
)(); );
expect(mockPubSub.publish).toHaveBeenCalledWith( expect(mockPubSub.publish).toHaveBeenCalledWith(
`team_environment/${teamEnvironment.teamID}/created`, `team_environment/${teamEnvironment.teamID}/created`,
result, {
...teamEnvironment,
variables: JSON.stringify(teamEnvironment.variables),
},
); );
}); });
}); });
describe('deleteTeamEnvironment', () => { describe('deleteTeamEnvironment', () => {
test('should resolve to true given a valid team environment ID', async () => { test('should successfully delete a TeamEnvironment with a valid ID', async () => {
mockPrisma.teamEnvironment.delete.mockResolvedValueOnce(teamEnvironment); mockPrisma.teamEnvironment.delete.mockResolvedValueOnce(teamEnvironment);
const result = await teamEnvironmentsService.deleteTeamEnvironment( const result = await teamEnvironmentsService.deleteTeamEnvironment(
teamEnvironment.id, teamEnvironment.id,
)(); );
expect(result).toEqualRight(true); expect(result).toEqualRight(true);
}); });
@@ -159,7 +119,7 @@ describe('TeamEnvironmentsService', () => {
const result = await teamEnvironmentsService.deleteTeamEnvironment( const result = await teamEnvironmentsService.deleteTeamEnvironment(
'invalidid', 'invalidid',
)(); );
expect(result).toEqualLeft(TEAM_ENVIRONMENT_NOT_FOUND); expect(result).toEqualLeft(TEAM_ENVIRONMENT_NOT_FOUND);
}); });
@@ -169,7 +129,7 @@ describe('TeamEnvironmentsService', () => {
const result = await teamEnvironmentsService.deleteTeamEnvironment( const result = await teamEnvironmentsService.deleteTeamEnvironment(
teamEnvironment.id, teamEnvironment.id,
)(); );
expect(mockPubSub.publish).toHaveBeenCalledWith( expect(mockPubSub.publish).toHaveBeenCalledWith(
`team_environment/${teamEnvironment.teamID}/deleted`, `team_environment/${teamEnvironment.teamID}/deleted`,
@@ -182,7 +142,7 @@ describe('TeamEnvironmentsService', () => {
}); });
describe('updateVariablesInTeamEnvironment', () => { describe('updateVariablesInTeamEnvironment', () => {
test('should add new variable to a team environment', async () => { test('should successfully add new variable to a team environment', async () => {
mockPrisma.teamEnvironment.update.mockResolvedValueOnce({ mockPrisma.teamEnvironment.update.mockResolvedValueOnce({
...teamEnvironment, ...teamEnvironment,
variables: [{ key: 'value' }], variables: [{ key: 'value' }],
@@ -192,7 +152,7 @@ describe('TeamEnvironmentsService', () => {
teamEnvironment.id, teamEnvironment.id,
teamEnvironment.name, teamEnvironment.name,
JSON.stringify([{ key: 'value' }]), JSON.stringify([{ key: 'value' }]),
)(); );
expect(result).toEqualRight(<TeamEnvironment>{ expect(result).toEqualRight(<TeamEnvironment>{
...teamEnvironment, ...teamEnvironment,
@@ -200,7 +160,7 @@ describe('TeamEnvironmentsService', () => {
}); });
}); });
test('should add new variable to already existing list of variables in a team environment', async () => { test('should successfully add new variable to already existing list of variables in a team environment', async () => {
mockPrisma.teamEnvironment.update.mockResolvedValueOnce({ mockPrisma.teamEnvironment.update.mockResolvedValueOnce({
...teamEnvironment, ...teamEnvironment,
variables: [{ key: 'value' }, { key_2: 'value_2' }], variables: [{ key: 'value' }, { key_2: 'value_2' }],
@@ -210,7 +170,7 @@ describe('TeamEnvironmentsService', () => {
teamEnvironment.id, teamEnvironment.id,
teamEnvironment.name, teamEnvironment.name,
JSON.stringify([{ key: 'value' }, { key_2: 'value_2' }]), JSON.stringify([{ key: 'value' }, { key_2: 'value_2' }]),
)(); );
expect(result).toEqualRight(<TeamEnvironment>{ expect(result).toEqualRight(<TeamEnvironment>{
...teamEnvironment, ...teamEnvironment,
@@ -218,7 +178,7 @@ describe('TeamEnvironmentsService', () => {
}); });
}); });
test('should edit existing variables in a team environment', async () => { test('should successfully edit existing variables in a team environment', async () => {
mockPrisma.teamEnvironment.update.mockResolvedValueOnce({ mockPrisma.teamEnvironment.update.mockResolvedValueOnce({
...teamEnvironment, ...teamEnvironment,
variables: [{ key: '1234' }], variables: [{ key: '1234' }],
@@ -228,7 +188,7 @@ describe('TeamEnvironmentsService', () => {
teamEnvironment.id, teamEnvironment.id,
teamEnvironment.name, teamEnvironment.name,
JSON.stringify([{ key: '1234' }]), JSON.stringify([{ key: '1234' }]),
)(); );
expect(result).toEqualRight(<TeamEnvironment>{ expect(result).toEqualRight(<TeamEnvironment>{
...teamEnvironment, ...teamEnvironment,
@@ -236,22 +196,7 @@ describe('TeamEnvironmentsService', () => {
}); });
}); });
test('should delete existing variable in a team environment', async () => { test('should successfully edit name of an existing team environment', async () => {
mockPrisma.teamEnvironment.update.mockResolvedValueOnce(teamEnvironment);
const result = await teamEnvironmentsService.updateTeamEnvironment(
teamEnvironment.id,
teamEnvironment.name,
JSON.stringify([{}]),
)();
expect(result).toEqualRight(<TeamEnvironment>{
...teamEnvironment,
variables: JSON.stringify([{}]),
});
});
test('should edit name of an existing team environment', async () => {
mockPrisma.teamEnvironment.update.mockResolvedValueOnce({ mockPrisma.teamEnvironment.update.mockResolvedValueOnce({
...teamEnvironment, ...teamEnvironment,
variables: [{ key: '123' }], variables: [{ key: '123' }],
@@ -261,7 +206,7 @@ describe('TeamEnvironmentsService', () => {
teamEnvironment.id, teamEnvironment.id,
teamEnvironment.name, teamEnvironment.name,
JSON.stringify([{ key: '123' }]), JSON.stringify([{ key: '123' }]),
)(); );
expect(result).toEqualRight(<TeamEnvironment>{ expect(result).toEqualRight(<TeamEnvironment>{
...teamEnvironment, ...teamEnvironment,
@@ -269,14 +214,24 @@ describe('TeamEnvironmentsService', () => {
}); });
}); });
test('should reject to TEAM_ENVIRONMMENT_NOT_FOUND if provided id is invalid', async () => { test('should throw TEAM_ENVIRONMENT_SHORT_NAME if input TeamEnvironment name is invalid', async () => {
const result = await teamEnvironmentsService.updateTeamEnvironment(
teamEnvironment.id,
'12',
JSON.stringify([{ key: 'value' }]),
);
expect(result).toEqualLeft(TEAM_ENVIRONMENT_SHORT_NAME);
});
test('should throw TEAM_ENVIRONMMENT_NOT_FOUND if provided id is invalid', async () => {
mockPrisma.teamEnvironment.update.mockRejectedValue('RecordNotFound'); mockPrisma.teamEnvironment.update.mockRejectedValue('RecordNotFound');
const result = await teamEnvironmentsService.updateTeamEnvironment( const result = await teamEnvironmentsService.updateTeamEnvironment(
'invalidid', 'invalidid',
teamEnvironment.name, teamEnvironment.name,
JSON.stringify(teamEnvironment.variables), JSON.stringify(teamEnvironment.variables),
)(); );
expect(result).toEqualLeft(TEAM_ENVIRONMENT_NOT_FOUND); expect(result).toEqualLeft(TEAM_ENVIRONMENT_NOT_FOUND);
}); });
@@ -288,7 +243,7 @@ describe('TeamEnvironmentsService', () => {
teamEnvironment.id, teamEnvironment.id,
teamEnvironment.name, teamEnvironment.name,
JSON.stringify([{ key: 'value' }]), JSON.stringify([{ key: 'value' }]),
)(); );
expect(mockPubSub.publish).toHaveBeenCalledWith( expect(mockPubSub.publish).toHaveBeenCalledWith(
`team_environment/${teamEnvironment.teamID}/updated`, `team_environment/${teamEnvironment.teamID}/updated`,
@@ -301,13 +256,13 @@ describe('TeamEnvironmentsService', () => {
}); });
describe('deleteAllVariablesFromTeamEnvironment', () => { describe('deleteAllVariablesFromTeamEnvironment', () => {
test('should delete all variables in a team environment', async () => { test('should successfully delete all variables in a team environment', async () => {
mockPrisma.teamEnvironment.update.mockResolvedValueOnce(teamEnvironment); mockPrisma.teamEnvironment.update.mockResolvedValueOnce(teamEnvironment);
const result = const result =
await teamEnvironmentsService.deleteAllVariablesFromTeamEnvironment( await teamEnvironmentsService.deleteAllVariablesFromTeamEnvironment(
teamEnvironment.id, teamEnvironment.id,
)(); );
expect(result).toEqualRight(<TeamEnvironment>{ expect(result).toEqualRight(<TeamEnvironment>{
...teamEnvironment, ...teamEnvironment,
@@ -315,13 +270,13 @@ describe('TeamEnvironmentsService', () => {
}); });
}); });
test('should reject to TEAM_ENVIRONMMENT_NOT_FOUND if provided id is invalid', async () => { test('should throw TEAM_ENVIRONMMENT_NOT_FOUND if provided id is invalid', async () => {
mockPrisma.teamEnvironment.update.mockRejectedValue('RecordNotFound'); mockPrisma.teamEnvironment.update.mockRejectedValue('RecordNotFound');
const result = const result =
await teamEnvironmentsService.deleteAllVariablesFromTeamEnvironment( await teamEnvironmentsService.deleteAllVariablesFromTeamEnvironment(
'invalidid', 'invalidid',
)(); );
expect(result).toEqualLeft(TEAM_ENVIRONMENT_NOT_FOUND); expect(result).toEqualLeft(TEAM_ENVIRONMENT_NOT_FOUND);
}); });
@@ -332,7 +287,7 @@ describe('TeamEnvironmentsService', () => {
const result = const result =
await teamEnvironmentsService.deleteAllVariablesFromTeamEnvironment( await teamEnvironmentsService.deleteAllVariablesFromTeamEnvironment(
teamEnvironment.id, teamEnvironment.id,
)(); );
expect(mockPubSub.publish).toHaveBeenCalledWith( expect(mockPubSub.publish).toHaveBeenCalledWith(
`team_environment/${teamEnvironment.teamID}/updated`, `team_environment/${teamEnvironment.teamID}/updated`,
@@ -345,7 +300,7 @@ describe('TeamEnvironmentsService', () => {
}); });
describe('createDuplicateEnvironment', () => { describe('createDuplicateEnvironment', () => {
test('should duplicate an existing team environment', async () => { test('should successfully duplicate an existing team environment', async () => {
mockPrisma.teamEnvironment.findFirst.mockResolvedValueOnce( mockPrisma.teamEnvironment.findFirst.mockResolvedValueOnce(
teamEnvironment, teamEnvironment,
); );
@@ -357,21 +312,21 @@ describe('TeamEnvironmentsService', () => {
const result = await teamEnvironmentsService.createDuplicateEnvironment( const result = await teamEnvironmentsService.createDuplicateEnvironment(
teamEnvironment.id, teamEnvironment.id,
)(); );
expect(result).toEqualRight(<TeamEnvironment>{ expect(result).toEqualRight(<TeamEnvironment>{
...teamEnvironment,
id: 'newid', id: 'newid',
...teamEnvironment,
variables: JSON.stringify(teamEnvironment.variables), variables: JSON.stringify(teamEnvironment.variables),
}); });
}); });
test('should reject to TEAM_ENVIRONMMENT_NOT_FOUND if provided id is invalid', async () => { test('should throw TEAM_ENVIRONMMENT_NOT_FOUND if provided id is invalid', async () => {
mockPrisma.teamEnvironment.findFirst.mockRejectedValue('NotFoundError'); mockPrisma.teamEnvironment.findFirst.mockRejectedValue('NotFoundError');
const result = await teamEnvironmentsService.createDuplicateEnvironment( const result = await teamEnvironmentsService.createDuplicateEnvironment(
teamEnvironment.id, teamEnvironment.id,
)(); );
expect(result).toEqualLeft(TEAM_ENVIRONMENT_NOT_FOUND); expect(result).toEqualLeft(TEAM_ENVIRONMENT_NOT_FOUND);
}); });
@@ -388,13 +343,13 @@ describe('TeamEnvironmentsService', () => {
const result = await teamEnvironmentsService.createDuplicateEnvironment( const result = await teamEnvironmentsService.createDuplicateEnvironment(
teamEnvironment.id, teamEnvironment.id,
)(); );
expect(mockPubSub.publish).toHaveBeenCalledWith( expect(mockPubSub.publish).toHaveBeenCalledWith(
`team_environment/${teamEnvironment.teamID}/created`, `team_environment/${teamEnvironment.teamID}/created`,
{ {
...teamEnvironment,
id: 'newid', id: 'newid',
...teamEnvironment,
variables: JSON.stringify([{}]), variables: JSON.stringify([{}]),
}, },
); );

View File

@@ -1,15 +1,14 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { pipe } from 'fp-ts/function'; import { TeamEnvironment as DBTeamEnvironment, Prisma } from '@prisma/client';
import * as T from 'fp-ts/Task';
import * as TO from 'fp-ts/TaskOption';
import * as TE from 'fp-ts/TaskEither';
import * as A from 'fp-ts/Array';
import { Prisma } from '@prisma/client';
import { PrismaService } from 'src/prisma/prisma.service'; import { PrismaService } from 'src/prisma/prisma.service';
import { PubSubService } from 'src/pubsub/pubsub.service'; import { PubSubService } from 'src/pubsub/pubsub.service';
import { TeamEnvironment } from './team-environments.model'; import { TeamEnvironment } from './team-environments.model';
import { TEAM_ENVIRONMENT_NOT_FOUND } from 'src/errors'; import {
TEAM_ENVIRONMENT_NOT_FOUND,
TEAM_ENVIRONMENT_SHORT_NAME,
} from 'src/errors';
import * as E from 'fp-ts/Either';
import { isValidLength } from 'src/utils';
@Injectable() @Injectable()
export class TeamEnvironmentsService { export class TeamEnvironmentsService {
constructor( constructor(
@@ -17,219 +16,218 @@ export class TeamEnvironmentsService {
private readonly pubsub: PubSubService, private readonly pubsub: PubSubService,
) {} ) {}
getTeamEnvironment(id: string) { TITLE_LENGTH = 3;
return TO.tryCatch(() =>
this.prisma.teamEnvironment.findFirst({ /**
where: { id }, * TeamEnvironments are saved in the DB in the following way
* [{ key: value }, { key: value },....]
*
*/
/**
* Typecast a database TeamEnvironment to a TeamEnvironment model
* @param teamEnvironment database TeamEnvironment
* @returns TeamEnvironment model
*/
private cast(teamEnvironment: DBTeamEnvironment): TeamEnvironment {
return {
id: teamEnvironment.id,
name: teamEnvironment.name,
teamID: teamEnvironment.teamID,
variables: JSON.stringify(teamEnvironment.variables),
};
}
/**
* Get details of a TeamEnvironment.
*
* @param id TeamEnvironment ID
* @returns Either of a TeamEnvironment or error message
*/
async getTeamEnvironment(id: string) {
try {
const teamEnvironment =
await this.prisma.teamEnvironment.findFirstOrThrow({
where: { id },
});
return E.right(teamEnvironment);
} catch (error) {
return E.left(TEAM_ENVIRONMENT_NOT_FOUND);
}
}
/**
* Create a new TeamEnvironment.
*
* @param name name of new TeamEnvironment
* @param teamID teamID of new TeamEnvironment
* @param variables JSONified string of contents of new TeamEnvironment
* @returns Either of a TeamEnvironment or error message
*/
async createTeamEnvironment(name: string, teamID: string, variables: string) {
const isTitleValid = isValidLength(name, this.TITLE_LENGTH);
if (!isTitleValid) return E.left(TEAM_ENVIRONMENT_SHORT_NAME);
const result = await this.prisma.teamEnvironment.create({
data: {
name: name,
teamID: teamID,
variables: JSON.parse(variables),
},
});
const createdTeamEnvironment = this.cast(result);
this.pubsub.publish(
`team_environment/${createdTeamEnvironment.teamID}/created`,
createdTeamEnvironment,
);
return E.right(createdTeamEnvironment);
}
/**
* Delete a TeamEnvironment.
*
* @param id TeamEnvironment ID
* @returns Either of boolean or error message
*/
async deleteTeamEnvironment(id: string) {
try {
const result = await this.prisma.teamEnvironment.delete({
where: {
id: id,
},
});
const deletedTeamEnvironment = this.cast(result);
this.pubsub.publish(
`team_environment/${deletedTeamEnvironment.teamID}/deleted`,
deletedTeamEnvironment,
);
return E.right(true);
} catch (error) {
return E.left(TEAM_ENVIRONMENT_NOT_FOUND);
}
}
/**
* Update a TeamEnvironment.
*
* @param id TeamEnvironment ID
* @param name TeamEnvironment name
* @param variables JSONified string of contents of new TeamEnvironment
* @returns Either of a TeamEnvironment or error message
*/
async updateTeamEnvironment(id: string, name: string, variables: string) {
try {
const isTitleValid = isValidLength(name, this.TITLE_LENGTH);
if (!isTitleValid) return E.left(TEAM_ENVIRONMENT_SHORT_NAME);
const result = await this.prisma.teamEnvironment.update({
where: { id: id },
data: {
name,
variables: JSON.parse(variables),
},
});
const updatedTeamEnvironment = this.cast(result);
this.pubsub.publish(
`team_environment/${updatedTeamEnvironment.teamID}/updated`,
updatedTeamEnvironment,
);
return E.right(updatedTeamEnvironment);
} catch (error) {
return E.left(TEAM_ENVIRONMENT_NOT_FOUND);
}
}
/**
* Clear contents of a TeamEnvironment.
*
* @param id TeamEnvironment ID
* @returns Either of a TeamEnvironment or error message
*/
async deleteAllVariablesFromTeamEnvironment(id: string) {
try {
const result = await this.prisma.teamEnvironment.update({
where: { id: id },
data: {
variables: [],
},
});
const teamEnvironment = this.cast(result);
this.pubsub.publish(
`team_environment/${teamEnvironment.teamID}/updated`,
teamEnvironment,
);
return E.right(teamEnvironment);
} catch (error) {
return E.left(TEAM_ENVIRONMENT_NOT_FOUND);
}
}
/**
* Create a duplicate of a existing TeamEnvironment.
*
* @param id TeamEnvironment ID
* @returns Either of a TeamEnvironment or error message
*/
async createDuplicateEnvironment(id: string) {
try {
const environment = await this.prisma.teamEnvironment.findFirst({
where: {
id: id,
},
rejectOnNotFound: true, rejectOnNotFound: true,
}), });
);
const result = await this.prisma.teamEnvironment.create({
data: {
name: environment.name,
teamID: environment.teamID,
variables: environment.variables as Prisma.JsonArray,
},
});
const duplicatedTeamEnvironment = this.cast(result);
this.pubsub.publish(
`team_environment/${duplicatedTeamEnvironment.teamID}/created`,
duplicatedTeamEnvironment,
);
return E.right(duplicatedTeamEnvironment);
} catch (error) {
return E.left(TEAM_ENVIRONMENT_NOT_FOUND);
}
} }
createTeamEnvironment(name: string, teamID: string, variables: string) { /**
return pipe( * Fetch all TeamEnvironments of a team.
() => *
this.prisma.teamEnvironment.create({ * @param teamID teamID of new TeamEnvironment
data: { * @returns List of TeamEnvironments
name: name, */
teamID: teamID, async fetchAllTeamEnvironments(teamID: string) {
variables: JSON.parse(variables), const result = await this.prisma.teamEnvironment.findMany({
}, where: {
}), teamID: teamID,
T.chainFirst( },
(environment) => () => });
this.pubsub.publish( const teamEnvironments = result.map((item) => {
`team_environment/${environment.teamID}/created`, return this.cast(item);
<TeamEnvironment>{ });
id: environment.id,
name: environment.name,
teamID: environment.teamID,
variables: JSON.stringify(environment.variables),
},
),
),
T.map((data) => {
return <TeamEnvironment>{
id: data.id,
name: data.name,
teamID: data.teamID,
variables: JSON.stringify(data.variables),
};
}),
);
}
deleteTeamEnvironment(id: string) { return teamEnvironments;
return pipe(
TE.tryCatch(
() =>
this.prisma.teamEnvironment.delete({
where: {
id: id,
},
}),
() => TEAM_ENVIRONMENT_NOT_FOUND,
),
TE.chainFirst((environment) =>
TE.fromTask(() =>
this.pubsub.publish(
`team_environment/${environment.teamID}/deleted`,
<TeamEnvironment>{
id: environment.id,
name: environment.name,
teamID: environment.teamID,
variables: JSON.stringify(environment.variables),
},
),
),
),
TE.map((data) => true),
);
}
updateTeamEnvironment(id: string, name: string, variables: string) {
return pipe(
TE.tryCatch(
() =>
this.prisma.teamEnvironment.update({
where: { id: id },
data: {
name,
variables: JSON.parse(variables),
},
}),
() => TEAM_ENVIRONMENT_NOT_FOUND,
),
TE.chainFirst((environment) =>
TE.fromTask(() =>
this.pubsub.publish(
`team_environment/${environment.teamID}/updated`,
<TeamEnvironment>{
id: environment.id,
name: environment.name,
teamID: environment.teamID,
variables: JSON.stringify(environment.variables),
},
),
),
),
TE.map(
(environment) =>
<TeamEnvironment>{
id: environment.id,
name: environment.name,
teamID: environment.teamID,
variables: JSON.stringify(environment.variables),
},
),
);
}
deleteAllVariablesFromTeamEnvironment(id: string) {
return pipe(
TE.tryCatch(
() =>
this.prisma.teamEnvironment.update({
where: { id: id },
data: {
variables: [],
},
}),
() => TEAM_ENVIRONMENT_NOT_FOUND,
),
TE.chainFirst((environment) =>
TE.fromTask(() =>
this.pubsub.publish(
`team_environment/${environment.teamID}/updated`,
<TeamEnvironment>{
id: environment.id,
name: environment.name,
teamID: environment.teamID,
variables: JSON.stringify(environment.variables),
},
),
),
),
TE.map(
(environment) =>
<TeamEnvironment>{
id: environment.id,
name: environment.name,
teamID: environment.teamID,
variables: JSON.stringify(environment.variables),
},
),
);
}
createDuplicateEnvironment(id: string) {
return pipe(
TE.tryCatch(
() =>
this.prisma.teamEnvironment.findFirst({
where: {
id: id,
},
rejectOnNotFound: true,
}),
() => TEAM_ENVIRONMENT_NOT_FOUND,
),
TE.chain((environment) =>
TE.fromTask(() =>
this.prisma.teamEnvironment.create({
data: {
name: environment.name,
teamID: environment.teamID,
variables: environment.variables as Prisma.JsonArray,
},
}),
),
),
TE.chainFirst((environment) =>
TE.fromTask(() =>
this.pubsub.publish(
`team_environment/${environment.teamID}/created`,
<TeamEnvironment>{
id: environment.id,
name: environment.name,
teamID: environment.teamID,
variables: JSON.stringify(environment.variables),
},
),
),
),
TE.map(
(environment) =>
<TeamEnvironment>{
id: environment.id,
name: environment.name,
teamID: environment.teamID,
variables: JSON.stringify(environment.variables),
},
),
);
}
fetchAllTeamEnvironments(teamID: string) {
return pipe(
() =>
this.prisma.teamEnvironment.findMany({
where: {
teamID: teamID,
},
}),
T.map(
A.map(
(environment) =>
<TeamEnvironment>{
id: environment.id,
name: environment.name,
teamID: environment.teamID,
variables: JSON.stringify(environment.variables),
},
),
),
);
} }
/** /**

View File

@@ -11,6 +11,6 @@ export class TeamEnvsTeamResolver {
description: 'Returns all Team Environments for the given Team', description: 'Returns all Team Environments for the given Team',
}) })
teamEnvironments(@Parent() team: Team): Promise<TeamEnvironment[]> { teamEnvironments(@Parent() team: Team): Promise<TeamEnvironment[]> {
return this.teamEnvironmentService.fetchAllTeamEnvironments(team.id)(); return this.teamEnvironmentService.fetchAllTeamEnvironments(team.id);
} }
} }

View File

@@ -0,0 +1,20 @@
import { ArgsType, Field, ID } from '@nestjs/graphql';
import { TeamMemberRole } from 'src/team/team.model';
@ArgsType()
export class CreateTeamInvitationArgs {
@Field(() => ID, {
name: 'teamID',
description: 'ID of the Team ID to invite from',
})
teamID: string;
@Field({ name: 'inviteeEmail', description: 'Email of the user to invite' })
inviteeEmail: string;
@Field(() => TeamMemberRole, {
name: 'inviteeRole',
description: 'Role to be given to the user',
})
inviteeRole: TeamMemberRole;
}

View File

@@ -12,15 +12,10 @@ import { TeamInvitation } from './team-invitation.model';
import { TeamInvitationService } from './team-invitation.service'; import { TeamInvitationService } from './team-invitation.service';
import { pipe } from 'fp-ts/function'; import { pipe } from 'fp-ts/function';
import * as TE from 'fp-ts/TaskEither'; import * as TE from 'fp-ts/TaskEither';
import * as E from 'fp-ts/Either';
import * as O from 'fp-ts/Option'; import * as O from 'fp-ts/Option';
import { Team, TeamMember, TeamMemberRole } from 'src/team/team.model'; import { Team, TeamMember, TeamMemberRole } from 'src/team/team.model';
import { EmailCodec } from 'src/types/Email'; import { TEAM_INVITE_NO_INVITE_FOUND, USER_NOT_FOUND } from 'src/errors';
import {
INVALID_EMAIL,
TEAM_INVITE_EMAIL_DO_NOT_MATCH,
TEAM_INVITE_NO_INVITE_FOUND,
USER_NOT_FOUND,
} from 'src/errors';
import { GqlUser } from 'src/decorators/gql-user.decorator'; import { GqlUser } from 'src/decorators/gql-user.decorator';
import { User } from 'src/user/user.model'; import { User } from 'src/user/user.model';
import { UseGuards } from '@nestjs/common'; import { UseGuards } from '@nestjs/common';
@@ -36,6 +31,8 @@ import { UserService } from 'src/user/user.service';
import { PubSubService } from 'src/pubsub/pubsub.service'; import { PubSubService } from 'src/pubsub/pubsub.service';
import { GqlThrottlerGuard } from 'src/guards/gql-throttler.guard'; import { GqlThrottlerGuard } from 'src/guards/gql-throttler.guard';
import { SkipThrottle } from '@nestjs/throttler'; import { SkipThrottle } from '@nestjs/throttler';
import { AuthUser } from 'src/types/AuthUser';
import { CreateTeamInvitationArgs } from './input-type.args';
@UseGuards(GqlThrottlerGuard) @UseGuards(GqlThrottlerGuard)
@Resolver(() => TeamInvitation) @Resolver(() => TeamInvitation)
@@ -79,8 +76,8 @@ export class TeamInvitationResolver {
'Gets the Team Invitation with the given ID, or null if not exists', 'Gets the Team Invitation with the given ID, or null if not exists',
}) })
@UseGuards(GqlAuthGuard, TeamInviteViewerGuard) @UseGuards(GqlAuthGuard, TeamInviteViewerGuard)
teamInvitation( async teamInvitation(
@GqlUser() user: User, @GqlUser() user: AuthUser,
@Args({ @Args({
name: 'inviteID', name: 'inviteID',
description: 'ID of the Team Invitation to lookup', description: 'ID of the Team Invitation to lookup',
@@ -88,17 +85,11 @@ export class TeamInvitationResolver {
}) })
inviteID: string, inviteID: string,
): Promise<TeamInvitation> { ): Promise<TeamInvitation> {
return pipe( const teamInvitation = await this.teamInvitationService.getInvitation(
this.teamInvitationService.getInvitation(inviteID), inviteID,
TE.fromTaskOption(() => TEAM_INVITE_NO_INVITE_FOUND), );
TE.chainW( if (O.isNone(teamInvitation)) throwErr(TEAM_INVITE_NO_INVITE_FOUND);
TE.fromPredicate( return teamInvitation.value;
(a) => a.inviteeEmail.toLowerCase() === user.email?.toLowerCase(),
() => TEAM_INVITE_EMAIL_DO_NOT_MATCH,
),
),
TE.getOrElse(throwErr),
)();
} }
@Mutation(() => TeamInvitation, { @Mutation(() => TeamInvitation, {
@@ -106,56 +97,19 @@ export class TeamInvitationResolver {
}) })
@UseGuards(GqlAuthGuard, GqlTeamMemberGuard) @UseGuards(GqlAuthGuard, GqlTeamMemberGuard)
@RequiresTeamRole(TeamMemberRole.OWNER) @RequiresTeamRole(TeamMemberRole.OWNER)
createTeamInvitation( async createTeamInvitation(
@GqlUser() @GqlUser() user: AuthUser,
user: User, @Args() args: CreateTeamInvitationArgs,
@Args({
name: 'teamID',
description: 'ID of the Team ID to invite from',
type: () => ID,
})
teamID: string,
@Args({
name: 'inviteeEmail',
description: 'Email of the user to invite',
})
inviteeEmail: string,
@Args({
name: 'inviteeRole',
type: () => TeamMemberRole,
description: 'Role to be given to the user',
})
inviteeRole: TeamMemberRole,
): Promise<TeamInvitation> { ): Promise<TeamInvitation> {
return pipe( const teamInvitation = await this.teamInvitationService.createInvitation(
TE.Do, user,
args.teamID,
args.inviteeEmail,
args.inviteeRole,
);
// Validate email if (E.isLeft(teamInvitation)) throwErr(teamInvitation.left);
TE.bindW('email', () => return teamInvitation.right;
pipe(
EmailCodec.decode(inviteeEmail),
TE.fromEither,
TE.mapLeft(() => INVALID_EMAIL),
),
),
// Validate and get Team
TE.bindW('team', () => this.teamService.getTeamWithIDTE(teamID)),
// Create team
TE.chainW(({ email, team }) =>
this.teamInvitationService.createInvitation(
user,
team,
email,
inviteeRole,
),
),
// If failed, throw err (so the message is passed) else return value
TE.getOrElse(throwErr),
)();
} }
@Mutation(() => Boolean, { @Mutation(() => Boolean, {
@@ -163,7 +117,7 @@ export class TeamInvitationResolver {
}) })
@UseGuards(GqlAuthGuard, TeamInviteTeamOwnerGuard) @UseGuards(GqlAuthGuard, TeamInviteTeamOwnerGuard)
@RequiresTeamRole(TeamMemberRole.OWNER) @RequiresTeamRole(TeamMemberRole.OWNER)
revokeTeamInvitation( async revokeTeamInvitation(
@Args({ @Args({
name: 'inviteID', name: 'inviteID',
type: () => ID, type: () => ID,
@@ -171,19 +125,19 @@ export class TeamInvitationResolver {
}) })
inviteID: string, inviteID: string,
): Promise<true> { ): Promise<true> {
return pipe( const isRevoked = await this.teamInvitationService.revokeInvitation(
this.teamInvitationService.revokeInvitation(inviteID), inviteID,
TE.map(() => true as const), );
TE.getOrElse(throwErr), if (E.isLeft(isRevoked)) throwErr(isRevoked.left);
)(); return true;
} }
@Mutation(() => TeamMember, { @Mutation(() => TeamMember, {
description: 'Accept an Invitation', description: 'Accept an Invitation',
}) })
@UseGuards(GqlAuthGuard, TeamInviteeGuard) @UseGuards(GqlAuthGuard, TeamInviteeGuard)
acceptTeamInvitation( async acceptTeamInvitation(
@GqlUser() user: User, @GqlUser() user: AuthUser,
@Args({ @Args({
name: 'inviteID', name: 'inviteID',
type: () => ID, type: () => ID,
@@ -191,10 +145,12 @@ export class TeamInvitationResolver {
}) })
inviteID: string, inviteID: string,
): Promise<TeamMember> { ): Promise<TeamMember> {
return pipe( const teamMember = await this.teamInvitationService.acceptInvitation(
this.teamInvitationService.acceptInvitation(inviteID, user), inviteID,
TE.getOrElse(throwErr), user,
)(); );
if (E.isLeft(teamMember)) throwErr(teamMember.left);
return teamMember.right;
} }
// Subscriptions // Subscriptions

View File

@@ -1,24 +1,25 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import * as T from 'fp-ts/Task';
import * as O from 'fp-ts/Option'; import * as O from 'fp-ts/Option';
import * as TO from 'fp-ts/TaskOption'; import * as E from 'fp-ts/Either';
import * as TE from 'fp-ts/TaskEither';
import { pipe, flow, constVoid } from 'fp-ts/function';
import { PrismaService } from 'src/prisma/prisma.service'; import { PrismaService } from 'src/prisma/prisma.service';
import { Team, TeamMemberRole } from 'src/team/team.model'; import { TeamInvitation as DBTeamInvitation } from '@prisma/client';
import { Email } from 'src/types/Email'; import { TeamMember, TeamMemberRole } from 'src/team/team.model';
import { User } from 'src/user/user.model';
import { TeamService } from 'src/team/team.service'; import { TeamService } from 'src/team/team.service';
import { import {
INVALID_EMAIL,
TEAM_INVALID_ID,
TEAM_INVITE_ALREADY_MEMBER, TEAM_INVITE_ALREADY_MEMBER,
TEAM_INVITE_EMAIL_DO_NOT_MATCH, TEAM_INVITE_EMAIL_DO_NOT_MATCH,
TEAM_INVITE_MEMBER_HAS_INVITE, TEAM_INVITE_MEMBER_HAS_INVITE,
TEAM_INVITE_NO_INVITE_FOUND, TEAM_INVITE_NO_INVITE_FOUND,
TEAM_MEMBER_NOT_FOUND,
} from 'src/errors'; } from 'src/errors';
import { TeamInvitation } from './team-invitation.model'; import { TeamInvitation } from './team-invitation.model';
import { MailerService } from 'src/mailer/mailer.service'; import { MailerService } from 'src/mailer/mailer.service';
import { UserService } from 'src/user/user.service'; import { UserService } from 'src/user/user.service';
import { PubSubService } from 'src/pubsub/pubsub.service'; import { PubSubService } from 'src/pubsub/pubsub.service';
import { validateEmail } from '../utils';
import { AuthUser } from 'src/types/AuthUser';
@Injectable() @Injectable()
export class TeamInvitationService { export class TeamInvitationService {
@@ -29,245 +30,221 @@ export class TeamInvitationService {
private readonly mailerService: MailerService, private readonly mailerService: MailerService,
private readonly pubsub: PubSubService, private readonly pubsub: PubSubService,
) { ) {}
this.getInvitation = this.getInvitation.bind(this);
}
getInvitation(inviteID: string): TO.TaskOption<TeamInvitation> { /**
return pipe( * Cast a DBTeamInvitation to a TeamInvitation
() => * @param dbTeamInvitation database TeamInvitation
this.prisma.teamInvitation.findUnique({ * @returns TeamInvitation model
where: { */
id: inviteID, cast(dbTeamInvitation: DBTeamInvitation): TeamInvitation {
}, return {
}), ...dbTeamInvitation,
TO.fromTask, inviteeRole: TeamMemberRole[dbTeamInvitation.inviteeRole],
TO.chain(flow(O.fromNullable, TO.fromOption)), };
TO.map((x) => x as TeamInvitation),
);
}
getInvitationWithEmail(email: Email, team: Team) {
return pipe(
() =>
this.prisma.teamInvitation.findUnique({
where: {
teamID_inviteeEmail: {
inviteeEmail: email,
teamID: team.id,
},
},
}),
TO.fromTask,
TO.chain(flow(O.fromNullable, TO.fromOption)),
);
}
createInvitation(
creator: User,
team: Team,
inviteeEmail: Email,
inviteeRole: TeamMemberRole,
) {
return pipe(
// Perform all validation checks
TE.sequenceArray([
// creator should be a TeamMember
pipe(
this.teamService.getTeamMemberTE(team.id, creator.uid),
TE.map(constVoid),
),
// Invitee should not be a team member
pipe(
async () => await this.userService.findUserByEmail(inviteeEmail),
TO.foldW(
() => TE.right(undefined), // If no user, short circuit to completion
(user) =>
pipe(
// If user is found, check if team member
this.teamService.getTeamMemberTE(team.id, user.uid),
TE.foldW(
() => TE.right(undefined), // Not team-member, this is good
() => TE.left(TEAM_INVITE_ALREADY_MEMBER), // Is team member, not good
),
),
),
TE.map(constVoid),
),
// Should not have an existing invite
pipe(
this.getInvitationWithEmail(inviteeEmail, team),
TE.fromTaskOption(() => null),
TE.swap,
TE.map(constVoid),
TE.mapLeft(() => TEAM_INVITE_MEMBER_HAS_INVITE),
),
]),
// Create the invitation
TE.chainTaskK(
() => () =>
this.prisma.teamInvitation.create({
data: {
teamID: team.id,
inviteeEmail,
inviteeRole,
creatorUid: creator.uid,
},
}),
),
// Send email, this is a side effect
TE.chainFirstTaskK((invitation) =>
pipe(
this.mailerService.sendMail(inviteeEmail, {
template: 'team-invitation',
variables: {
invitee: creator.displayName ?? 'A Hoppscotch User',
action_url: `${process.env.VITE_BASE_URL}/join-team?id=${invitation.id}`,
invite_team_name: team.name,
},
}),
TE.getOrElseW(() => T.of(undefined)), // This value doesn't matter as we don't mind the return value (chainFirst) as long as the task completes
),
),
// Send PubSub topic
TE.chainFirstTaskK((invitation) =>
TE.fromTask(async () => {
const inv: TeamInvitation = {
id: invitation.id,
teamID: invitation.teamID,
creatorUid: invitation.creatorUid,
inviteeEmail: invitation.inviteeEmail,
inviteeRole: TeamMemberRole[invitation.inviteeRole],
};
this.pubsub.publish(`team/${inv.teamID}/invite_added`, inv);
}),
),
// Map to model type
TE.map((x) => x as TeamInvitation),
);
}
revokeInvitation(inviteID: string) {
return pipe(
// Make sure invite exists
this.getInvitation(inviteID),
TE.fromTaskOption(() => TEAM_INVITE_NO_INVITE_FOUND),
// Delete team invitation
TE.chainTaskK(
() => () =>
this.prisma.teamInvitation.delete({
where: {
id: inviteID,
},
}),
),
// Emit Pubsub Event
TE.chainFirst((invitation) =>
TE.fromTask(() =>
this.pubsub.publish(
`team/${invitation.teamID}/invite_removed`,
invitation.id,
),
),
),
// We are not returning anything
TE.map(constVoid),
);
}
getAllInvitationsInTeam(team: Team) {
return pipe(
() =>
this.prisma.teamInvitation.findMany({
where: {
teamID: team.id,
},
}),
T.map((x) => x as TeamInvitation[]),
);
}
acceptInvitation(inviteID: string, acceptedBy: User) {
return pipe(
TE.Do,
// First get the invitation
TE.bindW('invitation', () =>
pipe(
this.getInvitation(inviteID),
TE.fromTaskOption(() => TEAM_INVITE_NO_INVITE_FOUND),
),
),
// Validation checks
TE.chainFirstW(({ invitation }) =>
TE.sequenceArray([
// Make sure the invited user is not part of the team
pipe(
this.teamService.getTeamMemberTE(invitation.teamID, acceptedBy.uid),
TE.swap,
TE.bimap(
() => TEAM_INVITE_ALREADY_MEMBER,
constVoid, // The return type is ignored
),
),
// Make sure the invited user and accepting user has the same email
pipe(
undefined,
TE.fromPredicate(
(a) => acceptedBy.email === invitation.inviteeEmail,
() => TEAM_INVITE_EMAIL_DO_NOT_MATCH,
),
),
]),
),
// Add the team member
// TODO: Somehow bring subscriptions to this ?
TE.bindW('teamMember', ({ invitation }) =>
pipe(
TE.tryCatch(
() =>
this.teamService.addMemberToTeam(
invitation.teamID,
acceptedBy.uid,
invitation.inviteeRole,
),
() => TEAM_INVITE_ALREADY_MEMBER, // Can only fail if Team Member already exists, which we checked, but due to async lets assert that here too
),
),
),
TE.chainFirstW(({ invitation }) => this.revokeInvitation(invitation.id)),
TE.map(({ teamMember }) => teamMember),
);
} }
/** /**
* Fetch the count invitations for a given team. * Get the team invite
* @param teamID team id * @param inviteID invite id
* @returns a count team invitations for a team * @returns an Option of team invitation or none
*/ */
async getAllTeamInvitations(teamID: string) { async getInvitation(inviteID: string) {
const invitations = await this.prisma.teamInvitation.findMany({ try {
const dbInvitation = await this.prisma.teamInvitation.findUniqueOrThrow({
where: {
id: inviteID,
},
});
return O.some(this.cast(dbInvitation));
} catch (e) {
return O.none;
}
}
/**
* Get the team invite for an invitee with email and teamID.
* @param inviteeEmail invitee email
* @param teamID team id
* @returns an Either of team invitation for the invitee or error
*/
async getTeamInviteByEmailAndTeamID(inviteeEmail: string, teamID: string) {
const isEmailValid = validateEmail(inviteeEmail);
if (!isEmailValid) return E.left(INVALID_EMAIL);
try {
const teamInvite = await this.prisma.teamInvitation.findUniqueOrThrow({
where: {
teamID_inviteeEmail: {
inviteeEmail: inviteeEmail,
teamID: teamID,
},
},
});
return E.right(teamInvite);
} catch (e) {
return E.left(TEAM_INVITE_NO_INVITE_FOUND);
}
}
/**
* Create a team invitation
* @param creator creator of the invitation
* @param teamID team id
* @param inviteeEmail invitee email
* @param inviteeRole invitee role
* @returns an Either of team invitation or error message
*/
async createInvitation(
creator: AuthUser,
teamID: string,
inviteeEmail: string,
inviteeRole: TeamMemberRole,
) {
// validate email
const isEmailValid = validateEmail(inviteeEmail);
if (!isEmailValid) return E.left(INVALID_EMAIL);
// team ID should valid
const team = await this.teamService.getTeamWithID(teamID);
if (!team) return E.left(TEAM_INVALID_ID);
// invitation creator should be a TeamMember
const isTeamMember = await this.teamService.getTeamMember(
team.id,
creator.uid,
);
if (!isTeamMember) return E.left(TEAM_MEMBER_NOT_FOUND);
// Checking to see if the invitee is already part of the team or not
const inviteeUser = await this.userService.findUserByEmail(inviteeEmail);
if (O.isSome(inviteeUser)) {
// invitee should not already a member
const isTeamMember = await this.teamService.getTeamMember(
team.id,
inviteeUser.value.uid,
);
if (isTeamMember) return E.left(TEAM_INVITE_ALREADY_MEMBER);
}
// check invitee already invited earlier or not
const teamInvitation = await this.getTeamInviteByEmailAndTeamID(
inviteeEmail,
team.id,
);
if (E.isRight(teamInvitation)) return E.left(TEAM_INVITE_MEMBER_HAS_INVITE);
// create the invitation
const dbInvitation = await this.prisma.teamInvitation.create({
data: {
teamID: team.id,
inviteeEmail,
inviteeRole,
creatorUid: creator.uid,
},
});
await this.mailerService.sendEmail(inviteeEmail, {
template: 'team-invitation',
variables: {
invitee: creator.displayName ?? 'A Hoppscotch User',
action_url: `${process.env.VITE_BASE_URL}/join-team?id=${dbInvitation.id}`,
invite_team_name: team.name,
},
});
const invitation = this.cast(dbInvitation);
this.pubsub.publish(`team/${invitation.teamID}/invite_added`, invitation);
return E.right(invitation);
}
/**
* Revoke a team invitation
* @param inviteID invite id
* @returns an Either of true or error message
*/
async revokeInvitation(inviteID: string) {
// check if the invite exists
const invitation = await this.getInvitation(inviteID);
if (O.isNone(invitation)) return E.left(TEAM_INVITE_NO_INVITE_FOUND);
// delete the invite
await this.prisma.teamInvitation.delete({
where: {
id: inviteID,
},
});
this.pubsub.publish(
`team/${invitation.value.teamID}/invite_removed`,
invitation.value.id,
);
return E.right(true);
}
/**
* Accept a team invitation
* @param inviteID invite id
* @param acceptedBy user who accepted the invitation
* @returns an Either of team member or error message
*/
async acceptInvitation(inviteID: string, acceptedBy: AuthUser) {
// check if the invite exists
const invitation = await this.getInvitation(inviteID);
if (O.isNone(invitation)) return E.left(TEAM_INVITE_NO_INVITE_FOUND);
// make sure the user is not already a member of the team
const teamMemberInvitee = await this.teamService.getTeamMember(
invitation.value.teamID,
acceptedBy.uid,
);
if (teamMemberInvitee) return E.left(TEAM_INVITE_ALREADY_MEMBER);
// make sure the user is the same as the invitee
if (
acceptedBy.email.toLowerCase() !==
invitation.value.inviteeEmail.toLowerCase()
)
return E.left(TEAM_INVITE_EMAIL_DO_NOT_MATCH);
// add the user to the team
let teamMember: TeamMember;
try {
teamMember = await this.teamService.addMemberToTeam(
invitation.value.teamID,
acceptedBy.uid,
invitation.value.inviteeRole,
);
} catch (e) {
return E.left(TEAM_INVITE_ALREADY_MEMBER);
}
// delete the invite
await this.revokeInvitation(inviteID);
return E.right(teamMember);
}
/**
* Fetch all team invitations for a given team.
* @param teamID team id
* @returns array of team invitations for a team
*/
async getTeamInvitations(teamID: string) {
const dbInvitations = await this.prisma.teamInvitation.findMany({
where: { where: {
teamID: teamID, teamID: teamID,
}, },
}); });
const invitations: TeamInvitation[] = dbInvitations.map((dbInvitation) =>
this.cast(dbInvitation),
);
return invitations; return invitations;
} }
} }

View File

@@ -1,21 +1,21 @@
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common'; import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
import { pipe } from 'fp-ts/function';
import { TeamService } from 'src/team/team.service'; import { TeamService } from 'src/team/team.service';
import { TeamInvitationService } from './team-invitation.service'; import { TeamInvitationService } from './team-invitation.service';
import * as O from 'fp-ts/Option'; import * as O from 'fp-ts/Option';
import * as T from 'fp-ts/Task';
import * as TE from 'fp-ts/TaskEither';
import { GqlExecutionContext } from '@nestjs/graphql'; import { GqlExecutionContext } from '@nestjs/graphql';
import { import {
BUG_AUTH_NO_USER_CTX, BUG_AUTH_NO_USER_CTX,
BUG_TEAM_INVITE_NO_INVITE_ID, BUG_TEAM_INVITE_NO_INVITE_ID,
TEAM_INVITE_NO_INVITE_FOUND, TEAM_INVITE_NO_INVITE_FOUND,
TEAM_MEMBER_NOT_FOUND,
TEAM_NOT_REQUIRED_ROLE, TEAM_NOT_REQUIRED_ROLE,
} from 'src/errors'; } from 'src/errors';
import { User } from 'src/user/user.model';
import { throwErr } from 'src/utils'; import { throwErr } from 'src/utils';
import { TeamMemberRole } from 'src/team/team.model'; import { TeamMemberRole } from 'src/team/team.model';
/**
* This guard only allows team owner to execute the resolver
*/
@Injectable() @Injectable()
export class TeamInviteTeamOwnerGuard implements CanActivate { export class TeamInviteTeamOwnerGuard implements CanActivate {
constructor( constructor(
@@ -24,48 +24,30 @@ export class TeamInviteTeamOwnerGuard implements CanActivate {
) {} ) {}
async canActivate(context: ExecutionContext): Promise<boolean> { async canActivate(context: ExecutionContext): Promise<boolean> {
return pipe( // Get GQL context
TE.Do, const gqlExecCtx = GqlExecutionContext.create(context);
TE.bindW('gqlCtx', () => TE.of(GqlExecutionContext.create(context))), // Get user
const { user } = gqlExecCtx.getContext().req;
if (!user) throwErr(BUG_AUTH_NO_USER_CTX);
// Get the invite // Get the invite
TE.bindW('invite', ({ gqlCtx }) => const { inviteID } = gqlExecCtx.getArgs<{ inviteID: string }>();
pipe( if (!inviteID) throwErr(BUG_TEAM_INVITE_NO_INVITE_ID);
O.fromNullable(gqlCtx.getArgs<{ inviteID?: string }>().inviteID),
TE.fromOption(() => BUG_TEAM_INVITE_NO_INVITE_ID),
TE.chainW((inviteID) =>
pipe(
this.teamInviteService.getInvitation(inviteID),
TE.fromTaskOption(() => TEAM_INVITE_NO_INVITE_FOUND),
),
),
),
),
TE.bindW('user', ({ gqlCtx }) => const invitation = await this.teamInviteService.getInvitation(inviteID);
pipe( if (O.isNone(invitation)) throwErr(TEAM_INVITE_NO_INVITE_FOUND);
gqlCtx.getContext().req.user,
O.fromNullable,
TE.fromOption(() => BUG_AUTH_NO_USER_CTX),
),
),
TE.bindW('userMember', ({ invite, user }) => // Fetch team member details of this user
this.teamService.getTeamMemberTE(invite.teamID, user.uid), const teamMember = await this.teamService.getTeamMember(
), invitation.value.teamID,
user.uid,
);
TE.chainW( if (!teamMember) throwErr(TEAM_MEMBER_NOT_FOUND);
TE.fromPredicate( if (teamMember.role !== TeamMemberRole.OWNER)
({ userMember }) => userMember.role === TeamMemberRole.OWNER, throwErr(TEAM_NOT_REQUIRED_ROLE);
() => TEAM_NOT_REQUIRED_ROLE,
),
),
TE.fold( return true;
(err) => throwErr(err),
() => T.of(true),
),
)();
} }
} }

View File

@@ -1,20 +1,23 @@
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common'; import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
import { TeamInvitationService } from './team-invitation.service'; import { TeamInvitationService } from './team-invitation.service';
import { pipe, flow } from 'fp-ts/function';
import * as TE from 'fp-ts/TaskEither';
import * as T from 'fp-ts/Task';
import * as O from 'fp-ts/Option'; import * as O from 'fp-ts/Option';
import { GqlExecutionContext } from '@nestjs/graphql'; import { GqlExecutionContext } from '@nestjs/graphql';
import { import {
BUG_AUTH_NO_USER_CTX, BUG_AUTH_NO_USER_CTX,
BUG_TEAM_INVITE_NO_INVITE_ID, BUG_TEAM_INVITE_NO_INVITE_ID,
TEAM_INVITE_NOT_VALID_VIEWER,
TEAM_INVITE_NO_INVITE_FOUND, TEAM_INVITE_NO_INVITE_FOUND,
TEAM_MEMBER_NOT_FOUND,
} from 'src/errors'; } from 'src/errors';
import { User } from 'src/user/user.model';
import { throwErr } from 'src/utils'; import { throwErr } from 'src/utils';
import { TeamService } from 'src/team/team.service'; import { TeamService } from 'src/team/team.service';
/**
* This guard only allows user to execute the resolver
* 1. If user is invitee, allow
* 2. Or else, if user is team member, allow
*
* TLDR: Allow if user is invitee or team member
*/
@Injectable() @Injectable()
export class TeamInviteViewerGuard implements CanActivate { export class TeamInviteViewerGuard implements CanActivate {
constructor( constructor(
@@ -23,50 +26,32 @@ export class TeamInviteViewerGuard implements CanActivate {
) {} ) {}
async canActivate(context: ExecutionContext): Promise<boolean> { async canActivate(context: ExecutionContext): Promise<boolean> {
return pipe( // Get GQL context
TE.Do, const gqlExecCtx = GqlExecutionContext.create(context);
// Get GQL Context // Get user
TE.bindW('gqlCtx', () => TE.of(GqlExecutionContext.create(context))), const { user } = gqlExecCtx.getContext().req;
if (!user) throwErr(BUG_AUTH_NO_USER_CTX);
// Get user // Get the invite
TE.bindW('user', ({ gqlCtx }) => const { inviteID } = gqlExecCtx.getArgs<{ inviteID: string }>();
pipe( if (!inviteID) throwErr(BUG_TEAM_INVITE_NO_INVITE_ID);
O.fromNullable(gqlCtx.getContext<{ user?: User }>().user),
TE.fromOption(() => BUG_AUTH_NO_USER_CTX),
),
),
// Get the invite const invitation = await this.teamInviteService.getInvitation(inviteID);
TE.bindW('invite', ({ gqlCtx }) => if (O.isNone(invitation)) throwErr(TEAM_INVITE_NO_INVITE_FOUND);
pipe(
O.fromNullable(gqlCtx.getArgs<{ inviteID?: string }>().inviteID),
TE.fromOption(() => BUG_TEAM_INVITE_NO_INVITE_ID),
TE.chainW(
flow(
this.teamInviteService.getInvitation,
TE.fromTaskOption(() => TEAM_INVITE_NO_INVITE_FOUND),
),
),
),
),
// Check if the user and the invite email match, else if we can resolver the user as a team member // Check if the user and the invite email match, else if user is a team member
// any better solution ? if (
TE.chainW(({ user, invite }) => user.email?.toLowerCase() !== invitation.value.inviteeEmail.toLowerCase()
user.email?.toLowerCase() === invite.inviteeEmail.toLowerCase() ) {
? TE.of(true) const teamMember = await this.teamService.getTeamMember(
: pipe( invitation.value.teamID,
this.teamService.getTeamMemberTE(invite.teamID, user.uid), user.uid,
TE.map(() => true), );
),
),
TE.mapLeft((e) => if (!teamMember) throwErr(TEAM_MEMBER_NOT_FOUND);
e === 'team/member_not_found' ? TEAM_INVITE_NOT_VALID_VIEWER : e, }
),
TE.fold(throwErr, () => T.of(true)), return true;
)();
} }
} }

View File

@@ -1,11 +1,7 @@
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common'; import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
import { TeamInvitationService } from './team-invitation.service'; import { TeamInvitationService } from './team-invitation.service';
import { pipe, flow } from 'fp-ts/function';
import * as O from 'fp-ts/Option'; import * as O from 'fp-ts/Option';
import * as T from 'fp-ts/Task';
import * as TE from 'fp-ts/TaskEither';
import { GqlExecutionContext } from '@nestjs/graphql'; import { GqlExecutionContext } from '@nestjs/graphql';
import { User } from 'src/user/user.model';
import { import {
BUG_AUTH_NO_USER_CTX, BUG_AUTH_NO_USER_CTX,
BUG_TEAM_INVITE_NO_INVITE_ID, BUG_TEAM_INVITE_NO_INVITE_ID,
@@ -24,44 +20,26 @@ export class TeamInviteeGuard implements CanActivate {
constructor(private readonly teamInviteService: TeamInvitationService) {} constructor(private readonly teamInviteService: TeamInvitationService) {}
async canActivate(context: ExecutionContext): Promise<boolean> { async canActivate(context: ExecutionContext): Promise<boolean> {
return pipe( // Get GQL Context
TE.Do, const gqlExecCtx = GqlExecutionContext.create(context);
// Get execution context // Get user
TE.bindW('gqlCtx', () => TE.of(GqlExecutionContext.create(context))), const { user } = gqlExecCtx.getContext().req;
if (!user) throwErr(BUG_AUTH_NO_USER_CTX);
// Get user // Get the invite
TE.bindW('user', ({ gqlCtx }) => const { inviteID } = gqlExecCtx.getArgs<{ inviteID: string }>();
pipe( if (!inviteID) throwErr(BUG_TEAM_INVITE_NO_INVITE_ID);
O.fromNullable(gqlCtx.getContext<{ user?: User }>().user),
TE.fromOption(() => BUG_AUTH_NO_USER_CTX),
),
),
// Get invite const invitation = await this.teamInviteService.getInvitation(inviteID);
TE.bindW('invite', ({ gqlCtx }) => if (O.isNone(invitation)) throwErr(TEAM_INVITE_NO_INVITE_FOUND);
pipe(
O.fromNullable(gqlCtx.getArgs<{ inviteID?: string }>().inviteID),
TE.fromOption(() => BUG_TEAM_INVITE_NO_INVITE_ID),
TE.chainW(
flow(
this.teamInviteService.getInvitation,
TE.fromTaskOption(() => TEAM_INVITE_NO_INVITE_FOUND),
),
),
),
),
// Check if the emails match if (
TE.chainW( user.email.toLowerCase() !== invitation.value.inviteeEmail.toLowerCase()
TE.fromPredicate( ) {
({ user, invite }) => user.email === invite.inviteeEmail, throwErr(TEAM_INVITE_EMAIL_DO_NOT_MATCH);
() => TEAM_INVITE_EMAIL_DO_NOT_MATCH, }
),
),
// Fold it to a promise return true;
TE.fold(throwErr, () => T.of(true)),
)();
} }
} }

View File

@@ -12,6 +12,6 @@ export class TeamTeamInviteExtResolver {
complexity: 10, complexity: 10,
}) })
teamInvitations(@Parent() team: Team): Promise<TeamInvitation[]> { teamInvitations(@Parent() team: Team): Promise<TeamInvitation[]> {
return this.teamInviteService.getAllInvitationsInTeam(team)(); return this.teamInviteService.getTeamInvitations(team.id);
} }
} }

View File

@@ -2,7 +2,7 @@ import { HttpStatus } from '@nestjs/common';
/** /**
** Custom interface to handle errors specific to Auth module ** Custom interface to handle errors specific to Auth module
** Since its REST we need to return HTTP status code along with error message ** Since its REST we need to return the HTTP status code along with the error message
*/ */
export type AuthError = { export type AuthError = {
message: string; message: string;

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-star"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg>

After

Width:  |  Height:  |  Size: 337 B

View File

@@ -432,6 +432,7 @@
"view_my_links": "View my links" "view_my_links": "View my links"
}, },
"response": { "response": {
"audio": "Audio",
"body": "Reaksie liggaam", "body": "Reaksie liggaam",
"filter_response_body": "Filter JSON response body (uses JSONPath syntax)", "filter_response_body": "Filter JSON response body (uses JSONPath syntax)",
"headers": "Opskrifte", "headers": "Opskrifte",
@@ -445,6 +446,7 @@
"status": "Status", "status": "Status",
"time": "Tyd", "time": "Tyd",
"title": "Reaksie", "title": "Reaksie",
"video": "Video",
"waiting_for_connection": "wag vir verbinding", "waiting_for_connection": "wag vir verbinding",
"xml": "XML" "xml": "XML"
}, },

View File

@@ -432,6 +432,7 @@
"view_my_links": "View my links" "view_my_links": "View my links"
}, },
"response": { "response": {
"audio": "Audio",
"body": "هيئة الاستجابة", "body": "هيئة الاستجابة",
"filter_response_body": "Filter JSON response body (uses JSONPath syntax)", "filter_response_body": "Filter JSON response body (uses JSONPath syntax)",
"headers": "الرؤوس", "headers": "الرؤوس",
@@ -445,6 +446,7 @@
"status": "حالة", "status": "حالة",
"time": "وقت", "time": "وقت",
"title": "إجابة", "title": "إجابة",
"video": "Video",
"waiting_for_connection": "في انتظار الاتصال", "waiting_for_connection": "في انتظار الاتصال",
"xml": "XML" "xml": "XML"
}, },

View File

@@ -432,6 +432,7 @@
"view_my_links": "Visualitzar els meus enllaços" "view_my_links": "Visualitzar els meus enllaços"
}, },
"response": { "response": {
"audio": "Audio",
"body": "Cos de resposta", "body": "Cos de resposta",
"filter_response_body": "Filtrar el cos de la resposta JSON (utilitza la sintaxi JSONPath)", "filter_response_body": "Filtrar el cos de la resposta JSON (utilitza la sintaxi JSONPath)",
"headers": "Capçaleres", "headers": "Capçaleres",
@@ -445,6 +446,7 @@
"status": "Estat", "status": "Estat",
"time": "Temps", "time": "Temps",
"title": "Resposta", "title": "Resposta",
"video": "Video",
"waiting_for_connection": "esperant la connexió", "waiting_for_connection": "esperant la connexió",
"xml": "XML" "xml": "XML"
}, },

View File

@@ -432,6 +432,7 @@
"view_my_links": "查看我的链接" "view_my_links": "查看我的链接"
}, },
"response": { "response": {
"audio": "Audio",
"body": "响应体", "body": "响应体",
"filter_response_body": "筛选JSON响应本体使用JSONPath语法", "filter_response_body": "筛选JSON响应本体使用JSONPath语法",
"headers": "响应头", "headers": "响应头",
@@ -445,6 +446,7 @@
"status": "状态", "status": "状态",
"time": "时间", "time": "时间",
"title": "响应", "title": "响应",
"video": "Video",
"waiting_for_connection": "等待连接", "waiting_for_connection": "等待连接",
"xml": "XML" "xml": "XML"
}, },

View File

@@ -432,6 +432,7 @@
"view_my_links": "View my links" "view_my_links": "View my links"
}, },
"response": { "response": {
"audio": "Audio",
"body": "Odpovědní orgán", "body": "Odpovědní orgán",
"filter_response_body": "Filter JSON response body (uses JSONPath syntax)", "filter_response_body": "Filter JSON response body (uses JSONPath syntax)",
"headers": "Záhlaví", "headers": "Záhlaví",
@@ -445,6 +446,7 @@
"status": "Postavení", "status": "Postavení",
"time": "Čas", "time": "Čas",
"title": "Odezva", "title": "Odezva",
"video": "Video",
"waiting_for_connection": "čekání na připojení", "waiting_for_connection": "čekání na připojení",
"xml": "XML" "xml": "XML"
}, },

View File

@@ -432,6 +432,7 @@
"view_my_links": "View my links" "view_my_links": "View my links"
}, },
"response": { "response": {
"audio": "Audio",
"body": "Svarorgan", "body": "Svarorgan",
"filter_response_body": "Filter JSON response body (uses JSONPath syntax)", "filter_response_body": "Filter JSON response body (uses JSONPath syntax)",
"headers": "Overskrifter", "headers": "Overskrifter",
@@ -445,6 +446,7 @@
"status": "Status", "status": "Status",
"time": "Tid", "time": "Tid",
"title": "Respons", "title": "Respons",
"video": "Video",
"waiting_for_connection": "venter på forbindelse", "waiting_for_connection": "venter på forbindelse",
"xml": "XML" "xml": "XML"
}, },

View File

@@ -432,6 +432,7 @@
"view_my_links": "View my links" "view_my_links": "View my links"
}, },
"response": { "response": {
"audio": "Audio",
"body": "Antworttext", "body": "Antworttext",
"filter_response_body": "Filter JSON response body (uses JSONPath syntax)", "filter_response_body": "Filter JSON response body (uses JSONPath syntax)",
"headers": "Header", "headers": "Header",
@@ -445,6 +446,7 @@
"status": "Status", "status": "Status",
"time": "Zeit", "time": "Zeit",
"title": "Antwort", "title": "Antwort",
"video": "Video",
"waiting_for_connection": "auf Verbindung warten", "waiting_for_connection": "auf Verbindung warten",
"xml": "XML" "xml": "XML"
}, },

View File

@@ -432,6 +432,7 @@
"view_my_links": "Προβολή των links μου" "view_my_links": "Προβολή των links μου"
}, },
"response": { "response": {
"audio": "Audio",
"body": "Σώμα απόκρισης", "body": "Σώμα απόκρισης",
"filter_response_body": "Filter JSON response body (uses JSONPath syntax)", "filter_response_body": "Filter JSON response body (uses JSONPath syntax)",
"headers": "Κεφαλίδες", "headers": "Κεφαλίδες",
@@ -445,6 +446,7 @@
"status": "Κατάσταση", "status": "Κατάσταση",
"time": "χρόνος", "time": "χρόνος",
"title": "Απάντηση", "title": "Απάντηση",
"video": "Video",
"waiting_for_connection": "περιμένοντας τη σύνδεση", "waiting_for_connection": "περιμένοντας τη σύνδεση",
"xml": "XML" "xml": "XML"
}, },

View File

@@ -432,6 +432,7 @@
"view_my_links": "Ver mis enlaces" "view_my_links": "Ver mis enlaces"
}, },
"response": { "response": {
"audio": "Audio",
"body": "Cuerpo de respuesta", "body": "Cuerpo de respuesta",
"filter_response_body": "Filtrar el cuerpo de la respuesta JSON (utiliza la sintaxis JSONPath)", "filter_response_body": "Filtrar el cuerpo de la respuesta JSON (utiliza la sintaxis JSONPath)",
"headers": "Encabezados", "headers": "Encabezados",
@@ -445,6 +446,7 @@
"status": "Estado", "status": "Estado",
"time": "Tiempo", "time": "Tiempo",
"title": "Respuesta", "title": "Respuesta",
"video": "Video",
"waiting_for_connection": "esperando la conexión", "waiting_for_connection": "esperando la conexión",
"xml": "XML" "xml": "XML"
}, },

View File

@@ -432,6 +432,7 @@
"view_my_links": "View my links" "view_my_links": "View my links"
}, },
"response": { "response": {
"audio": "Audio",
"body": "Vastauselin", "body": "Vastauselin",
"filter_response_body": "Filter JSON response body (uses JSONPath syntax)", "filter_response_body": "Filter JSON response body (uses JSONPath syntax)",
"headers": "Otsikot", "headers": "Otsikot",
@@ -445,6 +446,7 @@
"status": "Tila", "status": "Tila",
"time": "Aika", "time": "Aika",
"title": "Vastaus", "title": "Vastaus",
"video": "Video",
"waiting_for_connection": "yhteyttä odotellessa", "waiting_for_connection": "yhteyttä odotellessa",
"xml": "XML" "xml": "XML"
}, },

View File

@@ -432,6 +432,7 @@
"view_my_links": "Voir mes liens" "view_my_links": "Voir mes liens"
}, },
"response": { "response": {
"audio": "Audio",
"body": "Corps de réponse", "body": "Corps de réponse",
"filter_response_body": "Filter JSON response body (uses JSONPath syntax)", "filter_response_body": "Filter JSON response body (uses JSONPath syntax)",
"headers": "En-têtes", "headers": "En-têtes",
@@ -445,6 +446,7 @@
"status": "Statut", "status": "Statut",
"time": "Temps", "time": "Temps",
"title": "Réponse", "title": "Réponse",
"video": "Video",
"waiting_for_connection": "En attente de connexion", "waiting_for_connection": "En attente de connexion",
"xml": "XML" "xml": "XML"
}, },

View File

@@ -432,6 +432,7 @@
"view_my_links": "View my links" "view_my_links": "View my links"
}, },
"response": { "response": {
"audio": "Audio",
"body": "גוף תגובה", "body": "גוף תגובה",
"filter_response_body": "Filter JSON response body (uses JSONPath syntax)", "filter_response_body": "Filter JSON response body (uses JSONPath syntax)",
"headers": "כותרות", "headers": "כותרות",
@@ -445,6 +446,7 @@
"status": "סטָטוּס", "status": "סטָטוּס",
"time": "זְמַן", "time": "זְמַן",
"title": "תְגוּבָה", "title": "תְגוּבָה",
"video": "Video",
"waiting_for_connection": "מחכה לחיבור", "waiting_for_connection": "מחכה לחיבור",
"xml": "XML" "xml": "XML"
}, },

View File

@@ -433,6 +433,7 @@
"view_my_links": "मेरे लिंक देखें" "view_my_links": "मेरे लिंक देखें"
}, },
"response": { "response": {
"audio": "Audio",
"body": "प्रतिक्रिया निकाय", "body": "प्रतिक्रिया निकाय",
"filter_response_body": "फ़िल्टर JSON रिस्पांस बॉडी (JSONPATH सिंटैक्स का उपयोग करता है)", "filter_response_body": "फ़िल्टर JSON रिस्पांस बॉडी (JSONPATH सिंटैक्स का उपयोग करता है)",
"headers": "हेडर", "headers": "हेडर",
@@ -446,6 +447,7 @@
"status": "दर्जा", "status": "दर्जा",
"time": "समय", "time": "समय",
"title": "जवाब", "title": "जवाब",
"video": "Video",
"waiting_for_connection": "जुडने के लिए इंतजार", "waiting_for_connection": "जुडने के लिए इंतजार",
"xml": "एक्सएमएल" "xml": "एक्सएमएल"
}, },

View File

@@ -5,29 +5,29 @@
"choose_file": "Válasszon egy fájlt", "choose_file": "Válasszon egy fájlt",
"clear": "Törlés", "clear": "Törlés",
"clear_all": "Összes törlése", "clear_all": "Összes törlése",
"close": "Close", "close": "Bezárás",
"connect": "Kapcsolódás", "connect": "Kapcsolódás",
"connecting": "Connecting", "connecting": "Kapcsolódás",
"copy": "Másolás", "copy": "Másolás",
"delete": "Törlés", "delete": "Törlés",
"disconnect": "Leválasztás", "disconnect": "Leválasztás",
"dismiss": "Eltüntetés", "dismiss": "Eltüntetés",
"dont_save": "Ne mentse", "dont_save": "Ne mentse",
"download_file": "Fájl letöltése", "download_file": "Fájl letöltése",
"drag_to_reorder": "Drag to reorder", "drag_to_reorder": "Húzza az átrendezéshez",
"duplicate": "Kettőzés", "duplicate": "Kettőzés",
"edit": "Szerkesztés", "edit": "Szerkesztés",
"filter": "Filter", "filter": "Szűrő",
"go_back": "Vissza", "go_back": "Vissza",
"go_forward": "Go forward", "go_forward": "Előre",
"group_by": "Group by", "group_by": "Csoportosítás",
"label": "Címke", "label": "Címke",
"learn_more": "Tudjon meg többet", "learn_more": "Tudjon meg többet",
"less": "Kevesebb", "less": "Kevesebb",
"more": "Több", "more": "Több",
"new": "Új", "new": "Új",
"no": "Nem", "no": "Nem",
"open_workspace": "Open workspace", "open_workspace": "Munkaterület megnyitása",
"paste": "Beillesztés", "paste": "Beillesztés",
"prettify": "Csinosítás", "prettify": "Csinosítás",
"remove": "Eltávolítás", "remove": "Eltávolítás",
@@ -38,7 +38,7 @@
"search": "Keresés", "search": "Keresés",
"send": "Küldés", "send": "Küldés",
"start": "Indítás", "start": "Indítás",
"starting": "Starting", "starting": "Indítás",
"stop": "Leállítás", "stop": "Leállítás",
"to_close": "a bezáráshoz", "to_close": "a bezáráshoz",
"to_navigate": "a navigáláshoz", "to_navigate": "a navigáláshoz",
@@ -118,16 +118,16 @@
}, },
"collection": { "collection": {
"created": "Gyűjtemény létrehozva", "created": "Gyűjtemény létrehozva",
"different_parent": "Cannot reorder collection with different parent", "different_parent": "Nem lehet átrendezni a különböző szülővel rendelkező gyűjteményt",
"edit": "Gyűjtemény szerkesztése", "edit": "Gyűjtemény szerkesztése",
"invalid_name": "Adjon nevet a gyűjteménynek", "invalid_name": "Adjon nevet a gyűjteménynek",
"invalid_root_move": "Collection already in the root", "invalid_root_move": "A gyűjtemény már a gyökérben van",
"moved": "Moved Successfully", "moved": "Sikeresen áthelyezve",
"my_collections": "Saját gyűjtemények", "my_collections": "Saját gyűjtemények",
"name": "Saját új gyűjtemény", "name": "Saját új gyűjtemény",
"name_length_insufficient": "A gyűjtemény nevének legalább 3 karakter hosszúságúnak kell lennie", "name_length_insufficient": "A gyűjtemény nevének legalább 3 karakter hosszúságúnak kell lennie",
"new": "Új gyűjtemény", "new": "Új gyűjtemény",
"order_changed": "Collection Order Updated", "order_changed": "Gyűjtemény sorrendje frissítve",
"renamed": "Gyűjtemény átnevezve", "renamed": "Gyűjtemény átnevezve",
"request_in_use": "A kérés használatban", "request_in_use": "A kérés használatban",
"save_as": "Mentés másként", "save_as": "Mentés másként",
@@ -147,7 +147,7 @@
"remove_team": "Biztosan törölni szeretné ezt a csapatot?", "remove_team": "Biztosan törölni szeretné ezt a csapatot?",
"remove_telemetry": "Biztosan ki szeretné kapcsolni a telemetriát?", "remove_telemetry": "Biztosan ki szeretné kapcsolni a telemetriát?",
"request_change": "Biztosan el szeretné vetni a jelenlegi kérést? Minden mentetlen változtatás el fog veszni.", "request_change": "Biztosan el szeretné vetni a jelenlegi kérést? Minden mentetlen változtatás el fog veszni.",
"save_unsaved_tab": "Do you want to save changes made in this tab?", "save_unsaved_tab": "Szeretné menteni az ezen a lapon elvégzett változtatásokat?",
"sync": "Szeretné visszaállítani a munkaterületét a felhőből? Ez el fogja vetni a helyi folyamatát." "sync": "Szeretné visszaállítani a munkaterületét a felhőből? Ez el fogja vetni a helyi folyamatát."
}, },
"count": { "count": {
@@ -180,8 +180,8 @@
"profile": "Jelentkezzen be a profilja megtekintéséhez", "profile": "Jelentkezzen be a profilja megtekintéséhez",
"protocols": "A protokollok üresek", "protocols": "A protokollok üresek",
"schema": "Kapcsolódjon egy GraphQL-végponthoz a séma megtekintéséhez", "schema": "Kapcsolódjon egy GraphQL-végponthoz a séma megtekintéséhez",
"shortcodes": "Shortcodes are empty", "shortcodes": "A rövid kódok üresek",
"subscription": "Subscriptions are empty", "subscription": "A feliratkozások üresek",
"team_name": "A csapat neve üres", "team_name": "A csapat neve üres",
"teams": "Ön nem tartozik semmilyen csapathoz", "teams": "Ön nem tartozik semmilyen csapathoz",
"tests": "Nincsenek tesztek ehhez a kéréshez" "tests": "Nincsenek tesztek ehhez a kéréshez"
@@ -194,13 +194,13 @@
"deleted": "Környezet törlése", "deleted": "Környezet törlése",
"edit": "Környezet szerkesztése", "edit": "Környezet szerkesztése",
"invalid_name": "Adjon nevet a környezetnek", "invalid_name": "Adjon nevet a környezetnek",
"my_environments": "My Environments", "my_environments": "Saját környezetek",
"nested_overflow": "az egymásba ágyazott környezeti változók 10 szintre vannak korlátozva", "nested_overflow": "az egymásba ágyazott környezeti változók 10 szintre vannak korlátozva",
"new": "Új környezet", "new": "Új környezet",
"no_environment": "Nincs környezet", "no_environment": "Nincs környezet",
"no_environment_description": "Nem lettek környezetek kiválasztva. Válassza ki, hogy mit kell tenni a következő változókkal.", "no_environment_description": "Nem lettek környezetek kiválasztva. Válassza ki, hogy mit kell tenni a következő változókkal.",
"select": "Környezet kiválasztása", "select": "Környezet kiválasztása",
"team_environments": "Team Environments", "team_environments": "Csapatkörnyezetek",
"title": "Környezetek", "title": "Környezetek",
"updated": "Környezet frissítve", "updated": "Környezet frissítve",
"variable_list": "Változólista" "variable_list": "Változólista"
@@ -209,9 +209,9 @@
"browser_support_sse": "Úgy tűnik, hogy ez a böngésző nem támogatja a kiszolgáló által küldött eseményeket.", "browser_support_sse": "Úgy tűnik, hogy ez a böngésző nem támogatja a kiszolgáló által küldött eseményeket.",
"check_console_details": "Nézze meg a konzolnaplót a részletekért.", "check_console_details": "Nézze meg a konzolnaplót a részletekért.",
"curl_invalid_format": "A cURL nincs megfelelően formázva", "curl_invalid_format": "A cURL nincs megfelelően formázva",
"danger_zone": "Danger zone", "danger_zone": "Veszélyes zóna",
"delete_account": "Your account is currently an owner in these teams:", "delete_account": "Az Ön fiókja jelenleg tulajdonos ezekben a csapatokban:",
"delete_account_description": "You must either remove yourself, transfer ownership, or delete these teams before you can delete your account.", "delete_account_description": "El kell távolítani magát, át kell adnia a tulajdonjogot vagy törölnie kell ezeket a csapatokat, mielőtt törölhetné a fiókját.",
"empty_req_name": "Üres kérésnév", "empty_req_name": "Üres kérésnév",
"f12_details": "(F12 a részletekért)", "f12_details": "(F12 a részletekért)",
"gql_prettify_invalid_query": "Nem sikerült csinosítani egy érvénytelen lekérdezést, oldja meg a lekérdezés szintaktikai hibáit, és próbálja újra", "gql_prettify_invalid_query": "Nem sikerült csinosítani egy érvénytelen lekérdezést, oldja meg a lekérdezés szintaktikai hibáit, és próbálja újra",
@@ -219,13 +219,13 @@
"incorrect_email": "Hibás e-mail", "incorrect_email": "Hibás e-mail",
"invalid_link": "Érvénytelen hivatkozás", "invalid_link": "Érvénytelen hivatkozás",
"invalid_link_description": "A kattintott hivatkozás érvénytelen vagy lejárt.", "invalid_link_description": "A kattintott hivatkozás érvénytelen vagy lejárt.",
"json_parsing_failed": "Invalid JSON", "json_parsing_failed": "Érvénytelen JSON",
"json_prettify_invalid_body": "Nem sikerült csinosítani egy érvénytelen törzset, oldja meg a JSON szintaktikai hibáit, és próbálja újra", "json_prettify_invalid_body": "Nem sikerült csinosítani egy érvénytelen törzset, oldja meg a JSON szintaktikai hibáit, és próbálja újra",
"network_error": "Úgy tűnik, hogy hálózati hiba van. Próbálja újra.", "network_error": "Úgy tűnik, hogy hálózati hiba van. Próbálja újra.",
"network_fail": "Nem sikerült elküldeni a kérést", "network_fail": "Nem sikerült elküldeni a kérést",
"no_duration": "Nincs időtartam", "no_duration": "Nincs időtartam",
"no_results_found": "No matches found", "no_results_found": "Nincs találat",
"page_not_found": "This page could not be found", "page_not_found": "Ez az oldal nem található",
"script_fail": "Nem sikerült végrehajtani a kérés előtti parancsfájlt", "script_fail": "Nem sikerült végrehajtani a kérés előtti parancsfájlt",
"something_went_wrong": "Valami elromlott", "something_went_wrong": "Valami elromlott",
"test_script_fail": "Nem sikerült végrehajtani a kérés utáni parancsfájlt" "test_script_fail": "Nem sikerült végrehajtani a kérés utáni parancsfájlt"
@@ -238,9 +238,9 @@
"title": "Exportálás" "title": "Exportálás"
}, },
"filter": { "filter": {
"all": "All", "all": "Összes",
"none": "None", "none": "Nincs",
"starred": "Starred" "starred": "Csillagozott"
}, },
"folder": { "folder": {
"created": "Mappa létrehozva", "created": "Mappa létrehozva",
@@ -256,7 +256,7 @@
"subscriptions": "Feliratkozások" "subscriptions": "Feliratkozások"
}, },
"group": { "group": {
"time": "Time", "time": "Idő",
"url": "URL" "url": "URL"
}, },
"header": { "header": {
@@ -316,32 +316,32 @@
"zen_mode": "Zen mód" "zen_mode": "Zen mód"
}, },
"modal": { "modal": {
"close_unsaved_tab": "You have unsaved changes", "close_unsaved_tab": "Elmentetlen változtatásai vannak",
"collections": "Gyűjtemények", "collections": "Gyűjtemények",
"confirm": "Megerősítés", "confirm": "Megerősítés",
"edit_request": "Kérés szerkesztése", "edit_request": "Kérés szerkesztése",
"import_export": "Importálás és exportálás" "import_export": "Importálás és exportálás"
}, },
"mqtt": { "mqtt": {
"already_subscribed": "You are already subscribed to this topic.", "already_subscribed": "Ön már feliratkozott erre a témára.",
"clean_session": "Clean Session", "clean_session": "Munkamenet törlése",
"clear_input": "Clear input", "clear_input": "Bevitel törlése",
"clear_input_on_send": "Clear input on send", "clear_input_on_send": "Bevitel törlése küldéskor",
"client_id": "Client ID", "client_id": "Ügyfél-azonosító",
"color": "Pick a color", "color": "Válasszon színt",
"communication": "Kommunikáció", "communication": "Kommunikáció",
"connection_config": "Connection Config", "connection_config": "Kapcsolat beállításai",
"connection_not_authorized": "This MQTT connection does not use any authentication.", "connection_not_authorized": "Ez az MQTT-kapcsolat nem használ semmilyen hitelesítést.",
"invalid_topic": "Please provide a topic for the subscription", "invalid_topic": "Adjon témát a feliratkozáshoz",
"keep_alive": "Keep Alive", "keep_alive": "Életben tartás",
"log": "Napló", "log": "Napló",
"lw_message": "Last-Will Message", "lw_message": "Utolsó kívánság üzenet",
"lw_qos": "Last-Will QoS", "lw_qos": "Utolsó kívánság QoS",
"lw_retain": "Last-Will Retain", "lw_retain": "Utolsó kívánság megtartás",
"lw_topic": "Last-Will Topic", "lw_topic": "Utolsó kívánság téma",
"message": "Üzenet", "message": "Üzenet",
"new": "New Subscription", "new": "Új feliratkozás",
"not_connected": "Please start a MQTT connection first.", "not_connected": "Először indítson egy MQTT-kapcsolatot.",
"publish": "Közzététel", "publish": "Közzététel",
"qos": "QoS", "qos": "QoS",
"ssl": "SSL", "ssl": "SSL",
@@ -368,7 +368,7 @@
}, },
"profile": { "profile": {
"app_settings": "Alkalmazás beállításai", "app_settings": "Alkalmazás beállításai",
"default_hopp_displayname": "Unnamed User", "default_hopp_displayname": "Névtelen felhasználó",
"editor": "Szerkesztő", "editor": "Szerkesztő",
"editor_description": "A szerkesztők hozzáadhatnak, szerkeszthetnek és törölhetnek kéréseket.", "editor_description": "A szerkesztők hozzáadhatnak, szerkeszthetnek és törölhetnek kéréseket.",
"email_verification_mail": "Egy ellenőrző e-mail el lett küldve az e-mail-címére. Kattintson a hivatkozásra az e-mail-címe ellenőrzéséhez.", "email_verification_mail": "Egy ellenőrző e-mail el lett küldve az e-mail-címére. Kattintson a hivatkozásra az e-mail-címe ellenőrzéséhez.",
@@ -391,26 +391,26 @@
"choose_language": "Nyelv kiválasztása", "choose_language": "Nyelv kiválasztása",
"content_type": "Tartalom típusa", "content_type": "Tartalom típusa",
"content_type_titles": { "content_type_titles": {
"others": "Others", "others": "Egyebek",
"structured": "Structured", "structured": "Szerkesztett",
"text": "Text" "text": "Szöveg"
}, },
"copy_link": "Hivatkozás másolása", "copy_link": "Hivatkozás másolása",
"different_collection": "Cannot reorder requests from different collections", "different_collection": "Nem lehet átrendezni a különböző gyűjteményekből érkező kéréseket",
"duplicated": "Request duplicated", "duplicated": "Kérés megkettőzve",
"duration": "Időtartam", "duration": "Időtartam",
"enter_curl": "cURL megadása", "enter_curl": "cURL-parancs megadása",
"generate_code": "Kód előállítása", "generate_code": "Kód előállítása",
"generated_code": "Előállított kód", "generated_code": "Előállított kód",
"header_list": "Fejléclista", "header_list": "Fejléclista",
"invalid_name": "Adjon nevet a kérésnek", "invalid_name": "Adjon nevet a kérésnek",
"method": "Módszer", "method": "Módszer",
"moved": "Request moved", "moved": "Kérés áthelyezve",
"name": "Kérés neve", "name": "Kérés neve",
"new": "Új kérés", "new": "Új kérés",
"order_changed": "Request Order Updated", "order_changed": "Kérés sorrendje frissítve",
"override": "Felülbírálás", "override": "Felülbírálás",
"override_help": "A <kbd>Content-Type</kbd> beállítása a fejlécekben", "override_help": "<kbd>Content-Type</kbd> beállítása a fejlécekben",
"overriden": "Felülbírálva", "overriden": "Felülbírálva",
"parameter_list": "Lekérdezési paraméterek", "parameter_list": "Lekérdezési paraméterek",
"parameters": "Paraméterek", "parameters": "Paraméterek",
@@ -429,11 +429,12 @@
"type": "Kérés típusa", "type": "Kérés típusa",
"url": "URL", "url": "URL",
"variables": "Változók", "variables": "Változók",
"view_my_links": "View my links" "view_my_links": "Saját hivatkozások megtekintése"
}, },
"response": { "response": {
"audio": "Hang",
"body": "Válasz törzse", "body": "Válasz törzse",
"filter_response_body": "Filter JSON response body (uses JSONPath syntax)", "filter_response_body": "JSON-válasz törzsének szűrése (JSONPath szintaxist használ)",
"headers": "Fejlécek", "headers": "Fejlécek",
"html": "HTML", "html": "HTML",
"image": "Kép", "image": "Kép",
@@ -445,13 +446,14 @@
"status": "Állapot", "status": "Állapot",
"time": "Idő", "time": "Idő",
"title": "Válasz", "title": "Válasz",
"video": "Videó",
"waiting_for_connection": "várakozás kapcsolódásra", "waiting_for_connection": "várakozás kapcsolódásra",
"xml": "XML" "xml": "XML"
}, },
"settings": { "settings": {
"accent_color": "Kiemelőszín", "accent_color": "Kiemelőszín",
"account": "Fiók", "account": "Fiók",
"account_deleted": "Your account has been deleted", "account_deleted": "A fiókja törölve lett",
"account_description": "A fiókbeállítások személyre szabása.", "account_description": "A fiókbeállítások személyre szabása.",
"account_email_description": "Az Ön elsődleges e-mail-címe.", "account_email_description": "Az Ön elsődleges e-mail-címe.",
"account_name_description": "Ez a megjelenített neve.", "account_name_description": "Ez a megjelenített neve.",
@@ -460,8 +462,8 @@
"change_font_size": "Betűméret megváltoztatása", "change_font_size": "Betűméret megváltoztatása",
"choose_language": "Nyelv kiválasztása", "choose_language": "Nyelv kiválasztása",
"dark_mode": "Sötét", "dark_mode": "Sötét",
"delete_account": "Delete account", "delete_account": "Fiók törlése",
"delete_account_description": "Once you delete your account, all your data will be permanently deleted. This action cannot be undone.", "delete_account_description": "Ha törli a fiókját, akkor az összes adata véglegesen törlésre kerül. Ezt a műveletet nem lehet visszavonni.",
"expand_navigation": "Navigáció kinyitása", "expand_navigation": "Navigáció kinyitása",
"experiments": "Kísérletek", "experiments": "Kísérletek",
"experiments_notice": "Ez olyan kísérletek gyűjteménye, amelyeken dolgozunk, és amelyek hasznosak, szórakoztatóak lehetnek, mindkettő, vagy egyik sem. Ezek nem véglegesek és nem stabilak, ezért ha valami túl furcsa dolog történik, ne essen pánikba. Egyszerűen kapcsolja ki a hibás dolgot. Viccet félretéve, ", "experiments_notice": "Ez olyan kísérletek gyűjteménye, amelyeken dolgozunk, és amelyek hasznosak, szórakoztatóak lehetnek, mindkettő, vagy egyik sem. Ezek nem véglegesek és nem stabilak, ezért ha valami túl furcsa dolog történik, ne essen pánikba. Egyszerűen kapcsolja ki a hibás dolgot. Viccet félretéve, ",
@@ -488,8 +490,8 @@
"proxy_use_toggle": "A proxy középprogram használata a kérések küldéséhez", "proxy_use_toggle": "A proxy középprogram használata a kérések küldéséhez",
"read_the": "Olvassa el:", "read_the": "Olvassa el:",
"reset_default": "Visszaállítás az alapértelmezettre", "reset_default": "Visszaállítás az alapértelmezettre",
"short_codes": "Short codes", "short_codes": "Rövid kódok",
"short_codes_description": "Short codes which were created by you.", "short_codes_description": "Az Ön által létrehozott rövid kódok.",
"sidebar_on_left": "Oldalsáv a bal oldalon", "sidebar_on_left": "Oldalsáv a bal oldalon",
"sync": "Szinkronizálás", "sync": "Szinkronizálás",
"sync_collections": "Gyűjtemények", "sync_collections": "Gyűjtemények",
@@ -503,16 +505,16 @@
"theme_description": "Az alkalmazás témájának személyre szabása.", "theme_description": "Az alkalmazás témájának személyre szabása.",
"use_experimental_url_bar": "Kísérleti URL-sáv használata a környezet kiemelésével", "use_experimental_url_bar": "Kísérleti URL-sáv használata a környezet kiemelésével",
"user": "Felhasználó", "user": "Felhasználó",
"verified_email": "Verified email", "verified_email": "Ellenőrzött e-mail-cím",
"verify_email": "E-mail-cím ellenőrzése" "verify_email": "E-mail-cím ellenőrzése"
}, },
"shortcodes": { "shortcodes": {
"actions": "Actions", "actions": "Műveletek",
"created_on": "Created on", "created_on": "Létrehozva",
"deleted": "Shortcode deleted", "deleted": "Rövid kód törölve",
"method": "Method", "method": "Módszer",
"not_found": "Shortcode not found", "not_found": "A rövid kód nem található",
"short_code": "Short code", "short_code": "Rövid kód",
"url": "URL" "url": "URL"
}, },
"shortcut": { "shortcut": {
@@ -554,9 +556,9 @@
"title": "Kérés" "title": "Kérés"
}, },
"response": { "response": {
"copy": "Copy response to clipboard", "copy": "Válasz másolása a vágólapra",
"download": "Download response as file", "download": "Válasz letöltés fájlként",
"title": "Response" "title": "Válasz"
}, },
"theme": { "theme": {
"black": "Téma átváltása fekete módra", "black": "Téma átváltása fekete módra",
@@ -574,8 +576,8 @@
}, },
"socketio": { "socketio": {
"communication": "Kommunikáció", "communication": "Kommunikáció",
"connection_not_authorized": "This SocketIO connection does not use any authentication.", "connection_not_authorized": "Ez a SocketIO-kapcsolat nem használ semmilyen hitelesítést.",
"event_name": "Esemény neve", "event_name": "Esemény vagy téma neve",
"events": "Események", "events": "Események",
"log": "Napló", "log": "Napló",
"url": "URL" "url": "URL"
@@ -592,9 +594,9 @@
"connected": "Kapcsolódva", "connected": "Kapcsolódva",
"connected_to": "Kapcsolódva ehhez: {name}", "connected_to": "Kapcsolódva ehhez: {name}",
"connecting_to": "Kapcsolódás ehhez: {name}…", "connecting_to": "Kapcsolódás ehhez: {name}…",
"connection_error": "Failed to connect", "connection_error": "Nem sikerült kapcsolódni",
"connection_failed": "Connection failed", "connection_failed": "A kapcsolódás sikertelen",
"connection_lost": "Connection lost", "connection_lost": "A kapcsolat elveszett",
"copied_to_clipboard": "Vágólapra másolva", "copied_to_clipboard": "Vágólapra másolva",
"deleted": "Törölve", "deleted": "Törölve",
"deprecated": "ELAVULT", "deprecated": "ELAVULT",
@@ -609,17 +611,17 @@
"history_deleted": "Előzmények törölve", "history_deleted": "Előzmények törölve",
"linewrap": "Sorok tördelése", "linewrap": "Sorok tördelése",
"loading": "Betöltés…", "loading": "Betöltés…",
"message_received": "Message: {message} arrived on topic: {topic}", "message_received": "Üzenet: {message} érkezett ehhez a témához: {topic}",
"mqtt_subscription_failed": "Something went wrong while subscribing to topic: {topic}", "mqtt_subscription_failed": "Valami elromlott a következő témára való feliratkozás során: {topic}",
"none": "Nincs", "none": "Nincs",
"nothing_found": "Semmi sem található ehhez:", "nothing_found": "Semmi sem található ehhez:",
"published_error": "Something went wrong while publishing msg: {topic} to topic: {message}", "published_error": "Valami elromlott a következő üzenet közzététele során: {topic}, ehhez a témához: {message}",
"published_message": "Published message: {message} to topic: {topic}", "published_message": "Közzétett üzenet: {message}, ehhez a témához: {topic}",
"reconnection_error": "Failed to reconnect", "reconnection_error": "Nem sikerült újrakapcsolódni",
"subscribed_failed": "Failed to subscribe to topic: {topic}", "subscribed_failed": "Nem sikerült feliratkozni erre a témára: {topic}",
"subscribed_success": "Successfully subscribed to topic: {topic}", "subscribed_success": "Sikeresen feliratkozott erre a témára: {topic}",
"unsubscribed_failed": "Failed to unsubscribe from topic: {topic}", "unsubscribed_failed": "Nem sikerült leiratkozni erről a témáról: {topic}",
"unsubscribed_success": "Successfully unsubscribed from topic: {topic}", "unsubscribed_success": "Sikeresen leiratkozott erről a témáról: {topic}",
"waiting_send_request": "Várakozás a kérés elküldésére" "waiting_send_request": "Várakozás a kérés elküldésére"
}, },
"support": { "support": {
@@ -639,7 +641,7 @@
"body": "Törzs", "body": "Törzs",
"collections": "Gyűjtemények", "collections": "Gyűjtemények",
"documentation": "Dokumentáció", "documentation": "Dokumentáció",
"environments": "Environments", "environments": "Környezetek",
"headers": "Fejlécek", "headers": "Fejlécek",
"history": "Előzmények", "history": "Előzmények",
"mqtt": "MQTT", "mqtt": "MQTT",
@@ -664,7 +666,7 @@
"email_do_not_match": "Az e-mail-cím nem egyezik a fiókja részleteivel. Vegye fel a kapcsolatot a csapat tulajdonosával.", "email_do_not_match": "Az e-mail-cím nem egyezik a fiókja részleteivel. Vegye fel a kapcsolatot a csapat tulajdonosával.",
"exit": "Kilépés a csapatból", "exit": "Kilépés a csapatból",
"exit_disabled": "Csak a tulajdonos nem léphet ki a csapatból", "exit_disabled": "Csak a tulajdonos nem léphet ki a csapatból",
"invalid_coll_id": "Invalid collection ID", "invalid_coll_id": "Érvénytelen gyűjteményazonosító",
"invalid_email_format": "Az e-mail formátuma érvénytelen", "invalid_email_format": "Az e-mail formátuma érvénytelen",
"invalid_id": "Érvénytelen csapatazonosító. Vegye fel a kapcsolatot a csapat tulajdonosával.", "invalid_id": "Érvénytelen csapatazonosító. Vegye fel a kapcsolatot a csapat tulajdonosával.",
"invalid_invite_link": "Érvénytelen meghívási hivatkozás", "invalid_invite_link": "Érvénytelen meghívási hivatkozás",
@@ -688,7 +690,7 @@
"member_removed": "Felhasználó eltávolítva", "member_removed": "Felhasználó eltávolítva",
"member_role_updated": "Felhasználói szerepek frissítve", "member_role_updated": "Felhasználói szerepek frissítve",
"members": "Tagok", "members": "Tagok",
"more_members": "+{count} more", "more_members": "+{count} további",
"name_length_insufficient": "A csapat nevének legalább 6 karakter hosszúságúnak kell lennie", "name_length_insufficient": "A csapat nevének legalább 6 karakter hosszúságúnak kell lennie",
"name_updated": "Csapatnév frissítve", "name_updated": "Csapatnév frissítve",
"new": "Új csapat", "new": "Új csapat",
@@ -696,13 +698,13 @@
"new_name": "Saját új csapat", "new_name": "Saját új csapat",
"no_access": "Nincs szerkesztési jogosultsága ezekhez a gyűjteményekhez", "no_access": "Nincs szerkesztési jogosultsága ezekhez a gyűjteményekhez",
"no_invite_found": "A meghívás nem található. Vegye fel a kapcsolatot a csapat tulajdonosával.", "no_invite_found": "A meghívás nem található. Vegye fel a kapcsolatot a csapat tulajdonosával.",
"no_request_found": "Request not found.", "no_request_found": "A kérés nem található.",
"not_found": "A csapat nem található. Vegye fel a kapcsolatot a csapat tulajdonosával.", "not_found": "A csapat nem található. Vegye fel a kapcsolatot a csapat tulajdonosával.",
"not_valid_viewer": "Ön nem érvényes megtekintő. Vegye fel a kapcsolatot a csapat tulajdonosával.", "not_valid_viewer": "Ön nem érvényes megtekintő. Vegye fel a kapcsolatot a csapat tulajdonosával.",
"parent_coll_move": "Cannot move collection to a child collection", "parent_coll_move": "Nem lehet áthelyezni a gyűjteményt egy gyermekgyűjteménybe",
"pending_invites": "Függőben lévő meghívások", "pending_invites": "Függőben lévő meghívások",
"permissions": "Jogosultságok", "permissions": "Jogosultságok",
"same_target_destination": "Same target and destination", "same_target_destination": "Ugyanaz a cél és célhely",
"saved": "Csapat elmentve", "saved": "Csapat elmentve",
"select_a_team": "Csapat kiválasztása", "select_a_team": "Csapat kiválasztása",
"title": "Csapatok", "title": "Csapatok",
@@ -710,9 +712,9 @@
"we_sent_invite_link_description": "Kérje meg az összes meghívottat, hogy nézzék meg a beérkező leveleiket. Kattintsanak a hivatkozásra a csapathoz való csatlakozáshoz." "we_sent_invite_link_description": "Kérje meg az összes meghívottat, hogy nézzék meg a beérkező leveleiket. Kattintsanak a hivatkozásra a csapathoz való csatlakozáshoz."
}, },
"team_environment": { "team_environment": {
"deleted": "Environment Deleted", "deleted": "Környezet törölve",
"duplicate": "Environment Duplicated", "duplicate": "Környezet megkettőzve",
"not_found": "Environment not found." "not_found": "A környezet nem található."
}, },
"test": { "test": {
"failed": "teszt sikertelen", "failed": "teszt sikertelen",
@@ -732,9 +734,9 @@
"url": "URL" "url": "URL"
}, },
"workspace": { "workspace": {
"change": "Change workspace", "change": "Munkaterület váltása",
"personal": "My Workspace", "personal": "Saját munkaterület",
"team": "Team Workspace", "team": "Csapat-munkaterület",
"title": "Workspaces" "title": "Munkaterületek"
} }
} }

View File

@@ -432,6 +432,7 @@
"view_my_links": "Lihat tautan saya" "view_my_links": "Lihat tautan saya"
}, },
"response": { "response": {
"audio": "Audio",
"body": "Response Body", "body": "Response Body",
"filter_response_body": "Filter body respons JSON (menggunakan sintaks JSONPath)", "filter_response_body": "Filter body respons JSON (menggunakan sintaks JSONPath)",
"headers": "Headers", "headers": "Headers",
@@ -445,6 +446,7 @@
"status": "Status", "status": "Status",
"time": "Waktu", "time": "Waktu",
"title": "Response", "title": "Response",
"video": "Video",
"waiting_for_connection": "Menunggu koneksi", "waiting_for_connection": "Menunggu koneksi",
"xml": "XML" "xml": "XML"
}, },

View File

@@ -432,6 +432,7 @@
"view_my_links": "View my links" "view_my_links": "View my links"
}, },
"response": { "response": {
"audio": "Audio",
"body": "Corpo della risposta", "body": "Corpo della risposta",
"filter_response_body": "Filter JSON response body (uses JSONPath syntax)", "filter_response_body": "Filter JSON response body (uses JSONPath syntax)",
"headers": "Intestazioni", "headers": "Intestazioni",
@@ -445,6 +446,7 @@
"status": "Stato", "status": "Stato",
"time": "Tempo impiegato", "time": "Tempo impiegato",
"title": "Risposta", "title": "Risposta",
"video": "Video",
"waiting_for_connection": "In attesa di connessione", "waiting_for_connection": "In attesa di connessione",
"xml": "XML" "xml": "XML"
}, },

View File

@@ -432,6 +432,7 @@
"view_my_links": "自分のリンクを見る" "view_my_links": "自分のリンクを見る"
}, },
"response": { "response": {
"audio": "Audio",
"body": "レスポンスボディ", "body": "レスポンスボディ",
"filter_response_body": "JSONレスポンスボディをフィルタ (JSONPathシンタックスを使用)", "filter_response_body": "JSONレスポンスボディをフィルタ (JSONPathシンタックスを使用)",
"headers": "ヘッダー", "headers": "ヘッダー",
@@ -445,6 +446,7 @@
"status": "ステータス", "status": "ステータス",
"time": "時間", "time": "時間",
"title": "レスポンス", "title": "レスポンス",
"video": "Video",
"waiting_for_connection": "接続を待っています", "waiting_for_connection": "接続を待っています",
"xml": "XML" "xml": "XML"
}, },

View File

@@ -432,6 +432,7 @@
"view_my_links": "내 링크 보기" "view_my_links": "내 링크 보기"
}, },
"response": { "response": {
"audio": "Audio",
"body": "응답 본문", "body": "응답 본문",
"filter_response_body": "Filter JSON response body (uses JSONPath syntax)", "filter_response_body": "Filter JSON response body (uses JSONPath syntax)",
"headers": "헤더", "headers": "헤더",
@@ -445,6 +446,7 @@
"status": "상태", "status": "상태",
"time": "시간", "time": "시간",
"title": "제목", "title": "제목",
"video": "Video",
"waiting_for_connection": "연결 대기 중", "waiting_for_connection": "연결 대기 중",
"xml": "XML" "xml": "XML"
}, },

View File

@@ -432,6 +432,7 @@
"view_my_links": "View my links" "view_my_links": "View my links"
}, },
"response": { "response": {
"audio": "Audio",
"body": "Reactie inhoud", "body": "Reactie inhoud",
"filter_response_body": "Filter JSON response body (uses JSONPath syntax)", "filter_response_body": "Filter JSON response body (uses JSONPath syntax)",
"headers": "Headers", "headers": "Headers",
@@ -445,6 +446,7 @@
"status": "Status", "status": "Status",
"time": "Tijd", "time": "Tijd",
"title": "Antwoord", "title": "Antwoord",
"video": "Video",
"waiting_for_connection": "wachten op verbinding", "waiting_for_connection": "wachten op verbinding",
"xml": "XML" "xml": "XML"
}, },

View File

@@ -432,6 +432,7 @@
"view_my_links": "View my links" "view_my_links": "View my links"
}, },
"response": { "response": {
"audio": "Audio",
"body": "Svarkropp", "body": "Svarkropp",
"filter_response_body": "Filter JSON response body (uses JSONPath syntax)", "filter_response_body": "Filter JSON response body (uses JSONPath syntax)",
"headers": "Overskrifter", "headers": "Overskrifter",
@@ -445,6 +446,7 @@
"status": "Status", "status": "Status",
"time": "Tid", "time": "Tid",
"title": "Respons", "title": "Respons",
"video": "Video",
"waiting_for_connection": "venter på tilkobling", "waiting_for_connection": "venter på tilkobling",
"xml": "XML" "xml": "XML"
}, },

View File

@@ -432,6 +432,7 @@
"view_my_links": "View my links" "view_my_links": "View my links"
}, },
"response": { "response": {
"audio": "Audio",
"body": "Ciało odpowiedzi", "body": "Ciało odpowiedzi",
"filter_response_body": "Filter JSON response body (uses JSONPath syntax)", "filter_response_body": "Filter JSON response body (uses JSONPath syntax)",
"headers": "Nagłówki", "headers": "Nagłówki",
@@ -445,6 +446,7 @@
"status": "Status", "status": "Status",
"time": "Czas", "time": "Czas",
"title": "Odpowiedź", "title": "Odpowiedź",
"video": "Video",
"waiting_for_connection": "oczekiwanie na połączenie", "waiting_for_connection": "oczekiwanie na połączenie",
"xml": "XML" "xml": "XML"
}, },

View File

@@ -432,6 +432,7 @@
"view_my_links": "View my links" "view_my_links": "View my links"
}, },
"response": { "response": {
"audio": "Audio",
"body": "Corpo de Resposta", "body": "Corpo de Resposta",
"filter_response_body": "Filter JSON response body (uses JSONPath syntax)", "filter_response_body": "Filter JSON response body (uses JSONPath syntax)",
"headers": "Cabeçalhos", "headers": "Cabeçalhos",
@@ -445,6 +446,7 @@
"status": "Status", "status": "Status",
"time": "Tempo", "time": "Tempo",
"title": "Resposta", "title": "Resposta",
"video": "Video",
"waiting_for_connection": "aguardando conexão", "waiting_for_connection": "aguardando conexão",
"xml": "XML" "xml": "XML"
}, },

View File

@@ -432,6 +432,7 @@
"view_my_links": "View my links" "view_my_links": "View my links"
}, },
"response": { "response": {
"audio": "Audio",
"body": "Corpo de Resposta", "body": "Corpo de Resposta",
"filter_response_body": "Filter JSON response body (uses JSONPath syntax)", "filter_response_body": "Filter JSON response body (uses JSONPath syntax)",
"headers": "Cabeçalhos", "headers": "Cabeçalhos",
@@ -445,6 +446,7 @@
"status": "Status", "status": "Status",
"time": "Tempo", "time": "Tempo",
"title": "Resposta", "title": "Resposta",
"video": "Video",
"waiting_for_connection": "aguardando conexão", "waiting_for_connection": "aguardando conexão",
"xml": "XML" "xml": "XML"
}, },

View File

@@ -432,6 +432,7 @@
"view_my_links": "Vizualizare link-uri" "view_my_links": "Vizualizare link-uri"
}, },
"response": { "response": {
"audio": "Audio",
"body": "Corpul de răspuns", "body": "Corpul de răspuns",
"filter_response_body": "Filtrează corpul răspunsului JSON (folosește sintaxa JSONPath)", "filter_response_body": "Filtrează corpul răspunsului JSON (folosește sintaxa JSONPath)",
"headers": "Anteturi", "headers": "Anteturi",
@@ -445,6 +446,7 @@
"status": "Stare", "status": "Stare",
"time": "Timp", "time": "Timp",
"title": "Raspuns", "title": "Raspuns",
"video": "Video",
"waiting_for_connection": "Așteptând conexiunea", "waiting_for_connection": "Așteptând conexiunea",
"xml": "XML" "xml": "XML"
}, },

View File

@@ -432,6 +432,7 @@
"view_my_links": "View my links" "view_my_links": "View my links"
}, },
"response": { "response": {
"audio": "Audio",
"body": "Тело ответа", "body": "Тело ответа",
"filter_response_body": "Filter JSON response body (uses JSONPath syntax)", "filter_response_body": "Filter JSON response body (uses JSONPath syntax)",
"headers": "Заголовки", "headers": "Заголовки",
@@ -445,6 +446,7 @@
"status": "Статус", "status": "Статус",
"time": "Время", "time": "Время",
"title": "Ответ", "title": "Ответ",
"video": "Video",
"waiting_for_connection": "Ожидание соединения", "waiting_for_connection": "Ожидание соединения",
"xml": "XML" "xml": "XML"
}, },

View File

@@ -432,6 +432,7 @@
"view_my_links": "View my links" "view_my_links": "View my links"
}, },
"response": { "response": {
"audio": "Audio",
"body": "Тело за одговор", "body": "Тело за одговор",
"filter_response_body": "Filter JSON response body (uses JSONPath syntax)", "filter_response_body": "Filter JSON response body (uses JSONPath syntax)",
"headers": "Заглавља", "headers": "Заглавља",
@@ -445,6 +446,7 @@
"status": "Статус", "status": "Статус",
"time": "време", "time": "време",
"title": "Одговор", "title": "Одговор",
"video": "Video",
"waiting_for_connection": "чека везу", "waiting_for_connection": "чека везу",
"xml": "КСМЛ" "xml": "КСМЛ"
}, },

View File

@@ -432,6 +432,7 @@
"view_my_links": "View my links" "view_my_links": "View my links"
}, },
"response": { "response": {
"audio": "Audio",
"body": "Svarskommitté", "body": "Svarskommitté",
"filter_response_body": "Filter JSON response body (uses JSONPath syntax)", "filter_response_body": "Filter JSON response body (uses JSONPath syntax)",
"headers": "Rubriker", "headers": "Rubriker",
@@ -445,6 +446,7 @@
"status": "Status", "status": "Status",
"time": "Tid", "time": "Tid",
"title": "Svar", "title": "Svar",
"video": "Video",
"waiting_for_connection": "väntar på anslutning", "waiting_for_connection": "väntar på anslutning",
"xml": "XML" "xml": "XML"
}, },

View File

@@ -122,7 +122,7 @@
"edit": "Koleksiyonu düzenle", "edit": "Koleksiyonu düzenle",
"invalid_name": "Lütfen koleksiyon için geçerli bir ad girin", "invalid_name": "Lütfen koleksiyon için geçerli bir ad girin",
"invalid_root_move": "Collection already in the root", "invalid_root_move": "Collection already in the root",
"moved": "Moved Successfully", "moved": "Başarıyla taşındı",
"my_collections": "Koleksiyonlarım", "my_collections": "Koleksiyonlarım",
"name": "Yeni Koleksiyonum", "name": "Yeni Koleksiyonum",
"name_length_insufficient": "Koleksiyon adı en az 3 karakter uzunluğunda olmalıdır", "name_length_insufficient": "Koleksiyon adı en az 3 karakter uzunluğunda olmalıdır",
@@ -147,7 +147,7 @@
"remove_team": "Bu takımı silmek istediğinizden emin misiniz?", "remove_team": "Bu takımı silmek istediğinizden emin misiniz?",
"remove_telemetry": "Telemetriden çıkmak istediğinizden emin misiniz?", "remove_telemetry": "Telemetriden çıkmak istediğinizden emin misiniz?",
"request_change": "Are you sure you want to discard current request, unsaved changes will be lost.", "request_change": "Are you sure you want to discard current request, unsaved changes will be lost.",
"save_unsaved_tab": "Do you want to save changes made in this tab?", "save_unsaved_tab": "Bu sekmede yapılan değişiklikleri kaydetmek istiyor musunuz?",
"sync": "Bu çalışma alanını senkronize etmek istediğinizden emin misiniz?" "sync": "Bu çalışma alanını senkronize etmek istediğinizden emin misiniz?"
}, },
"count": { "count": {
@@ -368,9 +368,9 @@
}, },
"profile": { "profile": {
"app_settings": "Uygulama ayarları", "app_settings": "Uygulama ayarları",
"default_hopp_displayname": "Unnamed User", "default_hopp_displayname": "Adsız Kullanıcı",
"editor": "Düzenleyici", "editor": "Editör",
"editor_description": "Editors can add, edit, and delete requests.", "editor_description": "Editörler istekleri ekleyebilir, düzenleyebilir ve silebilir.",
"email_verification_mail": "Doğrulama bağlantısı e-postanıza gönderildi. E-postanızı doğrulamak için gelen bağlantıya tıklayınız.", "email_verification_mail": "Doğrulama bağlantısı e-postanıza gönderildi. E-postanızı doğrulamak için gelen bağlantıya tıklayınız.",
"no_permission": "Bu eylemi gerçekleştirmek için gerekli yetkiniz yok.", "no_permission": "Bu eylemi gerçekleştirmek için gerekli yetkiniz yok.",
"owner": "Kurucu", "owner": "Kurucu",
@@ -432,6 +432,7 @@
"view_my_links": "View my links" "view_my_links": "View my links"
}, },
"response": { "response": {
"audio": "Audio",
"body": "Yanıt gövdesi", "body": "Yanıt gövdesi",
"filter_response_body": "Filter JSON response body (uses JSONPath syntax)", "filter_response_body": "Filter JSON response body (uses JSONPath syntax)",
"headers": "Başlıklar", "headers": "Başlıklar",
@@ -445,6 +446,7 @@
"status": "Durum", "status": "Durum",
"time": "Zaman", "time": "Zaman",
"title": "Cevap", "title": "Cevap",
"video": "Video",
"waiting_for_connection": "Bağlantı için bekleniyor", "waiting_for_connection": "Bağlantı için bekleniyor",
"xml": "XML" "xml": "XML"
}, },

View File

@@ -432,6 +432,7 @@
"view_my_links": "檢視我的連結" "view_my_links": "檢視我的連結"
}, },
"response": { "response": {
"audio": "Audio",
"body": "回應本體", "body": "回應本體",
"filter_response_body": "篩選 JSON 回應本體 (使用 JSONPath 語法)", "filter_response_body": "篩選 JSON 回應本體 (使用 JSONPath 語法)",
"headers": "回應標頭", "headers": "回應標頭",
@@ -445,6 +446,7 @@
"status": "狀態", "status": "狀態",
"time": "時間", "time": "時間",
"title": "回應", "title": "回應",
"video": "Video",
"waiting_for_connection": "等待連線", "waiting_for_connection": "等待連線",
"xml": "XML" "xml": "XML"
}, },

View File

@@ -432,6 +432,7 @@
"view_my_links": "Переглянути мої посилання" "view_my_links": "Переглянути мої посилання"
}, },
"response": { "response": {
"audio": "Audio",
"body": "Орган реагування", "body": "Орган реагування",
"filter_response_body": "Фільтр тіла відповідей JSON (використовує синтаксис JSONPath)", "filter_response_body": "Фільтр тіла відповідей JSON (використовує синтаксис JSONPath)",
"headers": "Заголовки", "headers": "Заголовки",
@@ -445,6 +446,7 @@
"status": "Статус", "status": "Статус",
"time": "Час", "time": "Час",
"title": "Відповідь", "title": "Відповідь",
"video": "Video",
"waiting_for_connection": "очікування підключення", "waiting_for_connection": "очікування підключення",
"xml": "XML" "xml": "XML"
}, },

View File

@@ -4,7 +4,7 @@
"cancel": "Hủy bỏ", "cancel": "Hủy bỏ",
"choose_file": "Chọn một tệp", "choose_file": "Chọn một tệp",
"clear": "Thông thoáng", "clear": "Thông thoáng",
"clear_all": "Quet sạch tât cả", "clear_all": "Quet sạch tt cả",
"close": "Close", "close": "Close",
"connect": "Liên kết", "connect": "Liên kết",
"connecting": "Connecting", "connecting": "Connecting",
@@ -432,6 +432,7 @@
"view_my_links": "View my links" "view_my_links": "View my links"
}, },
"response": { "response": {
"audio": "Audio",
"body": "Cơ quan phản hồi", "body": "Cơ quan phản hồi",
"filter_response_body": "Filter JSON response body (uses JSONPath syntax)", "filter_response_body": "Filter JSON response body (uses JSONPath syntax)",
"headers": "Tiêu đề", "headers": "Tiêu đề",
@@ -445,6 +446,7 @@
"status": "Tình trạng", "status": "Tình trạng",
"time": "Thời gian", "time": "Thời gian",
"title": "Phản ứng", "title": "Phản ứng",
"video": "Video",
"waiting_for_connection": "Đang đợi kết nối", "waiting_for_connection": "Đang đợi kết nối",
"xml": "XML" "xml": "XML"
}, },

View File

@@ -1,7 +1,7 @@
{ {
"name": "@hoppscotch/common", "name": "@hoppscotch/common",
"private": true, "private": true,
"version": "2023.4.4", "version": "2023.4.7",
"scripts": { "scripts": {
"dev": "pnpm exec npm-run-all -p -l dev:*", "dev": "pnpm exec npm-run-all -p -l dev:*",
"dev:vite": "vite", "dev:vite": "vite",

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.9 KiB

After

Width:  |  Height:  |  Size: 12 KiB

View File

@@ -86,6 +86,7 @@ declare module '@vue/runtime-core' {
HoppSmartLink: typeof import('@hoppscotch/ui')['HoppSmartLink'] HoppSmartLink: typeof import('@hoppscotch/ui')['HoppSmartLink']
HoppSmartModal: typeof import('@hoppscotch/ui')['HoppSmartModal'] HoppSmartModal: typeof import('@hoppscotch/ui')['HoppSmartModal']
HoppSmartPicture: typeof import('@hoppscotch/ui')['HoppSmartPicture'] HoppSmartPicture: typeof import('@hoppscotch/ui')['HoppSmartPicture']
HoppSmartPlaceholder: typeof import('@hoppscotch/ui')['HoppSmartPlaceholder']
HoppSmartProgressRing: typeof import('@hoppscotch/ui')['HoppSmartProgressRing'] HoppSmartProgressRing: typeof import('@hoppscotch/ui')['HoppSmartProgressRing']
HoppSmartRadioGroup: typeof import('@hoppscotch/ui')['HoppSmartRadioGroup'] HoppSmartRadioGroup: typeof import('@hoppscotch/ui')['HoppSmartRadioGroup']
HoppSmartSlideOver: typeof import('@hoppscotch/ui')['HoppSmartSlideOver'] HoppSmartSlideOver: typeof import('@hoppscotch/ui')['HoppSmartSlideOver']
@@ -134,11 +135,13 @@ declare module '@vue/runtime-core' {
IconLucideUsers: typeof import('~icons/lucide/users')['default'] IconLucideUsers: typeof import('~icons/lucide/users')['default']
LensesHeadersRenderer: typeof import('./components/lenses/HeadersRenderer.vue')['default'] LensesHeadersRenderer: typeof import('./components/lenses/HeadersRenderer.vue')['default']
LensesHeadersRendererEntry: typeof import('./components/lenses/HeadersRendererEntry.vue')['default'] LensesHeadersRendererEntry: typeof import('./components/lenses/HeadersRendererEntry.vue')['default']
LensesRenderersAudioLensRenderer: typeof import('./components/lenses/renderers/AudioLensRenderer.vue')['default']
LensesRenderersHTMLLensRenderer: typeof import('./components/lenses/renderers/HTMLLensRenderer.vue')['default'] LensesRenderersHTMLLensRenderer: typeof import('./components/lenses/renderers/HTMLLensRenderer.vue')['default']
LensesRenderersImageLensRenderer: typeof import('./components/lenses/renderers/ImageLensRenderer.vue')['default'] LensesRenderersImageLensRenderer: typeof import('./components/lenses/renderers/ImageLensRenderer.vue')['default']
LensesRenderersJSONLensRenderer: typeof import('./components/lenses/renderers/JSONLensRenderer.vue')['default'] LensesRenderersJSONLensRenderer: typeof import('./components/lenses/renderers/JSONLensRenderer.vue')['default']
LensesRenderersPDFLensRenderer: typeof import('./components/lenses/renderers/PDFLensRenderer.vue')['default'] LensesRenderersPDFLensRenderer: typeof import('./components/lenses/renderers/PDFLensRenderer.vue')['default']
LensesRenderersRawLensRenderer: typeof import('./components/lenses/renderers/RawLensRenderer.vue')['default'] LensesRenderersRawLensRenderer: typeof import('./components/lenses/renderers/RawLensRenderer.vue')['default']
LensesRenderersVideoLensRenderer: typeof import('./components/lenses/renderers/VideoLensRenderer.vue')['default']
LensesRenderersXMLLensRenderer: typeof import('./components/lenses/renderers/XMLLensRenderer.vue')['default'] LensesRenderersXMLLensRenderer: typeof import('./components/lenses/renderers/XMLLensRenderer.vue')['default']
LensesResponseBodyRenderer: typeof import('./components/lenses/ResponseBodyRenderer.vue')['default'] LensesResponseBodyRenderer: typeof import('./components/lenses/ResponseBodyRenderer.vue')['default']
ProfileShortcode: typeof import('./components/profile/Shortcode.vue')['default'] ProfileShortcode: typeof import('./components/profile/Shortcode.vue')['default']

View File

@@ -44,8 +44,9 @@
class="flex flex-col items-center justify-center p-4 text-secondaryLight" class="flex flex-col items-center justify-center p-4 text-secondaryLight"
> >
<icon-lucide-search class="pb-2 opacity-75 svg-icons" /> <icon-lucide-search class="pb-2 opacity-75 svg-icons" />
<span class="my-2 text-center"> <span class="my-2 text-center flex flex-col">
{{ t("state.nothing_found") }} "{{ filterText }}" {{ t("state.nothing_found") }}
<span class="break-all">"{{ filterText }}"</span>
</span> </span>
</div> </div>
</div> </div>

View File

@@ -284,6 +284,14 @@ const importerAction = async (stepResults: StepReturnValue[]) => {
emit("import-to-teams", result) emit("import-to-teams", result)
} else { } else {
appendRESTCollections(result) appendRESTCollections(result)
platform.analytics?.logEvent({
type: "HOPP_IMPORT_COLLECTION",
importer: importerModule.value!.name,
platform: "rest",
workspaceType: "personal",
})
fileImported() fileImported()
} }
} }

View File

@@ -1,5 +1,5 @@
<template> <template>
<div class="flex flex-col flex-1 bg-primary"> <div class="flex flex-col flex-1">
<div <div
class="sticky z-10 flex justify-between flex-1 border-b bg-primary border-dividerLight" class="sticky z-10 flex justify-between flex-1 border-b bg-primary border-dividerLight"
:style=" :style="

View File

@@ -89,6 +89,7 @@ import {
import { GQLError } from "~/helpers/backend/GQLClient" import { GQLError } from "~/helpers/backend/GQLClient"
import { computedWithControl } from "@vueuse/core" import { computedWithControl } from "@vueuse/core"
import { currentActiveTab } from "~/helpers/rest/tab" import { currentActiveTab } from "~/helpers/rest/tab"
import { platform } from "~/platform"
const t = useI18n() const t = useI18n()
const toast = useToast() const toast = useToast()
@@ -223,6 +224,13 @@ const saveRequestAs = async () => {
}, },
} }
platform.analytics?.logEvent({
type: "HOPP_SAVE_REQUEST",
createdNow: true,
platform: "rest",
workspaceType: "personal",
})
requestSaved() requestSaved()
} else if (picked.value.pickedType === "my-folder") { } else if (picked.value.pickedType === "my-folder") {
if (!isHoppRESTRequest(requestUpdated)) if (!isHoppRESTRequest(requestUpdated))
@@ -243,6 +251,13 @@ const saveRequestAs = async () => {
}, },
} }
platform.analytics?.logEvent({
type: "HOPP_SAVE_REQUEST",
createdNow: true,
platform: "rest",
workspaceType: "personal",
})
requestSaved() requestSaved()
} else if (picked.value.pickedType === "my-request") { } else if (picked.value.pickedType === "my-request") {
if (!isHoppRESTRequest(requestUpdated)) if (!isHoppRESTRequest(requestUpdated))
@@ -264,17 +279,38 @@ const saveRequestAs = async () => {
}, },
} }
platform.analytics?.logEvent({
type: "HOPP_SAVE_REQUEST",
createdNow: false,
platform: "rest",
workspaceType: "personal",
})
requestSaved() requestSaved()
} else if (picked.value.pickedType === "teams-collection") { } else if (picked.value.pickedType === "teams-collection") {
if (!isHoppRESTRequest(requestUpdated)) if (!isHoppRESTRequest(requestUpdated))
throw new Error("requestUpdated is not a REST Request") throw new Error("requestUpdated is not a REST Request")
updateTeamCollectionOrFolder(picked.value.collectionID, requestUpdated) updateTeamCollectionOrFolder(picked.value.collectionID, requestUpdated)
platform.analytics?.logEvent({
type: "HOPP_SAVE_REQUEST",
createdNow: true,
platform: "rest",
workspaceType: "team",
})
} else if (picked.value.pickedType === "teams-folder") { } else if (picked.value.pickedType === "teams-folder") {
if (!isHoppRESTRequest(requestUpdated)) if (!isHoppRESTRequest(requestUpdated))
throw new Error("requestUpdated is not a REST Request") throw new Error("requestUpdated is not a REST Request")
updateTeamCollectionOrFolder(picked.value.folderID, requestUpdated) updateTeamCollectionOrFolder(picked.value.folderID, requestUpdated)
platform.analytics?.logEvent({
type: "HOPP_SAVE_REQUEST",
createdNow: true,
platform: "rest",
workspaceType: "team",
})
} else if (picked.value.pickedType === "teams-request") { } else if (picked.value.pickedType === "teams-request") {
if (!isHoppRESTRequest(requestUpdated)) if (!isHoppRESTRequest(requestUpdated))
throw new Error("requestUpdated is not a REST Request") throw new Error("requestUpdated is not a REST Request")
@@ -292,6 +328,13 @@ const saveRequestAs = async () => {
title: requestUpdated.name, title: requestUpdated.name,
} }
platform.analytics?.logEvent({
type: "HOPP_SAVE_REQUEST",
createdNow: false,
platform: "rest",
workspaceType: "team",
})
pipe( pipe(
updateTeamRequest(picked.value.requestID, data), updateTeamRequest(picked.value.requestID, data),
TE.match( TE.match(
@@ -313,6 +356,13 @@ const saveRequestAs = async () => {
requestUpdated as HoppGQLRequest requestUpdated as HoppGQLRequest
) )
platform.analytics?.logEvent({
type: "HOPP_SAVE_REQUEST",
createdNow: false,
platform: "gql",
workspaceType: "team",
})
requestSaved() requestSaved()
} else if (picked.value.pickedType === "gql-my-folder") { } else if (picked.value.pickedType === "gql-my-folder") {
// TODO: Check for GQL request ? // TODO: Check for GQL request ?
@@ -321,6 +371,13 @@ const saveRequestAs = async () => {
requestUpdated as HoppGQLRequest requestUpdated as HoppGQLRequest
) )
platform.analytics?.logEvent({
type: "HOPP_SAVE_REQUEST",
createdNow: true,
platform: "gql",
workspaceType: "team",
})
requestSaved() requestSaved()
} else if (picked.value.pickedType === "gql-my-collection") { } else if (picked.value.pickedType === "gql-my-collection") {
// TODO: Check for GQL request ? // TODO: Check for GQL request ?
@@ -329,6 +386,13 @@ const saveRequestAs = async () => {
requestUpdated as HoppGQLRequest requestUpdated as HoppGQLRequest
) )
platform.analytics?.logEvent({
type: "HOPP_SAVE_REQUEST",
createdNow: true,
platform: "gql",
workspaceType: "team",
})
requestSaved() requestSaved()
} }
} }

View File

@@ -1,5 +1,5 @@
<template> <template>
<div class="flex flex-col flex-1 bg-primary"> <div class="flex flex-col flex-1">
<div <div
class="sticky z-10 flex justify-between flex-1 border-b bg-primary border-dividerLight" class="sticky z-10 flex justify-between flex-1 border-b bg-primary border-dividerLight"
:style=" :style="

View File

@@ -46,6 +46,7 @@ import { useToast } from "@composables/toast"
import { useI18n } from "@composables/i18n" import { useI18n } from "@composables/i18n"
import { HoppGQLRequest, makeCollection } from "@hoppscotch/data" import { HoppGQLRequest, makeCollection } from "@hoppscotch/data"
import { addGraphqlCollection } from "~/newstore/collections" import { addGraphqlCollection } from "~/newstore/collections"
import { platform } from "~/platform"
export default defineComponent({ export default defineComponent({
props: { props: {
@@ -79,6 +80,13 @@ export default defineComponent({
) )
this.hideModal() this.hideModal()
platform.analytics?.logEvent({
type: "HOPP_CREATE_COLLECTION",
isRootCollection: true,
platform: "gql",
workspaceType: "personal",
})
}, },
hideModal() { hideModal() {
this.name = null this.name = null

View File

@@ -244,6 +244,14 @@ const importFromJSON = () => {
return return
} }
appendGraphqlCollections(collections) appendGraphqlCollections(collections)
platform.analytics?.logEvent({
type: "HOPP_IMPORT_COLLECTION",
importer: "json",
workspaceType: "personal",
platform: "gql",
})
fileImported() fileImported()
} }
reader.readAsText(inputChooseFileToImportFrom.value.files[0]) reader.readAsText(inputChooseFileToImportFrom.value.files[0])
@@ -257,6 +265,12 @@ const exportJSON = () => {
const url = URL.createObjectURL(file) const url = URL.createObjectURL(file)
a.href = url a.href = url
platform?.analytics?.logEvent({
type: "HOPP_EXPORT_COLLECTION",
exporter: "json",
platform: "gql",
})
// TODO: get uri from meta // TODO: get uri from meta
a.download = `${url.split("/").pop()!.split("#")[0].split("?")[0]}.json` a.download = `${url.split("/").pop()!.split("#")[0].split("?")[0]}.json`
document.body.appendChild(a) document.body.appendChild(a)

View File

@@ -153,6 +153,7 @@ import IconArchive from "~icons/lucide/archive"
import { useI18n } from "@composables/i18n" import { useI18n } from "@composables/i18n"
import { useReadonlyStream } from "@composables/stream" import { useReadonlyStream } from "@composables/stream"
import { useColorMode } from "@composables/theming" import { useColorMode } from "@composables/theming"
import { platform } from "~/platform"
export default defineComponent({ export default defineComponent({
props: { props: {
@@ -285,6 +286,13 @@ export default defineComponent({
response: "", response: "",
}) })
platform.analytics?.logEvent({
type: "HOPP_SAVE_REQUEST",
platform: "gql",
createdNow: true,
workspaceType: "personal",
})
this.displayModalAddRequest(false) this.displayModalAddRequest(false)
}, },
addRequest(payload) { addRequest(payload) {
@@ -294,6 +302,14 @@ export default defineComponent({
}, },
onAddFolder({ name, path }) { onAddFolder({ name, path }) {
addGraphqlFolder(name, path) addGraphqlFolder(name, path)
platform.analytics?.logEvent({
type: "HOPP_CREATE_COLLECTION",
isRootCollection: false,
platform: "gql",
workspaceType: "personal",
})
this.displayModalAddFolder(false) this.displayModalAddFolder(false)
}, },
addFolder(payload) { addFolder(payload) {

View File

@@ -125,8 +125,8 @@
@hide-modal="displayModalEditFolder(false)" @hide-modal="displayModalEditFolder(false)"
/> />
<CollectionsEditRequest <CollectionsEditRequest
v-model="editingRequestName"
:show="showModalEditRequest" :show="showModalEditRequest"
:model-value="editingRequest ? editingRequest.name : ''"
:loading-state="modalLoadingState" :loading-state="modalLoadingState"
@submit="updateEditingRequest" @submit="updateEditingRequest"
@hide-modal="displayModalEditRequest(false)" @hide-modal="displayModalEditRequest(false)"
@@ -157,7 +157,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, PropType, ref, watch } from "vue" import { computed, nextTick, PropType, ref, watch } from "vue"
import { useToast } from "@composables/toast" import { useToast } from "@composables/toast"
import { useI18n } from "@composables/i18n" import { useI18n } from "@composables/i18n"
import { Picked } from "~/helpers/types/HoppPicked" import { Picked } from "~/helpers/types/HoppPicked"
@@ -288,6 +288,7 @@ const editingFolder = ref<
const editingFolderName = ref<string | null>(null) const editingFolderName = ref<string | null>(null)
const editingFolderPath = ref<string | null>(null) const editingFolderPath = ref<string | null>(null)
const editingRequest = ref<HoppRESTRequest | null>(null) const editingRequest = ref<HoppRESTRequest | null>(null)
const editingRequestName = ref("")
const editingRequestIndex = ref<number | null>(null) const editingRequestIndex = ref<number | null>(null)
const editingRequestID = ref<string | null>(null) const editingRequestID = ref<string | null>(null)
@@ -598,11 +599,25 @@ const addNewRootCollection = (name: string) => {
}) })
) )
platform.analytics?.logEvent({
type: "HOPP_CREATE_COLLECTION",
platform: "rest",
workspaceType: "personal",
isRootCollection: true,
})
displayModalAdd(false) displayModalAdd(false)
} else if (hasTeamWriteAccess.value) { } else if (hasTeamWriteAccess.value) {
if (!collectionsType.value.selectedTeam) return if (!collectionsType.value.selectedTeam) return
modalLoadingState.value = true modalLoadingState.value = true
platform.analytics?.logEvent({
type: "HOPP_CREATE_COLLECTION",
platform: "rest",
workspaceType: "team",
isRootCollection: true,
})
pipe( pipe(
createNewRootCollection(name, collectionsType.value.selectedTeam.id), createNewRootCollection(name, collectionsType.value.selectedTeam.id),
TE.match( TE.match(
@@ -651,6 +666,13 @@ const onAddRequest = (requestName: string) => {
}, },
}) })
platform.analytics?.logEvent({
type: "HOPP_SAVE_REQUEST",
workspaceType: "personal",
createdNow: true,
platform: "rest",
})
displayModalAddRequest(false) displayModalAddRequest(false)
} else if (hasTeamWriteAccess.value) { } else if (hasTeamWriteAccess.value) {
const folder = editingFolder.value const folder = editingFolder.value
@@ -666,6 +688,13 @@ const onAddRequest = (requestName: string) => {
title: requestName, title: requestName,
} }
platform.analytics?.logEvent({
type: "HOPP_SAVE_REQUEST",
workspaceType: "team",
platform: "rest",
createdNow: true,
})
pipe( pipe(
createRequestInCollection(folder.id, data), createRequestInCollection(folder.id, data),
TE.match( TE.match(
@@ -711,6 +740,14 @@ const onAddFolder = (folderName: string) => {
if (collectionsType.value.type === "my-collections") { if (collectionsType.value.type === "my-collections") {
if (!path) return if (!path) return
addRESTFolder(folderName, path) addRESTFolder(folderName, path)
platform.analytics?.logEvent({
type: "HOPP_CREATE_COLLECTION",
workspaceType: "personal",
isRootCollection: false,
platform: "rest",
})
displayModalAddFolder(false) displayModalAddFolder(false)
} else if (hasTeamWriteAccess.value) { } else if (hasTeamWriteAccess.value) {
const folder = editingFolder.value const folder = editingFolder.value
@@ -718,6 +755,13 @@ const onAddFolder = (folderName: string) => {
modalLoadingState.value = true modalLoadingState.value = true
platform.analytics?.logEvent({
type: "HOPP_CREATE_COLLECTION",
workspaceType: "personal",
isRootCollection: false,
platform: "rest",
})
pipe( pipe(
createChildCollection(folderName, folder.id), createChildCollection(folderName, folder.id),
TE.match( TE.match(
@@ -860,6 +904,7 @@ const editRequest = (payload: {
}) => { }) => {
const { folderPath, requestIndex, request } = payload const { folderPath, requestIndex, request } = payload
editingRequest.value = request editingRequest.value = request
editingRequestName.value = request.name ?? ""
if (collectionsType.value.type === "my-collections" && folderPath) { if (collectionsType.value.type === "my-collections" && folderPath) {
editingFolderPath.value = folderPath editingFolderPath.value = folderPath
editingRequestIndex.value = parseInt(requestIndex) editingRequestIndex.value = parseInt(requestIndex)
@@ -893,6 +938,9 @@ const updateEditingRequest = (newName: string) => {
if (possibleActiveTab) { if (possibleActiveTab) {
possibleActiveTab.value.document.request.name = requestUpdated.name possibleActiveTab.value.document.request.name = requestUpdated.name
nextTick(() => {
possibleActiveTab.value.document.isDirty = false
})
} }
displayModalEditRequest(false) displayModalEditRequest(false)
@@ -931,6 +979,9 @@ const updateEditingRequest = (newName: string) => {
if (possibleTab) { if (possibleTab) {
possibleTab.value.document.request.name = requestName possibleTab.value.document.request.name = requestName
nextTick(() => {
possibleTab.value.document.isDirty = false
})
} }
} }
} }
@@ -1876,6 +1927,12 @@ const exportData = async (
} }
const exportJSONCollection = async () => { const exportJSONCollection = async () => {
platform.analytics?.logEvent({
type: "HOPP_EXPORT_COLLECTION",
exporter: "json",
platform: "rest",
})
await getJSONCollection() await getJSONCollection()
initializeDownloadCollection(collectionJSON.value, null) initializeDownloadCollection(collectionJSON.value, null)
@@ -1887,6 +1944,12 @@ const createCollectionGist = async () => {
return return
} }
platform.analytics?.logEvent({
type: "HOPP_EXPORT_COLLECTION",
exporter: "gist",
platform: "rest",
})
creatingGistCollection.value = true creatingGistCollection.value = true
await getJSONCollection() await getJSONCollection()
@@ -1917,6 +1980,12 @@ const importToTeams = async (collection: HoppCollection<HoppRESTRequest>[]) => {
importingMyCollections.value = true importingMyCollections.value = true
platform.analytics?.logEvent({
type: "HOPP_EXPORT_COLLECTION",
exporter: "import-to-teams",
platform: "rest",
})
pipe( pipe(
importJSONToTeam( importJSONToTeam(
JSON.stringify(collection), JSON.stringify(collection),

View File

@@ -190,6 +190,12 @@ const createEnvironmentGist = async () => {
) )
toast.success(t("export.gist_created").toString()) toast.success(t("export.gist_created").toString())
platform.analytics?.logEvent({
type: "HOPP_EXPORT_ENVIRONMENT",
platform: "rest",
})
window.open(res.data.html_url) window.open(res.data.html_url)
} catch (e) { } catch (e) {
toast.error(t("error.something_went_wrong").toString()) toast.error(t("error.something_went_wrong").toString())
@@ -249,6 +255,13 @@ const openDialogChooseFileToImportFrom = () => {
const importToTeams = async (content: Environment[]) => { const importToTeams = async (content: Environment[]) => {
loading.value = true loading.value = true
platform.analytics?.logEvent({
type: "HOPP_IMPORT_ENVIRONMENT",
platform: "rest",
workspaceType: "team",
})
for (const [i, env] of content.entries()) { for (const [i, env] of content.entries()) {
if (i === content.length - 1) { if (i === content.length - 1) {
await pipe( await pipe(
@@ -301,6 +314,12 @@ const importFromJSON = () => {
return return
} }
platform.analytics?.logEvent({
type: "HOPP_IMPORT_ENVIRONMENT",
platform: "rest",
workspaceType: "personal",
})
const reader = new FileReader() const reader = new FileReader()
reader.onload = ({ target }) => { reader.onload = ({ target }) => {
@@ -352,6 +371,7 @@ const importFromPostman = ({
const environment: Environment = { name, variables: [] } const environment: Environment = { name, variables: [] }
values.forEach(({ key, value }) => environment.variables.push({ key, value })) values.forEach(({ key, value }) => environment.variables.push({ key, value }))
const environments = [environment] const environments = [environment]
importFromHoppscotch(environments) importFromHoppscotch(environments)
} }

View File

@@ -49,7 +49,7 @@
/> />
<HoppSmartTabs <HoppSmartTabs
v-model="selectedEnvTab" v-model="selectedEnvTab"
styles="sticky overflow-x-auto my-2 border border-divider rounded flex-shrink-0 z-0 top-0 bg-primary" styles="sticky overflow-x-auto my-2 border border-divider rounded flex-shrink-0 z-10 top-0 bg-primary"
render-inactive-tabs render-inactive-tabs
> >
<HoppSmartTab <HoppSmartTab
@@ -97,7 +97,7 @@
<HoppSmartSpinner class="my-4" /> <HoppSmartSpinner class="my-4" />
<span class="text-secondaryLight">{{ t("state.loading") }}</span> <span class="text-secondaryLight">{{ t("state.loading") }}</span>
</div> </div>
<div v-if="isTeamSelected" class="flex flex-col"> <div v-else-if="isTeamSelected" class="flex flex-col">
<HoppSmartItem <HoppSmartItem
v-for="(gen, index) in teamEnvironmentList" v-for="(gen, index) in teamEnvironmentList"
:key="`gen-team-${index}`" :key="`gen-team-${index}`"
@@ -161,10 +161,14 @@ import {
selectedEnvironmentIndex$, selectedEnvironmentIndex$,
setSelectedEnvironmentIndex, setSelectedEnvironmentIndex,
} from "~/newstore/environments" } from "~/newstore/environments"
import { workspaceStatus$ } from "~/newstore/workspace" import { changeWorkspace, workspaceStatus$ } from "~/newstore/workspace"
import TeamEnvironmentAdapter from "~/helpers/teams/TeamEnvironmentAdapter" import TeamEnvironmentAdapter from "~/helpers/teams/TeamEnvironmentAdapter"
import { useColorMode } from "@composables/theming" import { useColorMode } from "@composables/theming"
import { breakpointsTailwind, useBreakpoints } from "@vueuse/core" import { breakpointsTailwind, useBreakpoints } from "@vueuse/core"
import TeamListAdapter from "~/helpers/teams/TeamListAdapter"
import { useLocalState } from "~/newstore/localstate"
import { onLoggedIn } from "~/composables/auth"
import { GetMyTeamsQuery } from "~/helpers/backend/graphql"
const breakpoints = useBreakpoints(breakpointsTailwind) const breakpoints = useBreakpoints(breakpointsTailwind)
const mdAndLarger = breakpoints.greater("md") const mdAndLarger = breakpoints.greater("md")
@@ -213,6 +217,38 @@ watch(
} }
) )
// TeamList-Adapter
const teamListAdapter = new TeamListAdapter(true)
const myTeams = useReadonlyStream(teamListAdapter.teamList$, null)
const teamListFetched = ref(false)
const REMEMBERED_TEAM_ID = useLocalState("REMEMBERED_TEAM_ID")
onLoggedIn(() => {
!teamListAdapter.isInitialized && teamListAdapter.initialize()
})
const switchToTeamWorkspace = (team: GetMyTeamsQuery["myTeams"][number]) => {
REMEMBERED_TEAM_ID.value = team.id
changeWorkspace({
teamID: team.id,
teamName: team.name,
type: "team",
})
}
watch(
() => myTeams.value,
(newTeams) => {
if (newTeams && !teamListFetched.value) {
teamListFetched.value = true
if (REMEMBERED_TEAM_ID.value) {
const team = newTeams.find((t) => t.id === REMEMBERED_TEAM_ID.value)
if (team) switchToTeamWorkspace(team)
}
}
}
)
const selectedEnv = computed(() => { const selectedEnv = computed(() => {
if (selectedEnvironmentIndex.value.type === "MY_ENV") { if (selectedEnvironmentIndex.value.type === "MY_ENV") {
return { return {

View File

@@ -148,6 +148,7 @@ import { useToast } from "@composables/toast"
import { useReadonlyStream } from "@composables/stream" import { useReadonlyStream } from "@composables/stream"
import { useColorMode } from "@composables/theming" import { useColorMode } from "@composables/theming"
import { environmentsStore } from "~/newstore/environments" import { environmentsStore } from "~/newstore/environments"
import { platform } from "~/platform"
type EnvironmentVariable = { type EnvironmentVariable = {
id: number id: number
@@ -311,6 +312,11 @@ const saveEnvironment = () => {
index: envList.value.length - 1, index: envList.value.length - 1,
}) })
toast.success(`${t("environment.created")}`) toast.success(`${t("environment.created")}`)
platform.analytics?.logEvent({
type: "HOPP_CREATE_ENVIRONMENT",
workspaceType: "personal",
})
} else if (props.editingEnvironmentIndex === "Global") { } else if (props.editingEnvironmentIndex === "Global") {
// Editing the Global environment // Editing the Global environment
setGlobalEnvVariables(environmentUpdated.variables) setGlobalEnvVariables(environmentUpdated.variables)

View File

@@ -156,6 +156,7 @@ import IconTrash from "~icons/lucide/trash"
import IconTrash2 from "~icons/lucide/trash-2" import IconTrash2 from "~icons/lucide/trash-2"
import IconDone from "~icons/lucide/check" import IconDone from "~icons/lucide/check"
import IconPlus from "~icons/lucide/plus" import IconPlus from "~icons/lucide/plus"
import { platform } from "~/platform"
type EnvironmentVariable = { type EnvironmentVariable = {
id: number id: number
@@ -294,6 +295,11 @@ const saveEnvironment = async () => {
) )
if (props.action === "new") { if (props.action === "new") {
platform.analytics?.logEvent({
type: "HOPP_CREATE_ENVIRONMENT",
workspaceType: "team",
})
await pipe( await pipe(
createTeamEnvironment( createTeamEnvironment(
JSON.stringify(filterdVariables), JSON.stringify(filterdVariables),

View File

@@ -143,33 +143,51 @@
<div class="flex flex-1 border-b border-dividerLight"> <div class="flex flex-1 border-b border-dividerLight">
<SmartEnvInput <SmartEnvInput
v-model="basicUsername" v-model="basicUsername"
:environment-highlights="false"
:placeholder="t('authorization.username')" :placeholder="t('authorization.username')"
/> />
</div> </div>
<div class="flex flex-1 border-b border-dividerLight"> <div class="flex flex-1 border-b border-dividerLight">
<SmartEnvInput <SmartEnvInput
v-model="basicPassword" v-model="basicPassword"
:environment-highlights="false"
:placeholder="t('authorization.password')" :placeholder="t('authorization.password')"
/> />
</div> </div>
</div> </div>
<div v-if="authType === 'bearer'"> <div v-if="authType === 'bearer'">
<div class="flex flex-1 border-b border-dividerLight"> <div class="flex flex-1 border-b border-dividerLight">
<SmartEnvInput v-model="bearerToken" placeholder="Token" /> <SmartEnvInput
v-model="bearerToken"
:environment-highlights="false"
placeholder="Token"
/>
</div> </div>
</div> </div>
<div v-if="authType === 'oauth-2'"> <div v-if="authType === 'oauth-2'">
<div class="flex flex-1 border-b border-dividerLight"> <div class="flex flex-1 border-b border-dividerLight">
<SmartEnvInput v-model="oauth2Token" placeholder="Token" /> <SmartEnvInput
v-model="oauth2Token"
:environment-highlights="false"
placeholder="Token"
/>
</div> </div>
<HttpOAuth2Authorization /> <HttpOAuth2Authorization />
</div> </div>
<div v-if="authType === 'api-key'"> <div v-if="authType === 'api-key'">
<div class="flex flex-1 border-b border-dividerLight"> <div class="flex flex-1 border-b border-dividerLight">
<SmartEnvInput v-model="apiKey" placeholder="Key" /> <SmartEnvInput
v-model="apiKey"
:environment-highlights="false"
placeholder="Key"
/>
</div> </div>
<div class="flex flex-1 border-b border-dividerLight"> <div class="flex flex-1 border-b border-dividerLight">
<SmartEnvInput v-model="apiValue" placeholder="Value" /> <SmartEnvInput
v-model="apiValue"
:environment-highlights="false"
placeholder="Value"
/>
</div> </div>
<div class="flex items-center border-b border-dividerLight"> <div class="flex items-center border-b border-dividerLight">
<span class="flex items-center"> <span class="flex items-center">

View File

@@ -17,6 +17,7 @@
<HoppButtonPrimary <HoppButtonPrimary
id="get" id="get"
name="get" name="get"
:loading="isLoading"
:label="!connected ? t('action.connect') : t('action.disconnect')" :label="!connected ? t('action.connect') : t('action.disconnect')"
class="w-32" class="w-32"
@click="onConnectClick" @click="onConnectClick"
@@ -31,7 +32,12 @@ import { GQLConnection } from "~/helpers/GQLConnection"
import { getCurrentStrategyID } from "~/helpers/network" import { getCurrentStrategyID } from "~/helpers/network"
import { useReadonlyStream, useStream } from "@composables/stream" import { useReadonlyStream, useStream } from "@composables/stream"
import { useI18n } from "@composables/i18n" import { useI18n } from "@composables/i18n"
import { gqlHeaders$, gqlURL$, setGQLURL } from "~/newstore/GQLSession" import {
gqlAuth$,
gqlHeaders$,
gqlURL$,
setGQLURL,
} from "~/newstore/GQLSession"
const t = useI18n() const t = useI18n()
@@ -40,15 +46,21 @@ const props = defineProps<{
}>() }>()
const connected = useReadonlyStream(props.conn.connected$, false) const connected = useReadonlyStream(props.conn.connected$, false)
const isLoading = useReadonlyStream(props.conn.isLoading$, false)
const headers = useReadonlyStream(gqlHeaders$, []) const headers = useReadonlyStream(gqlHeaders$, [])
const auth = useReadonlyStream(gqlAuth$, {
authType: "none",
authActive: true,
})
const url = useStream(gqlURL$, "", setGQLURL) const url = useStream(gqlURL$, "", setGQLURL)
const onConnectClick = () => { const onConnectClick = () => {
if (!connected.value) { if (!connected.value) {
props.conn.connect(url.value, headers.value as any) props.conn.connect(url.value, headers.value as any, auth.value)
platform.analytics?.logHoppRequestRunToAnalytics({ platform.analytics?.logEvent({
type: "HOPP_REQUEST_RUN",
platform: "graphql-schema", platform: "graphql-schema",
strategy: getCurrentStrategyID(), strategy: getCurrentStrategyID(),
}) })

View File

@@ -748,7 +748,8 @@ const runQuery = async () => {
console.error(e) console.error(e)
} }
platform.analytics?.logHoppRequestRunToAnalytics({ platform.analytics?.logEvent({
type: "HOPP_REQUEST_RUN",
platform: "graphql-query", platform: "graphql-query",
strategy: getCurrentStrategyID(), strategy: getCurrentStrategyID(),
}) })

View File

@@ -63,7 +63,7 @@ import { GQLHistoryEntry } from "~/newstore/history"
import { shortDateTime } from "~/helpers/utils/date" import { shortDateTime } from "~/helpers/utils/date"
import IconStar from "~icons/lucide/star" import IconStar from "~icons/lucide/star"
import IconStarOff from "~icons/lucide/star-off" import IconStarOff from "~icons/hopp/star-off"
import IconTrash from "~icons/lucide/trash" import IconTrash from "~icons/lucide/trash"
import IconMinimize2 from "~icons/lucide/minimize-2" import IconMinimize2 from "~icons/lucide/minimize-2"
import IconMaximize2 from "~icons/lucide/maximize-2" import IconMaximize2 from "~icons/lucide/maximize-2"

View File

@@ -72,9 +72,11 @@
class="flex items-center justify-between flex-1 min-w-0 transition cursor-pointer focus:outline-none text-secondaryLight text-tiny group" class="flex items-center justify-between flex-1 min-w-0 transition cursor-pointer focus:outline-none text-secondaryLight text-tiny group"
> >
<span <span
class="inline-flex items-center justify-center px-4 py-2 transition group-hover:text-secondary" class="inline-flex items-center justify-center px-4 py-2 transition group-hover:text-secondary truncate"
> >
<icon-lucide-chevron-right class="mr-2 indicator" /> <icon-lucide-chevron-right
class="mr-2 indicator flex flex-shrink-0"
/>
<span <span
:class="[ :class="[
{ 'capitalize-first': groupSelection === 'TIME' }, { 'capitalize-first': groupSelection === 'TIME' },

View File

@@ -55,7 +55,7 @@ import { RESTHistoryEntry } from "~/newstore/history"
import { shortDateTime } from "~/helpers/utils/date" import { shortDateTime } from "~/helpers/utils/date"
import IconStar from "~icons/lucide/star" import IconStar from "~icons/lucide/star"
import IconStarOff from "~icons/lucide/star-off" import IconStarOff from "~icons/hopp/star-off"
import IconTrash from "~icons/lucide/trash" import IconTrash from "~icons/lucide/trash"
const props = defineProps<{ const props = defineProps<{

View File

@@ -165,6 +165,7 @@ import IconCheck from "~icons/lucide/check"
import IconWrapText from "~icons/lucide/wrap-text" import IconWrapText from "~icons/lucide/wrap-text"
import { currentActiveTab } from "~/helpers/rest/tab" import { currentActiveTab } from "~/helpers/rest/tab"
import cloneDeep from "lodash-es/cloneDeep" import cloneDeep from "lodash-es/cloneDeep"
import { platform } from "~/platform"
const t = useI18n() const t = useI18n()
@@ -248,6 +249,10 @@ watch(
(goingToShow) => { (goingToShow) => {
if (goingToShow) { if (goingToShow) {
request.value = cloneDeep(currentActiveTab.value.document.request) request.value = cloneDeep(currentActiveTab.value.document.request)
platform.analytics?.logEvent({
type: "HOPP_REST_CODEGEN_OPENED",
})
} }
} }
) )

View File

@@ -338,7 +338,7 @@ watch(workingHeaders, (headersList) => {
// Sync logic between headers and working/bulk headers // Sync logic between headers and working/bulk headers
watch( watch(
request.value.headers, () => request.value.headers,
(newHeadersList) => { (newHeadersList) => {
// Sync should overwrite working headers // Sync should overwrite working headers
const filteredWorkingHeaders = pipe( const filteredWorkingHeaders = pipe(

View File

@@ -94,6 +94,7 @@ import IconClipboard from "~icons/lucide/clipboard"
import IconCheck from "~icons/lucide/check" import IconCheck from "~icons/lucide/check"
import IconTrash2 from "~icons/lucide/trash-2" import IconTrash2 from "~icons/lucide/trash-2"
import { currentActiveTab } from "~/helpers/rest/tab" import { currentActiveTab } from "~/helpers/rest/tab"
import { platform } from "~/platform"
const t = useI18n() const t = useI18n()
@@ -144,6 +145,10 @@ const handleImport = () => {
try { try {
const req = parseCurlToHoppRESTReq(text) const req = parseCurlToHoppRESTReq(text)
platform.analytics?.logEvent({
type: "HOPP_REST_IMPORT_CURL",
})
currentActiveTab.value.document.request = req currentActiveTab.value.document.request = req
} catch (e) { } catch (e) {
console.error(e) console.error(e)

View File

@@ -217,6 +217,7 @@
@hide-modal="showCodegenModal = false" @hide-modal="showCodegenModal = false"
/> />
<CollectionsSaveRequest <CollectionsSaveRequest
v-if="showSaveRequestModal"
mode="rest" mode="rest"
:show="showSaveRequestModal" :show="showSaveRequestModal"
@hide-modal="showSaveRequestModal = false" @hide-modal="showSaveRequestModal = false"
@@ -323,7 +324,8 @@ const newSendRequest = async () => {
loading.value = true loading.value = true
// Log the request run into analytics // Log the request run into analytics
platform.analytics?.logHoppRequestRunToAnalytics({ platform.analytics?.logEvent({
type: "HOPP_REQUEST_RUN",
platform: "rest", platform: "rest",
strategy: getCurrentStrategyID(), strategy: getCurrentStrategyID(),
}) })
@@ -445,6 +447,11 @@ const copyRequest = async () => {
shareLink.value = "" shareLink.value = ""
fetchingShareLink.value = true fetchingShareLink.value = true
const shortcodeResult = await createShortcode(tab.value.document.request)() const shortcodeResult = await createShortcode(tab.value.document.request)()
platform.analytics?.logEvent({
type: "HOPP_SHORTCODE_CREATED",
})
if (E.isLeft(shortcodeResult)) { if (E.isLeft(shortcodeResult)) {
toast.error(`${shortcodeResult.left.error}`) toast.error(`${shortcodeResult.left.error}`)
shareLink.value = `${t("error.something_went_wrong")}` shareLink.value = `${t("error.something_went_wrong")}`
@@ -515,6 +522,14 @@ const saveRequest = () => {
editRESTRequest(saveCtx.folderPath, saveCtx.requestIndex, req) editRESTRequest(saveCtx.folderPath, saveCtx.requestIndex, req)
tab.value.document.isDirty = false tab.value.document.isDirty = false
platform.analytics?.logEvent({
type: "HOPP_SAVE_REQUEST",
platform: "rest",
createdNow: false,
workspaceType: "personal",
})
toast.success(`${t("request.saved")}`) toast.success(`${t("request.saved")}`)
} catch (e) { } catch (e) {
tab.value.document.saveContext = undefined tab.value.document.saveContext = undefined
@@ -525,6 +540,13 @@ const saveRequest = () => {
// TODO: handle error case (NOTE: overwriteRequestTeams is async) // TODO: handle error case (NOTE: overwriteRequestTeams is async)
try { try {
platform.analytics?.logEvent({
type: "HOPP_SAVE_REQUEST",
platform: "rest",
createdNow: false,
workspaceType: "team",
})
runMutation(UpdateRequestDocument, { runMutation(UpdateRequestDocument, {
requestID: saveCtx.requestID, requestID: saveCtx.requestID,
data: { data: {

View File

@@ -54,7 +54,8 @@
<script setup lang="ts"> <script setup lang="ts">
import { useI18n } from "@composables/i18n" import { useI18n } from "@composables/i18n"
import { HoppRESTRequest } from "@hoppscotch/data" import { HoppRESTRequest } from "@hoppscotch/data"
import { computed, ref, watch } from "vue" import { useVModel } from "@vueuse/core"
import { computed, ref } from "vue"
export type RequestOptionTabs = export type RequestOptionTabs =
| "params" | "params"
@@ -70,15 +71,7 @@ const emit = defineEmits<{
(e: "update:modelValue", value: HoppRESTRequest): void (e: "update:modelValue", value: HoppRESTRequest): void
}>() }>()
const request = ref(props.modelValue) const request = useVModel(props, "modelValue", emit)
watch(
() => request.value,
(newVal) => {
emit("update:modelValue", newVal)
},
{ deep: true }
)
const selectedRealtimeTab = ref<RequestOptionTabs>("params") const selectedRealtimeTab = ref<RequestOptionTabs>("params")

View File

@@ -28,9 +28,11 @@
class="flex items-center justify-between flex-1 min-w-0 transition cursor-pointer focus:outline-none text-secondaryLight text-tiny group" class="flex items-center justify-between flex-1 min-w-0 transition cursor-pointer focus:outline-none text-secondaryLight text-tiny group"
> >
<span <span
class="inline-flex items-center justify-center px-4 py-2 transition group-hover:text-secondary" class="inline-flex items-center justify-center px-4 py-2 transition group-hover:text-secondary truncate"
> >
<icon-lucide-chevron-right class="mr-2 indicator" /> <icon-lucide-chevron-right
class="mr-2 indicator flex flex-shrink-0"
/>
<span class="truncate capitalize-first"> <span class="truncate capitalize-first">
{{ t("environment.title") }} {{ t("environment.title") }}
</span> </span>

View File

@@ -44,6 +44,7 @@ const props = withDefaults(
envs?: { key: string; value: string; source: string }[] | null envs?: { key: string; value: string; source: string }[] | null
focus?: boolean focus?: boolean
selectTextOnMount?: boolean selectTextOnMount?: boolean
environmentHighlights?: boolean
readonly?: boolean readonly?: boolean
}>(), }>(),
{ {
@@ -53,6 +54,7 @@ const props = withDefaults(
envs: null, envs: null,
focus: false, focus: false,
readonly: false, readonly: false,
environmentHighlights: true,
} }
) )
@@ -142,7 +144,7 @@ const initView = (el: any) => {
tooltips({ tooltips({
position: "absolute", position: "absolute",
}), }),
envTooltipPlugin, props.environmentHighlights ? envTooltipPlugin : [],
placeholderExt(props.placeholder), placeholderExt(props.placeholder),
EditorView.domEventHandlers({ EditorView.domEventHandlers({
paste(ev) { paste(ev) {

View File

@@ -44,6 +44,7 @@ import { createTeam } from "~/helpers/backend/mutations/Team"
import { TeamNameCodec } from "~/helpers/backend/types/TeamName" import { TeamNameCodec } from "~/helpers/backend/types/TeamName"
import { useI18n } from "@composables/i18n" import { useI18n } from "@composables/i18n"
import { useToast } from "@composables/toast" import { useToast } from "@composables/toast"
import { platform } from "~/platform"
const t = useI18n() const t = useI18n()
@@ -68,6 +69,12 @@ const addNewTeam = async () => {
TE.fromEither, TE.fromEither,
TE.mapLeft(() => "invalid_name" as const), TE.mapLeft(() => "invalid_name" as const),
TE.chainW(createTeam), TE.chainW(createTeam),
TE.chainFirstIOK(
() => () =>
platform.analytics?.logEvent({
type: "HOPP_CREATE_TEAM",
})
),
TE.match( TE.match(
(err) => { (err) => {
// err is of type "invalid_name" | GQLError<Err> // err is of type "invalid_name" | GQLError<Err>

View File

@@ -99,7 +99,7 @@ export class GQLConnection {
private timeoutSubscription: any private timeoutSubscription: any
public connect(url: string, headers: GQLHeader[]) { public connect(url: string, headers: GQLHeader[], auth: HoppGQLAuth) {
if (this.connected$.value) { if (this.connected$.value) {
throw new Error( throw new Error(
"A connection is already running. Close it before starting another." "A connection is already running. Close it before starting another."
@@ -110,7 +110,7 @@ export class GQLConnection {
this.connected$.next(true) this.connected$.next(true)
const poll = async () => { const poll = async () => {
await this.getSchema(url, headers) await this.getSchema(url, headers, auth)
this.timeoutSubscription = setTimeout(() => { this.timeoutSubscription = setTimeout(() => {
poll() poll()
}, GQL_SCHEMA_POLL_INTERVAL) }, GQL_SCHEMA_POLL_INTERVAL)
@@ -135,7 +135,11 @@ export class GQLConnection {
this.schema$.next(null) this.schema$.next(null)
} }
private async getSchema(url: string, headers: GQLHeader[]) { private async getSchema(
url: string,
reqHeaders: GQLHeader[],
auth: HoppGQLAuth
) {
try { try {
this.isLoading$.next(true) this.isLoading$.next(true)
@@ -143,10 +147,38 @@ export class GQLConnection {
query: getIntrospectionQuery(), query: getIntrospectionQuery(),
}) })
const headers = reqHeaders.filter((x) => x.active && x.key !== "")
// TODO: Support a better b64 implementation than btoa ?
if (auth.authType === "basic") {
const username = auth.username
const password = auth.password
headers.push({
active: true,
key: "Authorization",
value: `Basic ${btoa(`${username}:${password}`)}`,
})
} else if (auth.authType === "bearer" || auth.authType === "oauth-2") {
headers.push({
active: true,
key: "Authorization",
value: `Bearer ${auth.token}`,
})
} else if (auth.authType === "api-key") {
const { key, value, addTo } = auth
if (addTo === "Headers") {
headers.push({
active: true,
key,
value,
})
}
}
const finalHeaders: Record<string, string> = {} const finalHeaders: Record<string, string> = {}
headers headers.forEach((x) => (finalHeaders[x.key] = x.value))
.filter((x) => x.active && x.key !== "")
.forEach((x) => (finalHeaders[x.key] = x.value))
const reqOptions = { const reqOptions = {
method: "POST", method: "POST",

View File

@@ -6,14 +6,18 @@ import { isJSONContentType } from "./utils/contenttypes"
* Handles translations for all the hopp.io REST Shareable URL params * Handles translations for all the hopp.io REST Shareable URL params
*/ */
export function translateExtURLParams( export function translateExtURLParams(
urlParams: Record<string, any> urlParams: Record<string, any>,
initialReq?: HoppRESTRequest
): HoppRESTRequest { ): HoppRESTRequest {
if (urlParams.v) return parseV1ExtURL(urlParams) if (urlParams.v) return parseV1ExtURL(urlParams, initialReq)
else return parseV0ExtURL(urlParams) else return parseV0ExtURL(urlParams, initialReq)
} }
function parseV0ExtURL(urlParams: Record<string, any>): HoppRESTRequest { function parseV0ExtURL(
const resolvedReq = getDefaultRESTRequest() urlParams: Record<string, any>,
initialReq?: HoppRESTRequest
): HoppRESTRequest {
const resolvedReq = initialReq ?? getDefaultRESTRequest()
if (urlParams.method && typeof urlParams.method === "string") { if (urlParams.method && typeof urlParams.method === "string") {
resolvedReq.method = urlParams.method resolvedReq.method = urlParams.method
@@ -89,8 +93,11 @@ function parseV0ExtURL(urlParams: Record<string, any>): HoppRESTRequest {
return resolvedReq return resolvedReq
} }
function parseV1ExtURL(urlParams: Record<string, any>): HoppRESTRequest { function parseV1ExtURL(
const resolvedReq = getDefaultRESTRequest() urlParams: Record<string, any>,
initialReq?: HoppRESTRequest
): HoppRESTRequest {
const resolvedReq = initialReq ?? getDefaultRESTRequest()
if (urlParams.headers && typeof urlParams.headers === "string") { if (urlParams.headers && typeof urlParams.headers === "string") {
resolvedReq.headers = JSON.parse(urlParams.headers) resolvedReq.headers = JSON.parse(urlParams.headers)

View File

@@ -71,9 +71,11 @@ const parseURL = (urlText: string | number) =>
* @returns URL object * @returns URL object
*/ */
export function getURLObject(parsedArguments: parser.Arguments) { export function getURLObject(parsedArguments: parser.Arguments) {
const location = parsedArguments.location ?? undefined
return pipe( return pipe(
// contains raw url strings // contains raw url strings
parsedArguments._.slice(1), [...parsedArguments._.slice(1), location],
A.findFirstMap(parseURL), A.findFirstMap(parseURL),
// no url found // no url found
O.getOrElse(() => new URL(defaultRESTReq.endpoint)) O.getOrElse(() => new URL(defaultRESTReq.endpoint))

View File

@@ -105,7 +105,8 @@ export class MQTTConnection {
this.handleError(e) this.handleError(e)
} }
platform.analytics?.logHoppRequestRunToAnalytics({ platform.analytics?.logEvent({
type: "HOPP_REQUEST_RUN",
platform: "mqtt", platform: "mqtt",
}) })
} }

View File

@@ -113,7 +113,8 @@ export class SIOConnection {
this.handleError(error, "CONNECTION") this.handleError(error, "CONNECTION")
} }
platform.analytics?.logHoppRequestRunToAnalytics({ platform.analytics?.logEvent({
type: "HOPP_REQUEST_RUN",
platform: "socketio", platform: "socketio",
}) })
} }

View File

@@ -63,7 +63,8 @@ export class SSEConnection {
}) })
} }
platform.analytics?.logHoppRequestRunToAnalytics({ platform.analytics?.logEvent({
type: "HOPP_REQUEST_RUN",
platform: "sse", platform: "sse",
}) })
} }

View File

@@ -71,7 +71,8 @@ export class WSConnection {
this.handleError(error as SyntaxError) this.handleError(error as SyntaxError)
} }
platform.analytics?.logHoppRequestRunToAnalytics({ platform.analytics?.logEvent({
type: "HOPP_REQUEST_RUN",
platform: "wss", platform: "wss",
}) })
} }

View File

@@ -6,6 +6,7 @@ import { refWithControl } from "@vueuse/core"
import { HoppRESTResponse } from "../types/HoppRESTResponse" import { HoppRESTResponse } from "../types/HoppRESTResponse"
import { getDefaultRESTRequest } from "./default" import { getDefaultRESTRequest } from "./default"
import { HoppTestResult } from "../types/HoppTestResult" import { HoppTestResult } from "../types/HoppTestResult"
import { platform } from "~/platform"
export type HoppRESTTab = { export type HoppRESTTab = {
id: string id: string
@@ -147,6 +148,10 @@ export function createNewTab(document: HoppRESTDocument, switchToIt = true) {
currentTabID.value = id currentTabID.value = id
} }
platform.analytics?.logEvent({
type: "HOPP_REST_NEW_TAB_OPENED",
})
return tab return tab
} }

View File

@@ -275,7 +275,4 @@ export const gqlResponse$ = gqlSessionStore.subject$.pipe(
distinctUntilChanged() distinctUntilChanged()
) )
export const gqlAuth$ = gqlSessionStore.subject$.pipe( export const gqlAuth$ = gqlSessionStore.subject$.pipe(pluck("request", "auth"))
pluck("request", "auth"),
distinctUntilChanged()
)

View File

@@ -47,6 +47,8 @@ import {
loadTabsFromPersistedState, loadTabsFromPersistedState,
persistableTabState, persistableTabState,
} from "~/helpers/rest/tab" } from "~/helpers/rest/tab"
import { debounceTime } from "rxjs"
import { gqlSessionStore, setGQLSession } from "./GQLSession"
function checkAndMigrateOldSettings() { function checkAndMigrateOldSettings() {
if (window.localStorage.getItem("selectedEnvIndex")) { if (window.localStorage.getItem("selectedEnvIndex")) {
@@ -333,12 +335,35 @@ export function setupRESTTabsPersistence() {
) )
} }
// temporary persistence for GQL session
export function setupGQLPersistence() {
try {
const state = window.localStorage.getItem("gqlState")
if (state) {
const data = JSON.parse(state)
data["schema"] = ""
data["response"] = ""
setGQLSession(data)
}
} catch (e) {
console.error(
`Failed parsing persisted GraphQL state, state:`,
window.localStorage.getItem("gqlState")
)
}
gqlSessionStore.subject$.pipe(debounceTime(500)).subscribe((state) => {
window.localStorage.setItem("gqlState", JSON.stringify(state))
})
}
export function setupLocalPersistence() { export function setupLocalPersistence() {
checkAndMigrateOldSettings() checkAndMigrateOldSettings()
setupLocalStatePersistence() setupLocalStatePersistence()
setupSettingsPersistence() setupSettingsPersistence()
setupRESTTabsPersistence() setupRESTTabsPersistence()
setupGQLPersistence()
setupHistoryPersistence() setupHistoryPersistence()
setupCollectionsPersistence() setupCollectionsPersistence()
setupGlobalEnvsPersistence() setupGlobalEnvsPersistence()

View File

@@ -14,12 +14,10 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, onBeforeUnmount, watch } from "vue"
import { useReadonlyStream } from "@composables/stream"
import { useI18n } from "@composables/i18n"
import { usePageHead } from "@composables/head" import { usePageHead } from "@composables/head"
import { startPageProgress, completePageProgress } from "@modules/loadingbar" import { useI18n } from "@composables/i18n"
import { GQLConnection } from "@helpers/GQLConnection" import { GQLConnection } from "@helpers/GQLConnection"
import { computed, onBeforeUnmount } from "vue"
const t = useI18n() const t = useI18n()
@@ -28,16 +26,10 @@ usePageHead({
}) })
const gqlConn = new GQLConnection() const gqlConn = new GQLConnection()
const isLoading = useReadonlyStream(gqlConn.isLoading$, false)
onBeforeUnmount(() => { onBeforeUnmount(() => {
if (gqlConn.connected$.value) { if (gqlConn.connected$.value) {
gqlConn.disconnect() gqlConn.disconnect()
} }
}) })
watch(isLoading, () => {
if (isLoading.value) startPageProgress()
else completePageProgress()
})
</script> </script>

View File

@@ -79,8 +79,9 @@
@resolve="onResolveConfirmSaveTab" @resolve="onResolveConfirmSaveTab"
/> />
<CollectionsSaveRequest <CollectionsSaveRequest
v-if="savingRequest"
mode="rest"
:show="savingRequest" :show="savingRequest"
:mode="'rest'"
@hide-modal="onSaveModalClose" @hide-modal="onSaveModalClose"
/> />
</div> </div>
@@ -153,8 +154,11 @@ function bindRequestToURLParams() {
// If query params are empty, or contains code or error param (these are from Oauth Redirect) // If query params are empty, or contains code or error param (these are from Oauth Redirect)
// We skip URL params parsing // We skip URL params parsing
if (Object.keys(query).length === 0 || query.code || query.error) return if (Object.keys(query).length === 0 || query.code || query.error) return
const request = currentActiveTab.value.document.request
currentActiveTab.value.document.request = safelyExtractRESTRequest( currentActiveTab.value.document.request = safelyExtractRESTRequest(
translateExtURLParams(query), translateExtURLParams(query, request),
getDefaultRESTRequest() getDefaultRESTRequest()
) )
}) })

View File

@@ -11,6 +11,14 @@
<p class="mt-2 text-center"> <p class="mt-2 text-center">
{{ t("error.invalid_link_description") }} {{ t("error.invalid_link_description") }}
</p> </p>
<p class="mt-4">
<HoppButtonSecondary
to="/"
:icon="IconHome"
filled
:label="t('app.home')"
/>
</p>
</div> </div>
<div v-else class="flex flex-col items-center justify-center flex-1 p-4"> <div v-else class="flex flex-col items-center justify-center flex-1 p-4">
<div <div
@@ -76,6 +84,7 @@ import IconHome from "~icons/lucide/home"
import IconRefreshCW from "~icons/lucide/refresh-cw" import IconRefreshCW from "~icons/lucide/refresh-cw"
import { createNewTab } from "~/helpers/rest/tab" import { createNewTab } from "~/helpers/rest/tab"
import { getDefaultRESTRequest } from "~/helpers/rest/default" import { getDefaultRESTRequest } from "~/helpers/rest/default"
import { platform } from "~/platform"
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
@@ -107,6 +116,15 @@ const addRequestToTab = () => {
const data = shortcodeDetails.data const data = shortcodeDetails.data
if (E.isRight(data)) { if (E.isRight(data)) {
if (!data.right.shortcode?.request) {
invalidLink.value = true
return
}
platform.analytics?.logEvent({
type: "HOPP_SHORTCODE_RESOLVED",
})
const request: unknown = JSON.parse(data.right.shortcode?.request as string) const request: unknown = JSON.parse(data.right.shortcode?.request as string)
createNewTab({ createNewTab({

View File

@@ -5,8 +5,50 @@ export type HoppRequestEvent =
} }
| { platform: "wss" | "sse" | "socketio" | "mqtt" } | { platform: "wss" | "sse" | "socketio" | "mqtt" }
export type AnalyticsEvent =
| ({ type: "HOPP_REQUEST_RUN" } & HoppRequestEvent)
| {
type: "HOPP_CREATE_ENVIRONMENT"
workspaceType: "personal" | "team"
}
| {
type: "HOPP_CREATE_COLLECTION"
platform: "rest" | "gql"
isRootCollection: boolean
workspaceType: "personal" | "team"
}
| { type: "HOPP_CREATE_TEAM" }
| {
type: "HOPP_SAVE_REQUEST"
createdNow: boolean
workspaceType: "personal" | "team"
platform: "rest" | "gql"
}
| { type: "HOPP_SHORTCODE_CREATED" }
| { type: "HOPP_SHORTCODE_RESOLVED" }
| { type: "HOPP_REST_NEW_TAB_OPENED" }
| {
type: "HOPP_IMPORT_COLLECTION"
importer: string
workspaceType: "personal" | "team"
platform: "rest" | "gql"
}
| {
type: "HOPP_IMPORT_ENVIRONMENT"
workspaceType: "personal" | "team"
platform: "rest" | "gql"
}
| {
type: "HOPP_EXPORT_COLLECTION"
exporter: string
platform: "rest" | "gql"
}
| { type: "HOPP_EXPORT_ENVIRONMENT"; platform: "rest" | "gql" }
| { type: "HOPP_REST_CODEGEN_OPENED" }
| { type: "HOPP_REST_IMPORT_CURL" }
export type AnalyticsPlatformDef = { export type AnalyticsPlatformDef = {
initAnalytics: () => void initAnalytics: () => void
logHoppRequestRunToAnalytics: (ev: HoppRequestEvent) => void logEvent: (ev: AnalyticsEvent) => void
logPageView: (pagePath: string) => void logPageView: (pagePath: string) => void
} }

View File

@@ -1,7 +1,7 @@
{ {
"name": "@hoppscotch/selfhost-web", "name": "@hoppscotch/selfhost-web",
"private": true, "private": true,
"version": "2023.4.4", "version": "2023.4.7",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev:vite": "vite", "dev:vite": "vite",

View File

@@ -1,7 +1,7 @@
{ {
"name": "hoppscotch-sh-admin", "name": "hoppscotch-sh-admin",
"private": true, "private": true,
"version": "2023.4.4", "version": "2023.4.7",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "pnpm exec npm-run-all -p -l dev:*", "dev": "pnpm exec npm-run-all -p -l dev:*",

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