diff --git a/src/__tests__/tui/auth-effects.test.ts b/src/__tests__/tui/auth-effects.test.ts index d669f39..9229b0c 100644 --- a/src/__tests__/tui/auth-effects.test.ts +++ b/src/__tests__/tui/auth-effects.test.ts @@ -41,11 +41,20 @@ const { mockCredentials, mockLeetCodeClient } = vi.hoisted(() => ({ getPath: vi.fn(() => '/tmp/.leetcode/credentials.v2.enc.json'), }, mockLeetCodeClient: { + setSite: vi.fn(), setCredentials: vi.fn(), checkAuth: vi.fn(), }, })); +const { mockConfig } = vi.hoisted(() => ({ + mockConfig: { + setSite: vi.fn(), + getSite: vi.fn(() => 'leetcode.com'), + getConfig: vi.fn(() => ({ site: 'leetcode.com' })), + }, +})); + vi.mock('../../storage/credentials.js', () => ({ credentials: mockCredentials, describeCredentialStatus: vi.fn((status: { reason: string | null }) => { @@ -60,6 +69,10 @@ vi.mock('../../api/client.js', () => ({ leetcodeClient: mockLeetCodeClient, })); +vi.mock('../../storage/config.js', () => ({ + config: mockConfig, +})); + import { executeCommand } from '../../tui/commands/effects.js'; async function flushAsync(): Promise { @@ -84,7 +97,7 @@ describe('TUI auth effects', () => { const dispatched: AppMsg[] = []; executeCommand( - { type: 'CMD_LOGIN', session: 'session-token', csrf: 'csrf-token' }, + { type: 'CMD_LOGIN', session: 'session-token', csrf: 'csrf-token', site: 'leetcode.com' }, (msg) => dispatched.push(msg) ); @@ -109,7 +122,7 @@ describe('TUI auth effects', () => { const dispatched: AppMsg[] = []; executeCommand( - { type: 'CMD_LOGIN', session: 'session-token', csrf: 'csrf-token' }, + { type: 'CMD_LOGIN', session: 'session-token', csrf: 'csrf-token', site: 'leetcode.com' }, (msg) => dispatched.push(msg) ); @@ -165,4 +178,32 @@ describe('TUI auth effects', () => { expect(dispatched).toContainEqual({ type: 'GLOBAL_ERROR', error: 'System keychain is unavailable.' }); expect(dispatched).toContainEqual({ type: 'AUTH_CHECK_COMPLETE', user: null }); }); + + it('should persist selected site before verifying login credentials', async () => { + mockCredentials.status.mockResolvedValueOnce({ + mode: 'keychain', + backend: 'keychain', + source: null, + hasCredentials: false, + readOnly: false, + reason: null, + path: null, + } as any); + mockLeetCodeClient.checkAuth.mockResolvedValueOnce({ + isSignedIn: true, + username: 'dong', + }); + + const dispatched: AppMsg[] = []; + executeCommand( + { type: 'CMD_LOGIN', session: 'session-token', csrf: 'csrf-token', site: 'leetcode.cn' }, + (msg) => dispatched.push(msg) + ); + + await flushAsync(); + + expect(mockConfig.setSite).toHaveBeenCalledWith('leetcode.cn'); + expect(mockLeetCodeClient.setSite).toHaveBeenCalledWith('leetcode.cn'); + expect(dispatched).toContainEqual({ type: 'LOGIN_SUCCESS', username: 'dong' }); + }); }); diff --git a/src/__tests__/tui/login-screen.test.ts b/src/__tests__/tui/login-screen.test.ts new file mode 100644 index 0000000..c6e7cb5 --- /dev/null +++ b/src/__tests__/tui/login-screen.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it, vi } from 'vitest'; + +const { mockConfig } = vi.hoisted(() => ({ + mockConfig: { + getSite: vi.fn(() => 'leetcode.com'), + getConfig: vi.fn(() => ({ site: 'leetcode.com' })), + }, +})); + +vi.mock('../../storage/config.js', () => ({ + config: mockConfig, +})); + +import { init, update } from '../../tui/screens/login/index.js'; + +describe('TUI login screen', () => { + it('should move from instructions to site selection before credential entry', () => { + const [initialModel] = init(); + + const [siteModel] = update({ type: 'LOGIN_SUBMIT' }, initialModel); + const [inputModel] = update({ type: 'LOGIN_SUBMIT' }, siteModel); + + expect(siteModel.step).toBe('site'); + expect(inputModel.step).toBe('input'); + expect(inputModel.site).toBe('leetcode.com'); + }); + + it('should include selected site in the login command payload', () => { + const [initialModel] = init(); + const [siteModel] = update({ type: 'LOGIN_SUBMIT' }, initialModel); + const [cnModel] = update({ type: 'LOGIN_SWITCH_SITE' }, siteModel); + const [inputModel] = update({ type: 'LOGIN_SUBMIT' }, cnModel); + const [sessionModel] = update({ type: 'LOGIN_SESSION_INPUT', value: 'session-token' }, inputModel); + const [readyModel] = update({ type: 'LOGIN_CSRF_INPUT', value: 'csrf-token' }, sessionModel); + const [, cmd] = update({ type: 'LOGIN_SUBMIT' }, readyModel); + + expect(cmd).toEqual({ + type: 'CMD_LOGIN', + session: 'session-token', + csrf: 'csrf-token', + site: 'leetcode.cn', + }); + }); +}); diff --git a/src/tui/commands/effects.ts b/src/tui/commands/effects.ts index cc4bc85..ba0450f 100644 --- a/src/tui/commands/effects.ts +++ b/src/tui/commands/effects.ts @@ -123,7 +123,7 @@ export function executeCommand(cmd: Command, dispatch: Dispatch): void { return; case 'CMD_LOGIN': - login(cmd.session, cmd.csrf, dispatch); + login(cmd.session, cmd.csrf, cmd.site, dispatch); return; default: @@ -554,7 +554,12 @@ async function logout(dispatch: Dispatch): Promise { dispatch({ type: 'AUTH_CHECK_COMPLETE', user: null }); } -async function login(session: string, csrf: string, dispatch: Dispatch): Promise { +async function login( + session: string, + csrf: string, + site: import('../../types.js').LeetCodeSite, + dispatch: Dispatch +): Promise { const credentialStatus = await credentials.status(); if (credentialStatus.mode === 'env-readonly') { dispatch({ @@ -573,6 +578,9 @@ async function login(session: string, csrf: string, dispatch: Dispatch): Promise return; } + config.setSite(site); + configureLeetCodeClientSite(site); + const creds = { session, csrfToken: csrf }; leetcodeClient.setCredentials(creds); diff --git a/src/tui/screens/login/index.ts b/src/tui/screens/login/index.ts index e5e57e4..000ddcd 100644 --- a/src/tui/screens/login/index.ts +++ b/src/tui/screens/login/index.ts @@ -1,12 +1,17 @@ -import { AppModel, Cmd, Command, LoginScreenModel, LoginMsg } from '../../types.js'; -import { leetcodeClient } from '../../../api/client.js'; -import { credentials } from '../../../storage/credentials.js'; +import { Cmd, Command, LoginScreenModel, LoginMsg } from '../../types.js'; +import { config } from '../../../storage/config.js'; +import { DEFAULT_LEETCODE_SITE, normalizeLeetCodeSiteInput } from '../../../utils/site.js'; export { view } from './view.js'; +function getConfiguredSite() { + return normalizeLeetCodeSiteInput(config.getSite?.() ?? config.getConfig?.().site ?? '') ?? DEFAULT_LEETCODE_SITE; +} + export function init(): [LoginScreenModel, Command] { const model: LoginScreenModel = { step: 'instructions', + site: getConfiguredSite(), sessionToken: '', csrfToken: '', focusedField: 'session', @@ -17,6 +22,16 @@ export function init(): [LoginScreenModel, Command] { export function update(msg: LoginMsg, model: LoginScreenModel): [LoginScreenModel, Command] { switch (msg.type) { + case 'LOGIN_SWITCH_SITE': + return [ + { + ...model, + site: model.site === 'leetcode.cn' ? 'leetcode.com' : 'leetcode.cn', + error: null, + }, + Cmd.none(), + ]; + case 'LOGIN_SESSION_INPUT': return [{ ...model, sessionToken: msg.value }, Cmd.none()]; @@ -33,18 +48,24 @@ export function update(msg: LoginMsg, model: LoginScreenModel): [LoginScreenMode return [{ ...model, focusedField: msg.field }, Cmd.none()]; case 'LOGIN_BACK': + if (model.step === 'input' || model.step === 'error' || model.step === 'verifying') { + return [{ ...model, step: 'site', focusedField: 'session', error: null }, Cmd.none()]; + } return [{ ...model, step: 'instructions', focusedField: 'session', error: null }, Cmd.none()]; case 'LOGIN_SUBMIT': if (model.step === 'instructions') { - return [{ ...model, step: 'input', focusedField: 'session' }, Cmd.none()]; + return [{ ...model, step: 'site', focusedField: 'session', error: null }, Cmd.none()]; + } + if (model.step === 'site') { + return [{ ...model, step: 'input', focusedField: 'session', error: null }, Cmd.none()]; } if (!model.sessionToken || !model.csrfToken) { return [{ ...model, error: 'Both fields are required' }, Cmd.none()]; } return [ { ...model, step: 'verifying', error: null }, - Cmd.login(model.sessionToken, model.csrfToken), + Cmd.login(model.sessionToken, model.csrfToken, model.site), ]; case 'LOGIN_SUCCESS': diff --git a/src/tui/screens/login/view.ts b/src/tui/screens/login/view.ts index c70a2c2..4a865e9 100644 --- a/src/tui/screens/login/view.ts +++ b/src/tui/screens/login/view.ts @@ -32,13 +32,9 @@ export function view(model: LoginScreenModel, width: number, height: number): st const instructions = [ chalk.hex(colors.warning).bold('How to Login:'), '', - '1. Open https://leetcode.com in your browser', - '2. Login to your account', - '3. Open DevTools (F12) → Application → Cookies → leetcode.com', - '4. Copy the values of ' + - chalk.bold.cyan('LEETCODE_SESSION') + - ' and ' + - chalk.bold.cyan('csrftoken'), + '1. Continue to choose your LeetCode site', + '2. We will show the correct cookie domain on the next screen', + '3. Copy the values of ' + chalk.bold.cyan('LEETCODE_SESSION') + ' and ' + chalk.bold.cyan('csrftoken'), '', 'Default storage: system keychain.', 'Use LEETCODECLI_CREDENTIAL_BACKEND=file + LEETCODECLI_MASTER_KEY for encrypted file mode.', @@ -54,10 +50,34 @@ export function view(model: LoginScreenModel, width: number, height: number): st boxed.forEach((l) => contentLines.push(center(l, width))); contentLines.push(''); // Spacing - contentLines.push(center(keyHint('Enter', 'Continue to Login'), width)); + contentLines.push(center(keyHint('Enter', 'Choose Site'), width)); + } else if (model.step === 'site') { + const contentWidth = Math.max(28, Math.min(100, width - 4)); + const options = [ + model.site === 'leetcode.com' + ? chalk.hex(colors.primary)('> leetcode.com (Global)') + : ' leetcode.com (Global)', + model.site === 'leetcode.cn' + ? chalk.hex(colors.primary)('> leetcode.cn (China)') + : ' leetcode.cn (China)', + '', + chalk.gray('Use Up/Down/Tab to switch sites.'), + ]; + + const boxed = box(options, contentWidth, { + title: 'Choose Site', + borderColor: colors.primary, + padding: 1, + borderStyle: 'round', + }); + + boxed.forEach((l) => contentLines.push(center(l, width))); + contentLines.push(''); + contentLines.push(center(keyHint('Enter', 'Continue') + ' ' + keyHint('Esc', 'Back'), width)); } else if (model.step === 'input' || model.step === 'verifying' || model.step === 'error') { const contentWidth = Math.max(28, Math.min(100, width - 4)); const boxLines = []; + const domain = model.site === 'leetcode.cn' ? 'leetcode.cn' : 'leetcode.com'; const isSessionFocused = model.focusedField === 'session'; const isCsrfFocused = model.focusedField === 'csrf'; @@ -95,6 +115,8 @@ export function view(model: LoginScreenModel, width: number, height: number): st : ' ' + csrfVal + (model.csrfToken ? chalk.green(' ✔') : '') ); + boxLines.push(''); + boxLines.push(chalk.gray(`Cookie source: https://${domain} → DevTools → Application → Cookies → ${domain}`)); boxLines.push(''); if (model.error) { boxLines.push(center(chalk.red(model.error), contentWidth - 4)); diff --git a/src/tui/types.ts b/src/tui/types.ts index 72ffadb..e286ae1 100644 --- a/src/tui/types.ts +++ b/src/tui/types.ts @@ -4,6 +4,7 @@ import type { ProblemListFilters as RootProblemListFilters, DailyChallenge, } from '../types.js'; +import type { LeetCodeSite } from '../types.js'; export type Problem = RootProblem; export type ProblemDetail = RootProblemDetail; @@ -133,7 +134,8 @@ export interface HelpScreenModel { } export interface LoginScreenModel { - readonly step: 'instructions' | 'input' | 'verifying' | 'success' | 'error'; + readonly step: 'instructions' | 'site' | 'input' | 'verifying' | 'success' | 'error'; + readonly site: LeetCodeSite; readonly sessionToken: string; readonly csrfToken: string; readonly focusedField: 'session' | 'csrf'; @@ -356,6 +358,7 @@ export type HelpMsg = export type LoginMsg = | { readonly type: 'LOGIN_SESSION_INPUT'; readonly value: string } | { readonly type: 'LOGIN_CSRF_INPUT'; readonly value: string } + | { readonly type: 'LOGIN_SWITCH_SITE' } | { readonly type: 'LOGIN_SUBMIT' } | { readonly type: 'LOGIN_BACK' } | { readonly type: 'LOGIN_SWITCH_FOCUS' } @@ -438,7 +441,7 @@ export type Command = | { readonly type: 'CMD_SWITCH_WORKSPACE'; readonly name: string } | { readonly type: 'CMD_FETCH_CHANGELOG' } | { readonly type: 'CMD_LOGOUT' } - | { readonly type: 'CMD_LOGIN'; readonly session: string; readonly csrf: string }; + | { readonly type: 'CMD_LOGIN'; readonly session: string; readonly csrf: string; readonly site: LeetCodeSite }; export const Cmd = { none: (): Command => ({ type: 'CMD_NONE' }), @@ -482,7 +485,12 @@ export const Cmd = { switchWorkspace: (name: string): Command => ({ type: 'CMD_SWITCH_WORKSPACE', name }), fetchChangelog: (): Command => ({ type: 'CMD_FETCH_CHANGELOG' }), logout: (): Command => ({ type: 'CMD_LOGOUT' }), - login: (session: string, csrf: string): Command => ({ type: 'CMD_LOGIN', session, csrf }), + login: (session: string, csrf: string, site: LeetCodeSite): Command => ({ + type: 'CMD_LOGIN', + session, + csrf, + site, + }), }; export function createInitialModel(username?: string): AppModel { diff --git a/src/tui/update.ts b/src/tui/update.ts index e7948cd..df1afcb 100644 --- a/src/tui/update.ts +++ b/src/tui/update.ts @@ -127,13 +127,7 @@ export function update(msg: AppMsg, model: AppModel): [AppModel, Command] { user: null, screenState: { screen: 'login', - model: { - step: 'instructions', - sessionToken: '', - csrfToken: '', - focusedField: 'session', - error: null, - }, + model: LoginScreen.init()[0], }, needsRender: true, }, @@ -973,6 +967,14 @@ function handleLoginKeyPress(model: AppModel, msg: AppMsg): [AppModel, Command] } else if (key.name === 'escape') { return [model, Cmd.exit()]; } + } else if (loginModel.step === 'site') { + if (key.name === 'escape') { + return updateLogin(model, { type: 'LOGIN_BACK' }); + } else if (key.name === 'tab' || key.name === 'down' || key.name === 'up' || key.name === 'left' || key.name === 'right') { + return updateLogin(model, { type: 'LOGIN_SWITCH_SITE' }); + } else if (key.name === 'return' || key.name === 'enter') { + return updateLogin(model, { type: 'LOGIN_SUBMIT' }); + } } else if (loginModel.step === 'input' || loginModel.step === 'error') { if (key.name === 'escape') { return updateLogin(model, { type: 'LOGIN_BACK' });