diff --git a/.env.example b/.env.example index a0815c6..db9569d 100644 --- a/.env.example +++ b/.env.example @@ -20,3 +20,8 @@ YANDEX_REDIRECT_URI="http://localhost:4000/api/auth/yandex/callback" # GitHub API GITHUB_TOKEN= + +# Email (password reset, email verification) via Resend. +# Without these, emails are printed to the server log in development and disabled in production. +RESEND_API_KEY= +EMAIL_FROM="WidgeCode " diff --git a/AGENTS.md b/AGENTS.md index 4469485..2ea0b86 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -80,6 +80,8 @@ Server imports must use `.js` extensions (NodeNext). - `statsService` and the avatar cache are per-process in-memory caches; on Vercel each cold instance starts empty. Successful lookups are cached 15 min, failed ones 60 s; the public SVG `Cache-Control` follows the same TTL. Rate limits (`express-rate-limit`, memory store) are also per instance; `trust proxy` is enabled on Vercel so limits are per client IP. - `api/*.ts` import from `server/dist/src/app.js`. `server/tsconfig.build.json` pins `rootDir: "."` to keep that path stable, and CI runs `typecheck:api` to catch breakage. - Auth: short-lived access token in memory (Authorization: Bearer), rotating refresh token in an httpOnly cookie scoped to `/api/auth`, sessions stored hashed in `AuthSession`. Yandex OAuth returns the access token in the URL hash of `/auth/callback`. Accounts are keyed by provider id (`yandexId`), never linked by email; linking happens from `/account` (`/api/auth/yandex?intent=link`, the intent is stored with the OAuth state and the callback identifies the user by the refresh cookie). +- Email: `server/src/lib/mailer.ts` sends via the Resend HTTP API when `RESEND_API_KEY` + `EMAIL_FROM` are set; otherwise it logs emails in development and is disabled in production (`GET /api/auth/features` → `{ email }`, the client hides reset/resend). One-time tokens live in `AuthToken` (SHA-256 hash only, single use, expiry) and travel in the URL hash (`/reset-password#token=…`, `/verify-email#token=…`). A password reset revokes all sessions and marks the email verified. +- Vercel function budget: `api/` has 10 of the Hobby plan's 12 functions. Add endpoints behind an existing router function (`api/widgets-resource.ts`, `api/auth-resource.ts`) plus a `vercel.json` rewrite, not new files. - Errors: throw `AppError(status, message)` from services; controllers pass errors to `next()`; `errorMiddleware` maps them. Non-AppError → 500 with a generic message. - All user-visible strings are bilingual (ru/en). Add both. - `.env` is gitignored; `.env.example` must list every variable the code reads. diff --git a/CHANGELOG.md b/CHANGELOG.md index 39e1666..c1d050d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ All notable changes to this project are documented here. ### Added +- Added password reset by email ("Forgot password?" on sign-in) and email confirmation after sign-up, with a resend option on the account page. Emails are sent through Resend once `RESEND_API_KEY` and `EMAIL_FROM` are configured; until then the features stay hidden in production and emails are printed to the server log in development. - Added an account page with sign-in methods: connect Yandex ID to an existing account, or disconnect it when a password remains. - Added 404 and 500 pages for unknown addresses, missing or unpublished widgets, failed widget loads, and unexpected rendering errors. - Added live SVG image export URLs and README-ready HTML snippets for public widgets. diff --git a/api/auth-resource.ts b/api/auth-resource.ts new file mode 100644 index 0000000..c211792 --- /dev/null +++ b/api/auth-resource.ts @@ -0,0 +1,25 @@ +import type { Request, Response } from 'express'; + +import { createApp } from '../server/dist/src/app.js'; + +const app = createApp(); + +// One Vercel function for the smaller auth endpoints (the Hobby plan caps the function count). +// vercel.json rewrites /api/auth/ here as ?route=. +const ROUTES = new Set([ + 'features', + 'forgot-password', + 'reset-password', + 'verify-email', + 'resend-verification', +]); + +export default (request: Request, response: Response) => { + const route = typeof request.query.route === 'string' ? request.query.route : ''; + if (!ROUTES.has(route)) { + response.status(404).json({ error: 'Auth route not found' }); + return; + } + request.url = `/api/auth/${route}`; + app(request, response); +}; diff --git a/client/src/app/App.tsx b/client/src/app/App.tsx index 354e422..c3f9485 100644 --- a/client/src/app/App.tsx +++ b/client/src/app/App.tsx @@ -2,7 +2,7 @@ import { ThemeProvider } from '@gravity-ui/uikit'; import { AnimatePresence, motion, useReducedMotion } from 'framer-motion'; import { useCallback, useEffect, useState, type MouseEvent } from 'react'; -import { useAuthStore } from '@/features/auth'; +import { useAuthFeatures, useAuthStore } from '@/features/auth'; import type { WidgetCardData, Widget } from '@/entities/widget'; import { AuthPage, type AuthTab } from '@/pages/auth'; import { LandingHeader, LandingPage } from '@/pages/landing'; @@ -11,6 +11,9 @@ import { PublicWidgetPage } from '@/pages/public-widget'; import { WidgetEditorPage } from '@/pages/widget-editor'; import { ErrorPage } from '@/widgets/error-page'; import { AccountPage } from '@/pages/account'; +import { ForgotPasswordPage, ResetPasswordPage } from '@/pages/password-reset'; +import { VerifyEmailPage } from '@/pages/verify-email'; +import { messages } from '@/shared/locale/content'; import { AppErrorBoundary } from '@/app/AppErrorBoundary'; import { createWidget, @@ -35,7 +38,17 @@ import styles from '@/app/App.module.css'; import '@/app/Theme.css'; type AppRoute = - 'landing' | 'auth' | 'dashboard' | 'editor' | 'account' | 'public' | 'callback' | 'not-found'; + | 'landing' + | 'auth' + | 'dashboard' + | 'editor' + | 'account' + | 'public' + | 'callback' + | 'forgot-password' + | 'reset-password' + | 'verify-email' + | 'not-found'; const isPrivateRoute = (route: AppRoute) => route === 'dashboard' || route === 'editor' || route === 'account'; @@ -47,6 +60,9 @@ const getRoute = (): AppRoute => { if (pathname === '/dashboard') return 'dashboard'; if (/^\/widgets\/[^/]+$/.test(pathname)) return 'editor'; if (pathname === '/account') return 'account'; + if (pathname === '/forgot-password') return 'forgot-password'; + if (pathname === '/reset-password') return 'reset-password'; + if (pathname === '/verify-email') return 'verify-email'; if (/^\/w\/[^/]+$/.test(pathname)) return 'public'; if (pathname === '/auth/callback') return 'callback'; if (['/auth', '/login', '/register'].includes(pathname)) return 'auth'; @@ -97,6 +113,9 @@ const getDocumentTitle = (route: AppRoute, authTab: AuthTab) => { if (route === 'public') return 'WidgeCode | Public widget'; if (route === 'callback') return 'WidgeCode | Sign in'; if (route === 'not-found') return 'WidgeCode | Page not found'; + if (route === 'forgot-password' || route === 'reset-password') + return 'WidgeCode | Reset password'; + if (route === 'verify-email') return 'WidgeCode | Confirm email'; return 'WidgeCode'; }; @@ -119,6 +138,7 @@ export const App = () => { const authUser = useAuthStore((state) => state.user); const authError = useAuthStore((state) => state.error); const prefersReducedMotion = useReducedMotion(); + const authFeatures = useAuthFeatures(); const widgetLoadKey = authStatus === 'authenticated' && (route === 'dashboard' || route === 'editor') ? `${authUser?.id ?? 'current'}:${route}` @@ -262,7 +282,7 @@ export const App = () => { if (authTab === 'signin') { await useAuthStore.getState().login(values.email, values.password); } else { - await useAuthStore.getState().register(values.email, values.password, values.name); + await useAuthStore.getState().register(values.email, values.password, values.name, locale); } navigate('/dashboard'); } catch { @@ -435,7 +455,11 @@ export const App = () => { ? styles.authorizedShell : route === 'auth' || isPrivateRoute(route) ? styles.authRoute - : route === 'public' || route === 'not-found' + : route === 'public' || + route === 'not-found' || + route === 'forgot-password' || + route === 'reset-password' || + route === 'verify-email' ? styles.publicRoute : styles.landingLayout } @@ -455,6 +479,14 @@ export const App = () => { onAuthTabChange={handleAuthTabChange} isSubmitting={authStatus === 'loading'} error={authError || oauthError} + notice={ + new URLSearchParams(window.location.search).get('password') === 'changed' + ? messages[locale].passwordChanged + : null + } + onForgotPassword={ + authFeatures?.email ? () => navigate('/forgot-password') : undefined + } onSubmit={handleAuthSubmit} onYandexAuth={() => window.location.assign(`${API_BASE_URL}/auth/yandex`)} /> @@ -465,6 +497,27 @@ export const App = () => { embed={isEmbedRoute} onHome={() => navigate('/')} /> + ) : route === 'forgot-password' ? ( + navigate('/login')} /> + ) : route === 'reset-password' ? ( + { + // The reset revoked every session, including this browser's. + useAuthStore.setState({ + token: null, + user: null, + status: 'unauthenticated', + }); + navigate('/login?password=changed', true); + }} + onRequestNewLink={() => navigate('/forgot-password', true)} + /> + ) : route === 'verify-email' ? ( + navigate(isAuthorized ? '/dashboard' : '/login')} + /> ) : route === 'not-found' ? ( | null = null; + +// Server capabilities that depend on its configuration (email needs a mail provider). +// Fetched once per page load; failures read as "disabled". +const loadAuthFeatures = () => { + featuresPromise ??= apiClient('/auth/features', { skipAuthRefresh: true }).catch( + () => ({ email: false }), + ); + return featuresPromise; +}; + +export const resetAuthFeaturesCache = () => { + featuresPromise = null; +}; + +export const useAuthFeatures = () => { + const [features, setFeatures] = useState(null); + useEffect(() => { + let cancelled = false; + void loadAuthFeatures().then((value) => { + if (!cancelled) setFeatures(value); + }); + return () => { + cancelled = true; + }; + }, []); + return features; +}; diff --git a/client/src/features/auth/model/authStore.ts b/client/src/features/auth/model/authStore.ts index 4b86352..86e28c2 100644 --- a/client/src/features/auth/model/authStore.ts +++ b/client/src/features/auth/model/authStore.ts @@ -21,7 +21,7 @@ type AuthState = { status: AuthStatus; error: string | null; login: (email: string, password: string) => Promise; - register: (email: string, password: string, name?: string) => Promise; + register: (email: string, password: string, name?: string, locale?: 'ru' | 'en') => Promise; logout: () => Promise; checkAuth: () => Promise; refresh: () => Promise; @@ -57,12 +57,12 @@ export const useAuthStore = create((set, get) => ({ } }, - register: async (email, password, name) => { + register: async (email, password, name, locale) => { set({ status: 'loading', error: null }); try { const response = await apiClient('/auth/register', { method: 'POST', - body: JSON.stringify({ email, password, name: name || undefined }), + body: JSON.stringify({ email, password, name: name || undefined, locale }), skipAuthRefresh: true, }); applyAuthResponse(set, response); diff --git a/client/src/pages/account/ui/AccountPage.test.tsx b/client/src/pages/account/ui/AccountPage.test.tsx index 6f66c8c..939c0b9 100644 --- a/client/src/pages/account/ui/AccountPage.test.tsx +++ b/client/src/pages/account/ui/AccountPage.test.tsx @@ -7,14 +7,17 @@ const api = vi.hoisted(() => ({ apiClient: vi.fn() })); vi.mock('@/shared/api', () => ({ API_BASE_URL: '/api', apiClient: api.apiClient, + configureApiAuth: vi.fn(), getApiErrorMessage: (error: unknown) => (error instanceof Error ? error.message : 'Error'), })); const user = { id: 'user-1', email: 'person@example.com', name: 'Person' }; -const mockMe = (methods: { password: boolean; yandex: boolean }) => +const mockMe = (methods: { password: boolean; yandex: boolean }, emailVerified = true) => api.apiClient.mockImplementation(async (path: string, init?: RequestInit) => { - if (path === '/auth/me') return { user, methods }; + if (path === '/auth/me') return { user, methods, emailVerified }; + if (path === '/auth/features') return { email: true }; + if (path === '/auth/resend-verification') return { ok: true }; if (path === '/auth/yandex' && init?.method === 'DELETE') { return { methods: { ...methods, yandex: false } }; } @@ -64,3 +67,17 @@ it('shows the link result from the URL once and cleans the address', async () => ); expect(window.location.search).toBe(''); }); + +it('lets an unconfirmed user resend the confirmation email', async () => { + mockMe({ password: true, yandex: false }, false); + render( {}} />); + + expect(await screen.findByText('Not confirmed')).toBeInTheDocument(); + fireEvent.click(await screen.findByRole('button', { name: 'Send email' })); + + expect(await screen.findByRole('status')).toHaveTextContent('Email sent. Check your inbox.'); + expect(api.apiClient).toHaveBeenCalledWith('/auth/resend-verification', { + method: 'POST', + body: JSON.stringify({ locale: 'en' }), + }); +}); diff --git a/client/src/pages/account/ui/AccountPage.tsx b/client/src/pages/account/ui/AccountPage.tsx index bcaafdb..2af3ab8 100644 --- a/client/src/pages/account/ui/AccountPage.tsx +++ b/client/src/pages/account/ui/AccountPage.tsx @@ -1,14 +1,15 @@ -import { ArrowLeft, CircleCheck, Key, Person } from '@gravity-ui/icons'; +import { ArrowLeft, CircleCheck, Envelope, Key, Person } from '@gravity-ui/icons'; import { Button, Icon } from '@gravity-ui/uikit'; import { useEffect, useState, type ReactNode } from 'react'; -import type { AuthUser } from '@/features/auth'; +import { useAuthFeatures, type AuthUser } from '@/features/auth'; import { API_BASE_URL, apiClient, getApiErrorMessage } from '@/shared/api'; import { messages, type Locale } from '@/shared/locale/content'; import { ErrorPage } from '@/widgets/error-page'; import styles from '@/pages/account/ui/AccountPage.module.css'; type SignInMethods = { password: boolean; yandex: boolean }; +type Account = { user: AuthUser; methods: SignInMethods; emailVerified: boolean }; type Notice = { tone: 'success' | 'error'; text: string }; type AccountPageProps = { @@ -54,7 +55,9 @@ const MethodRow = ({ export const AccountPage = ({ locale, onBack }: AccountPageProps) => { const t = messages[locale]; - const [account, setAccount] = useState<{ user: AuthUser; methods: SignInMethods } | null>(null); + const [account, setAccount] = useState(null); + const features = useAuthFeatures(); + const [isSendingVerification, setSendingVerification] = useState(false); const [loadError, setLoadError] = useState(null); const [notice, setNotice] = useState(() => noticeFromUrl(t)); const [isUnlinking, setUnlinking] = useState(false); @@ -63,7 +66,7 @@ export const AccountPage = ({ locale, onBack }: AccountPageProps) => { // The link result arrives as query params; drop them so a reload doesn't repeat the notice. if (window.location.search) window.history.replaceState({}, '', window.location.pathname); let cancelled = false; - apiClient<{ user: AuthUser; methods: SignInMethods }>('/auth/me') + apiClient('/auth/me') .then((response) => { if (!cancelled) setAccount(response); }) @@ -90,6 +93,21 @@ export const AccountPage = ({ locale, onBack }: AccountPageProps) => { } }; + const resendVerification = async () => { + setSendingVerification(true); + try { + await apiClient('/auth/resend-verification', { + method: 'POST', + body: JSON.stringify({ locale }), + }); + setNotice({ tone: 'success', text: t.verificationSent }); + } catch (error) { + setNotice({ tone: 'error', text: getApiErrorMessage(error) }); + } finally { + setSendingVerification(false); + } + }; + if (loadError) return ( { {methods ? (
    + {account?.user.email && ( + } + title={`${t.emailAddress}: ${account.user.email}`} + connected={account.emailVerified} + status={account.emailVerified ? t.emailVerified : t.emailNotVerified} + action={ + !account.emailVerified && + features?.email && ( + + ) + } + /> + )} } title={t.passwordMethod} diff --git a/client/src/pages/auth/ui/AuthPage.module.css b/client/src/pages/auth/ui/AuthPage.module.css index bdb98c8..25bb268 100644 --- a/client/src/pages/auth/ui/AuthPage.module.css +++ b/client/src/pages/auth/ui/AuthPage.module.css @@ -218,6 +218,13 @@ line-height: 1.4; } +.authNotice { + margin: 0; + color: #1d8a64; + font-size: 13px; + line-height: 1.4; +} + .yandexMark { display: inline-grid; width: 28px; diff --git a/client/src/pages/auth/ui/AuthPage.tsx b/client/src/pages/auth/ui/AuthPage.tsx index 3beaf33..d688b2b 100644 --- a/client/src/pages/auth/ui/AuthPage.tsx +++ b/client/src/pages/auth/ui/AuthPage.tsx @@ -24,6 +24,9 @@ type AuthPageProps = { onAuthTabChange: (tab: AuthTab) => void; isSubmitting: boolean; error: string | null; + notice?: string | null; + /** Shown on the sign-in tab when password reset is available. */ + onForgotPassword?: () => void; onSubmit: (values: { email: string; password: string; name?: string }) => Promise; onYandexAuth: () => void; }; @@ -34,6 +37,8 @@ export const AuthPage = ({ onAuthTabChange, isSubmitting, error, + notice, + onForgotPassword, onSubmit, onYandexAuth, }: AuthPageProps) => { @@ -103,15 +108,26 @@ export const AuthPage = ({ type="password" />
    - {error && ( + {error ? (

    {localizeAuthError(error, locale)}

    + ) : ( + notice && ( +

    + {notice} +

    + ) )}
    + {authTab === 'signin' && onForgotPassword && ( + + )} diff --git a/client/src/pages/password-reset/index.ts b/client/src/pages/password-reset/index.ts new file mode 100644 index 0000000..83f3aac --- /dev/null +++ b/client/src/pages/password-reset/index.ts @@ -0,0 +1,2 @@ +export { ForgotPasswordPage } from '@/pages/password-reset/ui/ForgotPasswordPage'; +export { ResetPasswordPage } from '@/pages/password-reset/ui/ResetPasswordPage'; diff --git a/client/src/pages/password-reset/ui/ForgotPasswordPage.tsx b/client/src/pages/password-reset/ui/ForgotPasswordPage.tsx new file mode 100644 index 0000000..f0c9ed9 --- /dev/null +++ b/client/src/pages/password-reset/ui/ForgotPasswordPage.tsx @@ -0,0 +1,62 @@ +import { Button, TextInput } from '@gravity-ui/uikit'; +import { useState, type FormEvent } from 'react'; + +import { apiClient, getApiErrorMessage } from '@/shared/api'; +import { messages, type Locale } from '@/shared/locale/content'; +import { AuthCard, type AuthCardNotice } from '@/shared/ui/auth-card/AuthCard'; + +export const ForgotPasswordPage = ({ + locale, + onBackToSignin, +}: { + locale: Locale; + onBackToSignin: () => void; +}) => { + const t = messages[locale]; + const [email, setEmail] = useState(''); + const [isSubmitting, setSubmitting] = useState(false); + const [notice, setNotice] = useState(null); + + const submit = async (event: FormEvent) => { + event.preventDefault(); + setSubmitting(true); + try { + await apiClient('/auth/forgot-password', { + method: 'POST', + body: JSON.stringify({ email, locale }), + skipAuthRefresh: true, + }); + setNotice({ tone: 'success', text: t.resetLinkSent }); + } catch (error) { + setNotice({ tone: 'error', text: getApiErrorMessage(error) }); + } finally { + setSubmitting(false); + } + }; + + return ( + +
    + + + + +
    + ); +}; diff --git a/client/src/pages/password-reset/ui/PasswordReset.test.tsx b/client/src/pages/password-reset/ui/PasswordReset.test.tsx new file mode 100644 index 0000000..2a0a6fd --- /dev/null +++ b/client/src/pages/password-reset/ui/PasswordReset.test.tsx @@ -0,0 +1,81 @@ +import { ThemeProvider } from '@gravity-ui/uikit'; +import { fireEvent, render as rtlRender, screen, waitFor } from '@testing-library/react'; +import type { ReactElement } from 'react'; + +import { ForgotPasswordPage } from '@/pages/password-reset/ui/ForgotPasswordPage'; +import { ResetPasswordPage } from '@/pages/password-reset/ui/ResetPasswordPage'; + +const api = vi.hoisted(() => ({ apiClient: vi.fn() })); +vi.mock('@/shared/api', () => ({ + apiClient: api.apiClient, + getApiErrorMessage: (error: unknown) => (error instanceof Error ? error.message : 'Error'), +})); + +// Gravity inputs read the theme from context. +const render = (ui: ReactElement) => rtlRender({ui}); + +const fill = (placeholder: string, value: string) => + fireEvent.change(screen.getByPlaceholderText(placeholder), { target: { value } }); + +beforeEach(() => { + api.apiClient.mockReset(); +}); + +it('requests a reset link and shows a neutral confirmation', async () => { + api.apiClient.mockResolvedValue({ ok: true }); + render( {}} />); + + fill('Email', 'person@example.com'); + fireEvent.click(screen.getByRole('button', { name: 'Send link' })); + + expect(await screen.findByRole('status')).toHaveTextContent( + 'If an account with this email exists', + ); + expect(api.apiClient).toHaveBeenCalledWith('/auth/forgot-password', { + method: 'POST', + body: JSON.stringify({ email: 'person@example.com', locale: 'en' }), + skipAuthRefresh: true, + }); +}); + +describe('ResetPasswordPage', () => { + it('checks that both passwords match before calling the API', async () => { + window.history.replaceState({}, '', '/reset-password#token=tok'); + render( {}} onRequestNewLink={() => {}} />); + + fill('New password', 'secret123'); + fill('Repeat password', 'secret124'); + fireEvent.click(screen.getByRole('button', { name: 'Save password' })); + + expect(await screen.findByRole('alert')).toHaveTextContent("Passwords don't match"); + expect(api.apiClient).not.toHaveBeenCalled(); + }); + + it('saves the new password with the token from the link', async () => { + window.history.replaceState({}, '', '/reset-password#token=tok'); + api.apiClient.mockResolvedValue({ ok: true }); + const onDone = vi.fn(); + render( {}} />); + + fill('New password', 'secret123'); + fill('Repeat password', 'secret123'); + fireEvent.click(screen.getByRole('button', { name: 'Save password' })); + + await waitFor(() => expect(onDone).toHaveBeenCalled()); + expect(api.apiClient).toHaveBeenCalledWith('/auth/reset-password', { + method: 'POST', + body: JSON.stringify({ token: 'tok', password: 'secret123' }), + skipAuthRefresh: true, + }); + }); + + it('offers a new link when opened without a token', () => { + window.history.replaceState({}, '', '/reset-password'); + const onRequestNewLink = vi.fn(); + render( {}} onRequestNewLink={onRequestNewLink} />); + + expect(screen.getByRole('alert')).toHaveTextContent('This link is invalid or has expired'); + fireEvent.click(screen.getByRole('button', { name: 'Send link' })); + expect(onRequestNewLink).toHaveBeenCalled(); + }); +}); diff --git a/client/src/pages/password-reset/ui/ResetPasswordPage.tsx b/client/src/pages/password-reset/ui/ResetPasswordPage.tsx new file mode 100644 index 0000000..2b6fa9a --- /dev/null +++ b/client/src/pages/password-reset/ui/ResetPasswordPage.tsx @@ -0,0 +1,93 @@ +import { Button, TextInput } from '@gravity-ui/uikit'; +import { useState, type FormEvent } from 'react'; + +import { apiClient, getApiErrorMessage } from '@/shared/api'; +import { useHashToken } from '@/shared/lib/hashToken'; +import { messages, type Locale } from '@/shared/locale/content'; +import { AuthCard, type AuthCardNotice } from '@/shared/ui/auth-card/AuthCard'; + +export const ResetPasswordPage = ({ + locale, + onDone, + onRequestNewLink, +}: { + locale: Locale; + /** Called after the password is saved; the user signs in again. */ + onDone: () => void; + onRequestNewLink: () => void; +}) => { + const t = messages[locale]; + const token = useHashToken(); + const [password, setPassword] = useState(''); + const [repeat, setRepeat] = useState(''); + const [isSubmitting, setSubmitting] = useState(false); + const [notice, setNotice] = useState( + token ? null : { tone: 'error', text: t.resetLinkInvalid }, + ); + + const submit = async (event: FormEvent) => { + event.preventDefault(); + if (password !== repeat) { + setNotice({ tone: 'error', text: t.passwordsDontMatch }); + return; + } + if (password.length < 6) { + setNotice({ tone: 'error', text: t.authPasswordTooShort }); + return; + } + setSubmitting(true); + try { + await apiClient('/auth/reset-password', { + method: 'POST', + body: JSON.stringify({ token, password }), + skipAuthRefresh: true, + }); + onDone(); + } catch (error) { + const message = getApiErrorMessage(error); + setNotice({ + tone: 'error', + text: message.includes('invalid or has expired') ? t.resetLinkInvalid : message, + }); + } finally { + setSubmitting(false); + } + }; + + return ( + + {token ? ( +
    + + + + + ) : ( + + )} +
    + ); +}; diff --git a/client/src/pages/verify-email/index.ts b/client/src/pages/verify-email/index.ts new file mode 100644 index 0000000..b465b70 --- /dev/null +++ b/client/src/pages/verify-email/index.ts @@ -0,0 +1 @@ +export { VerifyEmailPage } from '@/pages/verify-email/ui/VerifyEmailPage'; diff --git a/client/src/pages/verify-email/ui/VerifyEmailPage.test.tsx b/client/src/pages/verify-email/ui/VerifyEmailPage.test.tsx new file mode 100644 index 0000000..9bcfddc --- /dev/null +++ b/client/src/pages/verify-email/ui/VerifyEmailPage.test.tsx @@ -0,0 +1,40 @@ +import { render, screen } from '@testing-library/react'; +import { StrictMode } from 'react'; + +import { VerifyEmailPage } from '@/pages/verify-email/ui/VerifyEmailPage'; + +const api = vi.hoisted(() => ({ apiClient: vi.fn() })); +vi.mock('@/shared/api', () => ({ apiClient: api.apiClient })); + +beforeEach(() => { + api.apiClient.mockReset(); +}); + +it('confirms once even when effects run twice, and clears the token from the URL', async () => { + window.history.replaceState({}, '', '/verify-email#token=abc123'); + api.apiClient.mockResolvedValue({ ok: true }); + + render( + + {}} /> + , + ); + + expect(await screen.findByRole('status')).toHaveTextContent('Email confirmed. Thank you!'); + expect(api.apiClient).toHaveBeenCalledTimes(1); + expect(api.apiClient).toHaveBeenCalledWith('/auth/verify-email', { + method: 'POST', + body: JSON.stringify({ token: 'abc123' }), + skipAuthRefresh: true, + }); + expect(window.location.hash).toBe(''); +}); + +it('explains an invalid link', async () => { + window.history.replaceState({}, '', '/verify-email#token=expired'); + api.apiClient.mockRejectedValue(new Error('This confirmation link is invalid or has expired')); + + render( {}} />); + + expect(await screen.findByRole('alert')).toHaveTextContent('Ссылка недействительна'); +}); diff --git a/client/src/pages/verify-email/ui/VerifyEmailPage.tsx b/client/src/pages/verify-email/ui/VerifyEmailPage.tsx new file mode 100644 index 0000000..3986f61 --- /dev/null +++ b/client/src/pages/verify-email/ui/VerifyEmailPage.tsx @@ -0,0 +1,64 @@ +import { Button } from '@gravity-ui/uikit'; +import { useEffect, useState } from 'react'; + +import { apiClient } from '@/shared/api'; +import { useHashToken } from '@/shared/lib/hashToken'; +import { messages, type Locale } from '@/shared/locale/content'; +import { AuthCard, type AuthCardNotice } from '@/shared/ui/auth-card/AuthCard'; + +// Tokens are single-use: share one request per token so a re-run effect (StrictMode, remount) +// doesn't consume it twice and report the second attempt as a failure. +const verifications = new Map>(); + +const verifyOnce = (token: string) => { + let request = verifications.get(token); + if (!request) { + request = apiClient('/auth/verify-email', { + method: 'POST', + body: JSON.stringify({ token }), + skipAuthRefresh: true, + }); + verifications.set(token, request); + } + return request; +}; + +export const VerifyEmailPage = ({ + locale, + onContinue, +}: { + locale: Locale; + onContinue: () => void; +}) => { + const t = messages[locale]; + const token = useHashToken(); + const [notice, setNotice] = useState( + token ? null : { tone: 'error', text: t.verifyLinkInvalid }, + ); + + useEffect(() => { + if (!token) return; + let cancelled = false; + verifyOnce(token) + .then(() => !cancelled && setNotice({ tone: 'success', text: t.emailConfirmed })) + .catch(() => !cancelled && setNotice({ tone: 'error', text: t.verifyLinkInvalid })); + return () => { + cancelled = true; + }; + }, [t.emailConfirmed, t.verifyLinkInvalid, token]); + + return ( + + {notice && ( + + )} + + ); +}; diff --git a/client/src/shared/lib/hashToken.ts b/client/src/shared/lib/hashToken.ts new file mode 100644 index 0000000..fe137ee --- /dev/null +++ b/client/src/shared/lib/hashToken.ts @@ -0,0 +1,18 @@ +import { useEffect, useState } from 'react'; + +const readTokenFromHash = () => new URLSearchParams(window.location.hash.slice(1)).get('token'); + +/** + * The one-time token that email links carry in the URL hash (`#token=…`). Read once, then + * removed from the address bar so it isn't left in history or shared by accident. Reading and + * clearing are separate so a double-run initializer (StrictMode) still sees the token. + */ +export const useHashToken = () => { + const [token] = useState(readTokenFromHash); + useEffect(() => { + if (window.location.hash) { + window.history.replaceState({}, '', window.location.pathname + window.location.search); + } + }, []); + return token; +}; diff --git a/client/src/shared/locale/content.ts b/client/src/shared/locale/content.ts index 28f216f..46b0236 100644 --- a/client/src/shared/locale/content.ts +++ b/client/src/shared/locale/content.ts @@ -47,6 +47,33 @@ export const messages = { linkSessionExpired: 'Сессия истекла. Войдите снова и повторите привязку.', yandexLinkFailed: 'Не удалось подключить Яндекс ID. Попробуйте ещё раз.', backToDashboard: 'К виджетам', + forgotPassword: 'Забыли пароль?', + forgotPasswordTitle: 'Восстановление пароля', + forgotPasswordSubtitle: 'Укажите email аккаунта — пришлём ссылку для нового пароля.', + sendResetLink: 'Отправить ссылку', + resetLinkSent: + 'Если аккаунт с таким email существует, письмо уже в пути. Проверьте и папку «Спам».', + backToSignin: 'Вернуться ко входу', + resetPasswordTitle: 'Новый пароль', + resetPasswordSubtitle: + 'Придумайте новый пароль. После смены все устройства выйдут из аккаунта.', + newPassword: 'Новый пароль', + repeatPassword: 'Повторите пароль', + passwordsDontMatch: 'Пароли не совпадают', + savePassword: 'Сохранить пароль', + passwordChanged: 'Пароль изменён. Войдите с новым паролем.', + resetLinkInvalid: 'Ссылка недействительна или устарела. Запросите новую.', + verifyEmailTitle: 'Подтверждение email', + verifyingEmail: 'Подтверждаем адрес…', + emailConfirmed: 'Email подтверждён. Спасибо!', + verifyLinkInvalid: + 'Ссылка недействительна или устарела. Отправьте письмо ещё раз со страницы аккаунта.', + goToWidgets: 'Перейти к виджетам', + emailAddress: 'Email', + emailVerified: 'Подтверждён', + emailNotVerified: 'Не подтверждён', + resendVerification: 'Отправить письмо', + verificationSent: 'Письмо отправлено. Проверьте почту.', createWidget: 'Создать виджет', starOnGithub: 'Поставить звезду на GitHub', open: 'Открыть', @@ -163,6 +190,32 @@ export const messages = { linkSessionExpired: 'Your session expired. Sign in again and retry.', yandexLinkFailed: "Couldn't connect Yandex ID. Please try again.", backToDashboard: 'Back to widgets', + forgotPassword: 'Forgot password?', + forgotPasswordTitle: 'Reset your password', + forgotPasswordSubtitle: + "Enter your account email and we'll send you a link to set a new password.", + sendResetLink: 'Send link', + resetLinkSent: + 'If an account with this email exists, the email is on its way. Check your spam folder too.', + backToSignin: 'Back to sign in', + resetPasswordTitle: 'New password', + resetPasswordSubtitle: 'Choose a new password. Changing it signs you out on all devices.', + newPassword: 'New password', + repeatPassword: 'Repeat password', + passwordsDontMatch: "Passwords don't match", + savePassword: 'Save password', + passwordChanged: 'Password changed. Sign in with your new password.', + resetLinkInvalid: 'This link is invalid or has expired. Request a new one.', + verifyEmailTitle: 'Email confirmation', + verifyingEmail: 'Confirming your address…', + emailConfirmed: 'Email confirmed. Thank you!', + verifyLinkInvalid: 'This link is invalid or has expired. Resend it from your account page.', + goToWidgets: 'Go to widgets', + emailAddress: 'Email', + emailVerified: 'Confirmed', + emailNotVerified: 'Not confirmed', + resendVerification: 'Send email', + verificationSent: 'Email sent. Check your inbox.', createWidget: 'Create widget', starOnGithub: 'Star on GitHub', open: 'Open', diff --git a/client/src/shared/ui/auth-card/AuthCard.module.css b/client/src/shared/ui/auth-card/AuthCard.module.css new file mode 100644 index 0000000..f201595 --- /dev/null +++ b/client/src/shared/ui/auth-card/AuthCard.module.css @@ -0,0 +1,64 @@ +.page { + display: grid; + min-height: 70vh; + place-items: center; + padding: 24px 16px; +} + +.card { + display: grid; + width: min(100%, 440px); + gap: 14px; + padding: 32px; + border: 1px solid color-mix(in srgb, var(--action) 22%, var(--line)); + border-radius: 28px; + background: linear-gradient(145deg, var(--glass-strong), var(--glass)); + box-shadow: var(--shadow); +} + +.card h1, +.card p { + margin: 0; +} + +.eyebrow { + color: var(--action); + font-size: 11px; + font-weight: 800; + letter-spacing: 0.1em; + text-transform: uppercase; +} + +.card h1 { + font-size: clamp(24px, 7vw, 32px); + letter-spacing: -0.06em; +} + +.subtitle { + color: var(--muted); + font-size: 14px; + line-height: 1.5; +} + +.success, +.error { + padding: 10px 14px; + border-radius: 14px; + font-size: 13px; + line-height: 1.45; +} + +.success { + color: #1d8a64; + background: color-mix(in srgb, #22a477 12%, transparent); +} + +.error { + color: #b2475a; + background: color-mix(in srgb, #d45c71 12%, transparent); +} + +.card form { + display: grid; + gap: 12px; +} diff --git a/client/src/shared/ui/auth-card/AuthCard.tsx b/client/src/shared/ui/auth-card/AuthCard.tsx new file mode 100644 index 0000000..31c81fe --- /dev/null +++ b/client/src/shared/ui/auth-card/AuthCard.tsx @@ -0,0 +1,37 @@ +import type { ReactNode } from 'react'; + +import styles from '@/shared/ui/auth-card/AuthCard.module.css'; + +export type AuthCardNotice = { tone: 'success' | 'error'; text: string }; + +/** Centered card for single-purpose auth screens (password reset, email confirmation). */ +export const AuthCard = ({ + eyebrow, + title, + subtitle, + notice, + children, +}: { + eyebrow: string; + title: string; + subtitle?: string; + notice?: AuthCardNotice | null; + children?: ReactNode; +}) => ( +
    +
    +

    {eyebrow}

    +

    {title}

    + {subtitle &&

    {subtitle}

    } + {notice && ( +

    + {notice.text} +

    + )} + {children} +
    +
    +); diff --git a/server/prisma/migrations/20260929153949_add_email_tokens/migration.sql b/server/prisma/migrations/20260929153949_add_email_tokens/migration.sql new file mode 100644 index 0000000..838226d --- /dev/null +++ b/server/prisma/migrations/20260929153949_add_email_tokens/migration.sql @@ -0,0 +1,24 @@ +-- AlterTable +ALTER TABLE "User" ADD COLUMN "emailVerifiedAt" TIMESTAMP(3); + +-- CreateTable +CREATE TABLE "AuthToken" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "type" TEXT NOT NULL, + "tokenHash" TEXT NOT NULL, + "expiresAt" TIMESTAMP(3) NOT NULL, + "usedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "AuthToken_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "AuthToken_tokenHash_key" ON "AuthToken"("tokenHash"); + +-- CreateIndex +CREATE INDEX "AuthToken_userId_type_idx" ON "AuthToken"("userId", "type"); + +-- AddForeignKey +ALTER TABLE "AuthToken" ADD CONSTRAINT "AuthToken_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/server/prisma/migrations/migration_lock.toml b/server/prisma/migrations/migration_lock.toml new file mode 100644 index 0000000..044d57c --- /dev/null +++ b/server/prisma/migrations/migration_lock.toml @@ -0,0 +1,3 @@ +# Please do not edit this file manually +# It should be added in your version-control system (e.g., Git) +provider = "postgresql" diff --git a/server/prisma/schema.prisma b/server/prisma/schema.prisma index 8d76bd7..6b96933 100644 --- a/server/prisma/schema.prisma +++ b/server/prisma/schema.prisma @@ -8,15 +8,32 @@ datasource db { } model User { - id String @id @default(cuid()) - email String? @unique - passwordHash String? - yandexId String? @unique - name String? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - authSessions AuthSession[] - widgets Widget[] + id String @id @default(cuid()) + email String? @unique + emailVerifiedAt DateTime? + passwordHash String? + yandexId String? @unique + name String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + authSessions AuthSession[] + authTokens AuthToken[] + widgets Widget[] +} + +// One-time tokens sent by email (password reset, email verification). Only the SHA-256 hash +// is stored; a token is consumed by setting usedAt. +model AuthToken { + id String @id @default(cuid()) + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + type String + tokenHash String @unique + expiresAt DateTime + usedAt DateTime? + createdAt DateTime @default(now()) + + @@index([userId, type]) } model AuthSession { diff --git a/server/src/auth.test.ts b/server/src/auth.test.ts index bc7862a..3d18a2f 100644 --- a/server/src/auth.test.ts +++ b/server/src/auth.test.ts @@ -16,6 +16,8 @@ const prismaMocks = vi.hoisted(() => ({ })); vi.mock('@server/lib/prisma.js', () => ({ prisma: prismaMocks })); +// Email flows are covered in emailFlows.test.ts; keep sign-up here free of mail side effects. +vi.mock('@server/lib/mailer.js', () => ({ isEmailEnabled: () => false, sendEmail: vi.fn() })); const app = createApp(); const user = { id: 'user-1', email: 'person@example.com', name: 'Person' }; @@ -70,7 +72,7 @@ it('protects the current user endpoint with a valid access token', async () => { await request(app) .get('/api/auth/me') .set('Authorization', `Bearer ${registerResponse.body.accessToken}`) - .expect(200, { user, methods: { password: true, yandex: false } }); + .expect(200, { user, methods: { password: true, yandex: false }, emailVerified: false }); }); it('rotates a refresh session and revokes it on logout', async () => { @@ -157,7 +159,12 @@ describe('Yandex OAuth callback', () => { /^http:\/\/localhost:5173\/auth\/callback#access_token=/, ); expect(prismaMocks.user.create).toHaveBeenCalledWith({ - data: { yandexId: 'ya-1', email: 'person@example.com', name: 'Person' }, + data: { + yandexId: 'ya-1', + email: 'person@example.com', + name: 'Person', + emailVerifiedAt: expect.any(Date), + }, }); }); }); diff --git a/server/src/controllers/authController.ts b/server/src/controllers/authController.ts index 8f0f2cb..0dd2e88 100644 --- a/server/src/controllers/authController.ts +++ b/server/src/controllers/authController.ts @@ -4,6 +4,7 @@ import { type NextFunction, type Request, type Response } from 'express'; import { z } from 'zod'; import { AppError } from '@server/lib/errors.js'; +import { isEmailEnabled } from '@server/lib/mailer.js'; import { type AuthRequest } from '@server/middleware/auth.js'; import { authService, @@ -12,12 +13,25 @@ import { type AuthResult, } from '@server/services/authService.js'; +const emailSchema = z.string().trim().toLowerCase().email('Enter a valid email'); +const passwordSchema = z.string().min(6, 'Password must contain at least 6 characters').max(128); +const localeSchema = z.enum(['ru', 'en']).catch('en'); +const tokenSchema = z.string().trim().min(1).max(200); + const credentialsSchema = z.object({ - email: z.string().trim().toLowerCase().email('Enter a valid email'), - password: z.string().min(6, 'Password must contain at least 6 characters').max(128), + email: emailSchema, + password: passwordSchema, name: z.string().trim().min(1).max(100).optional(), + locale: localeSchema.optional(), }); +const parseBody = (schema: z.ZodType, body: unknown): T => { + const parsed = schema.safeParse(body); + if (!parsed.success) + throw new AppError(400, parsed.error.issues[0]?.message ?? 'Invalid request'); + return parsed.data; +}; + const cookieOptions = (maxAge: number) => ({ httpOnly: true, secure: process.env.NODE_ENV === 'production', @@ -116,6 +130,51 @@ export class AuthController { } }; + features = (_req: Request, res: Response) => { + res.json({ email: isEmailEnabled() }); + }; + + forgotPassword = async (req: Request, res: Response, next: NextFunction) => { + try { + const input = parseBody(z.object({ email: emailSchema, locale: localeSchema }), req.body); + await authService.requestPasswordReset(input.email, input.locale); + res.json({ ok: true }); + } catch (error) { + next(error); + } + }; + + resetPassword = async (req: Request, res: Response, next: NextFunction) => { + try { + const input = parseBody(z.object({ token: tokenSchema, password: passwordSchema }), req.body); + await authService.resetPassword(input.token, input.password); + clearRefreshCookie(res); + res.json({ ok: true }); + } catch (error) { + next(error); + } + }; + + verifyEmail = async (req: Request, res: Response, next: NextFunction) => { + try { + const input = parseBody(z.object({ token: tokenSchema }), req.body); + await authService.verifyEmail(input.token); + res.json({ ok: true }); + } catch (error) { + next(error); + } + }; + + resendVerification = async (req: AuthRequest, res: Response, next: NextFunction) => { + try { + const input = parseBody(z.object({ locale: localeSchema }), req.body ?? {}); + await authService.sendEmailVerification(req.userId!, input.locale); + res.json({ ok: true }); + } catch (error) { + next(error); + } + }; + me = async (req: AuthRequest, res: Response, next: NextFunction) => { try { res.json(await authService.getCurrentUser(req.userId!)); diff --git a/server/src/emailFlows.test.ts b/server/src/emailFlows.test.ts new file mode 100644 index 0000000..7fe7000 --- /dev/null +++ b/server/src/emailFlows.test.ts @@ -0,0 +1,218 @@ +import { createHash } from 'node:crypto'; + +import request from 'supertest'; + +import { createApp } from '@server/app.js'; + +const prismaMocks = vi.hoisted(() => ({ + user: { create: vi.fn(), findUnique: vi.fn(), update: vi.fn() }, + authSession: { create: vi.fn(), findUnique: vi.fn(), updateMany: vi.fn() }, + authToken: { create: vi.fn(), findUnique: vi.fn(), updateMany: vi.fn() }, + $transaction: vi.fn(), +})); +const mailer = vi.hoisted(() => ({ enabled: true, sent: [] as { to: string; text: string }[] })); + +vi.mock('@server/lib/prisma.js', () => ({ prisma: prismaMocks })); +vi.mock('@server/lib/mailer.js', () => ({ + isEmailEnabled: () => mailer.enabled, + sendEmail: vi.fn(async (message: { to: string; text: string }) => { + mailer.sent.push(message); + }), +})); + +const app = createApp(); +const user = { + id: 'user-1', + email: 'person@example.com', + name: null, + passwordHash: 'old-hash', + yandexId: null, + emailVerifiedAt: null, +}; +const hash = (token: string) => createHash('sha256').update(token).digest('hex'); +const tokenFromLastEmail = () => { + const match = mailer.sent.at(-1)?.text.match(/#token=([0-9a-f]+)/); + if (!match) throw new Error('No token in the last email'); + return match[1]; +}; + +beforeEach(() => { + process.env.JWT_SECRET = 'test-access-secret'; + process.env.JWT_REFRESH_SECRET = 'test-refresh-secret'; + process.env.CLIENT_URL = 'http://localhost:5173'; + vi.clearAllMocks(); + mailer.enabled = true; + mailer.sent = []; + prismaMocks.$transaction.mockImplementation((operations: Promise[]) => + Promise.all(operations), + ); + prismaMocks.authToken.updateMany.mockResolvedValue({ count: 1 }); +}); + +it('reports whether email features are available', async () => { + await request(app).get('/api/auth/features').expect(200, { email: true }); + mailer.enabled = false; + await request(app).get('/api/auth/features').expect(200, { email: false }); +}); + +describe('password reset', () => { + it('emails a single-use link without revealing whether the account exists', async () => { + prismaMocks.user.findUnique.mockResolvedValueOnce(user).mockResolvedValueOnce(null); + + await request(app) + .post('/api/auth/forgot-password') + .send({ email: 'Person@Example.com', locale: 'ru' }) + .expect(200, { ok: true }); + await request(app) + .post('/api/auth/forgot-password') + .send({ email: 'nobody@example.com' }) + .expect(200, { ok: true }); + + expect(mailer.sent).toHaveLength(1); + expect(mailer.sent[0].to).toBe('person@example.com'); + expect(mailer.sent[0].text).toContain('http://localhost:5173/reset-password#token='); + const token = tokenFromLastEmail(); + expect(prismaMocks.authToken.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ type: 'password_reset', tokenHash: hash(token) }), + }); + // Earlier unused reset links are invalidated. + expect(prismaMocks.authToken.updateMany).toHaveBeenCalledWith({ + where: { userId: user.id, type: 'password_reset', usedAt: null }, + data: { usedAt: expect.any(Date) }, + }); + }); + + it('sets the new password, verifies the email and signs out every session', async () => { + prismaMocks.authToken.findUnique.mockResolvedValue({ + id: 'token-1', + userId: user.id, + type: 'password_reset', + }); + + await request(app) + .post('/api/auth/reset-password') + .send({ token: 'a'.repeat(64), password: 'new-secret' }) + .expect(200); + + expect(prismaMocks.authToken.findUnique).toHaveBeenCalledWith({ + where: { tokenHash: hash('a'.repeat(64)) }, + }); + expect(prismaMocks.user.update).toHaveBeenCalledWith({ + where: { id: user.id }, + data: { passwordHash: expect.stringMatching(/^\$2[aby]\$/) }, + }); + expect(prismaMocks.authSession.updateMany).toHaveBeenCalledWith({ + where: { userId: user.id, revokedAt: null }, + data: { revokedAt: expect.any(Date) }, + }); + expect(prismaMocks.user.update).toHaveBeenCalledWith({ + where: { id: user.id }, + data: { emailVerifiedAt: expect.any(Date) }, + }); + }); + + it('rejects used, expired or unknown tokens', async () => { + prismaMocks.authToken.findUnique.mockResolvedValue({ + id: 'token-1', + userId: user.id, + type: 'password_reset', + }); + prismaMocks.authToken.updateMany.mockResolvedValue({ count: 0 }); + + const response = await request(app) + .post('/api/auth/reset-password') + .send({ token: 'used-token', password: 'new-secret' }) + .expect(400); + + expect(response.body.error).toBe('This reset link is invalid or has expired'); + expect(prismaMocks.user.update).not.toHaveBeenCalled(); + }); + + it('does not accept an email verification token as a reset token', async () => { + prismaMocks.authToken.findUnique.mockResolvedValue({ + id: 'token-1', + userId: user.id, + type: 'email_verification', + }); + + await request(app) + .post('/api/auth/reset-password') + .send({ token: 'verify-token', password: 'new-secret' }) + .expect(400); + }); + + it('is unavailable when email is disabled', async () => { + mailer.enabled = false; + await request(app) + .post('/api/auth/forgot-password') + .send({ email: 'person@example.com' }) + .expect(503); + }); +}); + +describe('email verification', () => { + it('sends a confirmation email on sign-up', async () => { + prismaMocks.user.create.mockResolvedValue({ id: user.id, email: user.email, name: null }); + prismaMocks.user.findUnique.mockResolvedValue(user); + prismaMocks.authSession.create.mockResolvedValue({}); + + await request(app) + .post('/api/auth/register') + .send({ email: user.email, password: 'secret123', locale: 'ru' }) + .expect(201); + + expect(mailer.sent[0].text).toContain('http://localhost:5173/verify-email#token='); + expect(mailer.sent[0].text).toContain('Подтвердите email'); + }); + + it('still signs the user up when the email cannot be sent', async () => { + const { sendEmail } = await import('@server/lib/mailer.js'); + vi.mocked(sendEmail).mockRejectedValueOnce(new Error('Resend is down')); + vi.spyOn(console, 'error').mockImplementation(() => {}); + prismaMocks.user.create.mockResolvedValue({ id: user.id, email: user.email, name: null }); + prismaMocks.user.findUnique.mockResolvedValue(user); + prismaMocks.authSession.create.mockResolvedValue({}); + + await request(app) + .post('/api/auth/register') + .send({ email: user.email, password: 'secret123' }) + .expect(201); + }); + + it('confirms the email with a valid token', async () => { + prismaMocks.authToken.findUnique.mockResolvedValue({ + id: 'token-1', + userId: user.id, + type: 'email_verification', + }); + + await request(app).post('/api/auth/verify-email').send({ token: 'verify-token' }).expect(200); + + expect(prismaMocks.user.update).toHaveBeenCalledWith({ + where: { id: user.id }, + data: { emailVerifiedAt: expect.any(Date) }, + }); + }); + + it('lets a signed-in user resend the confirmation, but not once verified', async () => { + prismaMocks.user.create.mockResolvedValue({ id: user.id, email: user.email, name: null }); + prismaMocks.user.findUnique.mockResolvedValue({ ...user, emailVerifiedAt: new Date() }); + prismaMocks.authSession.create.mockResolvedValue({}); + const register = await request(app) + .post('/api/auth/register') + .send({ email: user.email, password: 'secret123' }); + const auth = `Bearer ${register.body.accessToken}`; + + await request(app).post('/api/auth/resend-verification').set('Authorization', auth).expect(200); + expect(mailer.sent).toHaveLength(0); + + prismaMocks.user.findUnique.mockResolvedValue(user); + await request(app) + .post('/api/auth/resend-verification') + .set('Authorization', auth) + .send({ locale: 'en' }) + .expect(200); + expect(mailer.sent).toHaveLength(1); + expect(mailer.sent[0].text).toContain('Confirm your email'); + }); +}); diff --git a/server/src/lib/emailTemplates.ts b/server/src/lib/emailTemplates.ts new file mode 100644 index 0000000..825580b --- /dev/null +++ b/server/src/lib/emailTemplates.ts @@ -0,0 +1,77 @@ +import type { EmailMessage } from '@server/lib/mailer.js'; + +export type EmailLocale = 'ru' | 'en'; + +const escapeHtml = (value: string) => + value.replace( + /[&<>"']/g, + (character) => + ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[character]!, + ); + +const layout = ( + title: string, + body: string, + action: { label: string; url: string }, +) => ` + + + + +
    +

    WidgeCode

    +

    ${escapeHtml(title)}

    +

    ${body}

    + ${escapeHtml(action.label)} +

    ${escapeHtml(action.url)}

    +
    + +`; + +const copy = { + ru: { + reset: { + subject: 'Сброс пароля WidgeCode', + title: 'Сброс пароля', + body: 'Кто-то запросил сброс пароля для вашего аккаунта. Ссылка действует 1 час. Если это были не вы, просто проигнорируйте письмо.', + action: 'Задать новый пароль', + }, + verify: { + subject: 'Подтвердите email для WidgeCode', + title: 'Подтвердите email', + body: 'Подтвердите, что этот адрес принадлежит вам. Ссылка действует 24 часа.', + action: 'Подтвердить email', + }, + }, + en: { + reset: { + subject: 'Reset your WidgeCode password', + title: 'Reset your password', + body: "Someone asked to reset the password for your account. The link is valid for 1 hour. If it wasn't you, just ignore this email.", + action: 'Set a new password', + }, + verify: { + subject: 'Confirm your email for WidgeCode', + title: 'Confirm your email', + body: 'Confirm that this address belongs to you. The link is valid for 24 hours.', + action: 'Confirm email', + }, + }, +} as const; + +const message = ( + to: string, + template: (typeof copy)[EmailLocale][keyof (typeof copy)['en']], + url: string, +): EmailMessage => ({ + to, + subject: template.subject, + html: layout(template.title, escapeHtml(template.body), { label: template.action, url }), + text: `${template.title}\n\n${template.body}\n\n${template.action}: ${url}`, +}); + +export const passwordResetEmail = (to: string, url: string, locale: EmailLocale) => + message(to, copy[locale].reset, url); + +export const emailVerificationEmail = (to: string, url: string, locale: EmailLocale) => + message(to, copy[locale].verify, url); diff --git a/server/src/lib/mailer.test.ts b/server/src/lib/mailer.test.ts new file mode 100644 index 0000000..316c394 --- /dev/null +++ b/server/src/lib/mailer.test.ts @@ -0,0 +1,60 @@ +import { isEmailEnabled, sendEmail } from '@server/lib/mailer.js'; + +const message = { to: 'person@example.com', subject: 'Hi', html: '

    Hi

    ', text: 'Hi' }; +const originalEnv = { ...process.env }; + +afterEach(() => { + process.env = { ...originalEnv }; + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); + +it('is disabled in production until Resend is configured', async () => { + process.env.NODE_ENV = 'production'; + delete process.env.RESEND_API_KEY; + delete process.env.EMAIL_FROM; + + expect(isEmailEnabled()).toBe(false); + await expect(sendEmail(message)).rejects.toThrow('Email is not configured'); +}); + +it('logs emails instead of sending them in development', async () => { + process.env.NODE_ENV = 'development'; + delete process.env.RESEND_API_KEY; + const log = vi.spyOn(console, 'info').mockImplementation(() => {}); + vi.stubGlobal('fetch', vi.fn()); + + expect(isEmailEnabled()).toBe(true); + await sendEmail(message); + + expect(log).toHaveBeenCalledWith(expect.stringContaining('to=person@example.com')); + expect(fetch).not.toHaveBeenCalled(); +}); + +it('sends through the Resend API when configured', async () => { + process.env.NODE_ENV = 'production'; + process.env.RESEND_API_KEY = 're_test'; + process.env.EMAIL_FROM = 'WidgeCode '; + const fetchMock = vi.fn().mockResolvedValue({ ok: true }); + vi.stubGlobal('fetch', fetchMock); + + expect(isEmailEnabled()).toBe(true); + await sendEmail(message); + + expect(fetchMock).toHaveBeenCalledWith('https://api.resend.com/emails', { + method: 'POST', + headers: { Authorization: 'Bearer re_test', 'Content-Type': 'application/json' }, + body: JSON.stringify({ from: 'WidgeCode ', ...message }), + }); +}); + +it('surfaces Resend errors', async () => { + process.env.RESEND_API_KEY = 're_test'; + process.env.EMAIL_FROM = 'noreply@example.ru'; + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ ok: false, status: 422, text: async () => 'domain not verified' }), + ); + + await expect(sendEmail(message)).rejects.toThrow('Resend returned 422: domain not verified'); +}); diff --git a/server/src/lib/mailer.ts b/server/src/lib/mailer.ts new file mode 100644 index 0000000..b473dcb --- /dev/null +++ b/server/src/lib/mailer.ts @@ -0,0 +1,32 @@ +// Email transport. Resend when RESEND_API_KEY and EMAIL_FROM are set; otherwise, outside +// production, emails are printed to the server log so the flows can be tested locally. In +// production without credentials email is disabled and the features that need it are hidden. + +export type EmailMessage = { to: string; subject: string; html: string; text: string }; + +const resendConfig = () => { + const apiKey = process.env.RESEND_API_KEY?.trim(); + const from = process.env.EMAIL_FROM?.trim(); + return apiKey && from ? { apiKey, from } : null; +}; + +export const isEmailEnabled = () => + Boolean(resendConfig()) || process.env.NODE_ENV !== 'production'; + +export const sendEmail = async (message: EmailMessage) => { + const config = resendConfig(); + if (!config) { + if (process.env.NODE_ENV === 'production') throw new Error('Email is not configured'); + console.info(`[email] to=${message.to} subject="${message.subject}"\n${message.text}`); + return; + } + + const response = await fetch('https://api.resend.com/emails', { + method: 'POST', + headers: { Authorization: `Bearer ${config.apiKey}`, 'Content-Type': 'application/json' }, + body: JSON.stringify({ from: config.from, ...message }), + }); + if (!response.ok) { + throw new Error(`Resend returned ${response.status}: ${await response.text()}`); + } +}; diff --git a/server/src/models/authModel.ts b/server/src/models/authModel.ts index 3189574..f23a716 100644 --- a/server/src/models/authModel.ts +++ b/server/src/models/authModel.ts @@ -4,6 +4,7 @@ import { prisma } from '@server/lib/prisma.js'; export type PublicUser = Pick; export type AuthSessionWithUser = AuthSession & { user: User }; +export type AuthTokenType = 'password_reset' | 'email_verification'; export class AuthModel { async createEmailUser(data: { @@ -52,7 +53,10 @@ export class AuthModel { email: string | null; name: string | null; }): Promise { - return prisma.user.create({ data }); + // Yandex only returns addresses it has verified. + return prisma.user.create({ + data: { ...data, emailVerifiedAt: data.email ? new Date() : null }, + }); } async createSession(data: { @@ -81,6 +85,48 @@ export class AuthModel { return result.count === 1; } + /** Stores a new one-time token and invalidates earlier unused ones of the same type. */ + async issueAuthToken(data: { + userId: string; + type: AuthTokenType; + tokenHash: string; + expiresAt: Date; + }): Promise { + await prisma.$transaction([ + prisma.authToken.updateMany({ + where: { userId: data.userId, type: data.type, usedAt: null }, + data: { usedAt: new Date() }, + }), + prisma.authToken.create({ data }), + ]); + } + + /** Marks a valid token as used and returns its user id; null if unknown, used or expired. */ + async consumeAuthToken(tokenHash: string, type: AuthTokenType): Promise { + const token = await prisma.authToken.findUnique({ where: { tokenHash } }); + if (!token || token.type !== type) return null; + const result = await prisma.authToken.updateMany({ + where: { id: token.id, usedAt: null, expiresAt: { gt: new Date() } }, + data: { usedAt: new Date() }, + }); + return result.count === 1 ? token.userId : null; + } + + async markEmailVerified(userId: string): Promise { + await prisma.user.update({ where: { id: userId }, data: { emailVerifiedAt: new Date() } }); + } + + /** Sets a new password and signs the user out everywhere. */ + async replacePassword(userId: string, passwordHash: string): Promise { + await prisma.$transaction([ + prisma.user.update({ where: { id: userId }, data: { passwordHash } }), + prisma.authSession.updateMany({ + where: { userId, revokedAt: null }, + data: { revokedAt: new Date() }, + }), + ]); + } + async revokeSession(id: string, userId: string): Promise { await prisma.authSession.updateMany({ where: { id, userId, revokedAt: null }, diff --git a/server/src/routes/auth.ts b/server/src/routes/auth.ts index 1961666..ec328c9 100644 --- a/server/src/routes/auth.ts +++ b/server/src/routes/auth.ts @@ -16,6 +16,16 @@ router.post('/login', credentialsLimiter, authController.login); router.post('/refresh', sessionLimiter, authController.refresh); router.post('/logout', sessionLimiter, authController.logout); router.get('/me', sessionLimiter, authMiddleware, authController.me); +router.get('/features', sessionLimiter, authController.features); +router.post('/forgot-password', credentialsLimiter, authController.forgotPassword); +router.post('/reset-password', credentialsLimiter, authController.resetPassword); +router.post('/verify-email', credentialsLimiter, authController.verifyEmail); +router.post( + '/resend-verification', + credentialsLimiter, + authMiddleware, + authController.resendVerification, +); router.get('/yandex', credentialsLimiter, authController.yandex); router.delete('/yandex', sessionLimiter, authMiddleware, authController.unlinkYandex); router.get('/yandex/callback', credentialsLimiter, authController.yandexCallback); diff --git a/server/src/services/authService.ts b/server/src/services/authService.ts index 52f208e..05503c1 100644 --- a/server/src/services/authService.ts +++ b/server/src/services/authService.ts @@ -10,7 +10,13 @@ import { getRefreshTokenTtlMs, verifyRefreshToken, } from '@server/lib/jwt.js'; -import { authModel, type PublicUser } from '@server/models/authModel.js'; +import { + emailVerificationEmail, + passwordResetEmail, + type EmailLocale, +} from '@server/lib/emailTemplates.js'; +import { isEmailEnabled, sendEmail } from '@server/lib/mailer.js'; +import { authModel, type AuthTokenType, type PublicUser } from '@server/models/authModel.js'; export const REFRESH_COOKIE_NAME = 'widgecode_refresh'; export const OAUTH_STATE_COOKIE_NAME = 'widgecode_oauth_state'; @@ -33,6 +39,18 @@ type YandexUserResponse = { const hashToken = (token: string) => createHash('sha256').update(token).digest('hex'); +const PASSWORD_RESET_TTL_MS = 60 * 60 * 1000; +const EMAIL_VERIFICATION_TTL_MS = 24 * 60 * 60 * 1000; + +const clientUrl = () => process.env.CLIENT_URL ?? 'http://localhost:5173'; + +// Tokens go in the URL hash so they never reach server logs, analytics or Referer headers. +const clientLink = (path: string, token: string) => { + const url = new URL(path, clientUrl()); + url.hash = new URLSearchParams({ token }).toString(); + return url.toString(); +}; + const toPublicUser = (user: Pick): PublicUser => ({ id: user.id, email: user.email, @@ -53,7 +71,12 @@ const isPrismaUniqueError = (error: unknown) => (error as { code?: string }).code === 'P2002'; export class AuthService { - async register(input: { email: string; password: string; name?: string }): Promise { + async register(input: { + email: string; + password: string; + name?: string; + locale?: EmailLocale; + }): Promise { const passwordHash = await bcrypt.hash(input.password, 12); let user: PublicUser; @@ -70,6 +93,13 @@ export class AuthService { throw error; } + if (isEmailEnabled()) { + // Awaited so serverless doesn't cut it off, but a mail failure must not fail sign-up; + // the user can resend from the account page. + await this.sendEmailVerification(user.id, input.locale ?? 'en').catch((error) => + console.error('Could not send verification email', error), + ); + } return this.createSession(user); } @@ -86,10 +116,59 @@ export class AuthService { return this.createSession(toPublicUser(user)); } - async getCurrentUser(userId: string): Promise<{ user: PublicUser; methods: SignInMethods }> { + async getCurrentUser( + userId: string, + ): Promise<{ user: PublicUser; methods: SignInMethods; emailVerified: boolean }> { + const user = await authModel.findUserById(userId); + if (!user) throw new AppError(401, 'User no longer exists'); + return { + user: toPublicUser(user), + methods: signInMethods(user), + emailVerified: Boolean(user.emailVerifiedAt), + }; + } + + private async issueToken(userId: string, type: AuthTokenType, ttlMs: number) { + const token = randomBytes(32).toString('hex'); + await authModel.issueAuthToken({ + userId, + type, + tokenHash: hashToken(token), + expiresAt: new Date(Date.now() + ttlMs), + }); + return token; + } + + /** Always resolves the same way, so the response doesn't reveal which emails exist. */ + async requestPasswordReset(email: string, locale: EmailLocale): Promise { + if (!isEmailEnabled()) throw new AppError(503, 'Email is not configured'); + const user = await authModel.findUserByEmail(email); + if (!user?.email) return; + const token = await this.issueToken(user.id, 'password_reset', PASSWORD_RESET_TTL_MS); + await sendEmail(passwordResetEmail(user.email, clientLink('/reset-password', token), locale)); + } + + async resetPassword(token: string, password: string): Promise { + const userId = await authModel.consumeAuthToken(hashToken(token), 'password_reset'); + if (!userId) throw new AppError(400, 'This reset link is invalid or has expired'); + await authModel.replacePassword(userId, await bcrypt.hash(password, 12)); + // Following the emailed link proves ownership of the address. + await authModel.markEmailVerified(userId); + } + + async sendEmailVerification(userId: string, locale: EmailLocale): Promise { + if (!isEmailEnabled()) throw new AppError(503, 'Email is not configured'); const user = await authModel.findUserById(userId); if (!user) throw new AppError(401, 'User no longer exists'); - return { user: toPublicUser(user), methods: signInMethods(user) }; + if (!user.email || user.emailVerifiedAt) return; + const token = await this.issueToken(user.id, 'email_verification', EMAIL_VERIFICATION_TTL_MS); + await sendEmail(emailVerificationEmail(user.email, clientLink('/verify-email', token), locale)); + } + + async verifyEmail(token: string): Promise { + const userId = await authModel.consumeAuthToken(hashToken(token), 'email_verification'); + if (!userId) throw new AppError(400, 'This confirmation link is invalid or has expired'); + await authModel.markEmailVerified(userId); } /** The live session behind a refresh token, or a 401. Does not rotate the token. */ diff --git a/server/src/widgets.test.ts b/server/src/widgets.test.ts index ffbfbc4..bbf1d81 100644 --- a/server/src/widgets.test.ts +++ b/server/src/widgets.test.ts @@ -18,6 +18,8 @@ const prismaMocks = vi.hoisted(() => ({ })); vi.mock('@server/lib/prisma.js', () => ({ prisma: prismaMocks })); +// Email flows are covered in emailFlows.test.ts; keep sign-up here free of mail side effects. +vi.mock('@server/lib/mailer.js', () => ({ isEmailEnabled: () => false, sendEmail: vi.fn() })); const app = createApp(); const user = { id: 'user-1', email: 'person@example.com', name: 'Person' }; diff --git a/vercel.json b/vercel.json index 5735034..3a85db2 100644 --- a/vercel.json +++ b/vercel.json @@ -5,6 +5,10 @@ "buildCommand": "npm run prisma:deploy -w server && npm run prisma:generate -w server && npm run build -w server && npm run build -w client", "outputDirectory": "client/dist", "rewrites": [ + { + "source": "/api/auth/:route(features|forgot-password|reset-password|verify-email|resend-verification)", + "destination": "/api/auth-resource?route=:route" + }, { "source": "/api/widgets/:widgetId/blocks/reorder", "destination": "/api/widgets-resource?resource=widget&widgetId=:widgetId&action=blocks"