Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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 <noreply@your-domain.ru>"
2 changes: 2 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
25 changes: 25 additions & 0 deletions api/auth-resource.ts
Original file line number Diff line number Diff line change
@@ -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/<route> here as ?route=<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);
};
61 changes: 57 additions & 4 deletions client/src/app/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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,
Expand All @@ -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';
Expand All @@ -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';
Expand Down Expand Up @@ -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';
};

Expand All @@ -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}`
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
}
Expand All @@ -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`)}
/>
Expand All @@ -465,6 +497,27 @@ export const App = () => {
embed={isEmbedRoute}
onHome={() => navigate('/')}
/>
) : route === 'forgot-password' ? (
<ForgotPasswordPage locale={locale} onBackToSignin={() => navigate('/login')} />
) : route === 'reset-password' ? (
<ResetPasswordPage
locale={locale}
onDone={() => {
// 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' ? (
<VerifyEmailPage
locale={locale}
onContinue={() => navigate(isAuthorized ? '/dashboard' : '/login')}
/>
) : route === 'not-found' ? (
<ErrorPage
code={404}
Expand Down
1 change: 1 addition & 0 deletions client/src/features/auth/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export { useAuthStore } from '@/features/auth/model/authStore';
export type { AuthStatus, AuthUser } from '@/features/auth/model/authStore';
export { useAuthFeatures, type AuthFeatures } from '@/features/auth/model/authFeatures';
34 changes: 34 additions & 0 deletions client/src/features/auth/model/authFeatures.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { useEffect, useState } from 'react';

import { apiClient } from '@/shared/api';

export type AuthFeatures = { email: boolean };

let featuresPromise: Promise<AuthFeatures> | 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<AuthFeatures>('/auth/features', { skipAuthRefresh: true }).catch(
() => ({ email: false }),
);
return featuresPromise;
};

export const resetAuthFeaturesCache = () => {
featuresPromise = null;
};

export const useAuthFeatures = () => {
const [features, setFeatures] = useState<AuthFeatures | null>(null);
useEffect(() => {
let cancelled = false;
void loadAuthFeatures().then((value) => {
if (!cancelled) setFeatures(value);
});
return () => {
cancelled = true;
};
}, []);
return features;
};
6 changes: 3 additions & 3 deletions client/src/features/auth/model/authStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ type AuthState = {
status: AuthStatus;
error: string | null;
login: (email: string, password: string) => Promise<void>;
register: (email: string, password: string, name?: string) => Promise<void>;
register: (email: string, password: string, name?: string, locale?: 'ru' | 'en') => Promise<void>;
logout: () => Promise<void>;
checkAuth: () => Promise<void>;
refresh: () => Promise<boolean>;
Expand Down Expand Up @@ -57,12 +57,12 @@ export const useAuthStore = create<AuthState>((set, get) => ({
}
},

register: async (email, password, name) => {
register: async (email, password, name, locale) => {
set({ status: 'loading', error: null });
try {
const response = await apiClient<AuthResponse>('/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);
Expand Down
21 changes: 19 additions & 2 deletions client/src/pages/account/ui/AccountPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 } };
}
Expand Down Expand Up @@ -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(<AccountPage locale="en" onBack={() => {}} />);

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' }),
});
});
46 changes: 42 additions & 4 deletions client/src/pages/account/ui/AccountPage.tsx
Original file line number Diff line number Diff line change
@@ -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 = {
Expand Down Expand Up @@ -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<Account | null>(null);
const features = useAuthFeatures();
const [isSendingVerification, setSendingVerification] = useState(false);
const [loadError, setLoadError] = useState<string | null>(null);
const [notice, setNotice] = useState<Notice | null>(() => noticeFromUrl(t));
const [isUnlinking, setUnlinking] = useState(false);
Expand All @@ -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<Account>('/auth/me')
.then((response) => {
if (!cancelled) setAccount(response);
})
Expand All @@ -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 (
<ErrorPage
Expand Down Expand Up @@ -143,6 +161,26 @@ export const AccountPage = ({ locale, onBack }: AccountPageProps) => {

{methods ? (
<ul className={styles.methods}>
{account?.user.email && (
<MethodRow
icon={<Icon data={Envelope} size={18} />}
title={`${t.emailAddress}: ${account.user.email}`}
connected={account.emailVerified}
status={account.emailVerified ? t.emailVerified : t.emailNotVerified}
action={
!account.emailVerified &&
features?.email && (
<Button
view="outlined-action"
onClick={() => void resendVerification()}
loading={isSendingVerification}
>
{t.resendVerification}
</Button>
)
}
/>
)}
<MethodRow
icon={<Icon data={Key} size={18} />}
title={t.passwordMethod}
Expand Down
7 changes: 7 additions & 0 deletions client/src/pages/auth/ui/AuthPage.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading
Loading