Skip to content
Draft
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
10 changes: 5 additions & 5 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
25 changes: 17 additions & 8 deletions docs/deployment.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
6 changes: 4 additions & 2 deletions frontend/src/api/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ export interface SignupResponse {
dev_mode?: boolean;
}

export type SignupResult = SignupResponse | AuthPayload;

export interface ResendCodeResponse {
ok: boolean;
dev_mode?: boolean;
Expand All @@ -34,8 +36,8 @@ export function signup(data: {
password: string;
name?: string;
locale?: string;
}): Promise<SignupResponse> {
return apiJson<SignupResponse>("/auth/signup", {
}): Promise<SignupResult> {
return apiJson<SignupResult>("/auth/signup", {
method: "POST",
body: JSON.stringify(data),
});
Expand Down
18 changes: 14 additions & 4 deletions frontend/src/components/auth/AuthProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand Down Expand Up @@ -131,7 +132,12 @@ export function AuthProvider({ children }: { children: ReactNode }) {
async (email: string, password: string, name?: string, locale?: string): Promise<SignupOutcome> => {
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,
Expand All @@ -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(
Expand Down
9 changes: 9 additions & 0 deletions frontend/src/i18n/locales/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,13 +29,22 @@ 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?",
"trial.needAccount": "Need an account?",
"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",
Expand Down
9 changes: 9 additions & 0 deletions frontend/src/i18n/locales/es.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,15 @@ export const es: Partial<Record<TranslationKey, string>> = {
"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",
Expand Down
9 changes: 9 additions & 0 deletions frontend/src/i18n/locales/ja.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,15 @@ export const ja: Partial<Record<TranslationKey, string>> = {
"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": "ダッシュボード",
Expand Down
9 changes: 9 additions & 0 deletions frontend/src/i18n/locales/ko.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,15 @@ export const ko: Partial<Record<TranslationKey, string>> = {
"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": "프로젝트가 아직 없습니다",
Expand Down
9 changes: 9 additions & 0 deletions frontend/src/i18n/locales/zh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,13 +31,22 @@ export const zh: Partial<Record<TranslationKey, string>> = {
"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": "已有账号?",
"trial.needAccount": "还没有账号?",
"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": "个试用项目",
Expand Down
40 changes: 37 additions & 3 deletions frontend/src/pages/SignupPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -17,18 +19,46 @@ 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 <Navigate to={next} replace />;
}

const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
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) {
Expand Down Expand Up @@ -81,7 +111,7 @@ export function SignupPage() {

<section className="rounded-xl border border-slate-200 bg-white p-6 shadow-sm">
<h2 className="text-xl font-semibold">{t("trial.createAccount")}</h2>
<form onSubmit={handleSubmit} className="mt-6 space-y-4">
<form onSubmit={handleSubmit} noValidate className="mt-6 space-y-4">
<label className="block">
<span className="text-sm font-medium text-slate-700">{t("trial.email")}</span>
<span className="mt-1 flex items-center gap-2 rounded-lg border border-slate-200 px-3 py-2.5">
Expand All @@ -95,6 +125,7 @@ export function SignupPage() {
className="min-w-0 flex-1 outline-none"
/>
</span>
<span className="mt-1 block text-xs text-slate-500">{t("trial.emailHint")}</span>
</label>
<label className="block">
<span className="text-sm font-medium text-slate-700">{t("trial.password")}</span>
Expand All @@ -110,6 +141,7 @@ export function SignupPage() {
className="min-w-0 flex-1 outline-none"
/>
</span>
<span className="mt-1 block text-xs text-slate-500">{t("trial.passwordHint")}</span>
</label>
<label className="block">
<span className="text-sm font-medium text-slate-700">{t("trial.displayName")}</span>
Expand All @@ -122,8 +154,10 @@ export function SignupPage() {
className="min-w-0 flex-1 outline-none"
/>
</span>
<span className="mt-1 block text-xs text-slate-500">{t("trial.displayNameHint")}</span>
</label>
{error && <p className="text-sm text-rose-600">{error}</p>}
<p className="text-xs leading-5 text-slate-500">{t("trial.instantAccessHint")}</p>
<button
type="submit"
disabled={loading}
Expand Down
13 changes: 13 additions & 0 deletions src/opencmo/web/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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,
Expand Down
Loading
Loading