Skip to content

Commit b4b6e46

Browse files
navidshadclaude
andcommitted
feat(auth): add dev-gated password login + MCP-driven CCW agent workflow
Unblocks cloud Claude agents (Claude Code on the Web) from developing on the extension by adding a username/password login path that doesn't need Google OAuth, plus a chrome-extension-tester-mcp config so the agent can load the unpacked extension, drive the popup, and screenshot results. The new email/password form in LoginView is build-flag gated by ENABLE_PASSWORD_AUTH. Stable + dev release builds keep it off (form stays hidden, real users continue to use OAuth); CCW + verify-job builds enable it. The flow reuses the existing handleTokenLogin path so the JWT lands in chrome.storage.sync["token"] indistinguishable from an OAuth token — no downstream consumer (modular-rest client, profile store, translate service, ConsoleCrane) sees the difference. Coverage: - tests/login-password.test.ts + login-password-disabled.test.ts — Vitest, form rendering / validation / success+failure paths. - tests/e2e/password-login.spec.ts — Playwright, end-to-end against stubbed /user/login and /verify/token via the existing fixture. The agent path uses only the MCP; tests/e2e/ stays the testing ground and the two share no code. CLAUDE.md documents the one-time CCW setup (network access, env vars, setup script) plus the curl + token-inject sequence — including the gotcha that the password must be base64-encoded in the /user/login body since modular-rest's client lib does this internally and raw curl doesn't. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent cbc26fd commit b4b6e46

8 files changed

Lines changed: 748 additions & 14 deletions

File tree

.env.example

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,3 +6,7 @@ UNINSTALL_FORM_URL=
66
SUBTURTLE_API_URL=
77
SUBTURTLE_DASHBOARD_URL=
88
GOOGLE_OAUTH_CLIENT_ID=
9+
# dev/agent only — set to "true" in CCW or local dev builds to expose the
10+
# email+password form in the popup. Leave empty/false for stable + dev release
11+
# builds (form stays hidden, real users continue to use Google OAuth).
12+
ENABLE_PASSWORD_AUTH=

.github/workflows/release.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,7 @@ jobs:
8888
SUBTURTLE_API_URL=http://localhost:4173
8989
SUBTURTLE_DASHBOARD_URL=http://localhost:4173/_dashboard_stub
9090
GOOGLE_OAUTH_CLIENT_ID=ci_e2e_stub_oauth_client
91+
ENABLE_PASSWORD_AUTH=true
9192
EOF
9293
9394
- name: Build extension
@@ -169,6 +170,7 @@ jobs:
169170
SUBTURTLE_API_URL=${SUBTURTLE_API_URL}
170171
SUBTURTLE_DASHBOARD_URL=${SUBTURTLE_DASHBOARD_URL}
171172
GOOGLE_OAUTH_CLIENT_ID=${GOOGLE_OAUTH_CLIENT_ID}
173+
ENABLE_PASSWORD_AUTH=false
172174
EOF
173175
174176
- name: Bump versions for build

.mcp.json

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
{
2+
"mcpServers": {
3+
"chrome-extension-tester": {
4+
"command": "npx",
5+
"args": ["-y", "chrome-extension-tester-mcp@^2.1"]
6+
}
7+
}
8+
}

CLAUDE.md

Lines changed: 127 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,7 @@ And the `SettingsObject` type in [src/common/types/messaging.ts](src/common/type
141141
- **The mount root in Nibble must not block the page.** Set `width: 0; height: 0; position: fixed; top: 0; left: 0`. Children use their own `position: fixed` to position themselves relative to the viewport.
142142
- **Theme dark class lives on `.subturtle-scope`, not `<html>`.** Tailwind's `dark:` rules are rewritten by `postcss-prefix-selector` to `.subturtle-scope.dark ...` — so the same element must carry both classes. The settings store handles this and a `MutationObserver` keeps Vue Teleport subtrees in sync.
143143
- **`src/stores/profile.ts` imports types from a sibling repo.** The path `../../../dashboard-app/frontend/types/database.type` resolves to a directory _next to_ this repo's root, not inside it. The actual repo is [`codebridger/subturtle-dashboard-app`](https://github.com/codebridger/subturtle-dashboard-app); local builds work because devs check both repos out side-by-side. CI clones the dashboard repo into `../dashboard-app/` before `yarn build` runs (see [.github/workflows/release.yml](.github/workflows/release.yml)). Don't try to "fix" the import to a relative-internal path or vendor the file — both will drift.
144+
- **Playwright Chromium download isn't on CCW's Trusted allowlist.** The chrome-extension-tester-mcp's `postinstall` runs `playwright install chromium`, which pulls from `cdn.playwright.dev` / `playwright.download.prss.microsoft.com`. CCW environments must use Custom network access with those hosts added — see [§ Cloud agent workflow](#cloud-agent-workflow-claude-code-on-the-web). The setup script caches Chromium into the VM snapshot so per-session cost is zero.
144145

145146
## Adding things
146147

@@ -312,6 +313,8 @@ tests/
312313
selection-popup.test.ts # @mousedown.prevent.stop regression
313314
nibble-surface.test.ts # bridge-driven hide/show
314315
translate-card.test.ts # popup translate input flow
316+
login-password.test.ts # popup password form (ENABLE_PASSWORD_AUTH=true)
317+
login-password-disabled.test.ts # popup password form hidden when flag is unset
315318
e2e/
316319
extension-fixture.ts # chromium.launchPersistentContext + extension load
317320
server.mjs # static fixtures HTTP server
@@ -320,12 +323,132 @@ tests/
320323
nibble-flow.spec.ts # content script mounting + Persian emitOpen
321324
console-crane-lifecycle.spec.ts # modal stays open while Nibble toggles off
322325
translate-flow.spec.ts # full Persian translate-and-save with page.route stubs
326+
password-login.spec.ts # popup password form end-to-end with stubbed /user/login
323327
visual-scale.spec.ts # rem→px rewrite regression net
324328
```
325329

326330
### Test totals
327331

328-
79 unit / component tests across 9 files; 11 E2E specs across 5 files. Full suite runs in ~15s once Playwright's Chromium is warm.
332+
138 unit / component tests across 19 files; 16 E2E specs across 6 files. Full suite runs in ~20s once Playwright's Chromium is warm.
333+
334+
## Cloud agent workflow (Claude Code on the Web)
335+
336+
Lets a cloud Claude agent on [Claude Code on the Web (CCW)](https://code.claude.com/docs/en/web-quickstart) clone the repo, build the extension, install it into a headless Chromium, log in with username/password against the live dev server at `https://dev.dashboard.subturtle.app/`, and drive popup + content scripts with screenshots — no Google OAuth, no local backend.
337+
338+
The agent path uses **only** the [chrome-extension-tester-mcp](https://github.com/BHUVAN-RJ/chrome-extension-testing-mcp) MCP server (declared in [.mcp.json](.mcp.json)). It is independent of the `tests/e2e/` Playwright fixture and shares no code with it; CI verify runs the spec, the agent runs the MCP.
339+
340+
### One-time CCW environment setup
341+
342+
Done once per CCW environment from [claude.ai/code](https://claude.ai/code) — these settings live in the cloud UI, not the repo.
343+
344+
**Network access:** `Custom`, keeping the Trusted defaults plus Playwright's Chromium-download hosts. Without these, the setup script's `npm install -g chrome-extension-tester-mcp` hangs while pulling Chromium:
345+
```
346+
cdn.playwright.dev
347+
playwright.download.prss.microsoft.com
348+
```
349+
350+
**Environment variables** (`.env` format, no quotes):
351+
```
352+
ENABLE_PASSWORD_AUTH=true
353+
SUBTURTLE_API_URL=https://dev.dashboard.subturtle.app
354+
SUBTURTLE_DASHBOARD_URL=https://dev.dashboard.subturtle.app
355+
AGENT_EMAIL=<provided by user>
356+
AGENT_PASSWORD=<provided by user>
357+
# stubs sufficient for the build, not used by the agent flow:
358+
MIXPANEL_PROJECT_TOKEN=dev_stub
359+
MIXPANEL_API_HOST=https://api-js.mixpanel.com
360+
GOOGLE_TRANSLATE_KEY=dev_stub
361+
GOOGLE_TRANSLATE_PROXY_URL=https://translate.googleapis.com
362+
UNINSTALL_FORM_URL=https://example.com/uninstall
363+
GOOGLE_OAUTH_CLIENT_ID=dev_stub
364+
```
365+
366+
`AGENT_EMAIL` / `AGENT_PASSWORD` are not consumed by the build — they exist so the agent's Bash step can reference `$AGENT_EMAIL` / `$AGENT_PASSWORD` without hardcoding into the prompt. They must match an account that exists on the dev server (the agent does not register; the human or dashboard team seeds the account).
367+
368+
**Setup script** (runs as root on Ubuntu 24.04, cached as a VM snapshot — first session ~5 min, subsequent ones reuse the cache):
369+
```bash
370+
#!/bin/bash
371+
set -e
372+
373+
cd "${CLAUDE_PROJECT_DIR:-/workspace}"
374+
375+
# 1. Install extension deps.
376+
yarn install --frozen-lockfile
377+
378+
# 2. Materialize .env.production from CCW env vars (dotenv-webpack's safe:true
379+
# requires every key in .env.example to be present at build time).
380+
cat > .env.production <<EOF
381+
MIXPANEL_PROJECT_TOKEN=${MIXPANEL_PROJECT_TOKEN}
382+
MIXPANEL_API_HOST=${MIXPANEL_API_HOST}
383+
GOOGLE_TRANSLATE_KEY=${GOOGLE_TRANSLATE_KEY}
384+
GOOGLE_TRANSLATE_PROXY_URL=${GOOGLE_TRANSLATE_PROXY_URL}
385+
UNINSTALL_FORM_URL=${UNINSTALL_FORM_URL}
386+
SUBTURTLE_API_URL=${SUBTURTLE_API_URL}
387+
SUBTURTLE_DASHBOARD_URL=${SUBTURTLE_DASHBOARD_URL}
388+
GOOGLE_OAUTH_CLIENT_ID=${GOOGLE_OAUTH_CLIENT_ID}
389+
ENABLE_PASSWORD_AUTH=${ENABLE_PASSWORD_AUTH}
390+
EOF
391+
392+
# 3. Build the extension once into dist/.
393+
NODE_ENV=production yarn build
394+
395+
# 4. Install the MCP globally so Playwright Chromium is downloaded once
396+
# into the cached snapshot. npx in .mcp.json resolves to this install.
397+
npm install -g chrome-extension-tester-mcp@^2.1
398+
```
399+
400+
### Driving the extension via the MCP
401+
402+
Once the environment is set up and the agent session starts, the cached snapshot already has `dist/` built and Playwright Chromium installed. The full login + screenshot loop becomes:
403+
404+
```
405+
# 1. Load the unpacked extension into headless Chromium.
406+
load_extension({ extension_path: "$PWD/dist" })
407+
408+
# 2. Get a JWT from the dev server with the credentials in CCW env vars.
409+
# modular-rest's authentication.login POSTs to /user/login.
410+
# The password MUST be base64-encoded — modular-rest's client library
411+
# does this internally, but raw curl has to pre-encode. Without it
412+
# the server returns HTTP 412 {"status":"error","e":{}}.
413+
PW_B64=$(printf '%s' "$AGENT_PASSWORD" | base64)
414+
curl -sX POST "$SUBTURTLE_API_URL/user/login" \
415+
-H 'Content-Type: application/json' \
416+
-d "{\"idType\":\"email\",\"id\":\"$AGENT_EMAIL\",\"password\":\"$PW_B64\"}"
417+
# → { "status": "success", "token": "<jwt>" }
418+
419+
# 3. Inject the JWT into chrome.storage.sync — same slot background.ts:62
420+
# reads on every load. The extension is now "logged in".
421+
extension_storage({ action: "set", area: "sync", data: { token: "<jwt>" } })
422+
423+
# 4. Open the popup and screenshot the logged-in view.
424+
interact_with_popup({ action: "open" })
425+
take_screenshot({ output_path: ".agent/popup.png" })
426+
# Expected: "Logged In Successfully!" view (LoginView.vue:57-68 v-else branch).
427+
```
428+
429+
The MCP exposes 14 tools — others worth knowing about: `inspect_dom` (eval JS in a page), `monitor_network` (capture requests during navigation), `send_message_to_background` (drive `chrome.runtime.onMessage` listeners), `get_service_worker_logs` (read background SW console output), `run_assertion` (returns structured PASS/FAIL). Full reference: the [chrome-extension-tester-mcp README](https://github.com/BHUVAN-RJ/chrome-extension-testing-mcp).
430+
431+
### Why password auth exists in this build
432+
433+
`ENABLE_PASSWORD_AUTH` gates the email + password form in [src/popup/views/LoginView.vue](src/popup/views/LoginView.vue) at build time via `dotenv-webpack`. CCW builds set it true so the agent (or a human dev) can log in by typing credentials; stable + dev release builds in [.github/workflows/release.yml](.github/workflows/release.yml) set it false so production users see only Google OAuth. The agent's direct-API path doesn't need the UI, but the UI is what makes manual testing possible.
434+
435+
### How auth works under the hood
436+
437+
The agent's direct-API path POSTs `/user/login` (via `curl`) and gets a JWT. Injecting that JWT into `chrome.storage.sync["token"]` (via the MCP's `extension_storage` tool) lands it in the *same slot* the post-OAuth `StoreUserTokenMessage` path uses — see [src/background.ts:62](src/background.ts) for the read side. modular-rest's client, the profile store, the translate service, and ConsoleCrane all see no difference between an OAuth-issued token and a password-issued token.
438+
439+
### Local dev fallback (no CCW, no MCP)
440+
441+
```bash
442+
echo "ENABLE_PASSWORD_AUTH=true" >> .env.production # plus the other 8 keys
443+
yarn build && yarn dev # webpack --watch
444+
# Load dist/ at chrome://extensions, click the extension icon, use the form.
445+
```
446+
447+
The popup form drives `authentication.login` from `@modular-rest/client`, hitting whatever `SUBTURTLE_API_URL` points at. No MCP, no Playwright — just the same UI a real user would see.
448+
449+
### Boundary
450+
451+
The agent path uses only the MCP. `tests/e2e/` is the testing ground for CI verify and stays untouched by agent tooling; the two never share code. If you need to add a new agent capability, route it through the MCP's tools or a new MCP — not through the Playwright fixture.
329452

330453
## Verification checklist
331454

@@ -341,6 +464,7 @@ Automated:
341464
- Per-host Nibble toggle persists and normalizes (`www.` strip, case fold, dedup). → [tests/settings-host.test.ts](tests/settings-host.test.ts).
342465
- ConsoleCrane on Persian / CJK / emoji inputs throws no `InvalidCharacterError` from `btoa` — covered at the encode level, the bridge level, and the full select-and-save flow. → [tests/route-params.test.ts](tests/route-params.test.ts), [tests/e2e/nibble-flow.spec.ts](tests/e2e/nibble-flow.spec.ts), [tests/e2e/translate-flow.spec.ts](tests/e2e/translate-flow.spec.ts).
343466
- Visual scale is consistent on default-html-fontsize and 24px-html-fontsize hosts (postcss `rem→px` rewrite regression net). → [tests/e2e/visual-scale.spec.ts](tests/e2e/visual-scale.spec.ts).
467+
- Password login form: build-flag gating, validation, success path lands JWT in `chrome.storage.sync["token"]`, 401 surfaces inline error. → [tests/login-password.test.ts](tests/login-password.test.ts), [tests/login-password-disabled.test.ts](tests/login-password-disabled.test.ts), [tests/e2e/password-login.spec.ts](tests/e2e/password-login.spec.ts).
344468

345469
Still manual:
346470

@@ -374,3 +498,5 @@ Still manual:
374498
- Playwright fixtures server: [tests/e2e/server.mjs](tests/e2e/server.mjs)
375499
- Typecheck wrapper (with upstream-error filter): [scripts/typecheck.mjs](scripts/typecheck.mjs)
376500
- Vue 3 SFC ambient declaration: [src/vue-shim.d.ts](src/vue-shim.d.ts)
501+
- MCP server config (chrome-extension-tester for CCW): [.mcp.json](.mcp.json)
502+
- Popup LoginView (password form + OAuth buttons): [src/popup/views/LoginView.vue](src/popup/views/LoginView.vue)

src/popup/views/LoginView.vue

Lines changed: 137 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -39,18 +39,77 @@
3939
<div class="flex-1 text-left px-4">With Google Account</div>
4040
</button>
4141

42-
<!-- Login with Phone -->
43-
<button
44-
disabled
45-
class="flex items-center justify-center text-gray-900 dark:text-white bg-gray-100 dark:bg-gray-800 rounded-md hover:bg-gray-200 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed"
46-
>
47-
<div
48-
class="p-2 py-3 flex justify-center items-center border-r-[1px] border-gray-300 dark:border-gray-700"
42+
<!-- Login with Email & Password (dev/agent only, gated by ENABLE_PASSWORD_AUTH at build time) -->
43+
<template v-if="enablePasswordAuth">
44+
<button
45+
v-if="!passwordFormOpen"
46+
:disabled="pending"
47+
@click="passwordFormOpen = true"
48+
data-testid="open-password-form"
49+
class="flex items-center justify-center text-gray-900 dark:text-white bg-gray-100 dark:bg-gray-800 rounded-md hover:bg-gray-200 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed"
4950
>
50-
<span class="i-flat-color-icons-phone" />
51-
</div>
52-
<div class="flex-1 text-left px-4">With Phone Number</div>
53-
</button>
51+
<div
52+
class="p-2 py-3 flex justify-center items-center border-r-[1px] border-gray-300 dark:border-gray-700"
53+
>
54+
<span class="i-flat-color-icons-key" />
55+
</div>
56+
<div class="flex-1 text-left px-4">With Email & Password</div>
57+
</button>
58+
59+
<form
60+
v-else
61+
@submit.prevent="loginWithPassword"
62+
data-testid="password-form"
63+
class="flex flex-col space-y-2 bg-gray-100 dark:bg-gray-800 rounded-md p-3"
64+
>
65+
<input
66+
v-model="passwordEmail"
67+
type="email"
68+
autocomplete="email"
69+
required
70+
placeholder="Email"
71+
data-testid="password-email"
72+
:disabled="pending"
73+
class="px-3 py-2 rounded text-gray-900 dark:text-white bg-white dark:bg-gray-900 border border-gray-300 dark:border-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:opacity-50"
74+
/>
75+
<input
76+
v-model="passwordValue"
77+
type="password"
78+
autocomplete="current-password"
79+
required
80+
placeholder="Password"
81+
data-testid="password-value"
82+
:disabled="pending"
83+
class="px-3 py-2 rounded text-gray-900 dark:text-white bg-white dark:bg-gray-900 border border-gray-300 dark:border-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:opacity-50"
84+
/>
85+
<p
86+
v-if="loginError"
87+
data-testid="password-error"
88+
class="text-red-600 dark:text-red-400 text-xs"
89+
>
90+
{{ loginError }}
91+
</p>
92+
<div class="flex space-x-2">
93+
<button
94+
type="button"
95+
@click="closePasswordForm"
96+
:disabled="pending"
97+
data-testid="password-cancel"
98+
class="px-3 py-2 rounded text-gray-700 dark:text-gray-200 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 disabled:opacity-50"
99+
>
100+
Back
101+
</button>
102+
<button
103+
type="submit"
104+
:disabled="pending || !isPasswordFormValid"
105+
data-testid="password-submit"
106+
class="flex-1 px-3 py-2 rounded text-white bg-blue-600 hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
107+
>
108+
{{ pending ? "Signing in…" : "Sign in" }}
109+
</button>
110+
</div>
111+
</form>
112+
</template>
54113
</div>
55114
</template>
56115

@@ -70,7 +129,7 @@
70129
</template>
71130

72131
<script setup lang="ts">
73-
import { onMounted, ref } from "vue";
132+
import { computed, onMounted, ref } from "vue";
74133
import {
75134
GetCurrentChromeUserToken,
76135
LoginStatusResponse,
@@ -79,12 +138,32 @@ import {
79138
import { sendMessage, sendMessageToTabs } from "../../common/helper/massage";
80139
import { get } from "../helper/http";
81140
import { joinToBaseUrl } from "../../common/helper/url";
82-
import { loginWithLastSession, isLogin } from "../../plugins/modular-rest";
141+
import {
142+
authentication,
143+
loginWithLastSession,
144+
isLogin,
145+
} from "../../plugins/modular-rest";
83146
84147
const pending = ref(false);
85148
86149
const chromeUserRes = ref<LoginStatusResponse | null>();
87150
151+
// Build-flag gated: dev/agent-only password form. Stable/prod builds omit the
152+
// key in .env.production so this resolves to false and the form is hidden.
153+
const enablePasswordAuth = process.env.ENABLE_PASSWORD_AUTH === "true";
154+
155+
const passwordFormOpen = ref(false);
156+
const passwordEmail = ref("");
157+
const passwordValue = ref("");
158+
const loginError = ref<string | null>(null);
159+
160+
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
161+
const isPasswordFormValid = computed(
162+
() =>
163+
emailRegex.test(passwordEmail.value.trim()) &&
164+
passwordValue.value.length >= 8
165+
);
166+
88167
onMounted(async () => {
89168
chromeUserRes.value = await sendMessage<LoginStatusResponse>(
90169
new GetCurrentChromeUserToken()
@@ -195,6 +274,51 @@ function launchWebAuthFlow(authURL) {
195274
});
196275
}
197276
277+
async function loginWithPassword() {
278+
if (!isPasswordFormValid.value) return;
279+
pending.value = true;
280+
loginError.value = null;
281+
282+
try {
283+
await authentication.login(
284+
{
285+
idType: "email",
286+
id: passwordEmail.value.trim(),
287+
password: passwordValue.value,
288+
},
289+
true
290+
);
291+
292+
const token = authentication.getToken;
293+
if (!token) {
294+
loginError.value = "Invalid email or password.";
295+
return;
296+
}
297+
await handleTokenLogin(token);
298+
passwordValue.value = "";
299+
} catch (err: any) {
300+
// modular-rest rejects with the server response or a network error. We
301+
// surface a generic message for the credential-mismatch case so we don't
302+
// leak whether the email exists, and a network-specific message when the
303+
// request never reached the server.
304+
if (err instanceof TypeError || err?.message?.includes("Failed to fetch")) {
305+
loginError.value = "Could not reach the server.";
306+
} else if (err?.status === false || err?.error) {
307+
loginError.value = "Invalid email or password.";
308+
} else {
309+
loginError.value = "Something went wrong. Please try again.";
310+
}
311+
} finally {
312+
pending.value = false;
313+
}
314+
}
315+
316+
function closePasswordForm() {
317+
passwordFormOpen.value = false;
318+
passwordValue.value = "";
319+
loginError.value = null;
320+
}
321+
198322
function closeWindow() {
199323
window.close();
200324
}

0 commit comments

Comments
 (0)