diff --git a/devenv.lock b/devenv.lock index ba163cb5b61..c39fd98b071 100644 --- a/devenv.lock +++ b/devenv.lock @@ -3,10 +3,11 @@ "devenv": { "locked": { "dir": "src/modules", - "lastModified": 1764669403, + "lastModified": 1774428097, + "narHash": "sha256-yQAutPgbsVHsN/SygZDyzMRxQn6Im53PJkrI377N8Sg=", "owner": "cachix", "repo": "devenv", - "rev": "3f2d25e7af748127da0571266054575dd8fec5ab", + "rev": "957d63f663f230dc8ac3b85f950690e56fe8b1e0", "type": "github" }, "original": { @@ -24,10 +25,11 @@ "rust-analyzer-src": "rust-analyzer-src" }, "locked": { - "lastModified": 1764658058, + "lastModified": 1774423251, + "narHash": "sha256-g/PP8G9WcP4vtZVOBNYwfGxLnwLQoTERHnef8irAMeQ=", "owner": "nix-community", "repo": "fenix", - "rev": "12bd9c7bcbeb949741b3ad0ca2b3506d0718cf4d", + "rev": "b70d7535088cd8a9e4322c372a475f66ffa18adf", "type": "github" }, "original": { @@ -36,68 +38,13 @@ "type": "github" } }, - "flake-compat": { - "flake": false, - "locked": { - "lastModified": 1761588595, - "owner": "edolstra", - "repo": "flake-compat", - "rev": "f387cd2afec9419c8ee37694406ca490c3f34ee5", - "type": "github" - }, - "original": { - "owner": "edolstra", - "repo": "flake-compat", - "type": "github" - } - }, - "git-hooks": { - "inputs": { - "flake-compat": "flake-compat", - "gitignore": "gitignore", - "nixpkgs": [ - "nixpkgs" - ] - }, - "locked": { - "lastModified": 1763988335, - "owner": "cachix", - "repo": "git-hooks.nix", - "rev": "50b9238891e388c9fdc6a5c49e49c42533a1b5ce", - "type": "github" - }, - "original": { - "owner": "cachix", - "repo": "git-hooks.nix", - "type": "github" - } - }, - "gitignore": { - "inputs": { - "nixpkgs": [ - "git-hooks", - "nixpkgs" - ] - }, - "locked": { - "lastModified": 1762808025, - "owner": "hercules-ci", - "repo": "gitignore.nix", - "rev": "cb5e3fdca1de58ccbc3ef53de65bd372b48f567c", - "type": "github" - }, - "original": { - "owner": "hercules-ci", - "repo": "gitignore.nix", - "type": "github" - } - }, "nixpkgs": { "locked": { - "lastModified": 1764611609, + "lastModified": 1774273680, + "narHash": "sha256-a++tZ1RQsDb1I0NHrFwdGuRlR5TORvCEUksM459wKUA=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "8c29968b3a942f2903f90797f9623737c215737c", + "rev": "fdc7b8f7b30fdbedec91b71ed82f36e1637483ed", "type": "github" }, "original": { @@ -111,21 +58,18 @@ "inputs": { "devenv": "devenv", "fenix": "fenix", - "git-hooks": "git-hooks", "nixpkgs": "nixpkgs", - "pre-commit-hooks": [ - "git-hooks" - ], "rust-overlay": "rust-overlay" } }, "rust-analyzer-src": { "flake": false, "locked": { - "lastModified": 1764603480, + "lastModified": 1774376228, + "narHash": "sha256-7oA0u4aghFjjIcIDKZ26NUpXH7hVXGPC0sI1OfK7NUk=", "owner": "rust-lang", "repo": "rust-analyzer", - "rev": "f25db5500baa047106d74962fe361ea59ce6f91e", + "rev": "eabb84b771420b8396ab4bb4747694302d9be277", "type": "github" }, "original": { @@ -142,10 +86,11 @@ ] }, "locked": { - "lastModified": 1764643237, + "lastModified": 1774408260, + "narHash": "sha256-Jn9d9r85dmf3gTMnSRt6t+DP2nQ5uJns/MMXg2FpzfM=", "owner": "oxalica", "repo": "rust-overlay", - "rev": "e66d6b924ac59e6c722f69332f6540ea57c69233", + "rev": "d6471ee5a8f470251e6e5b83a20a182eb6c46c9b", "type": "github" }, "original": { @@ -157,4 +102,4 @@ }, "root": "root", "version": 7 -} +} \ No newline at end of file diff --git a/devenv.nix b/devenv.nix index cbf44fe7887..b705c8e49d1 100644 --- a/devenv.nix +++ b/devenv.nix @@ -11,6 +11,8 @@ let ]; linuxPackages = with pkgs; [ + nodePackages.prisma + prisma-engines libsoup_3 webkitgtk_4_1 librsvg @@ -31,8 +33,6 @@ in { nodejs_22 nodePackages.typescript-language-server nodePackages."@volar/vue-language-server" - nodePackages.prisma - prisma-engines cargo-edit cargo-tauri ] ++ lib.optionals pkgs.stdenv.isDarwin darwinPackages @@ -169,7 +169,7 @@ in { }; go = { enable = true; - package = pkgs.go_1_24; + package = pkgs.go_1_25; }; rust = { enable = true; diff --git a/package.json b/package.json index eff22ddf253..db7859b3279 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "author": "Hoppscotch (support@hoppscotch.io)", "private": true, "license": "MIT", - "packageManager": "pnpm@10.29.3", + "packageManager": "pnpm@10.32.1", "scripts": { "preinstall": "npx only-allow pnpm", "prepare": "husky", @@ -24,30 +24,43 @@ "./packages/*" ], "devDependencies": { - "@commitlint/cli": "20.4.1", - "@commitlint/config-conventional": "20.4.1", + "@commitlint/cli": "20.5.0", + "@commitlint/config-conventional": "20.5.0", "@hoppscotch/ui": "0.2.5", "@types/node": "24.10.1", "cross-env": "10.1.0", "http-server": "14.1.1", "husky": "9.1.7", - "lint-staged": "16.2.7" + "lint-staged": "16.4.0" }, "pnpm": { "overrides": { + "@hono/node-server@>=1.0.0 <1.19.10": "1.19.10", "@nestjs-modules/mailer>mjml": "5.0.0-alpha.4", "apiconnect-wsdl": "2.0.36", "body-parser": "2.2.1", "cross-spawn": "7.0.6", + "dompurify@>=3.0.0 <3.3.3": "3.3.3", + "effect@3.18.4": "3.20.0", "execa@<2.0.0": "2.0.0", + "flatted@>=3.0.0 <3.4.2": "3.4.2", "form-data": "4.0.4", - "glob@<11.1.0": "11.1.0", - "hono@4.11.4": "4.11.7", - "lodash@4.17.21": "4.17.23", - "nodemailer@<7.0.11": "7.0.11", - "qs@6.14.1": "6.14.2", + "glob@>=10.2.0 <10.5.0": "10.5.0", + "glob@>=11.0.0 <11.1.0": "11.1.0", + "hono@>=4.0.0 <4.12.7": "4.12.7", + "liquidjs@>=10.0.0 <10.25.0": "10.25.0", + "lodash@>=4.0.0 <4.17.23": "4.17.23", + "mailparser@>=3.0.0 <3.9.3": "3.9.3", + "minimatch@>=3.0.0 <3.1.3": "3.1.5", + "minimatch@>=4.0.0 <4.2.5": "4.2.5", + "minimatch@>=5.0.0 <10.2.3": "10.2.3", + "path-to-regexp@>=8.0.0 <8.4.0": "8.4.0", + "preview-email@>=3.0.0 <3.1.1": "3.1.1", + "rollup@>=4.0.0 <4.59.0": "4.59.0", + "serialize-javascript@>=7.0.0 <7.0.3": "7.0.3", "subscriptions-transport-ws>ws": "7.5.10", - "vue": "3.5.28", + "svgo@4.0.0": "4.0.1", + "vue": "3.5.31", "ws": "8.17.1" }, "onlyBuiltDependencies": [ diff --git a/packages/codemirror-lang-graphql/package.json b/packages/codemirror-lang-graphql/package.json index d4acfd61a88..fb1c0b9a9c5 100644 --- a/packages/codemirror-lang-graphql/package.json +++ b/packages/codemirror-lang-graphql/package.json @@ -25,7 +25,7 @@ "@lezer/generator": "1.8.0", "@rollup/plugin-typescript": "12.1.4", "mocha": "11.7.5", - "rollup": "4.55.3", + "rollup": "4.59.0", "typescript": "5.9.3" } } diff --git a/packages/hoppscotch-agent/package.json b/packages/hoppscotch-agent/package.json index a7f47df9ecd..ccf60607d15 100644 --- a/packages/hoppscotch-agent/package.json +++ b/packages/hoppscotch-agent/package.json @@ -21,27 +21,27 @@ "@tauri-apps/api": "2.1.1", "@tauri-apps/plugin-shell": "2.3.3", "@vueuse/core": "14.2.1", - "axios": "1.13.5", + "axios": "1.13.6", "fp-ts": "2.16.11", "lodash-es": "4.17.23", - "vue": "3.5.28" + "vue": "3.5.31" }, "devDependencies": { - "@iconify-json/lucide": "1.2.91", + "@iconify-json/lucide": "1.2.99", "@tauri-apps/cli": "2.9.3", "@types/lodash-es": "4.17.12", "@types/node": "24.10.1", - "@typescript-eslint/eslint-plugin": "8.56.0", - "@typescript-eslint/parser": "8.56.0", - "@vitejs/plugin-vue": "6.0.4", + "@typescript-eslint/eslint-plugin": "8.57.2", + "@typescript-eslint/parser": "8.57.2", + "@vitejs/plugin-vue": "6.0.5", "@vue/eslint-config-typescript": "14.7.0", - "autoprefixer": "10.4.24", + "autoprefixer": "10.4.27", "cross-env": "10.1.0", "eslint": "9.39.2", "eslint-plugin-prettier": "5.5.5", "eslint-plugin-vue": "10.8.0", "globals": "16.5.0", - "postcss": "8.5.6", + "postcss": "8.5.8", "tailwindcss": "3.4.16", "typescript": "5.9.3", "unplugin-icons": "22.5.0", diff --git a/packages/hoppscotch-backend/package.json b/packages/hoppscotch-backend/package.json index 18484329b73..4f2d0258b25 100644 --- a/packages/hoppscotch-backend/package.json +++ b/packages/hoppscotch-backend/package.json @@ -1,6 +1,6 @@ { "name": "hoppscotch-backend", - "version": "2026.2.1", + "version": "2026.3.0", "description": "", "author": "", "private": true, @@ -31,81 +31,80 @@ "do-test": "pnpm run test" }, "dependencies": { - "@apollo/server": "5.4.0", + "@apollo/server": "5.5.0", "@as-integrations/express5": "1.1.2", "@nestjs-modules/mailer": "2.0.2", "@nestjs/apollo": "13.2.4", - "@nestjs/common": "11.1.13", + "@nestjs/common": "11.1.17", "@nestjs/config": "4.0.3", - "@nestjs/core": "11.1.13", + "@nestjs/core": "11.1.17", "@nestjs/graphql": "13.2.4", "@nestjs/jwt": "11.0.2", "@nestjs/passport": "11.0.0", - "@nestjs/platform-express": "11.1.13", + "@nestjs/platform-express": "11.1.17", "@nestjs/schedule": "6.1.1", "@nestjs/swagger": "11.2.6", - "@nestjs/terminus": "11.0.0", + "@nestjs/terminus": "11.1.1", "@nestjs/throttler": "6.5.0", - "@prisma/adapter-pg": "7.4.2", - "@prisma/client": "7.4.2", + "@prisma/adapter-pg": "7.5.0", + "@prisma/client": "7.5.0", "argon2": "0.44.0", "bcrypt": "6.0.0", "class-transformer": "0.5.1", - "class-validator": "0.14.3", + "class-validator": "0.15.1", "cookie": "1.1.1", "cookie-parser": "1.4.7", "dotenv": "17.3.1", "express": "5.2.1", "express-session": "1.19.0", "fp-ts": "2.16.11", - "graphql": "16.12.0", + "graphql": "16.13.1", "graphql-query-complexity": "1.1.0", "graphql-redis-subscriptions": "2.7.0", "graphql-subscriptions": "3.0.0", - "handlebars": "4.7.8", + "handlebars": "4.7.9", "io-ts": "2.2.22", "morgan": "1.10.1", - "nodemailer": "8.0.1", + "nodemailer": "8.0.4", "passport": "0.7.0", "passport-github2": "0.1.12", "passport-google-oauth20": "2.0.0", "passport-jwt": "4.0.1", "passport-local": "1.0.0", "passport-microsoft": "2.1.0", - "pg": "8.18.0", - "posthog-node": "5.24.15", - "prisma": "7.4.2", + "pg": "8.20.0", + "posthog-node": "5.28.4", + "prisma": "7.5.0", "reflect-metadata": "0.2.2", "rimraf": "6.1.3", "rxjs": "7.8.2" }, "devDependencies": { - "@eslint/eslintrc": "3.3.3", + "@eslint/eslintrc": "3.3.5", "@eslint/js": "10.0.1", "@nestjs/cli": "11.0.16", "@nestjs/schematics": "11.0.9", - "@nestjs/testing": "11.1.13", + "@nestjs/testing": "11.1.17", "@relmify/jest-fp-ts": "2.1.1", "@types/bcrypt": "6.0.0", "@types/cookie-parser": "1.4.10", "@types/express": "5.0.6", "@types/jest": "30.0.0", - "@types/node": "25.2.3", - "@types/nodemailer": "7.0.10", + "@types/node": "25.5.0", + "@types/nodemailer": "7.0.11", "@types/passport-github2": "1.2.9", "@types/passport-google-oauth20": "2.0.17", "@types/passport-jwt": "4.0.1", "@types/passport-microsoft": "2.1.1", - "@types/pg": "8.16.0", - "@types/supertest": "6.0.3", - "@typescript-eslint/eslint-plugin": "8.56.0", - "@typescript-eslint/parser": "8.56.0", + "@types/supertest": "7.2.0", + "@typescript-eslint/eslint-plugin": "8.57.2", + "@typescript-eslint/parser": "8.57.2", "cross-env": "10.1.0", - "eslint": "10.0.0", + "eslint": "10.0.3", "eslint-config-prettier": "10.1.8", "eslint-plugin-prettier": "5.5.5", - "globals": "17.3.0", - "jest": "30.2.0", + "globals": "17.4.0", + "jest": "30.3.0", "jest-mock-extended": "4.0.0", "prettier": "3.8.1", "source-map-support": "0.5.21", diff --git a/packages/hoppscotch-backend/src/auth/auth.controller.ts b/packages/hoppscotch-backend/src/auth/auth.controller.ts index be0a0a314fc..94fbe55385d 100644 --- a/packages/hoppscotch-backend/src/auth/auth.controller.ts +++ b/packages/hoppscotch-backend/src/auth/auth.controller.ts @@ -20,6 +20,7 @@ import { GqlUser } from 'src/decorators/gql-user.decorator'; import { AuthUser } from 'src/types/AuthUser'; import { RTCookie } from 'src/decorators/rt-cookie.decorator'; import { AuthProvider, authCookieHandler, authProviderCheck } from './helper'; +import { isValidLocalhostRedirectUri } from './redirect-uri.validator'; import { GoogleSSOGuard } from './guards/google-sso.guard'; import { GithubSSOGuard } from './guards/github-sso.guard'; import { MicrosoftSSOGuard } from './guards/microsoft-sso.guard'; @@ -204,7 +205,7 @@ export class AuthController { @GqlUser() user: AuthUser, @Query('redirect_uri') redirectUri: string, ) { - if (!redirectUri || !redirectUri.startsWith('http://localhost')) { + if (!isValidLocalhostRedirectUri(redirectUri)) { throwHTTPErr({ message: 'Invalid desktop callback URL', statusCode: 400, diff --git a/packages/hoppscotch-backend/src/auth/redirect-uri.validator.spec.ts b/packages/hoppscotch-backend/src/auth/redirect-uri.validator.spec.ts new file mode 100644 index 00000000000..cb060045c56 --- /dev/null +++ b/packages/hoppscotch-backend/src/auth/redirect-uri.validator.spec.ts @@ -0,0 +1,119 @@ +import { isValidLocalhostRedirectUri } from './redirect-uri.validator'; + +describe('isValidLocalhostRedirectUri', () => { + describe('valid loopback URIs', () => { + test('Should return true for http://localhost with a port', () => { + expect( + isValidLocalhostRedirectUri('http://localhost:3000/device-token'), + ).toBe(true); + }); + + test('Should return true for http://localhost without a port', () => { + expect(isValidLocalhostRedirectUri('http://localhost/callback')).toBe( + true, + ); + }); + + test('Should return true for http://127.0.0.1 with a port', () => { + expect( + isValidLocalhostRedirectUri('http://127.0.0.1:8080/callback'), + ).toBe(true); + }); + + test('Should return true for http://[::1] IPv6 loopback', () => { + expect(isValidLocalhostRedirectUri('http://[::1]:9090/callback')).toBe( + true, + ); + }); + }); + + describe('DNS wildcard bypass vectors', () => { + test('Should reject localhost subdomain via sslip.io wildcard DNS', () => { + expect( + isValidLocalhostRedirectUri( + 'http://localhost.1.2.3.4.sslip.io:3000/steal', + ), + ).toBe(false); + }); + + test('Should reject localhost subdomain via nip.io wildcard DNS', () => { + expect( + isValidLocalhostRedirectUri('http://localhost.10.0.0.1.nip.io/steal'), + ).toBe(false); + }); + + test('Should reject localhost as a subdomain of attacker domain', () => { + expect( + isValidLocalhostRedirectUri('http://localhost.evil.com/steal'), + ).toBe(false); + }); + + test('Should reject localhost with trailing dot FQDN trick', () => { + expect(isValidLocalhostRedirectUri('http://localhost./callback')).toBe( + false, + ); + }); + + test('Should reject domain that starts with localhost string', () => { + expect( + isValidLocalhostRedirectUri('http://localhostevil.com/callback'), + ).toBe(false); + }); + }); + + describe('protocol enforcement', () => { + test('Should reject https since loopback listeners do not serve TLS', () => { + expect( + isValidLocalhostRedirectUri('https://localhost:3000/callback'), + ).toBe(false); + }); + + test('Should reject ftp protocol', () => { + expect(isValidLocalhostRedirectUri('ftp://localhost/callback')).toBe( + false, + ); + }); + }); + + describe('credential and remote host rejection', () => { + test('Should reject URL with embedded credentials', () => { + expect( + isValidLocalhostRedirectUri('http://user:pass@localhost:3000/callback'), + ).toBe(false); + }); + + test('Should reject an arbitrary remote host', () => { + expect( + isValidLocalhostRedirectUri('http://attacker.com:3000/callback'), + ).toBe(false); + }); + + test('Should reject 0.0.0.0 since it is not a loopback address', () => { + expect(isValidLocalhostRedirectUri('http://0.0.0.0:3000/callback')).toBe( + false, + ); + }); + }); + + describe('malformed and empty input', () => { + test('Should return false for empty string', () => { + expect(isValidLocalhostRedirectUri('')).toBe(false); + }); + + test('Should return false for undefined', () => { + expect(isValidLocalhostRedirectUri(undefined)).toBe(false); + }); + + test('Should return false for null', () => { + expect(isValidLocalhostRedirectUri(null)).toBe(false); + }); + + test('Should return false for a plain string that is not a URL', () => { + expect(isValidLocalhostRedirectUri('not-a-url')).toBe(false); + }); + + test('Should return false for a relative path', () => { + expect(isValidLocalhostRedirectUri('/device-token')).toBe(false); + }); + }); +}); diff --git a/packages/hoppscotch-backend/src/auth/redirect-uri.validator.ts b/packages/hoppscotch-backend/src/auth/redirect-uri.validator.ts new file mode 100644 index 00000000000..e82a17409ed --- /dev/null +++ b/packages/hoppscotch-backend/src/auth/redirect-uri.validator.ts @@ -0,0 +1,24 @@ +// Only true loopback addresses are safe for native app redirects +const LOOPBACK_HOSTS = ['localhost', '127.0.0.1', '[::1]']; + +export function isValidLocalhostRedirectUri( + uri: string | undefined | null, +): boolean { + if (!uri) return false; + + let url: URL; + try { + url = new URL(uri); + } catch { + return false; + } + + // no https — desktop loopback listeners don't serve TLS + if (url.protocol !== 'http:') return false; + + // block credential-stuffed URIs like http://user:pass@localhost + if (url.username || url.password) return false; + + // exact match only + return LOOPBACK_HOSTS.indexOf(url.hostname) !== -1; +} diff --git a/packages/hoppscotch-backend/src/gql-schema.ts b/packages/hoppscotch-backend/src/gql-schema.ts index 708f76917f8..1e278e35b58 100644 --- a/packages/hoppscotch-backend/src/gql-schema.ts +++ b/packages/hoppscotch-backend/src/gql-schema.ts @@ -45,7 +45,6 @@ const RESOLVERS = [ AdminResolver, ShortcodeResolver, TeamResolver, - TeamEnvsTeamResolver, TeamMemberResolver, TeamCollectionResolver, TeamTeamInviteExtResolver, @@ -54,7 +53,6 @@ const RESOLVERS = [ TeamInvitationResolver, TeamRequestResolver, UserResolver, - UserCollectionResolver, UserEnvironmentsResolver, UserEnvsUserResolver, UserHistoryUserResolver, diff --git a/packages/hoppscotch-backend/src/infra-config/dto/onboarding.dto.ts b/packages/hoppscotch-backend/src/infra-config/dto/onboarding.dto.ts index f5613cfcc1f..71c35cfa22e 100644 --- a/packages/hoppscotch-backend/src/infra-config/dto/onboarding.dto.ts +++ b/packages/hoppscotch-backend/src/infra-config/dto/onboarding.dto.ts @@ -110,6 +110,10 @@ export class SaveOnboardingConfigRequest { @IsOptional() @IsString() [InfraConfigEnum.MAILER_TLS_REJECT_UNAUTHORIZED]: string; + @ApiPropertyOptional() + @IsOptional() + @IsString() + [InfraConfigEnum.MAILER_SMTP_IGNORE_TLS]: string; } export class SaveOnboardingConfigResponse { @@ -197,4 +201,7 @@ export class GetOnboardingConfigResponse { @ApiProperty() @Expose() [InfraConfigEnum.MAILER_TLS_REJECT_UNAUTHORIZED]: string; + @ApiProperty() + @Expose() + [InfraConfigEnum.MAILER_SMTP_IGNORE_TLS]: string; } diff --git a/packages/hoppscotch-backend/src/infra-config/helper.ts b/packages/hoppscotch-backend/src/infra-config/helper.ts index 084a78fd79c..ee583316052 100644 --- a/packages/hoppscotch-backend/src/infra-config/helper.ts +++ b/packages/hoppscotch-backend/src/infra-config/helper.ts @@ -71,8 +71,6 @@ export function getAuthProviderRequiredKeys( InfraConfigEnum.MAILER_SMTP_HOST, InfraConfigEnum.MAILER_SMTP_PORT, InfraConfigEnum.MAILER_SMTP_SECURE, - InfraConfigEnum.MAILER_SMTP_USER, - InfraConfigEnum.MAILER_SMTP_PASSWORD, InfraConfigEnum.MAILER_TLS_REJECT_UNAUTHORIZED, InfraConfigEnum.MAILER_ADDRESS_FROM, ] @@ -240,6 +238,11 @@ export async function getDefaultInfraConfigs(): Promise { value: null, isEncrypted: false, }, + { + name: InfraConfigEnum.MAILER_SMTP_IGNORE_TLS, + value: 'false', + isEncrypted: false, + }, { name: InfraConfigEnum.GOOGLE_CLIENT_ID, value: null, diff --git a/packages/hoppscotch-backend/src/infra-config/infra-config.service.ts b/packages/hoppscotch-backend/src/infra-config/infra-config.service.ts index 3a1b908ee76..ce43cd6841d 100644 --- a/packages/hoppscotch-backend/src/infra-config/infra-config.service.ts +++ b/packages/hoppscotch-backend/src/infra-config/infra-config.service.ts @@ -240,6 +240,10 @@ export class InfraConfigService implements OnModuleInit, OnModuleDestroy { const isValidate = this.validateEnvValues(infraConfigs); if (E.isLeft(isValidate)) return E.left(isValidate.left); + // Validate SMTP credentials pair against effective post-update state + const smtpPairCheck = await this.validateSmtpCredentialPair(infraConfigs); + if (E.isLeft(smtpPairCheck)) return E.left(smtpPairCheck.left); + try { const dbInfraConfig = await this.prisma.infraConfig.findMany({ select: { name: true, isEncrypted: true }, @@ -310,8 +314,6 @@ export class InfraConfigService implements OnModuleInit, OnModuleDestroy { configMap.MAILER_SMTP_HOST && configMap.MAILER_SMTP_PORT && configMap.MAILER_SMTP_SECURE && - configMap.MAILER_SMTP_USER && - configMap.MAILER_SMTP_PASSWORD && configMap.MAILER_TLS_REJECT_UNAUTHORIZED && configMap.MAILER_ADDRESS_FROM ); @@ -656,6 +658,58 @@ export class InfraConfigService implements OnModuleInit, OnModuleDestroy { } } + /** + * Validate that SMTP user and password are both provided or both empty, + * checking the effective post-update state (incoming merged with DB). + */ + private async validateSmtpCredentialPair( + infraConfigs: { name: InfraConfigEnum; value: string }[], + ) { + const incoming = new Map(infraConfigs.map((c) => [c.name, c.value])); + const smtpKeys = [ + InfraConfigEnum.MAILER_SMTP_USER, + InfraConfigEnum.MAILER_SMTP_PASSWORD, + ]; + + if (!smtpKeys.some((key) => incoming.has(key))) { + return E.right(true); + } + + const missingKeys = smtpKeys.filter((key) => !incoming.has(key)); + + const dbRows = + missingKeys.length === 0 + ? [] + : await this.prisma.infraConfig.findMany({ + where: { name: { in: missingKeys } }, + select: { name: true, value: true, isEncrypted: true }, + }); + + const dbValues = new Map( + dbRows.map((row) => [ + row.name, + row.value ? (row.isEncrypted ? decrypt(row.value) : row.value) : '', + ]), + ); + + const smtpUser = + incoming.get(InfraConfigEnum.MAILER_SMTP_USER) ?? + dbValues.get(InfraConfigEnum.MAILER_SMTP_USER) ?? + ''; + + const smtpPass = + incoming.get(InfraConfigEnum.MAILER_SMTP_PASSWORD) ?? + dbValues.get(InfraConfigEnum.MAILER_SMTP_PASSWORD) ?? + ''; + + const hasUser = smtpUser.trim() !== ''; + const hasPass = smtpPass.trim() !== ''; + + return hasUser !== hasPass + ? E.left(INFRA_CONFIG_INVALID_INPUT) + : E.right(true); + } + /** * Validate the values of the InfraConfigs */ @@ -678,6 +732,7 @@ export class InfraConfigService implements OnModuleInit, OnModuleDestroy { case InfraConfigEnum.MAILER_USE_CUSTOM_CONFIGS: case InfraConfigEnum.MAILER_SMTP_SECURE: case InfraConfigEnum.MAILER_TLS_REJECT_UNAUTHORIZED: + case InfraConfigEnum.MAILER_SMTP_IGNORE_TLS: if (value !== 'true' && value !== 'false') return fail(); break; @@ -702,8 +757,6 @@ export class InfraConfigService implements OnModuleInit, OnModuleDestroy { case InfraConfigEnum.MAILER_SMTP_HOST: case InfraConfigEnum.MAILER_SMTP_PORT: - case InfraConfigEnum.MAILER_SMTP_USER: - case InfraConfigEnum.MAILER_SMTP_PASSWORD: case InfraConfigEnum.GOOGLE_CLIENT_ID: case InfraConfigEnum.GOOGLE_CLIENT_SECRET: case InfraConfigEnum.GOOGLE_SCOPE: diff --git a/packages/hoppscotch-backend/src/mailer/helper.ts b/packages/hoppscotch-backend/src/mailer/helper.ts index 0fc0eedaa4a..d38dc71b237 100644 --- a/packages/hoppscotch-backend/src/mailer/helper.ts +++ b/packages/hoppscotch-backend/src/mailer/helper.ts @@ -1,9 +1,5 @@ import { TransportType } from '@nestjs-modules/mailer/dist/interfaces/mailer-options.interface'; -import { - MAILER_SMTP_PASSWORD_UNDEFINED, - MAILER_SMTP_URL_UNDEFINED, - MAILER_SMTP_USER_UNDEFINED, -} from 'src/errors'; +import { MAILER_SMTP_URL_UNDEFINED } from 'src/errors'; import { throwErr } from 'src/utils'; function isSMTPCustomConfigsEnabled(value) { @@ -24,17 +20,28 @@ export function getTransportOption(env): TransportType { return env.INFRA.MAILER_SMTP_URL ?? throwErr(MAILER_SMTP_URL_UNDEFINED); } else { console.log('Using advanced mailer configuration'); + + const smtpUser = env.INFRA.MAILER_SMTP_USER?.trim() || undefined; + const smtpPass = env.INFRA.MAILER_SMTP_PASSWORD?.trim() || undefined; + + // Both credentials must be provided together or both omitted + const hasUser = !!smtpUser; + const hasPass = !!smtpPass; + if (hasUser !== hasPass) { + throw new Error( + 'SMTP auth requires both MAILER_SMTP_USER and MAILER_SMTP_PASSWORD. Provide both or leave both empty for unauthenticated relay.', + ); + } + + const auth = + smtpUser && smtpPass ? { user: smtpUser, pass: smtpPass } : undefined; + return { host: env.INFRA.MAILER_SMTP_HOST, port: +env.INFRA.MAILER_SMTP_PORT, secure: env.INFRA.MAILER_SMTP_SECURE === 'true', - auth: { - user: - env.INFRA.MAILER_SMTP_USER ?? throwErr(MAILER_SMTP_USER_UNDEFINED), - pass: - env.INFRA.MAILER_SMTP_PASSWORD ?? - throwErr(MAILER_SMTP_PASSWORD_UNDEFINED), - }, + ...(auth && { auth }), + ignoreTLS: env.INFRA.MAILER_SMTP_IGNORE_TLS === 'true', tls: { rejectUnauthorized: env.INFRA.MAILER_TLS_REJECT_UNAUTHORIZED === 'true', }, diff --git a/packages/hoppscotch-backend/src/mock-server/mock-request.guard.ts b/packages/hoppscotch-backend/src/mock-server/mock-request.guard.ts index d69f0717ee1..525dc4e940e 100644 --- a/packages/hoppscotch-backend/src/mock-server/mock-request.guard.ts +++ b/packages/hoppscotch-backend/src/mock-server/mock-request.guard.ts @@ -103,6 +103,7 @@ export class MockRequestGuard implements CanActivate { // but the mock server ID comes from the subdomain, not the path const subdomainId = this.extractFromSubdomain(host); if (subdomainId) { + (request as any).isSubdomainAccess = true; return subdomainId; } @@ -111,6 +112,7 @@ export class MockRequestGuard implements CanActivate { // Route pattern: /mock/mock-server-id/... const routeId = this.extractFromRoute(path); if (routeId) { + (request as any).isSubdomainAccess = false; return routeId; } diff --git a/packages/hoppscotch-backend/src/mock-server/mock-server.controller.ts b/packages/hoppscotch-backend/src/mock-server/mock-server.controller.ts index be47f364c84..8872bba984b 100644 --- a/packages/hoppscotch-backend/src/mock-server/mock-server.controller.ts +++ b/packages/hoppscotch-backend/src/mock-server/mock-server.controller.ts @@ -15,6 +15,27 @@ import { MockRequestGuard } from './mock-request.guard'; import { MockServer } from 'src/generated/prisma/client'; import { ThrottlerBehindProxyGuard } from 'src/guards/throttler-behind-proxy.guard'; +/** Security-sensitive headers that mock responses must not override. */ +const SECURITY_HEADER_BLOCKLIST = new Set([ + 'content-security-policy', + 'x-content-type-options', + 'x-frame-options', + 'content-disposition', + 'set-cookie', +]); + +/** MIME types that can execute scripts and must be downgraded on same-origin (subpath) responses. */ +const ACTIVE_CONTENT_TYPES = new Set([ + 'application/javascript', + 'application/xhtml+xml', + 'application/xml', + 'image/svg+xml', + 'text/html', + 'text/javascript', + 'text/xml', + 'text/xsl', +]); + /** * Mock server controller with dual routing support: * 1. Subdomain pattern: mock-server-id.mock.hopp.io/product @@ -35,6 +56,7 @@ export class MockServerController { // Mock server ID and info are attached by the guard const mockServerId = (req as any).mockServerId as string; const mockServer = (req as any).mockServer as MockServer; + const isSubdomainAccess = (req as any).isSubdomainAccess === true; if (!mockServerId) { return res.status(HttpStatus.NOT_FOUND).json({ @@ -82,14 +104,29 @@ export class MockServerController { const mockResponse = result.right; - // Set response headers if any + // Set response headers if any, but exclude security-sensitive headers + // that could be abused for XSS if (mockResponse.headers) { try { const headers = JSON.parse(mockResponse.headers); - Object.keys(headers).forEach((key) => { - console.log('Setting header:', key, headers[key]); - res.setHeader(key, headers[key]); - }); + if ( + headers !== null && + typeof headers === 'object' && + !Array.isArray(headers) + ) { + Object.keys(headers).forEach((key) => { + if (!SECURITY_HEADER_BLOCKLIST.has(key.toLowerCase())) { + const rawValue = headers[key]; + // Only allow string and number values to prevent type bypass attacks + if ( + typeof rawValue === 'string' || + typeof rawValue === 'number' + ) { + res.setHeader(key, String(rawValue)); + } + } + }); + } } catch (error) { console.error('Error parsing response headers:', error); } @@ -102,6 +139,20 @@ export class MockServerController { ); } + // Prevent XSS on path-based (same-origin) requests by downgrading + // script-capable content types to text/plain. + // Subdomain-based requests are on a different origin, so HTML is safe. + if (!isSubdomainAccess) { + const rawContentType = res.getHeader('Content-Type'); + if (typeof rawContentType === 'string') { + // Normalize: trim, strip parameters (e.g. charset), lowercase + const mimeType = rawContentType.split(';')[0].trim().toLowerCase(); + if (ACTIVE_CONTENT_TYPES.has(mimeType) || mimeType.endsWith('+xml')) { + res.setHeader('Content-Type', 'text/plain'); + } + } + } + // Only set Content-Type if not already set if (!res.getHeader('Content-Type')) { let defaultContentType = 'text/plain'; @@ -123,8 +174,12 @@ export class MockServerController { res.setHeader('Content-Type', defaultContentType); } - // Security headers + // Security headers to prevent XSS via mock responses res.setHeader('X-Content-Type-Options', 'nosniff'); + if (!isSubdomainAccess) { + res.setHeader('Content-Security-Policy', "default-src 'none'; sandbox"); + res.setHeader('X-Frame-Options', 'DENY'); + } // Send response return res.status(mockResponse.statusCode).send(mockResponse.body); diff --git a/packages/hoppscotch-backend/src/team-request/team-request.service.spec.ts b/packages/hoppscotch-backend/src/team-request/team-request.service.spec.ts index c12782e13e9..04a22fa88af 100644 --- a/packages/hoppscotch-backend/src/team-request/team-request.service.spec.ts +++ b/packages/hoppscotch-backend/src/team-request/team-request.service.spec.ts @@ -481,7 +481,7 @@ describe('findRequestAndNextRequest', () => { nextRequest: dbTeamRequests[4], }); }); - test('Should resolve right if the request and next request null', () => { + test('Should resolve right if the request and next request null', async () => { const args: MoveTeamRequestArgs = { srcCollID: teamRequests[0].collectionID, destCollID: teamRequests[4].collectionID, @@ -489,22 +489,65 @@ describe('findRequestAndNextRequest', () => { nextRequestID: null, }; - mockPrisma.teamRequest.findFirst - .mockResolvedValueOnce(dbTeamRequests[0]) - .mockResolvedValueOnce(null); + mockPrisma.teamRequest.findFirst.mockResolvedValueOnce(dbTeamRequests[0]); + mockPrisma.teamCollection.findUnique.mockResolvedValueOnce(teamCollection); - const result = (teamRequestService as any).findRequestAndNextRequest( + const result = await (teamRequestService as any).findRequestAndNextRequest( args.srcCollID, args.requestID, args.destCollID, args.nextRequestID, ); - expect(result).resolves.toEqualRight({ + expect(result).toEqualRight({ request: dbTeamRequests[0], nextRequest: null, }); }); + test('Should resolve left if the destination collection does not exist when nextRequestID is null', async () => { + const args: MoveTeamRequestArgs = { + srcCollID: teamRequests[0].collectionID, + destCollID: 'non-existent-coll', + requestID: teamRequests[0].id, + nextRequestID: null, + }; + + mockPrisma.teamRequest.findFirst.mockResolvedValueOnce(dbTeamRequests[0]); + mockPrisma.teamCollection.findUnique.mockResolvedValueOnce(null); + + const result = await (teamRequestService as any).findRequestAndNextRequest( + args.srcCollID, + args.requestID, + args.destCollID, + args.nextRequestID, + ); + + expect(result).toEqualLeft(TEAM_INVALID_COLL_ID); + }); + test('Should resolve left if the destination collection belongs to a different team when nextRequestID is null', async () => { + const args: MoveTeamRequestArgs = { + srcCollID: teamRequests[0].collectionID, + destCollID: 'cross-team-coll', + requestID: teamRequests[0].id, + nextRequestID: null, + }; + + mockPrisma.teamRequest.findFirst.mockResolvedValueOnce(dbTeamRequests[0]); + mockPrisma.teamCollection.findUnique.mockResolvedValueOnce({ + ...teamCollection, + id: 'cross-team-coll', + teamID: 'different-team-id', + }); + + const result = await (teamRequestService as any).findRequestAndNextRequest( + args.srcCollID, + args.requestID, + args.destCollID, + args.nextRequestID, + ); + + expect(result).toEqualLeft(TEAM_REQ_INVALID_TARGET_COLL_ID); + }); test('Should resolve left if the request is not found', () => { const args: MoveTeamRequestArgs = { srcCollID: teamRequests[0].collectionID, diff --git a/packages/hoppscotch-backend/src/team-request/team-request.service.ts b/packages/hoppscotch-backend/src/team-request/team-request.service.ts index a0ff29ea51a..19275881934 100644 --- a/packages/hoppscotch-backend/src/team-request/team-request.service.ts +++ b/packages/hoppscotch-backend/src/team-request/team-request.service.ts @@ -380,6 +380,18 @@ export class TeamRequestService { ) { return E.left(TEAM_REQ_INVALID_TARGET_COLL_ID); } + } else { + // When nextRequestID is null, validate that the destination collection + // belongs to the same team as the request to prevent cross-team moves + const destCollection = await this.prisma.teamCollection.findUnique({ + where: { id: destCollID }, + select: { teamID: true }, + }); + if (!destCollection) return E.left(TEAM_INVALID_COLL_ID); + + if (destCollection.teamID !== request.teamID) { + return E.left(TEAM_REQ_INVALID_TARGET_COLL_ID); + } } return E.right({ request, nextRequest }); diff --git a/packages/hoppscotch-backend/src/team/team.resolver.ts b/packages/hoppscotch-backend/src/team/team.resolver.ts index 4760ae0c26b..91c6bf109f7 100644 --- a/packages/hoppscotch-backend/src/team/team.resolver.ts +++ b/packages/hoppscotch-backend/src/team/team.resolver.ts @@ -18,10 +18,14 @@ import { RequiresTeamRole } from './decorators/requires-team-role.decorator'; import { GqlTeamMemberGuard } from './guards/gql-team-member.guard'; import { PubSubService } from '../pubsub/pubsub.service'; import * as E from 'fp-ts/Either'; +import * as O from 'fp-ts/Option'; import { throwErr } from 'src/utils'; import { AuthUser } from 'src/types/AuthUser'; import { GqlThrottlerGuard } from 'src/guards/gql-throttler.guard'; import { SkipThrottle } from '@nestjs/throttler'; +import { GqlAdminGuard } from 'src/admin/guards/gql-admin.guard'; +import { UserService } from 'src/user/user.service'; +import { USER_NOT_FOUND } from 'src/errors'; @UseGuards(GqlThrottlerGuard) @Resolver(() => Team) @@ -29,6 +33,7 @@ export class TeamResolver { constructor( private readonly teamService: TeamService, private readonly pubsub: PubSubService, + private readonly userService: UserService, ) {} // Field Resolvers @@ -141,6 +146,39 @@ export class TeamResolver { return this.teamService.getTeamWithID(teamID); } + @Query(() => [Team], { + description: 'Returns the list of teams a user is a member of (admin-only)', + }) + @UseGuards(GqlAuthGuard, GqlAdminGuard) + async teamsOfUserByAdmin( + @Args({ + name: 'userUid', + type: () => ID, + description: 'UID of the user to fetch teams for', + }) + userUid: string, + @Args({ + name: 'cursor', + type: () => ID, + description: + 'The ID of the last returned team entry (used for pagination)', + nullable: true, + }) + cursor?: string, + @Args({ + name: 'take', + type: () => Int, + description: 'Number of teams to return per page', + nullable: true, + defaultValue: 10, + }) + take?: number, + ): Promise { + const user = await this.userService.findUserById(userUid); + if (O.isNone(user)) throwErr(USER_NOT_FOUND); + return this.teamService.getTeamsOfUser(userUid, cursor ?? null, take); + } + // Mutation @Mutation(() => Team, { description: 'Creates a team owned by the executing user', diff --git a/packages/hoppscotch-backend/src/team/team.service.spec.ts b/packages/hoppscotch-backend/src/team/team.service.spec.ts index 0ab92145bda..c70e2f3f8ba 100644 --- a/packages/hoppscotch-backend/src/team/team.service.spec.ts +++ b/packages/hoppscotch-backend/src/team/team.service.spec.ts @@ -800,17 +800,20 @@ describe('getMembersOfTeam', () => { describe('getTeamsOfUser', () => { test('resolves with the first 10 elements when no cursor is given', async () => { - mockPrisma.teamMember.findMany.mockResolvedValue([]); + mockPrisma.team.findMany.mockResolvedValue([]); await teamService.getTeamsOfUser(dbTeamMember.userUid, null); - expect(mockPrisma.teamMember.findMany).toHaveBeenCalledWith({ + expect(mockPrisma.team.findMany).toHaveBeenCalledWith({ take: 10, + skip: 0, + cursor: undefined, where: { - userUid: dbTeamMember.userUid, - }, - include: { - team: true, + members: { + some: { + userUid: dbTeamMember.userUid, + }, + }, }, }); }); @@ -818,30 +821,28 @@ describe('getTeamsOfUser', () => { test('resolves as expected for paginated requests with cursor', async () => { const cursor = 'secondpage'; - mockPrisma.teamMember.findMany.mockResolvedValue([]); + mockPrisma.team.findMany.mockResolvedValue([]); await teamService.getTeamsOfUser(dbTeamMember.userUid, cursor); - expect(mockPrisma.teamMember.findMany).toHaveBeenCalledWith({ + expect(mockPrisma.team.findMany).toHaveBeenCalledWith({ take: 10, skip: 1, cursor: { - teamID_userUid: { - teamID: cursor, - userUid: dbTeamMember.userUid, - }, + id: cursor, }, where: { - userUid: dbTeamMember.userUid, - }, - include: { - team: true, + members: { + some: { + userUid: dbTeamMember.userUid, + }, + }, }, }); }); test('resolves with an empty array for an invalid cursor', () => { // Invalid cursors return an empty array - mockPrisma.teamMember.findMany.mockResolvedValue([]); + mockPrisma.team.findMany.mockResolvedValue([]); return expect( teamService.getTeamsOfUser(dbTeamMember.userUid, 'invalidcursor'), @@ -849,7 +850,7 @@ describe('getTeamsOfUser', () => { }); test('resolves with an empty array for invalid user id and null cursor', () => { - mockPrisma.teamMember.findMany.mockResolvedValue([]); + mockPrisma.team.findMany.mockResolvedValue([]); return expect( teamService.getTeamsOfUser('invalidid', null), @@ -857,7 +858,7 @@ describe('getTeamsOfUser', () => { }); test('resolves with an empty array for invalid user id and invalid cursor', () => { - mockPrisma.teamMember.findMany.mockResolvedValue([]); + mockPrisma.team.findMany.mockResolvedValue([]); return expect( teamService.getTeamsOfUser('invalidId', 'invalidCursor'), diff --git a/packages/hoppscotch-backend/src/team/team.service.ts b/packages/hoppscotch-backend/src/team/team.service.ts index 225c5fe5ce9..cb5bc31c218 100644 --- a/packages/hoppscotch-backend/src/team/team.service.ts +++ b/packages/hoppscotch-backend/src/team/team.service.ts @@ -261,38 +261,25 @@ export class TeamService implements UserDataHandler, OnModuleInit { return E.right(team); } - async getTeamsOfUser(uid: string, cursor: string | null): Promise { - if (!cursor) { - const entries = await this.prisma.teamMember.findMany({ - take: 10, - where: { - userUid: uid, - }, - include: { - team: true, - }, - }); - - return entries.map((entry) => entry.team); - } else { - const entries = await this.prisma.teamMember.findMany({ - take: 10, - skip: 1, - cursor: { - teamID_userUid: { - teamID: cursor, + async getTeamsOfUser( + uid: string, + cursor: string | null, + take = 10, + ): Promise { + const teams = await this.prisma.team.findMany({ + take, + skip: cursor ? 1 : 0, + cursor: cursor ? { id: cursor } : undefined, + where: { + members: { + some: { userUid: uid, }, }, - where: { - userUid: uid, - }, - include: { - team: true, - }, - }); - return entries.map((entry) => entry.team); - } + }, + }); + + return teams; } async getTeamWithID(teamID: string): Promise { diff --git a/packages/hoppscotch-backend/src/types/InfraConfig.ts b/packages/hoppscotch-backend/src/types/InfraConfig.ts index 0a86e088b7c..5877ffba59f 100644 --- a/packages/hoppscotch-backend/src/types/InfraConfig.ts +++ b/packages/hoppscotch-backend/src/types/InfraConfig.ts @@ -25,6 +25,7 @@ export enum InfraConfigEnum { MAILER_SMTP_USER = 'MAILER_SMTP_USER', MAILER_SMTP_PASSWORD = 'MAILER_SMTP_PASSWORD', MAILER_TLS_REJECT_UNAUTHORIZED = 'MAILER_TLS_REJECT_UNAUTHORIZED', + MAILER_SMTP_IGNORE_TLS = 'MAILER_SMTP_IGNORE_TLS', GOOGLE_CLIENT_ID = 'GOOGLE_CLIENT_ID', GOOGLE_CLIENT_SECRET = 'GOOGLE_CLIENT_SECRET', diff --git a/packages/hoppscotch-cli/package.json b/packages/hoppscotch-cli/package.json index a8bf5d2af20..cf749c1e205 100644 --- a/packages/hoppscotch-cli/package.json +++ b/packages/hoppscotch-cli/package.json @@ -42,17 +42,17 @@ "private": false, "dependencies": { "aws4fetch": "1.0.20", - "axios": "1.13.5", + "axios": "1.13.6", "axios-cookiejar-support": "6.0.5", "chalk": "5.6.2", "commander": "14.0.3", - "isolated-vm": "6.0.2", + "isolated-vm": "6.1.2", "js-md5": "0.8.3", "jsonc-parser": "3.3.1", "lodash-es": "4.17.23", "papaparse": "5.5.3", "qs": "6.15.0", - "tough-cookie": "6.0.0", + "tough-cookie": "6.0.1", "verzod": "0.4.0", "xmlbuilder2": "4.0.3", "zod": "3.25.32" @@ -63,13 +63,13 @@ "@relmify/jest-fp-ts": "2.1.1", "@types/lodash-es": "4.17.12", "@types/papaparse": "5.5.2", - "@types/qs": "6.14.0", + "@types/qs": "6.15.0", "fp-ts": "2.16.11", "prettier": "3.8.1", "qs": "6.11.2", "semver": "7.7.4", "tsup": "8.5.1", "typescript": "5.9.3", - "vitest": "4.0.18" + "vitest": "4.1.2" } } diff --git a/packages/hoppscotch-common/assets/themes/tippy-themes.scss b/packages/hoppscotch-common/assets/themes/tippy-themes.scss index 30adb24175e..7aa669bf3fe 100644 --- a/packages/hoppscotch-common/assets/themes/tippy-themes.scss +++ b/packages/hoppscotch-common/assets/themes/tippy-themes.scss @@ -64,6 +64,15 @@ } } + // Override truncation for multiline member-list tooltips + .tippy-box[data-theme~="member-list"] { + .tippy-content { + @apply whitespace-pre-line; + @apply block; + @apply overflow-auto; + } + } + .tippy-box[data-theme~="popover"] { @apply bg-popover; @apply border-solid; @@ -164,7 +173,7 @@ .tippy-box[data-theme~="tooltip"] { @apply bg-primaryDark; - @apply border-solid; + @apply border-solid border-divider; @apply shadow-lg; .tippy-content { @@ -178,7 +187,7 @@ .tippy-svg-arrow { svg:first-child { - @apply fill-primaryDark; + @apply fill-divider; } svg:last-child { @@ -211,7 +220,7 @@ .tippy-box[data-theme~="tooltip"] { @apply bg-primaryLight; - @apply border-solid border-[#33363d]; + @apply border-solid border-divider; @apply shadow-lg; .tippy-content { @@ -225,7 +234,7 @@ .tippy-svg-arrow { svg:first-child { - @apply fill-primaryLight; + @apply fill-divider; } svg:last-child { diff --git a/packages/hoppscotch-common/locales/cn.json b/packages/hoppscotch-common/locales/cn.json index 0cea8453de0..b530e7d9a2c 100644 --- a/packages/hoppscotch-common/locales/cn.json +++ b/packages/hoppscotch-common/locales/cn.json @@ -54,6 +54,8 @@ "retry": "重试", "save": "保存", "save_as_example": "保存示例", + "add_example": "添加示例", + "invalid_request": "无效的请求数据", "scroll_to_bottom": "滚动至底部", "scroll_to_top": "滚动至顶部", "search": "搜索", @@ -83,7 +85,7 @@ "WORKSPACE_RENAME": "将工作区从 {old_name} 重命名为 {new_name}", "WORKSPACE_USER_ADD": "{user} 被添加到工作区的 {role} 角色", "WORKSPACE_USER_INVITE": "{inviteeEmail} 被 {user} 邀请为 {role} 角色", - "WORKSPACE_USER_INVITE_REVOKE": "已撤销对 {inviteeEmail} 成为 {inviteeRole} 角色的邀请", + "WORKSPACE_USER_INVITE_REVOKE": "已撤消对 {inviteeEmail} 成为 {inviteeRole} 角色的邀请", "WORKSPACE_USER_INVITE_ACCEPT": "{inviteeEmail} 已接受成为 {inviteeRole} 角色的邀请", "WORKSPACE_USER_REMOVE": "{user} 被移出工作区", "WORKSPACE_USER_ROLE_UPDATE": "{user} 的角色已从 {old_role} 更新为 {new_role}", @@ -144,14 +146,15 @@ "help": "帮助与反馈", "home": "主页", "invite": "邀请", - "invite_description": "Hoppscotch 是一个开源的 API 开发生态系统。我们设计了简单而直观的界面来创建和管理你的 API。Hoppscotch 是一个帮助你构建、测试、记录与分享你的 API 的工具。", - "invite_your_friends": "邀请你的伙伴", + "invite_description": "Hoppscotch 是一个开源的 API 开发生态系统。我们设计了简单而直观的界面来创建和管理您的 API。Hoppscotch 是一个帮助您构建、测试、记录与分享您的 API 的工具。", + "invite_your_friends": "邀请您的伙伴", "join_discord_community": "加入我们的 Discord 社区", "keyboard_shortcuts": "键盘快捷键", "name": "Hoppscotch", "new_version_found": "已发现新版本。刷新页面以更新。", "open_in_hoppscotch": "在 Hoppscotch 中打开", "options": "选项", + "powered_by": "Powered by Hoppscotch", "proxy_privacy_policy": "代理隐私政策", "reload": "重新加载", "search": "搜索", @@ -198,7 +201,7 @@ "send_magic_link": "发送魔术链接", "sync": "同步", "we_sent_magic_link": "已发送魔术链接!", - "we_sent_magic_link_description": "请检查你的收件箱 - 我们向 {email} 发送了一封邮件,其中包含了能够让你登录的魔术链接。" + "we_sent_magic_link_description": "请检查您的收件箱 - 我们向 {email} 发送了一封邮件,其中包含了能够让您登录的魔术链接。" }, "authorization": { "generate_token": "生成令牌", @@ -353,30 +356,32 @@ "duplicated": "集合已复制" }, "confirm": { - "close_unsaved_tab": "你确定要关闭此标签页吗?", - "close_unsaved_tabs": "你确定要关闭所有标签页吗? {count} 个未保存的标签页将被丢失。", + "close_unsaved_tab": "您确定要关闭此标签页吗?", + "close_unsaved_tabs": "您确定要关闭所有标签页吗? {count} 个未保存的标签页将被丢失。", "delete_all_activity_logs": "您确定要删除所有活动日志吗?", - "exit_team": "你确定要离开此团队吗?", - "logout": "你确定要退出登录吗?", - "remove_collection": "你确定要永久删除该集合吗?", - "remove_environment": "你确定要永久删除该环境吗?", - "remove_folder": "你确定要永久删除该文件夹吗?", - "remove_history": "你确定要永久删除全部历史记录吗?", - "remove_request": "你确定要永久删除该请求吗?", + "exit_team": "您确定要离开此团队吗?", + "logout": "您确定要退出登录吗?", + "remove_collection": "您确定要永久删除该集合吗?", + "remove_environment": "您确定要永久删除该环境吗?", + "remove_folder": "您确定要永久删除该文件夹吗?", + "remove_history": "您确定要永久删除全部历史记录吗?", + "remove_request": "您确定要永久删除该请求吗?", "remove_response": "您确定要永久删除该响应吗?", - "remove_shared_request": "你确定要永久删除该共享请求吗?", - "remove_team": "你确定要删除该团队吗?", - "remove_telemetry": "你确定要退出遥测服务吗?", - "request_change": "你确定你要放弃当前的请求,未保存的修改将被丢失。", - "save_unsaved_tab": "你想保存在此标签页中所作的修改吗?", + "remove_shared_request": "您确定要永久删除该共享请求吗?", + "remove_team": "您确定要删除该团队吗?", + "remove_telemetry": "您确定要退出遥测服务吗?", + "request_change": "您确定您要放弃当前的请求,未保存的修改将被丢失。", + "save_unsaved_tab": "您想保存在此标签页中所作的修改吗?", "sync": "您确定要同步该工作区吗?", - "delete_access_token": "你确定要删除这个授权令牌 {tokenLabel} 吗?", + "delete_access_token": "您确定要删除这个授权令牌 {tokenLabel} 吗?", "delete_mock_server": "您确定要删除这个 Mock 服务吗?" }, "context_menu": { "add_parameters": "添加至参数", "open_request_in_new_tab": "在新标签页中打开请求", - "set_environment_variable": "设置为变量" + "set_environment_variable": "设置为变量", + "encode_uri_component": "编码 URL 组件", + "decode_uri_component": "解码 URL 组件" }, "cookies": { "modal": { @@ -388,7 +393,7 @@ "empty_domain": "域名为空", "empty_domains": "域名列表为空", "enter_cookie_string": "输入 Cookie 字符串", - "interceptor_no_support": "你当前选择的中间件不支持 Cookie ,请选择一个其他的中间件并重试。", + "interceptor_no_support": "您当前选择的中间件不支持 Cookie,请选择一个其他的中间件并重试。", "managed_tab": "管理", "new_domain_name": "新域名", "no_cookies_in_domain": "该域名没有 Cookie", @@ -528,7 +533,33 @@ "update_title": "更新已发布文档", "url_copied": "URL 已复制到剪贴板!", "view_published": "查看已发布文档", - "view_title": "查看已发布文档" + "view_title": "已发布文档快照", + "versions": "版本", + "create_new_version": "创建新版本", + "invalid_version": "版本只能包含字母数字字符、点和连字符", + "snapshot_description": "这会将当前文档快照保存为此版本", + "live": "实时版", + "snapshot": "快照", + "version_immutable": "已发布版本为只读快照", + "view_snapshot": "查看快照", + "current_version": "当前", + "not_found": "未找到已发布文档", + "no_doc_id": "未提供文档 ID", + "version_label": "v{version}", + "unpublish_version": "取消发布此版本", + "loading_snapshot": "正在加载快照...", + "retry_snapshot": "重试", + "sensitive_data_warning": "请确保已发布文档中未暴露敏感数据。", + "snapshot_preview": "快照预览", + "snapshot_load_error": "加载快照预览失败", + "snapshot_empty": "此快照中没有请求或文件夹", + "snapshot_item_count": "{count} 项", + "auto_sync_live_notice": "此版本会与实时集合自动同步", + "untitled_project": "未命名项目", + "first_publish_hint": "您的文档将作为一个实时版本发布,并自动与您的集合保持同步", + "environment": "环境", + "no_environment": "无环境", + "environment_description": "附加一个环境以解析已发布文档中的变量" }, "request_opened_in_new_tab": "请求已在新标签页中打开!", "response": { @@ -574,14 +605,14 @@ "members": "团队为空", "parameters": "该请求没有任何参数", "pending_invites": "此团队无待办邀请", - "profile": "登录以查看你的个人资料", + "profile": "登录以查看您的个人资料", "protocols": "协议为空", "request_variables": "这个请求没有任何请求变量", "schema": "连接至 GraphQL 端点", "search_environment": "未找到匹配的环境", "secret_environments": "密钥不会被 Hoppscotch 同步", "shared_requests": "共享请求为空", - "shared_requests_logout": "登录并查看你的共享请求或创建一个新的共享请求", + "shared_requests_logout": "登录并查看您的共享请求或创建一个新的共享请求", "subscription": "订阅为空", "team_name": "团队名称为空", "teams": "团队为空", @@ -723,7 +754,8 @@ "fetching_access_tokens_list": "获取令牌列表时出错", "generate_access_token": "生成访问令牌时出错", "delete_access_token": "删除访问令牌时出错", - "extension_not_found": "未找到扩展" + "extension_not_found": "未找到扩展", + "invalid_request": "无效的请求数据" }, "export": { "as_json": "导出为 JSON", @@ -735,6 +767,22 @@ "title": "导出", "success": "成功导出" }, + "file_upload": { + "choose_file": "选择文件", + "max_size_format": "最大 5MB。支持 JPEG、PNG、GIF、WebP 格式", + "profile_photo_updated": "头像更新成功", + "profile_photo_removed": "头像已成功移除", + "org_logo_updated": "组织头像更新成功", + "error_size_limit": "文件大小必须小于 5MB", + "error_invalid_format": "文件必须是图片(JPEG、PNG、GIF 或 WebP)", + "error_invalid_upload_type": "无效的上传类型", + "error_invalid_org_id": "无效的组织 ID 格式", + "error_upload_failed": "上传失败,请重试", + "error_network_failed": "网络错误,请检查您的连接", + "error_timeout": "上传在 30 秒后超时,请重试", + "error_missing_backend_url": "未配置后端 URL。请在环境设置中设置 VITE_BACKEND_API_URL 环境变量", + "error_invalid_backend_url": "无效的后端 URL 配置" + }, "filename": { "cookie_key_value_pairs": "Cookie", "codegen": "{request_name} - code", @@ -789,13 +837,13 @@ "save_workspace": "保存我的工作区" }, "helpers": { - "authorization": "授权头将会在你发送请求时自动生成。", + "authorization": "授权头将会在您发送请求时自动生成。", "collection_properties_authorization": "这个授权将被应用在当前集合下的所有请求。", "collection_properties_header": "这个请求头将被应用在当前集合下的所有请求。", "generate_documentation_first": "请先生成文档", "network_fail": "无法到达 API 端点。请检查网络连接并重试。", - "offline": "你似乎处于离线状态,该工作区中的数据可能不是最新。", - "offline_short": "你似乎处于离线状态。", + "offline": "您似乎处于离线状态,该工作区中的数据可能不是最新。", + "offline_short": "您似乎处于离线状态。", "post_request_tests": "请求后脚本使用 JavaScript 编写,并在收到响应后执行。", "pre_request_script": "预请求脚本使用 JavaScript 编写,并在请求发送前执行。", "script_fail": "预请求脚本中似乎存在故障。 检查下面的错误并相应地修复脚本。", @@ -869,7 +917,7 @@ "cors_error_modal": { "title": "检测到 CORS 错误", "description": "由于服务器施加的 CORS(跨域资源共享)限制,导入失败。", - "explanation": "这是一项安全机制,用于防止网页向不同域名发起请求。你可以尝试使用我们的代理服务来绕过此限制并重新尝试。", + "explanation": "这是一项安全机制,用于防止网页向不同域名发起请求。您可以尝试使用我们的代理服务来绕过此限制并重新尝试。", "url_label": "尝试访问的 URL", "retry_with_proxy": "使用代理重试" } @@ -877,7 +925,7 @@ "instances": { "switch": "切换 Hoppscotch 实例", "enter_server_url": "连接到自托管实例", - "already_connected": "你已连接到该实例", + "already_connected": "您已连接到该实例", "recent_connections": "最近的连接", "add_instance": "添加实例", "add_new": "添加新实例", @@ -904,7 +952,8 @@ "instance_changed": "已切换到实例", "current_instance_error": "追踪当前实例失败", "not_available": "实例切换不可用", - "cleanup_completed": "实例切换器清理已完成" + "cleanup_completed": "实例切换器清理已完成", + "self_hosted": "自托管实例" }, "inspections": { "description": "检查可能存在的错误", @@ -1105,8 +1154,8 @@ "default_hopp_displayname": "未命名使用者", "editor": "编辑者", "editor_description": "编辑者可以添加、编辑和删除请求。", - "email_verification_mail": "确认邮件已发送至你的邮箱,请点击链接以验证你的电子邮箱。", - "no_permission": "你无权执行此操作。", + "email_verification_mail": "确认邮件已发送至您的邮箱,请点击链接以验证您的电子邮箱。", + "no_permission": "您无权执行此操作。", "owner": "所有者", "owner_description": "所有者可以添加、编辑和删除请求、集合及团队成员。", "roles": "角色", @@ -1114,7 +1163,7 @@ "updated": "已更新", "viewer": "查看者", "viewer_description": "查看者只可查看与使用请求。", - "verified_email_sent": "验证邮件已发送至你的邮箱地址。请在完成邮箱验证后刷新页面。如果该邮箱未关联任何其他账号,你将收到一封邮件。" + "verified_email_sent": "验证邮件已发送至您的邮箱地址。请在完成邮箱验证后刷新页面。如果该邮箱未关联任何其他账号,您将收到一封邮件。" }, "remove": { "star": "移除星标" @@ -1166,7 +1215,7 @@ "save_as": "另存为", "saved": "请求已保存", "share": "分享", - "share_description": "分享 Hoppscotch 给你的朋友", + "share_description": "分享 Hoppscotch 给您的朋友", "share_request": "分享请求", "stop": "停止", "title": "请求", @@ -1254,9 +1303,10 @@ "query_parameters_encoding": "查询参数编码", "query_parameters_encoding_description": "配置请求中查询参数的编码方式", "profile": "个人资料", - "profile_description": "更新你的资料", + "profile_description": "更新您的资料", "profile_email": "电子邮箱地址", "profile_name": "名称", + "profile_photo": "头像", "proxy": "网络代理", "proxy_url": "代理网址", "proxy_use_toggle": "使用代理中间件发送请求", @@ -1285,7 +1335,7 @@ "history_disabled": "历史记录功能已禁用。请联系您的组织管理员以启用该功能", "system_mode": "系统", "telemetry": "遥测服务", - "telemetry_helps_us": "遥测服务帮助我们进行个性化操作,为你提供最佳体验。", + "telemetry_helps_us": "遥测服务帮助我们进行个性化操作,为您提供最佳体验。", "theme": "主题", "theme_description": "自定义您的应用程序主题。", "use_experimental_url_bar": "使用实验性的带有环境高亮的 URL 栏", @@ -1322,16 +1372,16 @@ }, "shared_requests": { "button": "按钮", - "button_info": "为你的网站、博客或者 README 创建一个“Run in Hoppscotch”按钮。", + "button_info": "为您的网站、博客或者 README 创建一个“Run in Hoppscotch”按钮。", "copy_html": "复制 HTML", "copy_link": "复制链接", "copy_markdown": "复制 Markdown", "creating_widget": "创建控件", "customize": "自定义", "deleted": "共享请求已删除", - "description": "选择一个控件,之后你可以更改或者自定义", + "description": "选择一个控件,之后您可以更改或者自定义", "embed": "内嵌", - "embed_info": "为你的网站、博客或者文档添加一个小的“Hoppscotch API Playground”。", + "embed_info": "为您的网站、博客或者文档添加一个小的“Hoppscotch API Playground”。", "link": "链接", "link_info": "创建一个仅查看的分享链接给任何人", "modified": "共享请求与修改", @@ -1370,7 +1420,11 @@ "command_menu": "搜索与命令菜单", "help_menu": "帮助菜单", "show_all": "键盘快捷键", - "title": "通用" + "title": "通用", + "comment_uncomment": "注释/取消注释", + "close_tab": "关闭标签页", + "undo": "撤消", + "redo": "重做" }, "miscellaneous": { "invite": "邀请使用 Hoppscotch", @@ -1408,7 +1462,8 @@ "send_request": "发送请求", "share_request": "共享请求", "show_code": "生成代码片段", - "title": "请求" + "title": "请求", + "focus_url": "聚焦地址栏" }, "response": { "copy": "复制响应至剪贴板", @@ -1423,7 +1478,9 @@ "next_tab": "下一个标签页", "previous_tab": "上一个标签页", "first_tab": "切换到首个标签页", - "last_tab": "切换到最后一个标签页" + "last_tab": "切换到最后一个标签页", + "mru_switch": "切换到最近的标签页 (MRU)", + "mru_switch_reverse": "切换到上一个最近的标签页 (MRU)" }, "theme": { "black": "切换为黑色主题", @@ -1474,7 +1531,7 @@ "disconnect": "与服务器断开连接" }, "miscellaneous": { - "invite": "邀请你的朋友来 Hoppscotch", + "invite": "邀请您的朋友来 Hoppscotch", "title": "杂项" }, "request": { @@ -1520,6 +1577,8 @@ "previous": "切换到上一个标签页", "switch_to_first": "切换到第一个标签页", "switch_to_last": "切换到最后一个标签页", + "mru_switch": "切换到最近的标签页", + "mru_switch_reverse": "切换到上一个最近的标签页", "title": "标签页" }, "workspace": { @@ -1597,8 +1656,8 @@ "confirm_admin_to_user": "您确定要移除此用户的管理员权限吗?", "confirm_admins_to_users": "您确定要移除选中用户的管理员权限吗?", "confirm_delete_infra_token": "您确定要删除基础设施令牌 {tokenLabel} 吗?", - "confirm_delete_invite": "您确定要撤销选中的邀请吗?", - "confirm_delete_invites": "您确定要撤销选中的邀请吗?", + "confirm_delete_invite": "您确定要撤消选中的邀请吗?", + "confirm_delete_invites": "您确定要撤消选中的邀请吗?", "confirm_user_deletion": "确认删除用户?", "confirm_users_deletion": "您确定要删除选中的用户吗?", "confirm_user_to_admin": "您确定要将此用户设为管理员吗?", @@ -1740,7 +1799,7 @@ }, "team": { "activity_logs": "活动日志", - "already_member": "你已经是此团队的成员。请联系你的团队者。", + "already_member": "您已经是此团队的成员。请联系您的团队者。", "create_new": "创建新团队", "deleted": "团队已删除", "delete_all_activity_logs": "删除所有活动日志", @@ -1750,30 +1809,30 @@ "deleted_all_activity_logs": "已删除所有活动日志", "edit": "编辑团队", "email": "电子邮箱", - "email_do_not_match": "邮箱无法与你的帐户信息匹配。请联系你的团队者。", + "email_do_not_match": "邮箱无法与您的帐户信息匹配。请联系您的团队者。", "exit": "退出团队", "exit_disabled": "团队所有者无法退出团队", "failed_invites": "邀请失败", "invalid_coll_id": "无效的集合 ID", "invalid_email_format": "电子邮箱格式无效", - "invalid_id": "无效的团队 ID,请联系你的团队者。", + "invalid_id": "无效的团队 ID,请联系您的团队者。", "invalid_invite_link": "无效的邀请链接", - "invalid_invite_link_description": "你点击的链接无效。请联系你的团队者。", + "invalid_invite_link_description": "您点击的链接无效。请联系您的团队者。", "invalid_member_permission": "请为团队成员提供有效的权限", "invite": "邀请", "invite_more": "邀请更多成员", "invite_tooltip": "邀请成员加入此工作区", - "invited_to_team": "{owner} 邀请你加入 {team}", + "invited_to_team": "{owner} 邀请您加入 {workspace}", "join": "邀请已被接受", - "join_team": "加入 {team}", - "joined_team": "你已加入 {team}", - "joined_team_description": "你现在是此团队的成员了", - "left": "你已离开团队", + "join_team": "加入 {workspace}", + "joined_team": "您已加入 {workspace}", + "joined_team_description": "您现在是此团队的成员了", + "left": "您已离开团队", "login_to_continue": "登录以继续", - "login_to_continue_description": "你需要登录以加入团队", + "login_to_continue_description": "您需要登录以加入团队", "logout_and_try_again": "退出登录并以其他帐户登录", - "member_has_invite": "此邮箱 ID 已有邀请。请联系你的团队者。", - "member_not_found": "未找到成员。请联系你的团队者。", + "member_has_invite": "此邮箱 ID 已有邀请。请联系您的团队者。", + "member_not_found": "未找到成员。请联系您的团队者。", "member_removed": "用户已移除", "member_role_updated": "用户角色已更新", "members": "成员", @@ -1783,11 +1842,11 @@ "new": "新团队", "new_created": "已创建新团队", "new_name": "我的新团队", - "no_access": "你没有编辑集合的权限", - "no_invite_found": "未找到邀请。请联系你的团队者。", + "no_access": "您没有编辑集合的权限", + "no_invite_found": "未找到邀请。请联系您的团队者。", "no_request_found": "请求不存在", "not_found": "没有找到团队,请联系您的团队所有者。", - "not_valid_viewer": "你不是有效的查看者。请联系你的团队者。", + "not_valid_viewer": "您不是有效的查看者。请联系您的团队者。", "parent_coll_move": "不能将集合移动到一个子集合", "pending_invites": "待办邀请", "permissions": "权限", @@ -2162,7 +2221,7 @@ "remove_admin_privilege": "移除管理员权限", "remove_admin_status": "移除管理员状态", "rename": "重命名", - "revoke_invitation": "撤销邀请", + "revoke_invitation": "撤消邀请", "searchbar_placeholder": "按姓名或邮箱搜索...", "send_invite": "发送邀请", "show_more": "显示更多", @@ -2182,7 +2241,21 @@ "delete_account_description": "这将删除与您的 Hoppscotch 账户关联的所有数据,包括您当前所属以及参与的任何其他组织实例。", "delete_account": "删除 Hoppscotch 账户", "user_deletion_failed_sole_admin": "该用户是一个或多个组织实例的唯一管理员。请在删除前先取消其管理员权限。", - "user_deletion_failed_sole_team_owner": "该用户在一个或多个组织实例中是工作区的唯一所有者。请在删除前转移所有权或删除相关的工作区。" + "user_deletion_failed_sole_team_owner": "该用户在一个或多个组织实例中是工作区的唯一所有者。请在删除前转移所有权或删除相关的工作区。", + "no_organizations": "您不属于任何组织", + "admin": "管理员" + }, + "organization_sidebar": { + "hoppscotch_cloud": "Hoppscotch 云", + "cloud_locked": "默认实例无法移除", + "admin": "管理", + "error_loading": "加载组织失败", + "inactive_orgs": "未激活的组织", + "no_orgs_found": "未找到组织", + "no_active_orgs_found": "没有活跃组织", + "organizations_for": "{email} 的组织", + "multi_account_notice": "每个组织都使用独立的登录状态,基于最近访问的账号。", + "inactive_orgs_tooltip": "请联系支持以获取帮助。" }, "billing": { "confirm": { diff --git a/packages/hoppscotch-common/locales/en.json b/packages/hoppscotch-common/locales/en.json index f91583a4367..a4c26cbab09 100644 --- a/packages/hoppscotch-common/locales/en.json +++ b/packages/hoppscotch-common/locales/en.json @@ -132,6 +132,7 @@ "web_app": "Web App", "cli": "CLI" }, + "downloads": "Downloads", "chat_with_us": "Chat with us", "contact_us": "Contact us", "cookies": "Cookies", diff --git a/packages/hoppscotch-common/package.json b/packages/hoppscotch-common/package.json index 0ec5d4e8b3a..a0d1e1d3983 100644 --- a/packages/hoppscotch-common/package.json +++ b/packages/hoppscotch-common/package.json @@ -1,7 +1,7 @@ { "name": "@hoppscotch/common", "private": true, - "version": "2026.2.1", + "version": "2026.3.0", "scripts": { "dev": "pnpm exec npm-run-all -p -l dev:*", "test": "vitest --run", @@ -40,7 +40,7 @@ "@hoppscotch/httpsnippet": "3.0.9", "@hoppscotch/js-sandbox": "workspace:^", "@hoppscotch/kernel": "workspace:^", - "@hoppscotch/plugin-appload": "github:CuriousCorrelation/tauri-plugin-appload#168ff9533258a56de184fb69ad32f8a7f61bae0d", + "@hoppscotch/plugin-appload": "github:CuriousCorrelation/tauri-plugin-appload#0d58d53be2bc75aeb5916bd0d77794fd209426af", "@hoppscotch/ui": "0.2.5", "@hoppscotch/vue-toasted": "0.1.0", "@lezer/highlight": "1.2.1", @@ -52,23 +52,23 @@ "@types/hawk": "9.0.7", "@types/markdown-it": "14.1.2", "@types/node": "24.10.1", - "@unhead/vue": "2.1.4", + "@unhead/vue": "2.1.12", "@urql/core": "6.0.1", "@urql/devtools": "2.0.3", "@urql/exchange-auth": "3.0.0", "@vueuse/core": "14.2.1", - "acorn-walk": "8.3.4", + "acorn-walk": "8.3.5", "aws4fetch": "1.0.20", - "axios": "1.13.5", + "axios": "1.13.6", "buffer": "6.0.3", "cookie-es": "2.0.0", "dioc": "3.0.2", - "dompurify": "3.3.1", + "dompurify": "3.3.3", "esprima": "4.0.1", "events": "3.3.0", "fp-ts": "2.16.11", "globalthis": "1.0.4", - "graphql": "16.12.0", + "graphql": "16.13.1", "graphql-language-service-interface": "2.10.2", "graphql-tag": "2.12.6", "hawk": "9.0.2", @@ -88,11 +88,11 @@ "nprogress": "0.2.0", "paho-mqtt": "1.1.0", "path": "0.12.7", - "postman-collection": "5.2.1", + "postman-collection": "5.3.0", "process": "0.11.10", "qs": "6.15.0", "quicktype-core": "23.2.6", - "rollup": "4.55.3", + "rollup": "4.59.0", "rxjs": "7.8.2", "set-cookie-parser": "2.7.2", "set-cookie-parser-es": "1.0.5", @@ -111,68 +111,68 @@ "util": "0.12.5", "uuid": "13.0.0", "verzod": "0.4.0", - "vue": "3.5.28", - "vue-i18n": "11.2.8", + "vue": "3.5.31", + "vue-i18n": "11.3.0", "vue-json-pretty": "2.6.0", - "vue-pdf-embed": "2.1.3", + "vue-pdf-embed": "2.1.4", "vue-router": "4.6.4", "vue-tippy": "6.7.1", "vuedraggable-es": "4.1.1", - "wonka": "6.3.5", + "wonka": "6.3.6", "workbox-window": "7.4.0", - "xml-formatter": "3.6.7", + "xml-formatter": "3.7.0", "yargs-parser": "22.0.0", "zod": "3.25.32" }, "devDependencies": { "@esbuild-plugins/node-globals-polyfill": "0.2.3", "@esbuild-plugins/node-modules-polyfill": "0.2.2", - "@eslint/eslintrc": "3.3.3", + "@eslint/eslintrc": "3.3.5", "@eslint/js": "9.39.2", "@graphql-codegen/add": "6.0.0", - "@graphql-codegen/cli": "6.1.1", - "@graphql-codegen/typed-document-node": "6.1.6", - "@graphql-codegen/typescript": "5.0.8", - "@graphql-codegen/typescript-operations": "5.0.8", + "@graphql-codegen/cli": "6.2.1", + "@graphql-codegen/typed-document-node": "6.1.7", + "@graphql-codegen/typescript": "5.0.9", + "@graphql-codegen/typescript-operations": "5.0.9", "@graphql-codegen/typescript-urql-graphcache": "3.1.1", "@graphql-codegen/urql-introspection": "3.0.1", "@graphql-typed-document-node/core": "3.2.0", - "@iconify-json/lucide": "1.2.91", + "@iconify-json/lucide": "1.2.99", "@import-meta-env/cli": "0.7.4", "@intlify/unplugin-vue-i18n": "11.0.7", "@relmify/jest-fp-ts": "2.1.1", - "@rushstack/eslint-patch": "1.15.0", + "@rushstack/eslint-patch": "1.16.1", "@types/har-format": "1.2.16", "@types/js-yaml": "4.0.9", "@types/lodash-es": "4.17.12", "@types/nprogress": "0.2.3", "@types/paho-mqtt": "1.0.10", "@types/postman-collection": "3.5.11", - "@types/qs": "6.14.0", + "@types/qs": "6.15.0", "@types/splitpanes": "2.2.6", "@types/yargs-parser": "21.0.3", - "@typescript-eslint/eslint-plugin": "8.56.0", - "@typescript-eslint/parser": "8.56.0", - "@vitejs/plugin-vue": "6.0.4", - "@vue/compiler-sfc": "3.5.28", + "@typescript-eslint/eslint-plugin": "8.57.2", + "@typescript-eslint/parser": "8.57.2", + "@vitejs/plugin-vue": "6.0.5", + "@vue/compiler-sfc": "3.5.31", "@vue/eslint-config-typescript": "14.7.0", - "@vue/runtime-core": "3.5.28", - "autoprefixer": "10.4.24", + "@vue/runtime-core": "3.5.31", + "autoprefixer": "10.4.27", "cross-env": "10.1.0", "dotenv": "17.3.1", "eslint": "9.39.2", "eslint-plugin-prettier": "5.5.5", "eslint-plugin-vue": "10.8.0", - "glob": "13.0.5", + "glob": "13.0.6", "globals": "16.5.0", "jsdom": "27.4.0", "npm-run-all": "4.1.5", "openapi-types": "12.1.3", - "postcss": "8.5.6", + "postcss": "8.5.8", "prettier": "3.8.1", - "prettier-plugin-tailwindcss": "0.7.1", + "prettier-plugin-tailwindcss": "0.7.2", "rollup-plugin-polyfill-node": "0.13.0", - "sass": "1.97.3", + "sass": "1.98.0", "tailwindcss": "3.4.16", "tsup": "8.5.1", "typescript": "5.9.3", @@ -180,14 +180,14 @@ "unplugin-icons": "22.5.0", "unplugin-vue-components": "30.0.0", "vite": "7.3.1", - "vite-plugin-checker": "0.11.0", + "vite-plugin-checker": "0.12.0", "vite-plugin-fonts": "0.7.0", "vite-plugin-html-config": "2.0.2", - "vite-plugin-pages": "0.33.2", + "vite-plugin-pages": "0.33.3", "vite-plugin-pages-sitemap": "1.7.1", "vite-plugin-pwa": "1.2.0", "vite-plugin-vue-layouts": "0.11.0", - "vitest": "4.0.18", + "vitest": "4.1.2", "vue-tsc": "1.8.8" } } diff --git a/packages/hoppscotch-common/src/components/app/Header.vue b/packages/hoppscotch-common/src/components/app/Header.vue index 863a367e0e1..6bfd22e0dfd 100644 --- a/packages/hoppscotch-common/src/components/app/Header.vue +++ b/packages/hoppscotch-common/src/components/app/Header.vue @@ -41,7 +41,7 @@ > downloadableLinksRef.focus()" > @@ -399,6 +401,7 @@ const t = useI18n() const toast = useToast() const kernelMode = getKernelMode() +const headerRef = ref(null) const downloadableLinksRef = kernelMode === "web" ? ref(null) : ref(null) const switcherRef = ref(null) diff --git a/packages/hoppscotch-common/src/components/collections/SaveRequest.vue b/packages/hoppscotch-common/src/components/collections/SaveRequest.vue index 8931046e455..cc41478b809 100644 --- a/packages/hoppscotch-common/src/components/collections/SaveRequest.vue +++ b/packages/hoppscotch-common/src/components/collections/SaveRequest.vue @@ -358,6 +358,7 @@ const saveRequestAs = async () => { folderPath: `${picked.value.collectionIndex}`, requestIndex: insertionIndex, exampleID: undefined, + requestRefID: requestUpdated._ref_id, }, } @@ -392,6 +393,7 @@ const saveRequestAs = async () => { originLocation: "user-collection", folderPath: picked.value.folderPath, requestIndex: insertionIndex, + requestRefID: requestUpdated._ref_id, }, } diff --git a/packages/hoppscotch-common/src/components/collections/documentation/index.vue b/packages/hoppscotch-common/src/components/collections/documentation/index.vue index d06b7d2c135..5e18180055d 100644 --- a/packages/hoppscotch-common/src/components/collections/documentation/index.vue +++ b/packages/hoppscotch-common/src/components/collections/documentation/index.vue @@ -300,11 +300,7 @@ import { CreatePublishedDocsArgs, UpdatePublishedDocsArgs, } from "~/helpers/backend/graphql" -import { - createPublishedDoc, - deletePublishedDoc, - updatePublishedDoc, -} from "~/helpers/backend/mutations/PublishedDocs" +import { platform } from "~/platform" import * as E from "fp-ts/Either" import * as TE from "fp-ts/TaskEither" @@ -976,7 +972,7 @@ const handlePublish = async ( } await pipe( - createPublishedDoc(doc), + platform.backend.createPublishedDoc(doc), TE.match( (error) => { console.error("Error publishing documentation:", error) @@ -1032,7 +1028,7 @@ const handleUpdate = async ( } await pipe( - updatePublishedDoc(id, doc), + platform.backend.updatePublishedDoc(id, doc), TE.match( (error) => { console.error("Error updating documentation:", error) @@ -1077,7 +1073,7 @@ const handleDelete = async () => { isProcessingPublish.value = true await pipe( - deletePublishedDoc(publishedDocId.value), + platform.backend.deletePublishedDoc(publishedDocId.value), TE.match( (error) => { console.error("Error deleting documentation:", error) diff --git a/packages/hoppscotch-common/src/components/graphql/Query.vue b/packages/hoppscotch-common/src/components/graphql/Query.vue index 034182ecc12..abf2afe3623 100644 --- a/packages/hoppscotch-common/src/components/graphql/Query.vue +++ b/packages/hoppscotch-common/src/components/graphql/Query.vue @@ -47,9 +47,9 @@ /> diff --git a/packages/hoppscotch-sh-admin/src/components/settings/SmtpConfiguration.vue b/packages/hoppscotch-sh-admin/src/components/settings/SmtpConfiguration.vue index 704d512a812..02c02d450cf 100644 --- a/packages/hoppscotch-sh-admin/src/components/settings/SmtpConfiguration.vue +++ b/packages/hoppscotch-sh-admin/src/components/settings/SmtpConfiguration.vue @@ -171,6 +171,10 @@ const smtpConfigFields = reactive([ name: t('configs.mail_configs.secure'), key: 'mailer_smtp_secure', }, + { + name: t('configs.mail_configs.ignore_tls'), + key: 'mailer_smtp_ignore_tls', + }, { name: t('configs.mail_configs.tls_reject_unauthorized'), key: 'mailer_tls_reject_unauthorized', @@ -200,6 +204,7 @@ const fieldCondition = (field: Field) => { 'mailer_smtp_user', 'mailer_smtp_password', 'mailer_smtp_secure', + 'mailer_smtp_ignore_tls', 'mailer_tls_reject_unauthorized', ]; const basicFields = ['mailer_smtp_url']; @@ -216,7 +221,11 @@ const fieldCondition = (field: Field) => { }; const isCheckboxField = (field: Field) => { - const checkboxKeys = ['mailer_smtp_secure', 'mailer_tls_reject_unauthorized']; + const checkboxKeys = [ + 'mailer_smtp_secure', + 'mailer_smtp_ignore_tls', + 'mailer_tls_reject_unauthorized', + ]; return checkboxKeys.includes(field.key); }; diff --git a/packages/hoppscotch-sh-admin/src/components/users/Teams.vue b/packages/hoppscotch-sh-admin/src/components/users/Teams.vue new file mode 100644 index 00000000000..1d4efeda23d --- /dev/null +++ b/packages/hoppscotch-sh-admin/src/components/users/Teams.vue @@ -0,0 +1,112 @@ + + + diff --git a/packages/hoppscotch-sh-admin/src/composables/useConfigHandler.ts b/packages/hoppscotch-sh-admin/src/composables/useConfigHandler.ts index d3f6b582092..6c42afe2c57 100644 --- a/packages/hoppscotch-sh-admin/src/composables/useConfigHandler.ts +++ b/packages/hoppscotch-sh-admin/src/composables/useConfigHandler.ts @@ -137,6 +137,8 @@ export function useConfigHandler(updatedConfigs?: ServerConfigs) { ), mailer_smtp_secure: getFieldValue(InfraConfigEnum.MailerSmtpSecure) === 'true', + mailer_smtp_ignore_tls: + getFieldValue(InfraConfigEnum.MailerSmtpIgnoreTls) === 'true', mailer_tls_reject_unauthorized: getFieldValue(InfraConfigEnum.MailerTlsRejectUnauthorized) === 'true', @@ -273,8 +275,10 @@ export function useConfigHandler(updatedConfigs?: ServerConfigs) { if (section.name === 'email') { const { mailer_use_custom_configs, ...otherFields } = section.fields; + // SMTP user and password are optional as a pair (both or neither) + const optionalMailerKeys = ['mailer_smtp_user', 'mailer_smtp_password']; const excludeKeys = mailer_use_custom_configs - ? ['mailer_smtp_url'] + ? ['mailer_smtp_url', ...optionalMailerKeys] : [ 'mailer_smtp_host', 'mailer_smtp_port', @@ -285,7 +289,8 @@ export function useConfigHandler(updatedConfigs?: ServerConfigs) { return ( section.enabled && Object.entries(otherFields).some( - ([key, value]) => isFieldEmpty(value) && !excludeKeys.includes(key) + ([key, value]) => + isFieldEmpty(value) && !excludeKeys.includes(key) ) ); } @@ -312,6 +317,18 @@ export function useConfigHandler(updatedConfigs?: ServerConfigs) { return hasSectionWithEmptyFields; }; + const hasPartialSmtpCredentials = (config: ServerConfigs): boolean => { + if (!config.mailConfigs.enabled) return false; + + const fields = config.mailConfigs.fields; + if (!fields.mailer_use_custom_configs) return false; + + const hasUser = fields.mailer_smtp_user.trim() !== ''; + const hasPass = fields.mailer_smtp_password.trim() !== ''; + + return hasUser !== hasPass; + }; + // Extract the mail config fields (excluding the custom mail config fields) const mailConfigFields = Object.fromEntries( Object.entries(updatedConfigs?.mailConfigs.fields ?? {}).filter(([key]) => { @@ -659,5 +676,6 @@ export function useConfigHandler(updatedConfigs?: ServerConfigs) { infraConfigsError, allowedAuthProvidersError, AreAnyConfigFieldsEmpty, + hasPartialSmtpCredentials, }; } diff --git a/packages/hoppscotch-sh-admin/src/composables/useOnboardingConfigHandler.ts b/packages/hoppscotch-sh-admin/src/composables/useOnboardingConfigHandler.ts index 300c7a66808..0ac0bf26a8e 100644 --- a/packages/hoppscotch-sh-admin/src/composables/useOnboardingConfigHandler.ts +++ b/packages/hoppscotch-sh-admin/src/composables/useOnboardingConfigHandler.ts @@ -30,6 +30,7 @@ export type MailerConfigKeys = | 'SMTP_SECURE' | 'SMTP_USER' | 'SMTP_PASSWORD' + | 'SMTP_IGNORE_TLS' | 'TLS_REJECT_UNAUTHORIZED'; export type Configs = { @@ -89,6 +90,7 @@ function mapMailerConfigs( MAILER_SMTP_SECURE: configs.MAILER_SMTP_SECURE || 'false', MAILER_SMTP_USER: configs.MAILER_SMTP_USER ?? '', MAILER_SMTP_PASSWORD: configs.MAILER_SMTP_PASSWORD ?? '', + MAILER_SMTP_IGNORE_TLS: configs.MAILER_SMTP_IGNORE_TLS || 'false', MAILER_TLS_REJECT_UNAUTHORIZED: configs.MAILER_TLS_REJECT_UNAUTHORIZED || 'false', }; @@ -229,6 +231,7 @@ export function useOnboardingConfigHandler() { 'MAILER_ADDRESS_FROM', 'MAILER_USE_CUSTOM_CONFIGS', 'MAILER_SMTP_SECURE', + 'MAILER_SMTP_IGNORE_TLS', 'MAILER_TLS_REJECT_UNAUTHORIZED', 'MAILER_SMTP_ENABLE', ].includes(key); @@ -253,11 +256,21 @@ export function useOnboardingConfigHandler() { ); const neededKeys = filterNeededConfigs(relevantKeys); - const allFilled = neededKeys.every((key) => configs[key]); + // SMTP auth is optional (both blank = no-auth SMTP server). + // These keys are excluded from mandatory "filled" checks, but are still + // included in the returned payload even when empty so the backend can + // explicitly clear previously stored credentials on re-visits. + const optionalSmtpKeys = new Set([ + 'MAILER_SMTP_USER', + 'MAILER_SMTP_PASSWORD', + ]); + const allFilled = neededKeys.every( + (key) => configs[key] || optionalSmtpKeys.has(key) + ); if (!allFilled) { neededKeys.forEach((key) => { - if (!configs[key]) + if (!configs[key] && !optionalSmtpKeys.has(key)) toast.error( t('onboarding.please_fill_configurations', { fieldName: makeReadableKey(key), @@ -267,11 +280,28 @@ export function useOnboardingConfigHandler() { return; } + // SMTP credentials must be provided together or both left empty. + // Only enforce when custom SMTP mode is active (not simple URL mode). + if ( + enabledConfigs.value.includes('MAILER') && + configs['MAILER_USE_CUSTOM_CONFIGS'] === 'true' + ) { + const smtpUser = configs['MAILER_SMTP_USER']?.trim(); + const smtpPass = configs['MAILER_SMTP_PASSWORD']?.trim(); + + if (!!smtpUser !== !!smtpPass) { + toast.error(t('configs.mail_configs.smtp_auth_incomplete')); + return; + } + } + + // Allow empty strings through for optional SMTP keys so the backend + // receives an explicit clear rather than silently retaining old values return Object.fromEntries( Object.entries(configs).filter( ([key, val]) => enabledConfigs.value.includes(key.split('_')[0] as EnabledConfig) && - val + (val || optionalSmtpKeys.has(key)) ) ); }; diff --git a/packages/hoppscotch-sh-admin/src/helpers/auth.ts b/packages/hoppscotch-sh-admin/src/helpers/auth.ts index 1002d666ecd..48e4291a95d 100644 --- a/packages/hoppscotch-sh-admin/src/helpers/auth.ts +++ b/packages/hoppscotch-sh-admin/src/helpers/auth.ts @@ -56,12 +56,12 @@ export type OnboardingStatus = { const currentUser$ = new BehaviorSubject(null); const signOut = async (reloadWindow = false) => { - await authQuery.logout(); - - // Reload the window if both `access_token` and `refresh_token`is invalid - // there by the user is taken to the login page - if (reloadWindow) { - window.location.reload(); + // Best-effort backend logout — local state must be cleared regardless + // so the UI never stays stuck in an authenticated state. + try { + await authQuery.logout(); + } catch (_) { + // Backend unreachable — continue with local cleanup } currentUser$.next(null); @@ -70,6 +70,12 @@ const signOut = async (reloadWindow = false) => { authEvents$.next({ event: 'logout', }); + + // Reload the window if both `access_token` and `refresh_token` are invalid + // thereby the user is taken to the login page + if (reloadWindow) { + window.location.reload(); + } }; const getUserDetails = async () => { @@ -132,10 +138,15 @@ const setInitialUser = async () => { const refreshToken = async () => { try { const res = await authQuery.refreshToken(); - authEvents$.next({ - event: 'token_refresh', - }); - return res.status === 200; + const isSuccessful = res.status === 200; + + if (isSuccessful) { + authEvents$.next({ + event: 'token_refresh', + }); + } + + return isSuccessful; } catch { return false; } diff --git a/packages/hoppscotch-sh-admin/src/helpers/backend/gql/queries/TeamsOfUserByAdmin.graphql b/packages/hoppscotch-sh-admin/src/helpers/backend/gql/queries/TeamsOfUserByAdmin.graphql new file mode 100644 index 00000000000..c0643116684 --- /dev/null +++ b/packages/hoppscotch-sh-admin/src/helpers/backend/gql/queries/TeamsOfUserByAdmin.graphql @@ -0,0 +1,13 @@ +query TeamsOfUserByAdmin($userUid: ID!, $cursor: ID, $take: Int) { + teamsOfUserByAdmin(userUid: $userUid, cursor: $cursor, take: $take) { + id + name + teamMembers { + membershipID + role + user { + uid + } + } + } +} diff --git a/packages/hoppscotch-sh-admin/src/helpers/configs.ts b/packages/hoppscotch-sh-admin/src/helpers/configs.ts index 03271bfeab5..4b915a02bc0 100644 --- a/packages/hoppscotch-sh-admin/src/helpers/configs.ts +++ b/packages/hoppscotch-sh-admin/src/helpers/configs.ts @@ -53,6 +53,7 @@ export type ServerConfigs = { mailer_smtp_user: string; mailer_smtp_password: string; mailer_smtp_secure: boolean; + mailer_smtp_ignore_tls: boolean; mailer_tls_reject_unauthorized: boolean; mailer_use_custom_configs: boolean; }; @@ -221,6 +222,10 @@ export const CUSTOM_MAIL_CONFIGS: Config[] = [ name: InfraConfigEnum.MailerSmtpSecure, key: 'mailer_smtp_secure', }, + { + name: InfraConfigEnum.MailerSmtpIgnoreTls, + key: 'mailer_smtp_ignore_tls', + }, { name: InfraConfigEnum.MailerTlsRejectUnauthorized, key: 'mailer_tls_reject_unauthorized', diff --git a/packages/hoppscotch-sh-admin/src/helpers/retryAuthGuard.ts b/packages/hoppscotch-sh-admin/src/helpers/retryAuthGuard.ts new file mode 100644 index 00000000000..4742cf9fb41 --- /dev/null +++ b/packages/hoppscotch-sh-admin/src/helpers/retryAuthGuard.ts @@ -0,0 +1,82 @@ +/** + * Maximum number of consecutive auth refresh failures before signing out. + * @see https://github.com/hoppscotch/hoppscotch/issues/5885 + */ +const MAX_RETRIES = 3 + +/** + * Creates an auth retry guard that tracks consecutive refresh failures + * and triggers a sign-out after {@link MAX_RETRIES} consecutive failures. + * + * After exhaustion, subsequent calls short-circuit to `false` without + * invoking `refreshFn` or `onExhausted` again. Call `reset()` on + * successful login to re-enable refresh attempts. + * + * NOTE: This is a copy of `@hoppscotch/common/helpers/retryAuthGuard.ts`. + * `sh-admin` cannot depend on `@hoppscotch/common`, so the utility is + * duplicated here. Keep both copies in sync. + * + * @see https://github.com/hoppscotch/hoppscotch/issues/5885 + */ +export function createAuthRetryGuard(onExhausted: () => void | Promise) { + let failCount = 0 + let isExhausted = false + let exhaustionPromise: Promise | null = null + + return { + /** + * Wraps an auth refresh attempt with retry tracking. + * Resets on success. Calls `onExhausted` after {@link MAX_RETRIES} + * consecutive failures and stays exhausted until `reset()` is called. + */ + async execute(refreshFn: () => Promise): Promise { + // isExhausted covers the normal path; failCount >= MAX_RETRIES covers + // the concurrent-call edge case where two callers both passed the check + // at failCount = 2 before either could set isExhausted = true. + if (isExhausted || failCount >= MAX_RETRIES) { + return false + } + + let success: boolean + try { + success = await refreshFn() + } catch (_) { + // Treat thrown errors (network failures, etc.) as a failed refresh + // so they count toward exhaustion and don't bypass the guard. + success = false + } + + if (success) { + failCount = 0 + return true + } + + failCount++ + if (failCount >= MAX_RETRIES && !isExhausted) { + isExhausted = true + try { + exhaustionPromise = Promise.resolve().then(() => onExhausted()) + await exhaustionPromise + } catch (_) { + // Sign-out failed (e.g. network error), but the guard stays + // exhausted so we don't re-enter the refresh loop. + } finally { + exhaustionPromise = null + } + } + + return false + }, + + /** + * Reset the failure counter (e.g. on login or manual logout). + * No-op while an exhaustion callback (sign-out) is still in-flight. + */ + reset() { + if (exhaustionPromise) return + + failCount = 0 + isExhausted = false + }, + } +} diff --git a/packages/hoppscotch-sh-admin/src/main.ts b/packages/hoppscotch-sh-admin/src/main.ts index 1313c7a60a5..75909af04b3 100644 --- a/packages/hoppscotch-sh-admin/src/main.ts +++ b/packages/hoppscotch-sh-admin/src/main.ts @@ -1,6 +1,7 @@ import { authExchange } from '@urql/exchange-auth'; import urql, { cacheExchange, createClient, fetchExchange } from '@urql/vue'; import { createApp, h } from 'vue'; +import * as O from 'fp-ts/Option'; import App from './App.vue'; import ErrorComponent from './pages/_.vue'; @@ -13,12 +14,24 @@ import '../assets/scss/styles.scss'; import '../assets/scss/tailwind.scss'; // END STYLES -import { pipe } from 'fp-ts/function'; -import * as O from 'fp-ts/Option'; -import { auth } from './helpers/auth'; +import { auth, authEvents$ } from './helpers/auth'; import { GRAPHQL_UNAUTHORIZED } from './helpers/errors'; +import { createAuthRetryGuard } from './helpers/retryAuthGuard'; import { HOPP_MODULES } from './modules'; +/** + * Auth retry guard — prevents infinite refreshAuth loops when tokens + * are permanently invalid. Stays exhausted until the page reloads. + * @see https://github.com/hoppscotch/hoppscotch/issues/5885 + */ +const authRetryGuard = createAuthRetryGuard(() => auth.signOutUser(true)); + +authEvents$.subscribe((event) => { + if (event.event === 'login') { + authRetryGuard.reset(); + } +}); + (async () => { try { // Create URQL client @@ -35,10 +48,10 @@ import { HOPP_MODULES } from './modules'; return operation; }, async refreshAuth() { - pipe( - await auth.performAuthRefresh(), - O.getOrElseW(() => auth.signOutUser(true)) - ); + await authRetryGuard.execute(async () => { + const result = await auth.performAuthRefresh(); + return O.isSome(result); + }); }, didAuthError(error, _operation) { return error.message === GRAPHQL_UNAUTHORIZED; diff --git a/packages/hoppscotch-sh-admin/src/modules/tippy.ts b/packages/hoppscotch-sh-admin/src/modules/tippy.ts index a2149c3cda0..f50e3c56ce0 100644 --- a/packages/hoppscotch-sh-admin/src/modules/tippy.ts +++ b/packages/hoppscotch-sh-admin/src/modules/tippy.ts @@ -1,14 +1,102 @@ import { HoppModule } from '.'; -import VueTippy, { roundArrow, setDefaultProps } from 'vue-tippy'; +import type { DirectiveBinding, VNode } from 'vue'; +import VueTippy, { useTippy, roundArrow, setDefaultProps } from 'vue-tippy'; import 'tippy.js/dist/tippy.css'; import 'tippy.js/animations/scale-subtle.css'; import 'tippy.js/dist/border.css'; import 'tippy.js/dist/svg-arrow.css'; +interface TippyElement extends HTMLElement { + $tippy?: { + props: Record; + setProps: (opts: Record) => void; + destroy: () => void; + }; + _tippy?: { + props: Record; + setProps: (opts: Record) => void; + destroy: () => void; + }; +} + +// Resolves tooltip options from directive binding, vnode props, and DOM attrs. +// Reads from vnode.props first (immune to DOM stripping by parent +// components), then falls back to DOM attributes. +function resolveOpts( + el: TippyElement, + binding: DirectiveBinding, + vnode: VNode, +): Record { + const opts = + typeof binding.value === 'string' + ? { content: binding.value } + : { ...(binding.value || {}) }; + + if (!opts.content) { + const title = vnode.props?.title ?? el.getAttribute('title'); + if (title) { + opts.content = title; + } + } + + if (!opts.content && el.getAttribute('content')) { + opts.content = el.getAttribute('content'); + } + + return opts; +} + export default { onVueAppInit(app) { - app.use(VueTippy); + // Register VueTippy under a noop directive name so it doesn't conflict + // with the custom v-tippy below. + app.use(VueTippy, { directive: 'tippy-original' }); + + // Custom v-tippy directive: reads content from vnode.props instead of + // el.getAttribute('title') to fix tooltips inside wrappers where + // tippy.js strips the title attribute before the child directive reads it. + app.directive('tippy', { + mounted(el: TippyElement, binding: DirectiveBinding, vnode: VNode) { + const opts = resolveOpts(el, binding, vnode); + el.removeAttribute('title'); + // useTippy (not tippy() directly) so setDefaultProps are applied. + // Works outside setup() because vue-tippy v6.x guards getCurrentInstance(). + useTippy(el, opts); + }, + + // Explicit cleanup for v-if removals — useTippy's internal + // onBeforeUnmount only fires on component unmount, not element removal. + unmounted(el: TippyElement) { + if (el._tippy) { + el._tippy.destroy(); + } else if (el.$tippy) { + el.$tippy.destroy(); + } + }, + + // Sync tooltip content when reactive :title bindings change + // (e.g. "Add star" ↔ "Remove star" in History). + updated(el: TippyElement, binding: DirectiveBinding, vnode: VNode) { + const tippy = el.$tippy || el._tippy; + if (!tippy) return; + + const opts = resolveOpts(el, binding, vnode); + + if (!opts.content) { + opts.content = ''; + } + + // Vue re-applies :title on each patch cycle + el.removeAttribute('title'); + + // Skip if content unchanged — setProps is expensive (recreates Popper). + const currentContent = tippy.props?.content; + if (opts.content === currentContent) return; + + tippy.setProps(opts); + }, + }); setDefaultProps({ animation: 'scale-subtle', diff --git a/packages/hoppscotch-sh-admin/src/pages/settings.vue b/packages/hoppscotch-sh-admin/src/pages/settings.vue index 200ceeaca0e..2813f099043 100644 --- a/packages/hoppscotch-sh-admin/src/pages/settings.vue +++ b/packages/hoppscotch-sh-admin/src/pages/settings.vue @@ -95,6 +95,7 @@ const { fetchingAllowedAuthProviders, allowedAuthProvidersError, AreAnyConfigFieldsEmpty, + hasPartialSmtpCredentials, } = useConfigHandler(); // Check if the configs have been updated @@ -114,6 +115,10 @@ const triggerSaveChangesModal = () => { return toast.error(t('configs.input_empty')); } + if (workingConfigs.value && hasPartialSmtpCredentials(workingConfigs.value)) { + return toast.error(t('configs.mail_configs.smtp_auth_incomplete')); + } + if (hasInputValidationFailed.value) { return toast.error(t('configs.input_validation_error')); } diff --git a/packages/hoppscotch-sh-admin/src/pages/users/_id.vue b/packages/hoppscotch-sh-admin/src/pages/users/_id.vue index 414e7d05d1b..7881fe12f4f 100644 --- a/packages/hoppscotch-sh-admin/src/pages/users/_id.vue +++ b/packages/hoppscotch-sh-admin/src/pages/users/_id.vue @@ -36,6 +36,13 @@ + + + @@ -62,7 +69,7 @@