From d01a38483241c5de8a94889ad0d7ba918de9a1c7 Mon Sep 17 00:00:00 2001 From: thibaud-leclere Date: Wed, 6 May 2026 09:22:55 +0200 Subject: [PATCH] feat: require login for web app --- .../plans/2026-05-06-require-web-login.md | 203 ++++++++++++++++++ .../helpers/__tests__/appLoginGate.spec.ts | 53 +++++ .../src/helpers/appLoginGate.ts | 14 ++ .../src/components/WebLoginGate.vue | 31 +++ packages/hoppscotch-selfhost-web/src/main.ts | 3 +- .../src/services/webLoginGate.service.ts | 13 ++ 6 files changed, 316 insertions(+), 1 deletion(-) create mode 100644 docs/superpowers/plans/2026-05-06-require-web-login.md create mode 100644 packages/hoppscotch-common/src/helpers/__tests__/appLoginGate.spec.ts create mode 100644 packages/hoppscotch-common/src/helpers/appLoginGate.ts create mode 100644 packages/hoppscotch-selfhost-web/src/components/WebLoginGate.vue create mode 100644 packages/hoppscotch-selfhost-web/src/services/webLoginGate.service.ts diff --git a/docs/superpowers/plans/2026-05-06-require-web-login.md b/docs/superpowers/plans/2026-05-06-require-web-login.md new file mode 100644 index 00000000..f02c15a4 --- /dev/null +++ b/docs/superpowers/plans/2026-05-06-require-web-login.md @@ -0,0 +1,203 @@ +# Require Web Login Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Block the self-hosted web app UI until a user is authenticated. + +**Architecture:** Add a small web-only root extension component registered by `packages/hoppscotch-selfhost-web/src/main.ts`. The component observes the existing auth platform state and renders a full-screen login gate only for web mode while no confirmed user exists. + +**Tech Stack:** Vue 3, Hoppscotch platform auth streams, RxJS `BehaviorSubject`, Vitest for focused state logic. + +--- + +### Task 1: Auth Gate State Logic + +**Files:** +- Create: `packages/hoppscotch-common/src/helpers/appLoginGate.ts` +- Test: `packages/hoppscotch-common/src/helpers/__tests__/appLoginGate.spec.ts` + +- [ ] **Step 1: Write the failing test** + +```ts +import { describe, expect, test } from "vitest" +import { shouldBlockAppForLogin } from "../appLoginGate" + +describe("shouldBlockAppForLogin", () => { + const user = { + uid: "user-1", + displayName: "User", + email: "user@example.com", + photoURL: null, + emailVerified: true, + } + + test("blocks the web app while auth is still being checked", () => { + expect( + shouldBlockAppForLogin({ + platform: "web", + isAuthInitComplete: false, + currentUser: null, + }) + ).toBe(true) + }) + + test("blocks the web app when auth is confirmed anonymous", () => { + expect( + shouldBlockAppForLogin({ + platform: "web", + isAuthInitComplete: true, + currentUser: null, + }) + ).toBe(true) + }) + + test("does not block the web app once a user is authenticated", () => { + expect( + shouldBlockAppForLogin({ + platform: "web", + isAuthInitComplete: true, + currentUser: user, + }) + ).toBe(false) + }) + + test("does not block desktop", () => { + expect( + shouldBlockAppForLogin({ + platform: "desktop", + isAuthInitComplete: true, + currentUser: null, + }) + ).toBe(false) + }) +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `pnpm --dir packages/hoppscotch-common exec vitest --run src/helpers/__tests__/appLoginGate.spec.ts` +Expected: FAIL because `../appLoginGate` does not exist. + +- [ ] **Step 3: Write minimal implementation** + +```ts +type KernelMode = "web" | "desktop" + +export type LoginGateState = { + platform: KernelMode + isAuthInitComplete: boolean + currentUser: unknown | null +} + +export function shouldBlockAppForLogin(state: LoginGateState) { + return ( + state.platform === "web" && + (!state.isAuthInitComplete || !state.currentUser) + ) +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `pnpm --dir packages/hoppscotch-common exec vitest --run src/helpers/__tests__/appLoginGate.spec.ts` +Expected: PASS. + +### Task 2: Web Login Gate UI + +**Files:** +- Create: `packages/hoppscotch-selfhost-web/src/components/WebLoginGate.vue` +- Create: `packages/hoppscotch-selfhost-web/src/services/webLoginGate.service.ts` +- Modify: `packages/hoppscotch-selfhost-web/src/main.ts` + +- [ ] **Step 1: Create the root UI extension registration service** + +Create `packages/hoppscotch-selfhost-web/src/services/webLoginGate.service.ts`: + +```ts +import { Service } from "dioc" +import { getService } from "@hoppscotch/common/modules/dioc" +import { UIExtensionService } from "@hoppscotch/common/services/ui-extension.service" + +import WebLoginGate from "@app/components/WebLoginGate.vue" + +export class WebLoginGateService extends Service { + public static readonly ID = "WEB_LOGIN_GATE_SERVICE" + + override onServiceInit() { + getService(UIExtensionService).addRootUIExtension(WebLoginGate) + } +} +``` + +- [ ] **Step 2: Register the service for web only** + +In `packages/hoppscotch-selfhost-web/src/main.ts`, import the service: + +```ts +import { WebLoginGateService } from "@app/services/webLoginGate.service" +``` + +Then change: + +```ts +addedServices: [], +``` + +to: + +```ts +addedServices: platform === "web" ? [WebLoginGateService] : [], +``` + +- [ ] **Step 3: Implement the blocking component** + +Create `packages/hoppscotch-selfhost-web/src/components/WebLoginGate.vue`: + +```vue + + + +``` + +- [ ] **Step 4: Run targeted checks** + +Run: `pnpm --dir packages/hoppscotch-common exec vitest --run src/helpers/__tests__/appLoginGate.spec.ts` +Expected: PASS. + +Run: `pnpm --dir packages/hoppscotch-selfhost-web run lint` +Expected: PASS or only pre-existing unrelated failures. + +- [ ] **Step 5: Commit** + +```bash +git add docs/superpowers/plans/2026-05-06-require-web-login.md packages/hoppscotch-common/src/helpers/appLoginGate.ts packages/hoppscotch-common/src/helpers/__tests__/appLoginGate.spec.ts packages/hoppscotch-selfhost-web/src/components/WebLoginGate.vue packages/hoppscotch-selfhost-web/src/services/webLoginGate.service.ts packages/hoppscotch-selfhost-web/src/main.ts +git commit -m "feat: require login for web app" +``` diff --git a/packages/hoppscotch-common/src/helpers/__tests__/appLoginGate.spec.ts b/packages/hoppscotch-common/src/helpers/__tests__/appLoginGate.spec.ts new file mode 100644 index 00000000..d4076d9f --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/__tests__/appLoginGate.spec.ts @@ -0,0 +1,53 @@ +import { describe, expect, test } from "vitest" + +import { shouldBlockAppForLogin } from "../appLoginGate" + +describe("shouldBlockAppForLogin", () => { + const user = { + uid: "user-1", + displayName: "User", + email: "user@example.com", + photoURL: null, + emailVerified: true, + } + + test("blocks the web app while auth is still being checked", () => { + expect( + shouldBlockAppForLogin({ + platform: "web", + isAuthInitComplete: false, + currentUser: null, + }) + ).toBe(true) + }) + + test("blocks the web app when auth is confirmed anonymous", () => { + expect( + shouldBlockAppForLogin({ + platform: "web", + isAuthInitComplete: true, + currentUser: null, + }) + ).toBe(true) + }) + + test("does not block the web app once a user is authenticated", () => { + expect( + shouldBlockAppForLogin({ + platform: "web", + isAuthInitComplete: true, + currentUser: user, + }) + ).toBe(false) + }) + + test("does not block desktop", () => { + expect( + shouldBlockAppForLogin({ + platform: "desktop", + isAuthInitComplete: true, + currentUser: null, + }) + ).toBe(false) + }) +}) diff --git a/packages/hoppscotch-common/src/helpers/appLoginGate.ts b/packages/hoppscotch-common/src/helpers/appLoginGate.ts new file mode 100644 index 00000000..81fe3d8c --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/appLoginGate.ts @@ -0,0 +1,14 @@ +type KernelMode = "web" | "desktop" + +export type LoginGateState = { + platform: KernelMode + isAuthInitComplete: boolean + currentUser: unknown | null +} + +export function shouldBlockAppForLogin(state: LoginGateState) { + return ( + state.platform === "web" && + (!state.isAuthInitComplete || !state.currentUser) + ) +} diff --git a/packages/hoppscotch-selfhost-web/src/components/WebLoginGate.vue b/packages/hoppscotch-selfhost-web/src/components/WebLoginGate.vue new file mode 100644 index 00000000..6ac47764 --- /dev/null +++ b/packages/hoppscotch-selfhost-web/src/components/WebLoginGate.vue @@ -0,0 +1,31 @@ + + + diff --git a/packages/hoppscotch-selfhost-web/src/main.ts b/packages/hoppscotch-selfhost-web/src/main.ts index b03f7ee5..c1f3680b 100644 --- a/packages/hoppscotch-selfhost-web/src/main.ts +++ b/packages/hoppscotch-selfhost-web/src/main.ts @@ -26,6 +26,7 @@ import { stdSupportOptionItems } from "@hoppscotch/common/platform/std/ui/suppor import { InfraPlatform } from "@app/platform/infra/infra.platform" import { kernelIO } from "@hoppscotch/common/platform/std/kernel-io" import { HeaderDownloadableLinksService } from "@app/services/headerDownloadableLinks.service" +import { WebLoginGateService } from "@app/services/webLoginGate.service" import DesktopSettingsSection from "@hoppscotch/common/components/settings/Desktop.vue" @@ -188,7 +189,7 @@ async function initApp() { infra: InfraPlatform, backend: stdBackendDef, additionalLinks: [HeaderDownloadableLinksService], - addedServices: [], + addedServices: platform === "web" ? [WebLoginGateService] : [], }) if (platform === "desktop") { diff --git a/packages/hoppscotch-selfhost-web/src/services/webLoginGate.service.ts b/packages/hoppscotch-selfhost-web/src/services/webLoginGate.service.ts new file mode 100644 index 00000000..b76d2753 --- /dev/null +++ b/packages/hoppscotch-selfhost-web/src/services/webLoginGate.service.ts @@ -0,0 +1,13 @@ +import { Service } from "dioc" +import { getService } from "@hoppscotch/common/modules/dioc" +import { UIExtensionService } from "@hoppscotch/common/services/ui-extension.service" + +import WebLoginGate from "@app/components/WebLoginGate.vue" + +export class WebLoginGateService extends Service { + public static readonly ID = "WEB_LOGIN_GATE_SERVICE" + + override onServiceInit() { + getService(UIExtensionService).addRootUIExtension(WebLoginGate) + } +}