From eea56c1c171b12ed7be7408ce1f9db55b5f8cf45 Mon Sep 17 00:00:00 2001 From: JingWen Fan <106414602+study8677@users.noreply.github.com> Date: Mon, 25 May 2026 18:21:02 +0800 Subject: [PATCH 1/2] fix: bypass email verification for signup --- .env.example | 10 ++-- docs/deployment.md | 25 +++++++--- frontend/src/api/auth.ts | 6 ++- frontend/src/components/auth/AuthProvider.tsx | 10 +++- src/opencmo/web/app.py | 13 +++++ tests/test_email_verification.py | 50 +++++++++++++++++++ tests/test_trial_platform.py | 2 + 7 files changed, 99 insertions(+), 17 deletions(-) diff --git a/.env.example b/.env.example index 25e8f29..3aa3348 100644 --- a/.env.example +++ b/.env.example @@ -44,11 +44,11 @@ OPENAI_API_KEY=sk-your-api-key-here # DATAFORSEO_LOGIN= # Reserved; DataForSEO SERP provider is currently a disabled stub # DATAFORSEO_PASSWORD= -# === Optional: Email (reports + signup verification codes) === -# Configure these to deliver weekly reports AND email-verification codes for -# new signups via real SMTP. When OPENCMO_SMTP_HOST is unset, verification -# codes are logged to stderr (WARNING) instead of being sent — useful for -# local dev, NOT acceptable for production. +# === Optional: Email (reports + optional signup verification codes) === +# Email verification is currently disabled by default. New signups receive a +# session immediately. Set OPENCMO_EMAIL_VERIFICATION_ENABLED=1 to require +# verification codes again, then configure SMTP so codes can be delivered. +# OPENCMO_EMAIL_VERIFICATION_ENABLED=0 # OPENCMO_SMTP_HOST=smtp.gmail.com # OPENCMO_SMTP_PORT=587 # 465 = implicit TLS, 587 = STARTTLS (default) # OPENCMO_SMTP_USER= diff --git a/docs/deployment.md b/docs/deployment.md index f90f50d..44ec1e8 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -89,12 +89,21 @@ location / { ## Email Verification (signup codes) -OpenCMO requires every new user to verify their email before the first -session is issued. The signup endpoint creates the user, issues a 6-digit -code, emails it, and returns `needs_verification: true` — no session cookie -is set until the user confirms the code at `/verify-email`. +Email verification is disabled by default. New signups receive a session +immediately, and existing users who have not verified their email are allowed +to log in and are marked verified during that login. -To deliver real codes in production set the SMTP variables: +To require email verification again, set: + +``` +OPENCMO_EMAIL_VERIFICATION_ENABLED=1 +``` + +When verification is enabled, the signup endpoint creates the user, issues a +6-digit code, emails it, and returns `needs_verification: true` — no session +cookie is set until the user confirms the code at `/verify-email`. + +To deliver real codes in production, also set the SMTP variables: ``` OPENCMO_SMTP_HOST=smtp.yourprovider.com @@ -105,9 +114,9 @@ OPENCMO_SMTP_FROM=hello@aidcmo.com # optional OPENCMO_SMTP_FROM_NAME=OpenCMO # optional ``` -If `OPENCMO_SMTP_HOST` is unset the sender logs the code to stderr at -WARNING level so local dev still works — never run a production node in -that mode. +If verification is enabled and `OPENCMO_SMTP_HOST` is unset, the sender logs +the code to stderr at WARNING level so local dev still works — never run a +production node in that mode. Schema changes are idempotent (`ALTER TABLE … ADD COLUMN` is wrapped in try/except), so no manual SQL is needed: the next service restart adds diff --git a/frontend/src/api/auth.ts b/frontend/src/api/auth.ts index 59c9c35..3d6a810 100644 --- a/frontend/src/api/auth.ts +++ b/frontend/src/api/auth.ts @@ -18,6 +18,8 @@ export interface SignupResponse { dev_mode?: boolean; } +export type SignupResult = SignupResponse | AuthPayload; + export interface ResendCodeResponse { ok: boolean; dev_mode?: boolean; @@ -34,8 +36,8 @@ export function signup(data: { password: string; name?: string; locale?: string; -}): Promise { - return apiJson("/auth/signup", { +}): Promise { + return apiJson("/auth/signup", { method: "POST", body: JSON.stringify(data), }); diff --git a/frontend/src/components/auth/AuthProvider.tsx b/frontend/src/components/auth/AuthProvider.tsx index dcad583..1d5de28 100644 --- a/frontend/src/components/auth/AuthProvider.tsx +++ b/frontend/src/components/auth/AuthProvider.tsx @@ -11,6 +11,7 @@ import type { AccountUsage, AuthAccount, AuthUser } from "../../types"; export type SignupOutcome = | { ok: true; needsVerification: true; userId: number; email: string } + | { ok: true; needsVerification?: false } | { ok: false; error?: string }; export type LoginOutcome = @@ -131,7 +132,12 @@ export function AuthProvider({ children }: { children: ReactNode }) { async (email: string, password: string, name?: string, locale?: string): Promise => { try { const payload = await authApi.signup({ email, password, name, locale }); - if (payload.needs_verification) { + if ("authenticated" in payload && payload.authenticated) { + applyPayload(payload); + await queryClient.invalidateQueries(); + return { ok: true }; + } + if ("needs_verification" in payload && payload.needs_verification) { return { ok: true, needsVerification: true, @@ -146,7 +152,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { return { ok: false, error: apiErr?.message }; } }, - [], + [applyPayload, queryClient], ); const verifyEmail = useCallback( diff --git a/src/opencmo/web/app.py b/src/opencmo/web/app.py index 491332d..5087787 100644 --- a/src/opencmo/web/app.py +++ b/src/opencmo/web/app.py @@ -1414,6 +1414,10 @@ def _env_positive_int(name: str, default: int) -> int: return default +def _email_verification_enabled() -> bool: + return os.environ.get("OPENCMO_EMAIL_VERIFICATION_ENABLED", "").strip().lower() in {"1", "true", "yes", "on"} + + def _request_ip(request: Request) -> str: forwarded_for = request.headers.get("x-forwarded-for", "") if forwarded_for: @@ -1500,6 +1504,11 @@ async def api_v1_auth_signup(payload: _AuthSignupRequest, request: Request): status_code = 409 if str(exc) == "email_exists" else 400 return JSONResponse({"ok": False, "error": str(exc)}, status_code=status_code) + if not _email_verification_enabled(): + await storage.mark_user_verified(user["id"]) + refreshed = await storage.get_user_by_id(user["id"]) or user + return await _json_with_session(request, refreshed, account, status_code=201) + # Issue a verification code and email it. The session cookie is not set # until the user confirms the code via /api/v1/auth/verify-email. code = await storage.create_verification_code(user["id"], purpose="signup") @@ -1533,6 +1542,10 @@ async def api_v1_auth_login(payload: _AuthLoginRequest, request: Request): return JSONResponse({"ok": False, "error": "invalid_credentials"}, status_code=401) user, account = authenticated if not await storage.is_user_verified(user["id"]): + if not _email_verification_enabled(): + await storage.mark_user_verified(user["id"]) + user = await storage.get_user_by_id(user["id"]) or user + return await _json_with_session(request, user, account) return JSONResponse( { "ok": False, diff --git a/tests/test_email_verification.py b/tests/test_email_verification.py index b922d99..83b4eb1 100644 --- a/tests/test_email_verification.py +++ b/tests/test_email_verification.py @@ -54,6 +54,7 @@ def verification_db(tmp_path, monkeypatch): monkeypatch.setenv("OPENCMO_COOKIE_SECRET", "test-cookie-secret") monkeypatch.setenv("OPENCMO_SIGNUP_MODE", "open") monkeypatch.setenv("OPENCMO_ADMIN_EMAIL", "admin@example.test") + monkeypatch.setenv("OPENCMO_EMAIL_VERIFICATION_ENABLED", "1") db_path = tmp_path / "verify.db" web_app._AUTH_RATE_BUCKETS.clear() _CAPTURED_CODES.clear() @@ -88,6 +89,54 @@ def _signup(client: TestClient, email: str, password: str = "password123") -> di return payload +def test_signup_and_unverified_login_are_direct_when_verification_disabled(tmp_path, monkeypatch): + monkeypatch.setenv("OPENCMO_REQUIRE_SESSION_AUTH", "1") + monkeypatch.setenv("OPENCMO_COOKIE_SECRET", "test-cookie-secret") + monkeypatch.setenv("OPENCMO_SIGNUP_MODE", "open") + monkeypatch.setenv("OPENCMO_ADMIN_EMAIL", "admin@example.test") + monkeypatch.delenv("OPENCMO_EMAIL_VERIFICATION_ENABLED", raising=False) + db_path = tmp_path / "verification-disabled.db" + web_app._AUTH_RATE_BUCKETS.clear() + + async def _unexpected_send(to: str, subject: str, html: str, text: str | None = None, **_kwargs) -> dict: + raise AssertionError(f"verification email should not be sent to {to}: {subject}") + + with patch.object(storage, "_DB_PATH", db_path), \ + patch.object(storage, "_SCHEMA_READY_FOR", None), \ + patch("opencmo.tools.email_send.send_mail", side_effect=_unexpected_send), \ + patch("opencmo.tools.email_verification.send_mail", side_effect=_unexpected_send): + with TestClient(app) as client: + signup = client.post( + "/api/v1/auth/signup", + json={"email": "direct@example.test", "password": "password123", "name": "Direct"}, + ) + assert signup.status_code == 201, signup.text + payload = signup.json() + assert payload["authenticated"] is True + assert payload["user"]["email"] == "direct@example.test" + assert "needs_verification" not in payload + assert "opencmo_session" in client.cookies + assert asyncio.run(storage.is_user_verified(payload["user"]["id"])) is True + assert asyncio.run(storage.last_verification_send_at(payload["user"]["id"], "signup")) is None + + client.post("/api/v1/auth/logout") + client.cookies.clear() + + user, _account = asyncio.run( + storage.create_user_with_account("old-unverified@example.test", "password123", "Old") + ) + assert asyncio.run(storage.is_user_verified(user["id"])) is False + login = client.post( + "/api/v1/auth/login", + json={"email": "old-unverified@example.test", "password": "password123"}, + ) + assert login.status_code == 200, login.text + assert login.json()["authenticated"] is True + assert asyncio.run(storage.is_user_verified(user["id"])) is True + + web_app._AUTH_RATE_BUCKETS.clear() + + def test_signup_returns_needs_verification_without_session(verification_db): with TestClient(app) as client: signup = _signup(client, "newcomer@example.test") @@ -237,6 +286,7 @@ def test_existing_legacy_users_remain_verified_after_backfill(tmp_path, monkeypa monkeypatch.setenv("OPENCMO_COOKIE_SECRET", "test-cookie-secret") monkeypatch.setenv("OPENCMO_SIGNUP_MODE", "open") monkeypatch.setenv("OPENCMO_ADMIN_EMAIL", "admin@example.test") + monkeypatch.setenv("OPENCMO_EMAIL_VERIFICATION_ENABLED", "1") db_path = tmp_path / "legacy.db" # Hand-craft a pre-feature schema with one existing user + matching diff --git a/tests/test_trial_platform.py b/tests/test_trial_platform.py index 537cc47..29efadd 100644 --- a/tests/test_trial_platform.py +++ b/tests/test_trial_platform.py @@ -36,6 +36,7 @@ def trial_db(tmp_path, monkeypatch): monkeypatch.setenv("OPENCMO_COOKIE_SECRET", "test-cookie-secret") monkeypatch.setenv("OPENCMO_SIGNUP_MODE", "open") monkeypatch.setenv("OPENCMO_ADMIN_EMAIL", "admin@example.test") + monkeypatch.setenv("OPENCMO_EMAIL_VERIFICATION_ENABLED", "1") db_path = tmp_path / "trial.db" web_app._AUTH_RATE_BUCKETS.clear() _CAPTURED_VERIFICATION_CODES.clear() @@ -281,6 +282,7 @@ def test_legacy_project_global_unique_is_reconciled(tmp_path, monkeypatch): monkeypatch.setenv("OPENCMO_COOKIE_SECRET", "test-cookie-secret") monkeypatch.setenv("OPENCMO_SIGNUP_MODE", "open") monkeypatch.setenv("OPENCMO_ADMIN_EMAIL", "admin@example.test") + monkeypatch.setenv("OPENCMO_EMAIL_VERIFICATION_ENABLED", "1") db_path = tmp_path / "legacy.db" with sqlite3.connect(db_path) as db: db.execute("CREATE TABLE schema_version (version INTEGER NOT NULL)") From 13abd3c2232dde2d25b9aa38ab68e590d51e0d21 Mon Sep 17 00:00:00 2001 From: JingWen Fan <106414602+study8677@users.noreply.github.com> Date: Mon, 25 May 2026 18:30:48 +0800 Subject: [PATCH 2/2] fix: clarify signup requirements --- frontend/src/components/auth/AuthProvider.tsx | 8 +++- frontend/src/i18n/locales/en.ts | 9 +++++ frontend/src/i18n/locales/es.ts | 9 +++++ frontend/src/i18n/locales/ja.ts | 9 +++++ frontend/src/i18n/locales/ko.ts | 9 +++++ frontend/src/i18n/locales/zh.ts | 9 +++++ frontend/src/pages/SignupPage.tsx | 40 +++++++++++++++++-- 7 files changed, 88 insertions(+), 5 deletions(-) diff --git a/frontend/src/components/auth/AuthProvider.tsx b/frontend/src/components/auth/AuthProvider.tsx index 1d5de28..8a0e50f 100644 --- a/frontend/src/components/auth/AuthProvider.tsx +++ b/frontend/src/components/auth/AuthProvider.tsx @@ -148,8 +148,12 @@ export function AuthProvider({ children }: { children: ReactNode }) { // Legacy fallback: server may still return an AuthPayload shape. return { ok: false, error: "unexpected_response" }; } catch (err) { - const apiErr = err as { message?: string }; - return { ok: false, error: apiErr?.message }; + const apiErr = err as { + errorCode?: string; + message?: string; + payload?: { error?: string }; + }; + return { ok: false, error: apiErr?.errorCode ?? apiErr?.payload?.error ?? apiErr?.message }; } }, [applyPayload, queryClient], diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index e6a4c6f..ad49e1e 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -29,6 +29,10 @@ export const en = { "trial.email": "Email", "trial.password": "Password", "trial.displayName": "Display name", + "trial.emailHint": "Use a normal email format, for example name@example.com.", + "trial.passwordHint": "Use at least 8 characters.", + "trial.displayNameHint": "Optional. You can leave this blank.", + "trial.instantAccessHint": "No email code right now. After this form, you enter the console directly.", "trial.startTrial": "Start free trial", "trial.signIn": "Sign in", "trial.haveAccount": "Already have an account?", @@ -36,6 +40,11 @@ export const en = { "trial.loginTitle": "Sign in to OpenCMO.", "trial.loginSubtitle": "Continue to your personal growth console and project workspace.", "trial.authError": "Check your email and password, then try again.", + "trial.errorInvalidEmail": "Enter an email like name@example.com.", + "trial.errorPasswordTooShort": "Password must be at least 8 characters.", + "trial.errorEmailExists": "This email already has an account. Sign in instead.", + "trial.errorRateLimited": "Too many attempts. Wait a moment and try again.", + "trial.errorSignupClosed": "Signup is temporarily closed.", "trial.submitting": "Working...", "trial.limitDays": "trial days", "trial.limitProjects": "trial projects", diff --git a/frontend/src/i18n/locales/es.ts b/frontend/src/i18n/locales/es.ts index 95e8c40..ac91238 100644 --- a/frontend/src/i18n/locales/es.ts +++ b/frontend/src/i18n/locales/es.ts @@ -18,6 +18,15 @@ export const es: Partial> = { "auth.loggingIn": "Iniciando sesión...", "auth.login": "Iniciar sesión", "auth.invalidToken": "Token inválido", + "trial.emailHint": "Usa un formato de email normal, por ejemplo name@example.com.", + "trial.passwordHint": "Usa al menos 8 caracteres.", + "trial.displayNameHint": "Opcional. Puedes dejarlo vacío.", + "trial.instantAccessHint": "Ahora no se requiere código por email. Después de este formulario entras directamente al panel.", + "trial.errorInvalidEmail": "Introduce un email como name@example.com.", + "trial.errorPasswordTooShort": "La contraseña debe tener al menos 8 caracteres.", + "trial.errorEmailExists": "Este email ya tiene una cuenta. Inicia sesión.", + "trial.errorRateLimited": "Demasiados intentos. Espera un momento e inténtalo de nuevo.", + "trial.errorSignupClosed": "El registro está cerrado temporalmente.", "dashboard.title": "Panel", "dashboard.newMonitor": "Nuevo Monitor", "dashboard.noProjects": "Aún no hay proyectos", diff --git a/frontend/src/i18n/locales/ja.ts b/frontend/src/i18n/locales/ja.ts index 7fa2a2f..b32077a 100644 --- a/frontend/src/i18n/locales/ja.ts +++ b/frontend/src/i18n/locales/ja.ts @@ -23,6 +23,15 @@ export const ja: Partial> = { "auth.loggingIn": "ログイン中...", "auth.login": "ログイン", "auth.invalidToken": "無効なトークン", + "trial.emailHint": "通常のメール形式を使用してください。例: name@example.com。", + "trial.passwordHint": "8文字以上で入力してください。", + "trial.displayNameHint": "任意です。空欄でもかまいません。", + "trial.instantAccessHint": "現在、メールコードは不要です。このフォームの後、すぐにコンソールへ進みます。", + "trial.errorInvalidEmail": "name@example.com のようなメールアドレスを入力してください。", + "trial.errorPasswordTooShort": "パスワードは8文字以上必要です。", + "trial.errorEmailExists": "このメールはすでに登録されています。ログインしてください。", + "trial.errorRateLimited": "試行回数が多すぎます。少し待ってから再試行してください。", + "trial.errorSignupClosed": "現在、登録は一時的に停止されています。", // Dashboard "dashboard.title": "ダッシュボード", diff --git a/frontend/src/i18n/locales/ko.ts b/frontend/src/i18n/locales/ko.ts index 84dcba0..ce55ca0 100644 --- a/frontend/src/i18n/locales/ko.ts +++ b/frontend/src/i18n/locales/ko.ts @@ -18,6 +18,15 @@ export const ko: Partial> = { "auth.loggingIn": "로그인 중...", "auth.login": "로그인", "auth.invalidToken": "유효하지 않은 토큰", + "trial.emailHint": "일반 이메일 형식을 사용하세요. 예: name@example.com.", + "trial.passwordHint": "8자 이상 입력하세요.", + "trial.displayNameHint": "선택 사항입니다. 비워 두어도 됩니다.", + "trial.instantAccessHint": "현재 이메일 코드는 필요 없습니다. 이 양식 제출 후 바로 콘솔로 들어갑니다.", + "trial.errorInvalidEmail": "name@example.com 같은 이메일을 입력하세요.", + "trial.errorPasswordTooShort": "비밀번호는 8자 이상이어야 합니다.", + "trial.errorEmailExists": "이 이메일은 이미 가입되어 있습니다. 로그인하세요.", + "trial.errorRateLimited": "시도 횟수가 너무 많습니다. 잠시 후 다시 시도하세요.", + "trial.errorSignupClosed": "가입이 일시적으로 닫혀 있습니다.", "dashboard.title": "대시보드", "dashboard.newMonitor": "새 모니터", "dashboard.noProjects": "프로젝트가 아직 없습니다", diff --git a/frontend/src/i18n/locales/zh.ts b/frontend/src/i18n/locales/zh.ts index 00545aa..a8bca9d 100644 --- a/frontend/src/i18n/locales/zh.ts +++ b/frontend/src/i18n/locales/zh.ts @@ -31,6 +31,10 @@ export const zh: Partial> = { "trial.email": "Email", "trial.password": "密码", "trial.displayName": "显示名称", + "trial.emailHint": "使用常见邮箱格式,例如 name@example.com。", + "trial.passwordHint": "至少 8 个字符。", + "trial.displayNameHint": "可选,不填也可以。", + "trial.instantAccessHint": "当前不需要邮箱验证码。提交后会直接进入控制台。", "trial.startTrial": "开始免费试用", "trial.signIn": "登录", "trial.haveAccount": "已有账号?", @@ -38,6 +42,11 @@ export const zh: Partial> = { "trial.loginTitle": "登录 OpenCMO。", "trial.loginSubtitle": "继续进入你的个人增长控制台和项目工作区。", "trial.authError": "请检查邮箱和密码后重试。", + "trial.errorInvalidEmail": "请输入类似 name@example.com 的邮箱。", + "trial.errorPasswordTooShort": "密码至少需要 8 个字符。", + "trial.errorEmailExists": "这个邮箱已经注册过,请直接登录。", + "trial.errorRateLimited": "尝试次数过多,请稍后再试。", + "trial.errorSignupClosed": "注册暂时关闭。", "trial.submitting": "处理中...", "trial.limitDays": "天试用期", "trial.limitProjects": "个试用项目", diff --git a/frontend/src/pages/SignupPage.tsx b/frontend/src/pages/SignupPage.tsx index 7dc4d58..2d7f1a1 100644 --- a/frontend/src/pages/SignupPage.tsx +++ b/frontend/src/pages/SignupPage.tsx @@ -4,6 +4,8 @@ import { ArrowRight, Github, Lock, Mail, UserRound } from "lucide-react"; import { useAuth } from "../components/auth/useAuth"; import { useI18n } from "../i18n"; +const EMAIL_PATTERN = /^[^@\s]+@[^@\s]+\.[^@\s]+$/; + export function SignupPage() { const { t, locale } = useI18n(); const auth = useAuth(); @@ -17,6 +19,24 @@ export function SignupPage() { const url = params.get("url") || ""; const next = params.get("next") || (url ? `/console?url=${encodeURIComponent(url)}` : "/console"); + const signupErrorMessage = (code?: string) => { + switch (code) { + case "invalid_email": + return t("trial.errorInvalidEmail"); + case "password_too_short": + return t("trial.errorPasswordTooShort"); + case "email_exists": + return t("trial.errorEmailExists"); + case "rate_limited": + return t("trial.errorRateLimited"); + case "signup_closed": + case "invite_required": + return t("trial.errorSignupClosed"); + default: + return t("trial.authError"); + } + }; + if (!auth.isLoading && auth.isAuthenticated) { return ; } @@ -24,11 +44,21 @@ export function SignupPage() { const handleSubmit = async (event: FormEvent) => { event.preventDefault(); setError(""); + const normalizedEmail = email.trim().toLowerCase(); + const trimmedName = name.trim(); + if (!EMAIL_PATTERN.test(normalizedEmail)) { + setError(t("trial.errorInvalidEmail")); + return; + } + if (password.length < 8) { + setError(t("trial.errorPasswordTooShort")); + return; + } setLoading(true); - const result = await auth.signup(email, password, name, locale); + const result = await auth.signup(normalizedEmail, password, trimmedName, locale); setLoading(false); if (!result.ok) { - setError(t("trial.authError")); + setError(signupErrorMessage(result.error)); return; } if (result.needsVerification) { @@ -81,7 +111,7 @@ export function SignupPage() {

{t("trial.createAccount")}

-
+ {error &&

{error}

} +

{t("trial.instantAccessHint")}