feat: require login for web app
This commit is contained in:
parent
dacca46fbe
commit
d01a384832
6 changed files with 316 additions and 1 deletions
203
docs/superpowers/plans/2026-05-06-require-web-login.md
Normal file
203
docs/superpowers/plans/2026-05-06-require-web-login.md
Normal file
|
|
@ -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
|
||||||
|
<template>
|
||||||
|
<Teleport to="body">
|
||||||
|
<div
|
||||||
|
v-if="shouldBlockApp"
|
||||||
|
class="fixed inset-0 z-50 flex min-h-screen flex-col items-center justify-center bg-primary p-6"
|
||||||
|
>
|
||||||
|
<AppLogo class="mb-8 h-16 w-16 rounded" />
|
||||||
|
<FirebaseLogin />
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from "vue"
|
||||||
|
import { useReadonlyStream } from "@hoppscotch/common/composables/stream"
|
||||||
|
import { shouldBlockAppForLogin } from "@hoppscotch/common/helpers/appLoginGate"
|
||||||
|
import { platform } from "@hoppscotch/common/platform"
|
||||||
|
|
||||||
|
const currentUser = useReadonlyStream(
|
||||||
|
platform.auth.getCurrentUserStream(),
|
||||||
|
platform.auth.getCurrentUser()
|
||||||
|
)
|
||||||
|
|
||||||
|
const shouldBlockApp = computed(() =>
|
||||||
|
shouldBlockAppForLogin({
|
||||||
|
platform: "web",
|
||||||
|
isAuthInitComplete: true,
|
||||||
|
currentUser: currentUser.value,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
</script>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **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"
|
||||||
|
```
|
||||||
|
|
@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
14
packages/hoppscotch-common/src/helpers/appLoginGate.ts
Normal file
14
packages/hoppscotch-common/src/helpers/appLoginGate.ts
Normal file
|
|
@ -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)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,31 @@
|
||||||
|
<template>
|
||||||
|
<Teleport to="body">
|
||||||
|
<div
|
||||||
|
v-if="shouldBlockApp"
|
||||||
|
class="fixed inset-0 z-50 flex min-h-screen flex-col items-center justify-center bg-primary p-6"
|
||||||
|
>
|
||||||
|
<AppLogo class="mb-8 h-16 w-16 rounded" />
|
||||||
|
<FirebaseLogin />
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from "vue"
|
||||||
|
import { useReadonlyStream } from "@hoppscotch/common/composables/stream"
|
||||||
|
import { shouldBlockAppForLogin } from "@hoppscotch/common/helpers/appLoginGate"
|
||||||
|
import { platform } from "@hoppscotch/common/platform"
|
||||||
|
|
||||||
|
const currentUser = useReadonlyStream(
|
||||||
|
platform.auth.getCurrentUserStream(),
|
||||||
|
platform.auth.getCurrentUser()
|
||||||
|
)
|
||||||
|
|
||||||
|
const shouldBlockApp = computed(() =>
|
||||||
|
shouldBlockAppForLogin({
|
||||||
|
platform: "web",
|
||||||
|
isAuthInitComplete: true,
|
||||||
|
currentUser: currentUser.value,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
</script>
|
||||||
|
|
@ -26,6 +26,7 @@ import { stdSupportOptionItems } from "@hoppscotch/common/platform/std/ui/suppor
|
||||||
import { InfraPlatform } from "@app/platform/infra/infra.platform"
|
import { InfraPlatform } from "@app/platform/infra/infra.platform"
|
||||||
import { kernelIO } from "@hoppscotch/common/platform/std/kernel-io"
|
import { kernelIO } from "@hoppscotch/common/platform/std/kernel-io"
|
||||||
import { HeaderDownloadableLinksService } from "@app/services/headerDownloadableLinks.service"
|
import { HeaderDownloadableLinksService } from "@app/services/headerDownloadableLinks.service"
|
||||||
|
import { WebLoginGateService } from "@app/services/webLoginGate.service"
|
||||||
|
|
||||||
import DesktopSettingsSection from "@hoppscotch/common/components/settings/Desktop.vue"
|
import DesktopSettingsSection from "@hoppscotch/common/components/settings/Desktop.vue"
|
||||||
|
|
||||||
|
|
@ -188,7 +189,7 @@ async function initApp() {
|
||||||
infra: InfraPlatform,
|
infra: InfraPlatform,
|
||||||
backend: stdBackendDef,
|
backend: stdBackendDef,
|
||||||
additionalLinks: [HeaderDownloadableLinksService],
|
additionalLinks: [HeaderDownloadableLinksService],
|
||||||
addedServices: [],
|
addedServices: platform === "web" ? [WebLoginGateService] : [],
|
||||||
})
|
})
|
||||||
|
|
||||||
if (platform === "desktop") {
|
if (platform === "desktop") {
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue