Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -446,6 +446,8 @@ tests/
translate-card.test.ts # popup translate input flow
login-password.test.ts # popup password form (ENABLE_PASSWORD_AUTH=true)
login-password-disabled.test.ts # popup password form hidden when flag is unset
login-analytics.test.ts # user_logged-in source, login_failed source + reason (E-03)
save-login-prompt.test.ts # "Login to save" prompt viewed once per word view, clicked (E-03)
language-state.test.ts # round-1 rules: dark gate, own language, save row, plan limit, deep link
language-catalog.test.ts # one RTL list, picker names, bundled Vazirmatn
own-language.test.ts # translate request with / without a server profile; language store
Expand Down
7 changes: 6 additions & 1 deletion src/background.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,8 +106,13 @@ chrome.runtime.onMessage.addListener(function (request, _sender, sendResponse) {
const left = screen.width / 2 - width / 2;
const top = screen.height / 2 - height / 2;

// The login source rides on the hash route's query (E-03).
const query = request.source
? `?source=${encodeURIComponent(request.source)}`
: "";

chrome.windows.create({
url: chrome.runtime.getURL("popup.html") + "#/login",
url: chrome.runtime.getURL("popup.html") + "#/login" + query,
type: "popup",
width: width,
height: height,
Expand Down
12 changes: 11 additions & 1 deletion src/common/types/messaging.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,11 +74,21 @@ export class StoreUserTokenMessage implements BaseMessage {
}
}

/**
* Where a login started (E-03). The background puts it on the login window's
* URL (`popup.html#/login?source=…`) so LoginView can send it with
* `user_logged-in` and `login_failed`. A login opened from the toolbar popup
* itself has no source on the URL and counts as "popup".
*/
export type LoginSource = "save_prompt" | "practice" | "popup";

export class OpenLoginWindowMessage implements BaseMessage {
type: string;
source?: LoginSource;

constructor() {
constructor(source?: LoginSource) {
this.type = MESSAGE_TYPE.OPEN_LOGIN_WINDOW;
if (source) this.source = source;
}

static is(message: any): message is OpenLoginWindowMessage {
Expand Down
2 changes: 1 addition & 1 deletion src/console-crane/modules/practice-config/index.vue
Original file line number Diff line number Diff line change
Expand Up @@ -324,7 +324,7 @@ function upgrade() {
/** Logged out — open the login window; the isLogin watch advances the page. */
function handleLoginRequest() {
analytic.track("practice-now_login-clicked");
sendMessage(new OpenLoginWindowMessage());
sendMessage(new OpenLoginWindowMessage("practice"));
}

function start() {
Expand Down
28 changes: 26 additions & 2 deletions src/console-crane/modules/word-detail/index.vue
Original file line number Diff line number Diff line change
Expand Up @@ -249,7 +249,7 @@

<!-- Login prompt: only when there's a translation worth saving -->
<button
v-else-if="!isLogin && wordData?.translation?.phrase"
v-else-if="showLoginPrompt"
type="button"
class="self-center inline-flex items-center gap-2 px-5 py-2.5 rounded-full bg-gradient-to-r from-pink-500 to-purple-600 text-white text-sm font-semibold shadow-sm hover:from-pink-600 hover:to-purple-700 transition"
@click="handleLoginRequest"
Expand Down Expand Up @@ -324,6 +324,10 @@ onActivated(() => {
return;
}
analytic.track("word-detail-page_viewed");
// A new view of the same word: the prompt may already be on screen, so the
// watch below would not fire again.
loginPromptTracked = false;
trackLoginPromptView();
});

/**
Expand Down Expand Up @@ -365,6 +369,24 @@ const frameSize = inject<{ width: number; height: number }>("frameSize");
// Main state variables
const wordData = ref<LanguageLearningData | null>(null); // Stores detailed linguistic data

/**
* "Login to save this phrase" (E-03). `save-login-prompt_viewed` fires once
* per word view, when the button first shows: a new word or a new view resets
* it, a sign-in state flip or a re-render does not.
*/
const showLoginPrompt = computed(
() => !isLogin.value && !!wordData.value?.translation?.phrase
);
let loginPromptTracked = false;

function trackLoginPromptView() {
if (loginPromptTracked || !showLoginPrompt.value) return;
loginPromptTracked = true;
analytic.track("save-login-prompt_viewed");
}

watch(showLoginPrompt, trackLoginPromptView);

const pending = ref(false); // Loading state
const error = ref<string | null>(null); // Translation error message, null when ok
const key = ref(new Date().getTime()); // Key for forcing component refresh
Expand Down Expand Up @@ -684,6 +706,7 @@ watch(
(value) => {
key.value = new Date().getTime();
wordData.value = null;
loginPromptTracked = false;
error.value = null;
lastSavedLanguage.value = null;

Expand Down Expand Up @@ -758,7 +781,8 @@ function retryFetch() {
* Opens login window via messaging system
*/
function handleLoginRequest() {
sendMessage(new OpenLoginWindowMessage());
analytic.track("save-login-prompt_clicked");
sendMessage(new OpenLoginWindowMessage("save_prompt"));
}
</script>

Expand Down
36 changes: 36 additions & 0 deletions src/popup/helper/login-analytics.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
/**
* Login funnel analytics helpers (E-03), used by LoginView.
*
* `user_logged-in` and `login_failed` carry `source` (where the login started)
* and, for a failure, `reason`. The funnel is
* save-login-prompt_viewed → save-login-prompt_clicked → user_logged-in
* (source save_prompt) → phrase_saved.
*/
import type { LoginSource } from "../../common/types/messaging";

export type LoginFailReason = "window_closed" | "google_error" | "server_error";

const LOGIN_SOURCES: readonly LoginSource[] = ["save_prompt", "practice", "popup"];

/**
* The `source` query on the login window's route. Anything missing or unknown
* is "popup": the login view opened straight from the toolbar popup.
*/
export function loginSourceFrom(value: unknown): LoginSource {
return LOGIN_SOURCES.includes(value as LoginSource)
? (value as LoginSource)
: "popup";
}

/**
* Why the Google step failed. chrome.identity reports a closed or cancelled
* consent window as `chrome.runtime.lastError` "The user did not approve
* access."; everything else (no token in the redirect, a page that could not
* load) is a Google-side error.
*/
export function googleFailReason(err: unknown): LoginFailReason {
const message = String((err as any)?.message ?? err ?? "");
return /did not approve|cancel|closed/i.test(message)
? "window_closed"
: "google_error";
}
2 changes: 1 addition & 1 deletion src/popup/views/IntroView.vue
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ import { useRouter } from "vue-router";
const router = useRouter();

function openLogin() {
chrome.runtime.sendMessage(new OpenLoginWindowMessage());
chrome.runtime.sendMessage(new OpenLoginWindowMessage("popup"));
}

const slides = [
Expand Down
37 changes: 35 additions & 2 deletions src/popup/views/LoginView.vue
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,7 @@

<script setup lang="ts">
import { computed, onMounted, ref } from "vue";
import { useRoute } from "vue-router";
import {
GetCurrentChromeUserToken,
LoginStatusResponse,
Expand All @@ -145,9 +146,24 @@ import {
loginWithLastSession,
isLogin,
} from "../../plugins/modular-rest";
import {
googleFailReason,
loginSourceFrom,
type LoginFailReason,
} from "../helper/login-analytics";

const pending = ref(false);

// Where this login started (E-03): the background opens the login window as
// `#/login?source=save_prompt|practice|popup`. No router (unit tests) or no
// query means the toolbar popup.
const route = useRoute();
const loginSource = loginSourceFrom(route?.query?.source);

function trackLoginFailed(reason: LoginFailReason) {
analytic.track("login_failed", { source: loginSource, reason });
}

const chromeUserRes = ref<LoginStatusResponse | null>();

// Build-flag gated: dev/agent-only password form. Stable/prod builds omit the
Expand Down Expand Up @@ -197,26 +213,41 @@ async function loginWithChrome() {

if (status == "success") {
await handleTokenLogin(token);
} else {
trackLoginFailed("server_error");
}
} else {
// getAuthToken gave no token: the Chrome profile has no Google account,
// or the user dismissed Chrome's consent prompt. Chrome does not tell
// these apart here.
trackLoginFailed("google_error");
}

pending.value = false;
}

async function loginWithGoogle() {
pending.value = true;
// Which step failed decides the login_failed reason.
let step: "google" | "server" = "google";

await authorizeFromGoogleOAuthThroughAuthFlowAPI()
.then((accessToken) => {
step = "server";
return get(
joinToBaseUrl(
`/auth/google/access-token-login?access_token=${accessToken}`
)
);
})
.then(({ token }) => handleTokenLogin(token))
.then(({ token }) => {
// Never store an empty token as a login.
if (!token) throw new Error("No token in the login response");
return handleTokenLogin(token);
})
.catch((err) => {
console.log("err", err);
trackLoginFailed(step === "google" ? googleFailReason(err) : "server_error");
})
.finally(() => {
pending.value = false;
Expand Down Expand Up @@ -252,7 +283,7 @@ async function handleTokenLogin(token: string) {
await loginWithLastSession();
// Fired only on an explicit login from this view — session restores on
// startup do not pass through here.
analytic.track("user_logged-in");
analytic.track("user_logged-in", { source: loginSource });
}

function launchWebAuthFlow(authURL) {
Expand Down Expand Up @@ -305,11 +336,13 @@ async function loginWithPassword() {
const token = authentication.getToken;
if (!token) {
loginError.value = "Invalid email or password.";
trackLoginFailed("server_error");
return;
}
await handleTokenLogin(token);
passwordValue.value = "";
} catch (err: any) {
trackLoginFailed("server_error");
// modular-rest rejects with the server response or a network error. We
// surface a generic message for the credential-mismatch case so we don't
// leak whether the email exists, and a network-specific message when the
Expand Down
Loading
Loading