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..8a0e50f 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, @@ -142,11 +148,15 @@ 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], ); const verifyEmail = useCallback( 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")}