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 { 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") {
|
||||
|
|
|
|||
|
|
@ -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