diff --git a/.env.example b/.env.example index 4c0b76d..e186402 100644 --- a/.env.example +++ b/.env.example @@ -1,9 +1,15 @@ # Database MONGODB_URI=mongodb://root:password@localhost:27017/articlify?authSource=admin +# Set to true only when MongoDB is a replica set or mongos (e.g. production). Omit or false for standalone (e.g. local Docker). +# MONGODB_USE_TRANSACTIONS=true -# Auth (NextAuth.js / Auth.js v5) -NEXTAUTH_URL=http://localhost:3000 -NEXTAUTH_SECRET=your-secret-key-here-generate-with-openssl-rand-base64-32 +# Auth (Better Auth) +# Required. Generate with: openssl rand -base64 32 +BETTER_AUTH_SECRET=your-secret-key-here-generate-with-openssl-rand-base64-32 +# Optional: set if app URL differs (e.g. production/stage). Use same origin for sign-in and get-session. +# BETTER_AUTH_URL=https://your-domain.com +# Optional: extra allowed origins (comma-separated). Vercel preview/stage URLs are allowed automatically via VERCEL_URL. +# BETTER_AUTH_TRUSTED_ORIGINS=https://stage.example.com,https://preview.example.com # Storage Configuration # Set to 'minio' for local development, 's3' for production. @@ -20,7 +26,6 @@ S3_PUBLIC_URL=http://localhost:9000/articlify-images S3_FORCE_PATH_STYLE=true # Storage - AWS S3 (Production) - # Bucket must allow public read (or use CloudFront/custom domain and set S3_PUBLIC_URL). # STORAGE_PROVIDER=s3 # S3_REGION=us-east-1 @@ -29,3 +34,11 @@ S3_FORCE_PATH_STYLE=true # S3_BUCKET=articlify-production # S3_PUBLIC_URL=https://articlify-production.s3.us-east-1.amazonaws.com # S3_FORCE_PATH_STYLE=false + +# Mailer +# Set to 'log' for local (logs only), 'resend' for sending via Resend. +MAILER_PROVIDER=log +# MAILER_FROM=notifications@yourdomain.com +# RESEND_API_KEY=re_xxxx (required when MAILER_PROVIDER=resend) +# MAILER_RETRY_ENABLED=true +# MAILER_RATE_LIMIT_PER_MINUTE=10 diff --git a/.husky/commit-msg b/.husky/commit-msg index 00fe3c8..284b65c 100755 --- a/.husky/commit-msg +++ b/.husky/commit-msg @@ -1 +1 @@ -yarn commitlint --edit "$1" +commitlint --edit "$1" diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..941ea48 --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +lts/krypton \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..0bade58 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,113 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Commands + +```bash +yarn dev # Start development server +yarn build # Build for production +yarn test # Run tests (Vitest, jsdom env) +yarn test:watch # Run tests in watch mode +yarn lint # Run ESLint +yarn lint:fix # Fix ESLint errors +yarn type-check # TypeScript check (tsc --noEmit) +yarn docker:up # Start MongoDB + MinIO via Docker Compose +yarn docker:down # Stop Docker services +yarn email:dev # Preview emails at localhost:3001 +``` + +To run a single test file: +```bash +yarn vitest run src/path/to/file.test.ts +``` + +## Architecture + +This is a **Next.js 16 App Router** app using **Feature-Sliced Design (FSD) v2.1**. The package manager is **Yarn 4**. + +### Path aliases +- `~/*` → `./src/*` +- `app/*` → `./app/*` +- `i18n/*` → `./i18n/*` +- `server/*` → `./server/*` + +### Layer map + +``` +root/app/ → Next.js routing glue ONLY (page.tsx, layout.tsx) +src/app/ → App init: global providers (tRPC, theme, i18n) +src/views/ → Page-level orchestration (data fetch + widget assembly) +src/widgets/ → Large reusable UI blocks (header, editor, smart-list) +src/features/ → User-action slices (auth, avatar, article CRUD) +src/entities/ → Domain slices: article, user, tag (model/api/ui) +src/shared/ → Context-agnostic: ui primitives, lib, config, types +server/ → tRPC root (context, procedures, appRouter) +``` + +**Import direction is strictly top → bottom.** No upward or sideways cross-slice imports. `shared` must not import from any other layer. + +### FSD slice structure + +Each entity/feature follows: +``` +slice-name/ + model/ # types, schemas, Zod validators + api/ # repository, service, tRPC router + ui/ # React components for that slice +``` + +### Backend data flow + +tRPC procedures in `server/` call `entities//api/.router.ts` → `.service.ts` → `.repository.ts` (Mongoose). + +Three procedure types in `server/trpc.ts`: +- `publicProcedure` — open +- `protectedProcedure` — requires `ctx.session` +- `adminProcedure` — requires `role === 'admin'` + +tRPC app router is assembled at `server/routers/index.ts` (`article`, `user`). + +### tRPC client usage + +Client component: +```typescript +import { trpc } from '~/shared/api/trpc/client'; +const { data } = trpc.article.list.useQuery({ page: 1 }); +``` + +Server component: +```typescript +import { createServerCaller } from '~/shared/api/trpc/server'; +const caller = await createServerCaller(); +const articles = await caller.article.list({ page: 1 }); +``` + +### Auth + +**Better Auth** (`better-auth`) handles authentication. Config lives at `auth.ts` (root). API route at `app/api/auth/[...all]/`. Client helpers at `src/shared/api/auth-client.ts`. + +### Environment config + +Server env is validated with Zod at startup via `src/shared/config/env/server.ts` → `getServerConfig()`. All server code should use this instead of `process.env` directly. Required vars: `MONGODB_URI`, `BETTER_AUTH_SECRET`. + +### Emails + +React Email templates live in `src/shared/emails/`. The mailer package is `@basedest/mailer`. In development, `MAILER_PROVIDER=log` prints emails to the console; set `MAILER_PROVIDER=resend` with `RESEND_API_KEY` for real sending. Preview with `yarn email:dev`. + +### i18n + +`next-intl` with locale-based routing under `app/[locale]/`. Routing config in `i18n/`. All user-facing text must go through translation keys. + +### Testing + +Vitest with jsdom. Tests live alongside source as `*.test.ts(x)` inside `src/`. Setup file: `src/shared/test/setup.ts`. MSW is available for API mocking. + +## Key conventions + +- Folders: `kebab-case`. Components: `kebab-case` (older components may be `PascalCase`, but new ones should be `kebab-case`). Hooks: `useXxx`. +- Views: ≤ ~150 LOC. `root/app` files: ≤ ~30 LOC. +- All new business logic belongs in a service; routers only validate input and call services. +- Do not add logic to `root/app` pages — delegate to a `src/views` component. +- Do not create a generic `components/` folder; use the appropriate FSD layer. +- Commit messages follow Conventional Commits (enforced by commitlint). diff --git a/README.md b/README.md index 6c54dc3..4025888 100644 --- a/README.md +++ b/README.md @@ -8,11 +8,13 @@ This is a fully modernized Next.js application featuring: -- **Next.js 16** with App Router (full migration from Pages Router) +- **Next.js 16** with App Router - **React 19** with Server Components -- **Auth.js v5 (NextAuth v5)** for authentication +- **Better Auth** for authentication (username + password) - **tRPC** for end-to-end type-safe APIs -- **shadcn/ui** + **Tailwind CSS** for modern UI +- **shadcn/ui** + **Tailwind CSS** for UI +- **Feature-Sliced Design (FSD)** for code organization +- **next-intl** for internationalization (i18n) - **MongoDB** for data storage - **S3-compatible storage** (MinIO for local, AWS S3 for production) - **Docker Compose** for local infrastructure @@ -26,32 +28,38 @@ This is a fully modernized Next.js application featuring: - Tailwind CSS - shadcn/ui components - React Hook Form + Zod validation +- **Tiptap** for rich-text editing (replaces EditorJS) +- next-intl for i18n +- next-themes for dark/light theme ### Backend - tRPC for type-safe APIs -- Auth.js v5 (NextAuth) +- Better Auth - MongoDB with Mongoose - Clean architecture (Routers → Services → Repositories) ### Infrastructure - Docker Compose (MongoDB + MinIO) - S3-compatible storage (MinIO local / AWS S3 production) -- Modern ESLint (flat config) +- ESLint (flat config) - Prettier with Tailwind plugin +- Vitest for tests +- Husky + lint-staged + commitlint ## Features -- ✅ Article CRUD operations with rich-text editor (EditorJS) +- ✅ Article CRUD with rich-text editor (Tiptap) - ✅ User authentication and authorization - ✅ Role-based access control (user/admin) - ✅ Image upload to S3/MinIO -- ✅ Article search and filtering +- ✅ Article search and filtering (SmartList) - ✅ Category-based organization - ✅ Tag system - ✅ Pagination -- ✅ Static Site Generation (SSG) with ISR for article pages +- ✅ Internationalization (i18n) - ✅ Dark/light theme support - ✅ Responsive design +- ✅ User avatar upload ## Getting Started @@ -91,9 +99,8 @@ Edit `.env` and configure: # Database MONGODB_URI=mongodb://root:password@localhost:27017/articlify?authSource=admin -# Auth -NEXTAUTH_URL=http://localhost:3000 -NEXTAUTH_SECRET=your-secret-key-here-generate-with-openssl-rand-base64-32 +# Auth (Better Auth) +BETTER_AUTH_SECRET=your-secret-key-here-generate-with-openssl-rand-base64-32 # Storage (Local Development - MinIO) STORAGE_PROVIDER=minio @@ -106,7 +113,17 @@ S3_PUBLIC_URL=http://localhost:9000/articlify-images S3_FORCE_PATH_STYLE=true ``` -4. **Start Docker services** +4. **Migrating existing users (one-time)** + +If you have existing users in the Mongoose `users` collection, run the migration script once (prefer on a DB copy): + +```bash +MONGODB_URI="your-uri" node scripts/migrate-better-auth.cjs +``` + +This copies users into Better Auth `user` and `account` collections and preserves existing bcrypt passwords. + +5. **Start Docker services** ```bash yarn docker:up @@ -116,7 +133,7 @@ This starts: - MongoDB on port 27017 - MinIO on port 9000 (API) and 9001 (Console) -5. **Run the development server** +6. **Run the development server** ```bash yarn dev @@ -144,40 +161,44 @@ yarn docker:logs ## Project Structure +The codebase follows **Feature-Sliced Design (FSD)**. High-level layout: + ``` articlify/ -├── app/ # Next.js App Router -│ ├── (auth)/ # Auth route group (login, register) -│ ├── (protected)/ # Protected routes (dashboard, editor) -│ ├── api/ # API routes -│ │ ├── auth/ # Auth.js handlers -│ │ └── trpc/ # tRPC endpoint -│ ├── layout.tsx # Root layout with providers -│ └── page.tsx # Home page -├── components/ # React components -│ ├── ui/ # shadcn/ui components -│ ├── ArticleList/ -│ ├── ArticleItem/ -│ ├── Editor/ # EditorJS wrapper -│ └── ... -├── server/ # tRPC backend -│ ├── routers/ # tRPC routers -│ ├── services/ # Business logic -│ └── repositories/ # Data access layer -├── lib/ # Utilities and helpers -│ ├── server/ # Server-side utilities -│ │ └── storage/ # S3/MinIO abstraction -│ ├── trpc/ # tRPC client/server -│ └── ... -├── providers/ # React context providers -├── auth.ts # Auth.js configuration -├── middleware.ts # Route protection -└── docker-compose.yml # Local infrastructure +├── app/ # Next.js App Router (routing glue only) +│ ├── [locale]/ # Locale-based routes (i18n) +│ │ ├── (auth)/ # Login, register +│ │ ├── (protected)/ # Dashboard, editor (auth required) +│ │ ├── [category]/ # Category listing and article by slug +│ │ ├── articles/ # Articles list, user articles by author +│ │ ├── layout.tsx +│ │ └── page.tsx # Home +│ ├── api/ # API routes +│ │ ├── auth/[...all]/ # Better Auth +│ │ ├── trpc/[trpc]/ # tRPC endpoint +│ │ └── user/avatar/ # Avatar upload +│ ├── layout.tsx +│ └── not-found.tsx +├── src/ +│ ├── app/ # App init (providers, global styles) +│ ├── views/ # Page orchestration (dashboard, editor, article, etc.) +│ ├── widgets/ # Large UI blocks (header, footer, smart-list, editor, article-list) +│ ├── features/ # User actions (auth, login, register, user-menu, avatar, i18n) +│ ├── entities/ # Domain (article, tag, user) — API, model, UI +│ └── shared/ # Utilities, trpc client/server, UI primitives, config +├── server/ # tRPC root (context, trpc instance, auth router) +├── i18n/ # next-intl (routing, request, navigation) +├── docker/ +├── auth.ts # Better Auth config (when used from root) +├── middleware.ts +└── docker-compose.yml ``` +For layer rules and where to put new code, see [AGENTS.md](./AGENTS.md). + ## tRPC API -The application uses tRPC for type-safe API communication. Example usage: +The application uses tRPC for type-safe API communication. ### Client-side @@ -188,7 +209,6 @@ import { trpc } from '~/shared/api/trpc/client'; export function MyComponent() { const { data, isLoading } = trpc.article.list.useQuery({ page: 1 }); const createMutation = trpc.article.create.useMutation(); - // ... } ``` @@ -201,7 +221,6 @@ import { createServerCaller } from '~/shared/api/trpc/server'; export default async function Page() { const caller = await createServerCaller(); const articles = await caller.article.list({ page: 1 }); - return
{/* render articles */}
; } ``` @@ -209,17 +228,17 @@ export default async function Page() { ## Available Scripts ```bash -yarn dev # Start development server -yarn build # Build for production -yarn start # Start production server -yarn lint # Run ESLint -yarn lint:fix # Fix ESLint errors -yarn format # Format code with Prettier -yarn format:check # Check code formatting -yarn type-check # Run TypeScript type checking -yarn docker:up # Start Docker services -yarn docker:down # Stop Docker services -yarn docker:logs # View Docker logs +yarn dev # Start development server +yarn build # Build for production +yarn start # Start production server +yarn test # Run tests (Vitest) +yarn test:watch # Run tests in watch mode +yarn lint # Run ESLint +yarn lint:fix # Fix ESLint errors +yarn type-check # TypeScript check +yarn docker:up # Start Docker services +yarn docker:down # Stop Docker services +yarn docker:logs # View Docker logs ``` ## Production Deployment @@ -245,54 +264,16 @@ yarn build yarn start ``` -Or deploy to platforms like Vercel, Netlify, or AWS: - -```bash -# Vercel -vercel deploy --prod - -# Or configure your preferred deployment platform -``` - -## Migration Status - -This project has been fully migrated from: -- Pages Router → App Router -- NextAuth v4 → Auth.js v5 -- REST API → tRPC -- Custom components → shadcn/ui -- SCSS → Tailwind CSS -- Cloudinary → S3/MinIO - -### Completed -- ✅ Docker infrastructure setup -- ✅ Modern ESLint + Prettier configuration -- ✅ Auth.js v5 with MongoDB adapter -- ✅ Middleware for route protection -- ✅ tRPC with clean architecture -- ✅ S3 storage abstraction (MinIO/AWS S3) -- ✅ shadcn/ui components -- ✅ App directory structure -- ✅ Core pages migration (home, login, register, dashboard) -- ✅ Component updates (SmartList, ArticleList, ArticleItem, TagsList) - -### To Complete -- Editor page migration with EditorJS -- Article detail pages with SSG -- Category pages -- User articles page -- Header and Footer components migration -- UserMenu with shadcn DropdownMenu -- Complete SCSS removal -- Full testing and QA +Or deploy to Vercel, Netlify, or other platforms (e.g. `vercel deploy --prod`). ## Contributing 1. Fork the repository 2. Create a feature branch 3. Make your changes -4. Run linting and formatting: `yarn lint:fix && yarn format` -5. Submit a pull request +4. Run linting: `yarn lint:fix` +5. Run tests: `yarn test` +6. Submit a pull request ## License diff --git a/app/[category]/[slug]/page.tsx b/app/[category]/[slug]/page.tsx deleted file mode 100644 index bfd5c65..0000000 --- a/app/[category]/[slug]/page.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import { - ArticlePage, - generateMetadata as articleGenerateMetadata, - generateStaticParams as articleGenerateStaticParams, -} from '~/views/article'; - -export const revalidate = 30; -export const generateMetadata = articleGenerateMetadata; -export const generateStaticParams = articleGenerateStaticParams; - -interface PageProps { - params: Promise<{ category: string; slug: string }>; -} - -export default async function Page(props: PageProps) { - return ; -} diff --git a/app/(auth)/layout.tsx b/app/[locale]/(auth)/layout.tsx similarity index 100% rename from app/(auth)/layout.tsx rename to app/[locale]/(auth)/layout.tsx diff --git a/app/(auth)/login/page.tsx b/app/[locale]/(auth)/login/page.tsx similarity index 100% rename from app/(auth)/login/page.tsx rename to app/[locale]/(auth)/login/page.tsx diff --git a/app/(auth)/register/page.tsx b/app/[locale]/(auth)/register/page.tsx similarity index 100% rename from app/(auth)/register/page.tsx rename to app/[locale]/(auth)/register/page.tsx diff --git a/app/[locale]/(auth)/verify-email/page.tsx b/app/[locale]/(auth)/verify-email/page.tsx new file mode 100644 index 0000000..7533648 --- /dev/null +++ b/app/[locale]/(auth)/verify-email/page.tsx @@ -0,0 +1,10 @@ +import { getSession } from '~/features/auth/auth'; +import { maskEmail } from '~/shared/lib/mask-email'; +import { VerifyEmailPage } from '~/views/verify-email'; + +export default async function Page() { + const session = await getSession(); + const email = session?.user?.email; + const maskedEmail = email ? maskEmail(email) : null; + return ; +} diff --git a/app/(protected)/dashboard/page.tsx b/app/[locale]/(protected)/dashboard/page.tsx similarity index 100% rename from app/(protected)/dashboard/page.tsx rename to app/[locale]/(protected)/dashboard/page.tsx diff --git a/app/(protected)/editor/page.tsx b/app/[locale]/(protected)/editor/page.tsx similarity index 100% rename from app/(protected)/editor/page.tsx rename to app/[locale]/(protected)/editor/page.tsx diff --git a/app/(protected)/layout.tsx b/app/[locale]/(protected)/layout.tsx similarity index 100% rename from app/(protected)/layout.tsx rename to app/[locale]/(protected)/layout.tsx diff --git a/app/[locale]/[category]/[slug]/page.tsx b/app/[locale]/[category]/[slug]/page.tsx new file mode 100644 index 0000000..5e97c9f --- /dev/null +++ b/app/[locale]/[category]/[slug]/page.tsx @@ -0,0 +1,23 @@ +import { + ArticlePage, + generateMetadata as articleGenerateMetadata, + generateStaticParams as articleGenerateStaticParams, +} from '~/views/article'; +import { routing } from 'i18n/routing'; + +export const revalidate = 30; +export const generateMetadata = articleGenerateMetadata; + +export async function generateStaticParams() { + const baseParams = await articleGenerateStaticParams(); + return routing.locales.flatMap((locale) => baseParams.map((p) => ({ locale, category: p.category, slug: p.slug }))); +} + +interface PageProps { + params: Promise<{ locale: string; category: string; slug: string }>; +} + +export default async function Page(props: PageProps) { + const { category, slug } = await props.params; + return ; +} diff --git a/app/[category]/page.tsx b/app/[locale]/[category]/page.tsx similarity index 60% rename from app/[category]/page.tsx rename to app/[locale]/[category]/page.tsx index 5256b53..8a4762c 100644 --- a/app/[category]/page.tsx +++ b/app/[locale]/[category]/page.tsx @@ -3,10 +3,11 @@ import { CategoryPage, generateMetadata as categoryGenerateMetadata } from '~/vi export const generateMetadata = categoryGenerateMetadata; interface PageProps { - params: Promise<{ category: string }>; + params: Promise<{ locale: string; category: string }>; searchParams: Promise<{ [key: string]: string | string[] | undefined }>; } export default async function Page(props: PageProps) { - return ; + const { category } = await props.params; + return ; } diff --git a/app/articles/page.tsx b/app/[locale]/articles/page.tsx similarity index 75% rename from app/articles/page.tsx rename to app/[locale]/articles/page.tsx index b1a74a1..a584edd 100644 --- a/app/articles/page.tsx +++ b/app/[locale]/articles/page.tsx @@ -3,9 +3,10 @@ import { ArticlesPage, generateMetadata as articlesGenerateMetadata } from '~/vi export const generateMetadata = articlesGenerateMetadata; interface PageProps { + params: Promise<{ locale: string }>; searchParams: Promise<{ [key: string]: string | string[] | undefined }>; } export default async function Page(props: PageProps) { - return ; + return ; } diff --git a/app/articles/user/[author]/page.tsx b/app/[locale]/articles/user/[author]/page.tsx similarity index 61% rename from app/articles/user/[author]/page.tsx rename to app/[locale]/articles/user/[author]/page.tsx index fc7f785..62d98dc 100644 --- a/app/articles/user/[author]/page.tsx +++ b/app/[locale]/articles/user/[author]/page.tsx @@ -3,10 +3,11 @@ import { UserArticlesPage, generateMetadata as userArticlesGenerateMetadata } fr export const generateMetadata = userArticlesGenerateMetadata; interface PageProps { - params: Promise<{ author: string }>; + params: Promise<{ locale: string; author: string }>; searchParams: Promise<{ [key: string]: string | string[] | undefined }>; } export default async function Page(props: PageProps) { - return ; + const { author } = await props.params; + return ; } diff --git a/app/error.tsx b/app/[locale]/error.tsx similarity index 69% rename from app/error.tsx rename to app/[locale]/error.tsx index 749eb8e..beef67a 100644 --- a/app/error.tsx +++ b/app/[locale]/error.tsx @@ -5,24 +5,29 @@ import { Button } from '~/shared/ui/button'; import { Alert, AlertDescription, AlertTitle } from '~/shared/ui/alert'; import { AlertCircle } from 'lucide-react'; import { reportError } from '~/shared/lib/server/report-error'; +import { useTranslations } from 'next-intl'; export default function Error({ error, reset }: { error: Error & { digest?: string }; reset: () => void }) { + const t = useTranslations('common'); + const tButton = useTranslations('button'); + const tError = useTranslations('error'); + useEffect(() => { reportError({ - message: error.message || 'An unexpected error occurred.', + message: error.message || tError('unexpected'), digest: error.digest, stack: error.stack, }); - }, [error]); + }, [error, tError]); return (
- Something went wrong! - {error.message || 'An unexpected error occurred.'} + {t('somethingWentWrong')} + {error.message || tError('unexpected')}
diff --git a/app/[locale]/layout.tsx b/app/[locale]/layout.tsx new file mode 100644 index 0000000..7122366 --- /dev/null +++ b/app/[locale]/layout.tsx @@ -0,0 +1,46 @@ +import type { Metadata } from 'next'; +import { NextIntlClientProvider } from 'next-intl'; +import { getMessages, setRequestLocale } from 'next-intl/server'; +import { hasLocale } from 'next-intl'; +import { notFound } from 'next/navigation'; +import { routing } from 'i18n/routing'; +import { AppProviders } from '~/app/providers'; +import { Header } from '~/widgets/header'; +import { Footer } from '~/widgets/footer'; + +type Props = { + children: React.ReactNode; + params: Promise<{ locale: string }>; +}; + +export function generateStaticParams() { + return routing.locales.map((locale) => ({ locale })); +} + +export const metadata: Metadata = { + title: 'Articlify - Modern Blog Platform', + description: 'A modern blog platform', +}; + +export default async function LocaleLayout({ children, params }: Props) { + const { locale } = await params; + + if (!hasLocale(routing.locales, locale)) { + notFound(); + } + + setRequestLocale(locale); + const messages = await getMessages(); + + return ( + + +
+
+
{children}
+
+
+
+
+ ); +} diff --git a/app/loading.tsx b/app/[locale]/loading.tsx similarity index 100% rename from app/loading.tsx rename to app/[locale]/loading.tsx diff --git a/app/[locale]/not-found.tsx b/app/[locale]/not-found.tsx new file mode 100644 index 0000000..d331e01 --- /dev/null +++ b/app/[locale]/not-found.tsx @@ -0,0 +1,26 @@ +import { Link } from 'i18n/navigation'; +import { Button } from '~/shared/ui/button'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '~/shared/ui/card'; +import { getTranslations } from 'next-intl/server'; + +export default async function NotFound() { + const t = await getTranslations('common'); + const tButton = await getTranslations('button'); + + return ( +
+ + + 404 + {t('pageNotFound')} + + +

{t('pageNotFoundDescription')}

+ +
+
+
+ ); +} diff --git a/app/page.tsx b/app/[locale]/page.tsx similarity index 66% rename from app/page.tsx rename to app/[locale]/page.tsx index fdd6465..069a087 100644 --- a/app/page.tsx +++ b/app/[locale]/page.tsx @@ -1,9 +1,10 @@ import { HomePage } from '~/views/home'; interface PageProps { + params: Promise<{ locale: string }>; searchParams: Promise<{ [key: string]: string | string[] | undefined }>; } export default async function Page(props: PageProps) { - return ; + return ; } diff --git a/app/api/auth/[...all]/route.ts b/app/api/auth/[...all]/route.ts new file mode 100644 index 0000000..4d7e8eb --- /dev/null +++ b/app/api/auth/[...all]/route.ts @@ -0,0 +1,4 @@ +import { auth } from '~/features/auth/auth'; +import { toNextJsHandler } from 'better-auth/next-js'; + +export const { GET, POST } = toNextJsHandler(auth); diff --git a/app/api/auth/[...nextauth]/route.ts b/app/api/auth/[...nextauth]/route.ts deleted file mode 100644 index b366dd1..0000000 --- a/app/api/auth/[...nextauth]/route.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { handlers } from '~/auth'; - -export const { GET, POST } = handlers; diff --git a/app/api/trpc/[trpc]/route.ts b/app/api/trpc/[trpc]/route.ts index 3e77aa0..54cba13 100644 --- a/app/api/trpc/[trpc]/route.ts +++ b/app/api/trpc/[trpc]/route.ts @@ -1,6 +1,6 @@ import { fetchRequestHandler } from '@trpc/server/adapters/fetch'; -import { appRouter } from '~/server/routers'; -import { createContext } from '~/server/context'; +import { appRouter } from 'server/routers'; +import { createContext } from 'server/context'; import { withApiLogging } from '~/shared/lib/server/with-api-logging'; const trpcHandler = (req: Request) => diff --git a/app/api/user/avatar/[username]/route.ts b/app/api/user/avatar/[username]/route.ts new file mode 100644 index 0000000..53a7e0c --- /dev/null +++ b/app/api/user/avatar/[username]/route.ts @@ -0,0 +1,34 @@ +import { NextResponse } from 'next/server'; +import { connectDB } from '~/shared/lib/server/connection'; +import { UserModel } from '~/entities/user/model/types'; +import { withApiLogging } from '~/shared/lib/server/with-api-logging'; + +/** Public: redirect to a user's avatar image by username. Avatars are stored as http(s) URLs (S3/MinIO). */ +async function getAvatarByUsernameHandler( + _req: Request, + _logContext: { requestId: string; traceId?: string }, + username: string, +) { + if (!username) { + return new NextResponse(null, { status: 404 }); + } + + await connectDB(); + const user = await UserModel.findOne({ name: username }).select('image').lean(); + if (!user?.image) { + return new NextResponse(null, { status: 404 }); + } + + const image = user.image as string; + if (image.startsWith('http://') || image.startsWith('https://')) { + return NextResponse.redirect(image); + } + + return new NextResponse(null, { status: 404 }); +} + +export const GET = async (req: Request, routeContext: { params: Promise<{ username: string }> }) => { + const { username } = await routeContext.params; + const wrapped = withApiLogging((r, ctx) => getAvatarByUsernameHandler(r, ctx, username)); + return wrapped(req); +}; diff --git a/app/api/user/avatar/route.ts b/app/api/user/avatar/route.ts index af364a4..905197d 100644 --- a/app/api/user/avatar/route.ts +++ b/app/api/user/avatar/route.ts @@ -1,16 +1,21 @@ import { NextResponse } from 'next/server'; -import { auth } from '~/auth'; +import { getSession } from '~/features/auth/auth'; import { connectDB } from '~/shared/lib/server/connection'; import { UserModel } from '~/entities/user/model/types'; import { withApiLogging } from '~/shared/lib/server/with-api-logging'; /** Avatars are stored as http(s) URLs only (S3/MinIO). Redirect to that URL. */ async function getAvatarHandler() { - const session = await auth(); + const session = await getSession(); if (!session?.user?.id) { return new NextResponse(null, { status: 401 }); } + const authImage = session.user.image; + if (authImage && (authImage.startsWith('http://') || authImage.startsWith('https://'))) { + return NextResponse.redirect(authImage); + } + await connectDB(); const user = await UserModel.findById(session.user.id).select('image').lean(); if (!user?.image) { diff --git a/app/layout.tsx b/app/layout.tsx index 2c3144e..6d14ac0 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,32 +1,17 @@ -import type { Metadata } from 'next'; import { Inter } from 'next/font/google'; import '~/app/styles/globals.css'; -import { AppProviders } from '~/app/providers'; -import { Header } from '~/widgets/header'; -import { Footer } from '~/widgets/footer'; import { Analytics } from '@vercel/analytics/next'; import { SpeedInsights } from '@vercel/speed-insights/next'; const inter = Inter({ subsets: ['latin'] }); -export const metadata: Metadata = { - title: 'Articlify - Modern Blog Platform', - description: 'A modern blog platform built with Next.js, tRPC, and shadcn/ui', -}; - export default function RootLayout({ children }: { children: React.ReactNode }) { return ( - -
-
-
{children}
-
-
- - -
+ {children} + + ); diff --git a/app/not-found.tsx b/app/not-found.tsx index 5793a99..dcee10e 100644 --- a/app/not-found.tsx +++ b/app/not-found.tsx @@ -2,7 +2,7 @@ import Link from 'next/link'; import { Button } from '~/shared/ui/button'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '~/shared/ui/card'; -export default function NotFound() { +export default function RootNotFound() { return (
diff --git a/i18n/navigation.ts b/i18n/navigation.ts new file mode 100644 index 0000000..a10a415 --- /dev/null +++ b/i18n/navigation.ts @@ -0,0 +1,4 @@ +import { createNavigation } from 'next-intl/navigation'; +import { routing } from './routing'; + +export const { Link, redirect, usePathname, useRouter } = createNavigation(routing); diff --git a/i18n/request.ts b/i18n/request.ts new file mode 100644 index 0000000..a0fe8d6 --- /dev/null +++ b/i18n/request.ts @@ -0,0 +1,15 @@ +import { getRequestConfig } from 'next-intl/server'; +import { hasLocale } from 'next-intl'; +import { routing } from './routing'; + +export default getRequestConfig(async ({ requestLocale }) => { + const requested = await requestLocale; + const locale = hasLocale(routing.locales, requested) ? requested : routing.defaultLocale; + + const messages = (await import(`~/shared/lib/locales/${locale}.json`)).default; + + return { + locale, + messages, + }; +}); diff --git a/i18n/routing.ts b/i18n/routing.ts new file mode 100644 index 0000000..2cb4399 --- /dev/null +++ b/i18n/routing.ts @@ -0,0 +1,7 @@ +import { defineRouting } from 'next-intl/routing'; + +export const routing = defineRouting({ + locales: ['en', 'ru'], + defaultLocale: 'en', + localePrefix: 'always', +}); diff --git a/instrumentation.ts b/instrumentation.ts new file mode 100644 index 0000000..270f753 --- /dev/null +++ b/instrumentation.ts @@ -0,0 +1,69 @@ +import { registerOTel } from '@vercel/otel'; +import { MongoClient, ObjectId } from 'mongodb'; +import { getServerConfig } from '~/shared/config/env/server'; + +export async function register() { + registerOTel({ serviceName: 'articlify' }); + getServerConfig(); // Fail fast if env is invalid + await migrateBetterAuth(); +} + +async function migrateBetterAuth() { + const { mongodb } = getServerConfig(); + const client = new MongoClient(mongodb.uri); + await client.connect(); + const db = client.db(); + + const usersCol = db.collection('users'); + const userCol = db.collection('user'); + const accountCol = db.collection('account'); + + const existing = await userCol.countDocuments(); + if (existing > 0) { + console.warn('Better Auth "user" collection already has documents. Skipping to avoid duplicates.'); + await client.close(); + return; + } + console.log('Migrating Better Auth...'); + + const mongooseUsers = await usersCol.find({}).toArray(); + console.log(`Found ${mongooseUsers.length} users in "users" collection.`); + + for (const u of mongooseUsers) { + const id = u._id.toString(); + const now = u.regDate ? new Date(u.regDate) : new Date(); + + const userDoc = { + _id: new ObjectId(id), + name: u.name ?? '', + email: (u.email ?? '').toLowerCase(), + emailVerified: false, + image: u.image ?? null, + createdAt: now, + updatedAt: now, + role: u.role ?? 'user', + regDate: u.regDate ? new Date(u.regDate) : now, + preferredLanguage: u.preferredLanguage ?? null, + username: (u.name ?? '').toLowerCase(), + displayUsername: u.name ?? '', + }; + + await userCol.insertOne(userDoc); + + const accountDoc = { + _id: new ObjectId(), + userId: id, + providerId: 'credential', + accountId: id, + password: u.password ?? null, + createdAt: now, + updatedAt: now, + }; + + await accountCol.insertOne(accountDoc); + console.log(`Migrated user: ${u.name} (${id})`); + } + + console.log('Migration done.'); + await client.close(); +} diff --git a/next.config.mjs b/next.config.mjs index 7656ca5..03396bb 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -1,3 +1,7 @@ +import createNextIntlPlugin from 'next-intl/plugin'; + +const withNextIntl = createNextIntlPlugin('./i18n/request.ts'); + /** @type {import('next').NextConfig} */ // Derive image config from storage env (local MinIO vs prod S3) @@ -41,4 +45,4 @@ const nextConfig = { }, }; -export default nextConfig; +export default withNextIntl(nextConfig); diff --git a/package.json b/package.json index 9c11f1e..864c13d 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,9 @@ "commitlint": "commitlint --from=HEAD~1", "docker:up": "docker-compose up -d", "docker:down": "docker-compose down", - "docker:logs": "docker-compose logs -f" + "docker:logs": "docker-compose logs -f", + "email": "email", + "email:dev": "email dev -d src/shared/emails -p 3001" }, "lint-staged": { "*.{json,css,md}": [ @@ -28,15 +30,18 @@ ] }, "dependencies": { - "@auth/mongodb-adapter": "^3.11.1", "@aws-sdk/client-s3": "^3.978.0", "@aws-sdk/s3-request-presigner": "^3.978.0", + "@basedest/mailer": "^1.0.0", "@headlessui/react": "^2.2.9", "@hookform/resolvers": "^5.2.2", "@opentelemetry/api": "^1.9.0", "@opentelemetry/api-logs": "^0.211.0", "@opentelemetry/instrumentation": "^0.211.0", + "@opentelemetry/resources": "^2.5.0", "@opentelemetry/sdk-logs": "^0.211.0", + "@opentelemetry/sdk-metrics": "^2.5.0", + "@opentelemetry/sdk-trace-base": "^2.5.0", "@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-avatar": "^1.1.11", "@radix-ui/react-dialog": "^1.1.15", @@ -46,6 +51,8 @@ "@radix-ui/react-separator": "^1.1.8", "@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-toast": "^1.2.15", + "@radix-ui/react-tooltip": "^1.2.8", + "@react-email/components": "^1.0.6", "@tanstack/react-query": "^5.90.20", "@tiptap/core": "^2.10.0", "@tiptap/extension-code-block": "^2.10.0", @@ -70,6 +77,7 @@ "@vercel/otel": "^2.1.0", "@vercel/speed-insights": "^1.3.1", "bcryptjs": "^3.0.3", + "better-auth": "^1.4.18", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "formidable": "^3.5.4", @@ -79,13 +87,15 @@ "mongodb": "^7.0.0", "mongoose": "^9.1.5", "next": "^16.1.6", - "next-auth": "^5.0.0-beta.25", + "next-intl": "^4.8.2", "next-themes": "^0.4.6", "pino": "^10.3.0", "pino-pretty": "^13.1.3", "react": "^19.2.4", "react-dom": "^19.2.4", "react-hook-form": "^7.71.1", + "resend": "^6.9.1", + "server-only": "^0.0.1", "sharp": "^0.34.5", "superjson": "^2.2.6", "tailwind-merge": "^3.4.0", @@ -98,6 +108,7 @@ "@commitlint/cli": "^20.4.0", "@commitlint/config-conventional": "^20.4.0", "@eslint/js": "^9.39.2", + "@react-email/preview-server": "^5.2.8", "@semantic-release/commit-analyzer": "^13.0.1", "@semantic-release/git": "^10.0.1", "@semantic-release/github": "^12.0.3", @@ -124,6 +135,7 @@ "postcss": "^8.5.6", "prettier": "^3.8.1", "prettier-plugin-tailwindcss": "^0.7.2", + "react-email": "^5.2.8", "semantic-release": "^25.0.3", "tailwindcss": "^4.1.18", "typescript": "5.9.3", diff --git a/proxy.ts b/proxy.ts new file mode 100644 index 0000000..bbf0649 --- /dev/null +++ b/proxy.ts @@ -0,0 +1,8 @@ +import createMiddleware from 'next-intl/middleware'; +import { routing } from './i18n/routing'; + +export default createMiddleware(routing); + +export const config = { + matcher: ['/((?!api|trpc|_next|_vercel|.*\\..*).*)'], +}; diff --git a/src/server/context.ts b/server/context.ts similarity index 84% rename from src/server/context.ts rename to server/context.ts index 0b2927c..b7776ba 100644 --- a/src/server/context.ts +++ b/server/context.ts @@ -1,7 +1,6 @@ import { v4 as uuidv4 } from 'uuid'; import { context, trace } from '@opentelemetry/api'; -import { type Session } from 'next-auth'; -import { auth } from '~/auth'; +import { auth, getSession, type Session } from '~/features/auth/auth'; const X_REQUEST_ID = 'x-request-id'; @@ -9,6 +8,8 @@ export type Context = { session: Session | null; requestId?: string; traceId?: string; + headers?: Headers; + authApi: typeof auth.api; }; export type CreateContextOptions = { @@ -32,7 +33,7 @@ export async function createContext(opts?: CreateContextOptions): Promise - - + + + {children} - - - + + + ); } diff --git a/src/app/providers/index.ts b/src/app/providers/index.ts index be8effa..417717a 100644 --- a/src/app/providers/index.ts +++ b/src/app/providers/index.ts @@ -1,4 +1,3 @@ export { AppProviders } from './app-providers'; -export { SessionProvider } from './session-provider'; export { ThemeProvider } from './theme-provider'; export { TRPCProvider } from './trpc-provider'; diff --git a/src/app/providers/session-provider.tsx b/src/app/providers/session-provider.tsx deleted file mode 100644 index b4ee8ec..0000000 --- a/src/app/providers/session-provider.tsx +++ /dev/null @@ -1,7 +0,0 @@ -'use client'; - -import { SessionProvider as NextAuthSessionProvider } from 'next-auth/react'; - -export function SessionProvider({ children }: { children: React.ReactNode }) { - return {children}; -} diff --git a/src/auth.ts b/src/auth.ts deleted file mode 100644 index bf8e390..0000000 --- a/src/auth.ts +++ /dev/null @@ -1,121 +0,0 @@ -import NextAuth, { type DefaultSession, type NextAuthConfig } from 'next-auth'; -import Credentials from 'next-auth/providers/credentials'; -import { MongoDBAdapter } from '@auth/mongodb-adapter'; -import { clientPromise } from '~/shared/lib/server/mongodb-client'; -import { UserModel } from '~/entities/user/model/types'; -import { connectDB } from '~/shared/lib/server/connection'; - -/** Max length for image URL in JWT/session cookie to avoid ERR_RESPONSE_HEADERS_TOO_BIG. */ -const MAX_IMAGE_URL_LENGTH = 2000; - -function safeImageForCookie(image: string | undefined | null): string | undefined { - if (image == null || image === '') return undefined; - // Data URLs (base64) are huge; never put them in cookies - if (image.startsWith('data:')) return undefined; - if (image.length > MAX_IMAGE_URL_LENGTH) return undefined; - return image; -} - -declare module 'next-auth' { - interface Session { - user: { - id: string; - name: string; - email: string; - image?: string; - role?: string; - regDate?: Date; - } & DefaultSession['user']; - } - - interface User { - id: string; - name: string; - email: string; - image?: string; - role?: string; - regDate?: Date; - } -} - -export const { handlers, signIn, signOut, auth } = NextAuth({ - adapter: MongoDBAdapter(clientPromise) as NextAuthConfig['adapter'], - providers: [ - Credentials({ - name: 'credentials', - credentials: { - username: { label: 'Username', type: 'text' }, - password: { label: 'Password', type: 'password' }, - }, - authorize: async (credentials) => { - if (!credentials?.username || !credentials?.password) { - return null; - } - - await connectDB(); - const user = await UserModel.findOne({ name: credentials.username }); - - if (!user) { - return null; - } - - const isValid = await user.comparePassword(credentials.password as string); - - if (!isValid) { - return null; - } - - return { - id: user._id.toString(), - name: user.name, - email: user.email, - image: user.image, - role: user.role, - regDate: user.regDate, - }; - }, - }), - ], - session: { - strategy: 'jwt', - }, - callbacks: { - async jwt({ token, user }) { - if (user) { - token.id = user.id; - token.name = user.name; - token.email = user.email; - // Only store short URLs in JWT to avoid ERR_RESPONSE_HEADERS_TOO_BIG - token.image = safeImageForCookie(user.image); - token.role = user.role; - token.regDate = user.regDate; - } - return token; - }, - async session({ session, token }) { - if (session.user) { - session.user.id = token.id as string; - session.user.name = token.name as string; - session.user.email = token.email as string; - session.user.role = token.role as string | undefined; - session.user.regDate = token.regDate as Date | undefined; - // Load image from DB so avatar updates after upload without re-login (URLs only, cookie-safe) - try { - await connectDB(); - const user = await UserModel.findById(token.id).select('image').lean(); - if (user?.image !== undefined) { - session.user.image = safeImageForCookie(user.image as string) ?? undefined; - } else { - session.user.image = (token.image as string | undefined) || undefined; - } - } catch { - session.user.image = (token.image as string | undefined) || undefined; - } - } - return session; - }, - }, - pages: { - signIn: '/login', - }, -}); diff --git a/src/entities/article/api/article.repository.ts b/src/entities/article/api/article.repository.ts index ac57c82..094d967 100644 --- a/src/entities/article/api/article.repository.ts +++ b/src/entities/article/api/article.repository.ts @@ -24,7 +24,7 @@ export class ArticleRepository { } if (tags && tags.length > 0) { - filter.tags = { $all: tags }; + filter.tags = { $in: tags }; } if (title) { @@ -96,6 +96,12 @@ export class ArticleRepository { category: string; }>; } + + async getDistinctTags(): Promise { + await connectDB(); + const tags = await ArticleModel.distinct('tags'); + return (tags as string[]).filter(Boolean).sort(); + } } export const articleRepository = new ArticleRepository(); diff --git a/src/entities/article/api/article.router.ts b/src/entities/article/api/article.router.ts index 15d7983..d068e24 100644 --- a/src/entities/article/api/article.router.ts +++ b/src/entities/article/api/article.router.ts @@ -1,6 +1,6 @@ import { z } from 'zod'; import { TRPCError } from '@trpc/server'; -import { router, publicProcedure, protectedProcedure } from '~/server/trpc'; +import { router, publicProcedure, protectedProcedure } from 'server/trpc'; import { articleService } from '~/entities/article/api/article.service'; import { getStorageClient } from '~/shared/lib/server/storage/factory'; @@ -81,17 +81,31 @@ export const articleRouter = router({ ) .mutation(async ({ input, ctx }) => { const { slug, ...updateData } = input; - return await articleService.update(slug, updateData, ctx.session.user.name!, ctx.session.user.role); + return await articleService.update( + slug, + updateData, + ctx.session.user.name ?? '', + // TODO: why tf do we need to put role here? + (ctx.session.user as { role?: string }).role ?? undefined, + ); }), delete: protectedProcedure.input(z.object({ slug: z.string() })).mutation(async ({ input, ctx }) => { - return await articleService.delete(input.slug, ctx.session.user.name!, ctx.session.user.role); + return await articleService.delete( + input.slug, + ctx.session.user.name ?? '', + (ctx.session.user as { role?: string }).role ?? undefined, + ); }), getAllSlugs: publicProcedure.query(async () => { return await articleService.getAllSlugs(); }), + getDistinctTags: publicProcedure.query(async () => { + return await articleService.getDistinctTags(); + }), + uploadCoverImage: protectedProcedure .input( z.object({ diff --git a/src/entities/article/api/article.service.ts b/src/entities/article/api/article.service.ts index 15cc586..b2aeb7c 100644 --- a/src/entities/article/api/article.service.ts +++ b/src/entities/article/api/article.service.ts @@ -87,6 +87,10 @@ export class ArticleService { async getAllSlugs() { return await articleRepository.getAllSlugs(); } + + async getDistinctTags() { + return await articleRepository.getDistinctTags(); + } } export const articleService = new ArticleService(); diff --git a/src/entities/article/api/migrations/rename-content-fields.ts b/src/entities/article/api/migrations/rename-content-fields.ts deleted file mode 100644 index 7029842..0000000 --- a/src/entities/article/api/migrations/rename-content-fields.ts +++ /dev/null @@ -1,22 +0,0 @@ -/** - * Rename article content fields from snake_case to camelCase for consistency. - * content_pm -> contentPm, content_format -> contentFormat, content_schema_version -> contentSchemaVersion. - * Idempotent: only renames fields that exist (MongoDB $rename is a no-op for missing fields). - * Uses native collection.updateMany() so $rename is executed and acknowledged by MongoDB. - */ - -import { ArticleModel } from '~/entities/article/model/types'; - -export async function migrateRenameContentFields(): Promise<{ modified: number }> { - const result = await ArticleModel.collection.updateMany( - {}, - { - $rename: { - content_pm: 'contentPm', - content_format: 'contentFormat', - content_schema_version: 'contentSchemaVersion', - }, - }, - ); - return { modified: result.modifiedCount ?? 0 }; -} diff --git a/src/entities/article/ui/article-item.tsx b/src/entities/article/ui/article-item.tsx index f1dd598..6d48fea 100644 --- a/src/entities/article/ui/article-item.tsx +++ b/src/entities/article/ui/article-item.tsx @@ -1,9 +1,10 @@ 'use client'; -import { useSession } from 'next-auth/react'; +import { authClient } from '~/shared/api/auth-client'; +import type { SessionUser } from '~/shared/types/session'; import Image from 'next/image'; -import Link from 'next/link'; -import { useRouter } from 'next/navigation'; +import { useTranslations } from 'next-intl'; +import { Link, useRouter } from 'i18n/navigation'; import { useState } from 'react'; import type { Article } from '~/entities/article/model/types'; import { TagsList } from '~/entities/tag/ui/tags-list'; @@ -17,7 +18,8 @@ import { useToast } from '~/shared/ui/use-toast'; export function ArticleItem(props: Article) { const img = props.img ?? `/img/${props.category}.png`; - const { data: session } = useSession(); + const { data: session } = authClient.useSession(); + const tCategory = useTranslations('category'); const [dialogOpen, setDialogOpen] = useState(false); const router = useRouter(); const { toast } = useToast(); @@ -40,7 +42,8 @@ export function ArticleItem(props: Article) { }, }); - const canEdit = session?.user && (session.user.name === props.author || session.user.role === 'admin'); + const user = session?.user as SessionUser | undefined; + const canEdit = user && (user.name === props.author || user.role === 'admin'); const handleDelete = () => { deleteMutation.mutate({ slug: props.slug }); @@ -67,7 +70,7 @@ export function ArticleItem(props: Article) { - +
- +
- - {props.category} - + + + {tCategory(props.category)} + +
@@ -92,8 +100,8 @@ export function ArticleItem(props: Article) { {props.description}
- -
+ +
@{props.author} {new Date(props.createdAt).toLocaleDateString()} @@ -103,14 +111,14 @@ export function ArticleItem(props: Article) { {canEdit && (
diff --git a/src/features/auth/actions/resend-verification.ts b/src/features/auth/actions/resend-verification.ts new file mode 100644 index 0000000..d28d521 --- /dev/null +++ b/src/features/auth/actions/resend-verification.ts @@ -0,0 +1,36 @@ +'use server'; + +import { cookies } from 'next/headers'; +import { getServerConfig } from '~/shared/config/env/server'; +import { getSession } from '../auth'; + +export async function resendVerificationEmail(): Promise<{ ok: true } | { ok: false; error: string }> { + const session = await getSession(); + if (!session?.user?.email) { + return { ok: false, error: 'session_required' }; + } + + const cookieStore = await cookies(); + const cookieHeader = cookieStore.toString(); + const baseURL = getServerConfig().auth.baseUrl; + + const res = await fetch(`${baseURL}/api/auth/send-verification-email`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Cookie: cookieHeader, + }, + body: JSON.stringify({ + email: session.user.email, + callbackURL: '/dashboard', + }), + }); + + if (!res.ok) { + const data = await res.json().catch(() => ({})); + const message = (data as { message?: string })?.message ?? 'resend_failed'; + return { ok: false, error: message }; + } + + return { ok: true }; +} diff --git a/src/features/auth/auth.ts b/src/features/auth/auth.ts new file mode 100644 index 0000000..c3a7794 --- /dev/null +++ b/src/features/auth/auth.ts @@ -0,0 +1,105 @@ +import { headers } from 'next/headers'; +import { ObjectId } from 'mongodb'; +import bcrypt from 'bcryptjs'; +import { betterAuth, generateId } from 'better-auth'; +import { mongodbAdapter } from 'better-auth/adapters/mongodb'; +import { username } from 'better-auth/plugins'; +import { nextCookies } from 'better-auth/next-js'; +import { getServerConfig } from '~/shared/config/env/server'; +import { clientPromise } from '~/shared/lib/server/mongodb-client'; +import { getMailer } from '~/shared/lib/server/get-mailer'; +import { log } from '~/shared/lib/server/logger'; +import { createTranslator } from 'next-intl'; +import VerificationEmailBody from '~/shared/emails/verify-email'; + +const client = await clientPromise; +const db = client.db(); +const config = getServerConfig(); +const { baseUrl: baseURL, trustedOrigins } = config.auth; +const isDev = config.nodeEnv !== 'production'; + +export const auth = betterAuth({ + baseURL, + trustedOrigins, + database: mongodbAdapter(db, config.mongodb.useTransactions ? { client } : undefined), + session: { + cookieCache: { + enabled: true, + maxAge: 5 * 60, // 5 minutes – required for getSession to work in Next.js server context + }, + }, + advanced: { + ...(isDev && { + defaultCookieAttributes: { + secure: false, + sameSite: 'lax', + }, + }), + database: { + generateId: ({ model }) => (model === 'user' ? new ObjectId().toString() : generateId(32)), + }, + }, + emailAndPassword: { + enabled: true, + autoSignIn: true, + requireEmailVerification: true, + password: { + hash: (password: string) => bcrypt.hash(password, 10), + verify: (data: { password: string; hash: string }) => bcrypt.compare(data.password, data.hash), + }, + }, + emailVerification: { + sendOnSignUp: true, + sendOnSignIn: true, + autoSignInAfterVerification: true, + sendVerificationEmail: async ({ user, url }) => { + const rawLocale = (user as Record).preferredLanguage; + const locale = rawLocale === 'ru' ? 'ru' : 'en'; + + const messages = (await import(`~/shared/lib/locales/${locale}.json`)).default; + const t = createTranslator({ messages, namespace: 'email.verifyEmail', locale }); + + const mailer = getMailer(); + + void mailer.send({ + to: user.email, + subject: t('subject'), + body: await VerificationEmailBody({ url, locale }), + }); + + log({ + level: 'info', + message: 'sent verification email', + userId: user.id, + extra: { url, locale }, + }); + }, + }, + plugins: [username(), nextCookies()], + user: { + additionalFields: { + role: { + type: 'string', + required: false, + defaultValue: 'user', + input: false, + }, + regDate: { + type: 'date', + required: false, + input: false, + }, + preferredLanguage: { + type: 'string', + required: false, + input: true, + }, + }, + }, +}); + +export type Session = (typeof auth)['$Infer']['Session']; + +export async function getSession() { + return auth.api.getSession({ headers: await headers() }); +} diff --git a/src/features/auth/login/ui/login-form.tsx b/src/features/auth/login/ui/login-form.tsx index 29a2f2e..62eb9bc 100644 --- a/src/features/auth/login/ui/login-form.tsx +++ b/src/features/auth/login/ui/login-form.tsx @@ -1,23 +1,31 @@ 'use client'; import { useState } from 'react'; -import { signIn } from 'next-auth/react'; -import { useRouter, useSearchParams } from 'next/navigation'; -import Link from 'next/link'; +import { useRouter } from 'i18n/navigation'; +import { authClient } from '~/shared/api/auth-client'; +import { useSearchParams } from 'next/navigation'; +import { Link } from 'i18n/navigation'; import { Button } from '~/shared/ui/button'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '~/shared/ui/card'; import { Input } from '~/shared/ui/input'; import { Label } from '~/shared/ui/label'; import { Alert, AlertDescription } from '~/shared/ui/alert'; import { Loader2 } from 'lucide-react'; +import { useTranslations } from 'next-intl'; +import { LanguageSwitcher } from '~/features/i18n'; export function LoginForm() { const router = useRouter(); const searchParams = useSearchParams(); const callbackUrl = searchParams.get('callbackUrl') || '/'; + const t = useTranslations('auth'); + const tForm = useTranslations('form'); + const tButton = useTranslations('button'); + const tError = useTranslations('error'); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(''); + const [isEmailNotVerifiedError, setIsEmailNotVerifiedError] = useState(false); const [formData, setFormData] = useState({ username: '', password: '', @@ -27,22 +35,31 @@ export function LoginForm() { e.preventDefault(); setIsLoading(true); setError(''); + setIsEmailNotVerifiedError(false); try { - const result = await signIn('credentials', { + const { error } = await ( + authClient.signIn as { + username: (opts: { + username: string; + password: string; + }) => Promise<{ error?: { message?: string; status?: number } }>; + } + ).username({ username: formData.username, password: formData.password, - redirect: false, }); - if (result?.error) { - setError('Invalid username or password'); + if (error) { + const emailNotVerified = error.status === 403 || error.message === 'Email not verified'; + setIsEmailNotVerifiedError(emailNotVerified); + setError(emailNotVerified ? t('verifyEmailBeforeSignIn') : tError('invalidCredentials')); } else { router.push(callbackUrl); router.refresh(); } } catch { - setError('An error occurred. Please try again.'); + setError(tError('generic')); } finally { setIsLoading(false); } @@ -51,23 +68,38 @@ export function LoginForm() { return ( - Login - Enter your credentials to access your account +
+
+ {t('loginTitle')} + {t('loginDescription')} +
+ +
{error && ( - {error} + + {error} + {isEmailNotVerifiedError && ( + <> + {' '} + + {t('resendVerification')} + + + )} + )}
- + setFormData({ ...formData, username: e.target.value })} required @@ -75,11 +107,11 @@ export function LoginForm() {
- + setFormData({ ...formData, password: e.target.value })} required @@ -90,17 +122,17 @@ export function LoginForm() { {isLoading ? ( <> - Logging in... + {t('loggingIn')} ) : ( - 'Login' + tButton('login') )}

- Don't have an account?{' '} + {t('dontHaveAccount')}{' '} - Register + {tButton('register')}

diff --git a/src/features/auth/register/ui/register-form.tsx b/src/features/auth/register/ui/register-form.tsx index c2e5a0a..ff825ed 100644 --- a/src/features/auth/register/ui/register-form.tsx +++ b/src/features/auth/register/ui/register-form.tsx @@ -1,19 +1,25 @@ 'use client'; import { useState } from 'react'; -import { signIn } from 'next-auth/react'; -import { useRouter } from 'next/navigation'; -import Link from 'next/link'; +import { useRouter } from 'i18n/navigation'; +import { Link } from 'i18n/navigation'; +import { authClient } from '~/shared/api/auth-client'; import { Button } from '~/shared/ui/button'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '~/shared/ui/card'; import { Input } from '~/shared/ui/input'; import { Label } from '~/shared/ui/label'; import { Alert, AlertDescription } from '~/shared/ui/alert'; import { Loader2 } from 'lucide-react'; -import { trpc } from '~/shared/api/trpc/client'; +import { useTranslations } from 'next-intl'; +import { LanguageSwitcher } from '~/features/i18n'; export function RegisterForm() { const router = useRouter(); + const t = useTranslations('auth'); + const tForm = useTranslations('form'); + const tButton = useTranslations('button'); + const tError = useTranslations('error'); + const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(''); const [formData, setFormData] = useState({ @@ -23,40 +29,43 @@ export function RegisterForm() { confirmPassword: '', }); - const registerMutation = trpc.auth.register.useMutation(); - const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); setIsLoading(true); setError(''); if (formData.password !== formData.confirmPassword) { - setError('Passwords do not match'); + setError(tError('passwordsDoNotMatch')); setIsLoading(false); return; } try { - await registerMutation.mutateAsync({ - name: formData.username, + // API requires both name and username; form has a single "Username" field used for both. + const { error: signUpError } = await ( + authClient.signUp.email as (opts: { + email: string; + password: string; + name: string; + username: string; + }) => Promise<{ error?: { message?: string } }> + )({ email: formData.email, password: formData.password, - }); - - const result = await signIn('credentials', { + name: formData.username, username: formData.username, - password: formData.password, - redirect: false, }); - if (result?.error) { - setError('Registration successful, but login failed. Please try logging in.'); + if (signUpError) { + const msg = signUpError.message ?? ''; + const isEmailTaken = msg.includes('User already exists') && msg.includes('another email'); + setError(isEmailTaken ? tError('emailAlreadyTaken') : msg || tError('registrationFailed')); } else { - router.push('/dashboard'); + router.push('/verify-email'); router.refresh(); } } catch (err: unknown) { - setError(err instanceof Error ? err.message : 'Registration failed. Please try again.'); + setError(err instanceof Error ? err.message : tError('registrationFailed')); } finally { setIsLoading(false); } @@ -65,8 +74,13 @@ export function RegisterForm() { return ( - Register - Create a new account to start publishing articles +
+
+ {t('registerTitle')} + {t('registerDescription')} +
+ +
@@ -77,11 +91,11 @@ export function RegisterForm() { )}
- + setFormData({ ...formData, username: e.target.value })} required @@ -91,11 +105,11 @@ export function RegisterForm() {
- + setFormData({ ...formData, email: e.target.value })} required @@ -103,11 +117,11 @@ export function RegisterForm() {
- + setFormData({ ...formData, password: e.target.value })} required @@ -116,11 +130,11 @@ export function RegisterForm() {
- + setFormData({ ...formData, confirmPassword: e.target.value })} required @@ -131,17 +145,17 @@ export function RegisterForm() { {isLoading ? ( <> - Creating account... + {t('creatingAccount')} ) : ( - 'Register' + tButton('register') )}

- Already have an account?{' '} + {t('alreadyHaveAccount')}{' '} - Login + {tButton('login')}

diff --git a/src/features/auth/user-menu/ui/user-menu.tsx b/src/features/auth/user-menu/ui/user-menu.tsx index 523140c..e532f33 100644 --- a/src/features/auth/user-menu/ui/user-menu.tsx +++ b/src/features/auth/user-menu/ui/user-menu.tsx @@ -1,8 +1,8 @@ 'use client'; import React from 'react'; -import { signOut, useSession } from 'next-auth/react'; -import Link from 'next/link'; +import { Link } from 'i18n/navigation'; +import { authClient } from '~/shared/api/auth-client'; import { DropdownMenu, DropdownMenuContent, @@ -14,9 +14,12 @@ import { import { Avatar, AvatarFallback, AvatarImage } from '~/shared/ui/avatar'; import { Button } from '~/shared/ui/button'; import { LogOut, User, ChevronDown } from 'lucide-react'; +import { useTranslations } from 'next-intl'; export function UserMenu() { - const { data: session } = useSession(); + const { data: session } = authClient.useSession(); + const t = useTranslations('auth'); + const tButton = useTranslations('button'); if (!session?.user) { return null; @@ -35,21 +38,21 @@ export function UserMenu() { - My Account + {t('myAccount')} - Profile + {t('profile')} signOut()} - className="text-destructive focus:bg-destructive focus:text-destructive-foreground hover:bg-destructive hover:text-destructive-foreground cursor-pointer" + onClick={() => authClient.signOut()} + className="text-destructive focus:bg-destructive focus:text-destructive-foreground hover:bg-destructive hover:text-destructive-foreground cursor-pointer dark:text-red-400" > - Sign Out + {tButton('signOut')} diff --git a/src/features/avatar/upload/ui/avatar-editor.tsx b/src/features/avatar/upload/ui/avatar-editor.tsx index 9928cc0..4b122ec 100644 --- a/src/features/avatar/upload/ui/avatar-editor.tsx +++ b/src/features/avatar/upload/ui/avatar-editor.tsx @@ -1,8 +1,10 @@ 'use client'; import React, { useRef, useState } from 'react'; -import { useSession } from 'next-auth/react'; -import { useRouter } from 'next/navigation'; +import { authClient } from '~/shared/api/auth-client'; +import type { SessionUser } from '~/shared/types/session'; +import { useTranslations } from 'next-intl'; +import { useRouter } from '../../../../../i18n/navigation'; import { Avatar, AvatarFallback, AvatarImage } from '~/shared/ui/avatar'; import { Button } from '~/shared/ui/button'; import { Camera, Loader2 } from 'lucide-react'; @@ -13,24 +15,25 @@ const ACCEPTED_TYPES = 'image/jpeg,image/jpg,image/png,image/webp,image/gif'; const MAX_SIZE_BYTES = 2 * 1024 * 1024; // 2MB export function AvatarEditor() { - const { data: session, update: updateSession } = useSession(); + const { data: session } = authClient.useSession(); const router = useRouter(); const { toast } = useToast(); + const t = useTranslations('avatar'); const inputRef = useRef(null); const [uploading, setUploading] = useState(false); const uploadAvatar = trpc.user.uploadAvatar.useMutation({ onSuccess: async () => { - await updateSession(); + await authClient.getSession(); router.refresh(); toast({ - title: 'Avatar updated', - description: 'Your profile picture has been updated.', + title: t('avatarUpdated'), + description: t('avatarUpdatedDescription'), }); }, onError: (error) => { toast({ - title: 'Upload failed', + title: t('uploadFailed'), description: error.message, variant: 'destructive', }); @@ -44,8 +47,8 @@ export function AvatarEditor() { if (file.size > MAX_SIZE_BYTES) { toast({ - title: 'File too large', - description: 'Image must be smaller than 2MB.', + title: t('fileTooLarge'), + description: t('imageTooLarge'), variant: 'destructive', }); e.target.value = ''; @@ -69,8 +72,8 @@ export function AvatarEditor() { if (!session?.user) return null; - const user = session.user; - const displayName = user.name ?? 'User'; + const user = session.user as SessionUser; + const displayName = user.name ?? t('user'); return (
@@ -81,7 +84,7 @@ export function AvatarEditor() { onChange={handleFileChange} className="sr-only" disabled={uploading} - aria-label="Change avatar" + aria-label={t('changeAvatar')} />
@@ -95,14 +98,16 @@ export function AvatarEditor() { className="absolute right-0 bottom-0 h-8 w-8 rounded-full shadow" onClick={() => inputRef.current?.click()} disabled={uploading} - aria-label="Change avatar" + aria-label={t('changeAvatar')} > {uploading ? : }

{displayName}

-

{user.role === 'admin' ? 'Administrator' : 'User'}

+

+ {user.role === 'admin' ? t('administrator') : t('user')} +

diff --git a/src/features/i18n/index.ts b/src/features/i18n/index.ts new file mode 100644 index 0000000..78f5497 --- /dev/null +++ b/src/features/i18n/index.ts @@ -0,0 +1 @@ +export { LanguageSwitcher } from './ui/language-switcher'; diff --git a/src/features/i18n/ui/language-switcher.tsx b/src/features/i18n/ui/language-switcher.tsx new file mode 100644 index 0000000..857ca45 --- /dev/null +++ b/src/features/i18n/ui/language-switcher.tsx @@ -0,0 +1,85 @@ +'use client'; + +import { useLocale } from 'next-intl'; +import { usePathname, useRouter } from 'i18n/navigation'; +import { authClient } from '~/shared/api/auth-client'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '~/shared/ui/select'; +import { Label } from '~/shared/ui/label'; +import { useTranslations } from 'next-intl'; +import { routing } from 'i18n/routing'; +import { cn } from '~/shared/lib/utils'; + +const LOCALE_COOKIE = 'NEXT_LOCALE'; + +function setLocaleCookie(locale: string) { + document.cookie = `${LOCALE_COOKIE}=${locale};path=/;max-age=31536000;SameSite=Lax`; +} + +interface LanguageSwitcherProps { + id?: string; + className?: string; + variant?: 'default' | 'compact'; +} + +export function LanguageSwitcher({ id, className, variant = 'default' }: LanguageSwitcherProps) { + const locale = useLocale(); + const pathname = usePathname(); + const router = useRouter(); + const { data: session } = authClient.useSession(); + + const t = useTranslations('footer'); + const tCommon = useTranslations('common'); + + const handleChange = (newLocale: string) => { + if (!routing.locales.includes(newLocale as 'en' | 'ru')) return; + router.replace(pathname, { locale: newLocale }); + if (typeof window !== 'undefined') { + localStorage.setItem('preferredLanguage', newLocale); + setLocaleCookie(newLocale); + } + if (session?.user?.id) { + (authClient.updateUser as (body: { preferredLanguage?: string }) => Promise)({ + preferredLanguage: newLocale, + }).catch(() => {}); + } + }; + + if (variant === 'compact') { + return ( +
+ {routing.locales.map((loc) => ( + + ))} +
+ ); + } + + return ( +
+ + +
+ ); +} diff --git a/src/instrumentation.ts b/src/instrumentation.ts deleted file mode 100644 index 0c461f8..0000000 --- a/src/instrumentation.ts +++ /dev/null @@ -1,34 +0,0 @@ -/** - * Runs once when the Node.js server starts. - * Runs rename migration: content_pm -> contentPm, content_format -> contentFormat, content_schema_version -> contentSchemaVersion. - * TODO: remove migration code after migration is completed on production. - */ - -import { registerOTel } from '@vercel/otel'; - -export async function register() { - if (!process.env.MONGODB_URI) { - console.log('[migration] Skipping: MONGODB_URI not set'); - return; - } - - registerOTel({ serviceName: 'articlify' }); - - try { - const { connectDB } = await import('~/shared/lib/server/connection'); - await connectDB(); - - const { migrateRenameContentFields } = await import('~/entities/article/api/migrations/rename-content-fields'); - const result = await migrateRenameContentFields(); - - if (result.modified > 0) { - console.log( - `[migration] rename-content-fields: updated ${result.modified} article(s) (content_pm -> contentPm, etc.)`, - ); - } else { - console.log('[migration] rename-content-fields: no documents to update (already migrated or empty)'); - } - } catch (err) { - console.error('[migration] rename-content-fields failed:', err); - } -} diff --git a/src/proxy.ts b/src/proxy.ts deleted file mode 100644 index bb14aac..0000000 --- a/src/proxy.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { NextResponse } from 'next/server'; -import type { NextRequest } from 'next/server'; - -// Lightweight proxy - just for redirects or simple checks -// Auth protection is handled in app/(protected)/layout.tsx server-side -export function proxy(request: NextRequest) { - // You can add simple redirects here if needed - return NextResponse.next(); -} - -export const config = { - matcher: [ - /* - * Match all request paths except for the ones starting with: - * - api (API routes) - * - _next/static (static files) - * - _next/image (image optimization files) - * - favicon.ico (favicon file) - */ - '/((?!api|_next/static|_next/image|favicon.ico).*)', - ], -}; diff --git a/src/server/routers/auth.ts b/src/server/routers/auth.ts deleted file mode 100644 index 0dd13f3..0000000 --- a/src/server/routers/auth.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { z } from 'zod'; -import { router, publicProcedure } from '../trpc'; -import { userService } from '~/entities/user/api/user.service'; - -export const authRouter = router({ - register: publicProcedure - .input( - z.object({ - name: z.string().min(3).max(20), - email: z.string().email(), - password: z.string().min(6), - }), - ) - .mutation(async ({ input }) => { - return await userService.register(input); - }), -}); diff --git a/src/shared/api/auth-client.ts b/src/shared/api/auth-client.ts new file mode 100644 index 0000000..424166f --- /dev/null +++ b/src/shared/api/auth-client.ts @@ -0,0 +1,11 @@ +'use client'; + +import { createAuthClient } from 'better-auth/react'; +import { usernameClient } from 'better-auth/client/plugins'; + +const baseURL = typeof window !== 'undefined' ? window.location.origin : undefined; + +export const authClient = createAuthClient({ + baseURL, + plugins: [usernameClient()], +}); diff --git a/src/shared/api/trpc/client.ts b/src/shared/api/trpc/client.ts index 3a0f395..451e8ac 100644 --- a/src/shared/api/trpc/client.ts +++ b/src/shared/api/trpc/client.ts @@ -1,6 +1,6 @@ 'use client'; import { createTRPCReact } from '@trpc/react-query'; -import type { AppRouter } from '~/server/routers'; +import type { AppRouter } from 'server/routers'; export const trpc = createTRPCReact({}); diff --git a/src/shared/api/trpc/server.ts b/src/shared/api/trpc/server.ts index b7f83ca..3638125 100644 --- a/src/shared/api/trpc/server.ts +++ b/src/shared/api/trpc/server.ts @@ -1,5 +1,5 @@ -import { appRouter } from '~/server/routers'; -import { createContext, createPublicContext } from '~/server/context'; +import { appRouter } from 'server/routers'; +import { createContext, createPublicContext } from 'server/context'; export async function createServerCaller() { const context = await createContext(); diff --git a/src/shared/config/env/client.ts b/src/shared/config/env/client.ts new file mode 100644 index 0000000..587ac34 --- /dev/null +++ b/src/shared/config/env/client.ts @@ -0,0 +1,36 @@ +import { z } from 'zod'; + +/** + * Only NEXT_PUBLIC_* env vars are allowed here. + * Safe to import and use in client components. + */ +const clientEnvSchema = z.object({ + // Add NEXT_PUBLIC_* keys here when needed, e.g.: + // NEXT_PUBLIC_APP_URL: z.string().url().optional(), +}); + +export type ClientConfig = z.infer; + +function getRawClientEnv(): Record { + if (typeof window !== 'undefined') { + return { + // In browser, only NEXT_PUBLIC_* are available (injected at build) + ...Object.fromEntries(Object.entries(process.env).filter(([key]) => key.startsWith('NEXT_PUBLIC_'))), + }; + } + return Object.fromEntries(Object.entries(process.env).filter(([key]) => key.startsWith('NEXT_PUBLIC_'))); +} + +let cached: ClientConfig | null = null; + +export function getClientConfig(): ClientConfig { + if (cached) return cached; + const raw = getRawClientEnv(); + const parsed = clientEnvSchema.safeParse(raw); + if (!parsed.success) { + const msg = parsed.error.flatten().formErrors?.[0] ?? parsed.error.message; + throw new Error(`Client config validation failed: ${msg}`); + } + cached = parsed.data; + return parsed.data; +} diff --git a/src/shared/config/env/index.ts b/src/shared/config/env/index.ts new file mode 100644 index 0000000..c958cb1 --- /dev/null +++ b/src/shared/config/env/index.ts @@ -0,0 +1 @@ +export { getClientConfig, type ClientConfig } from './client'; diff --git a/src/shared/config/env/server.ts b/src/shared/config/env/server.ts new file mode 100644 index 0000000..8db8dbd --- /dev/null +++ b/src/shared/config/env/server.ts @@ -0,0 +1,221 @@ +import 'server-only'; +import { z } from 'zod'; + +const logLevelSchema = z.enum(['debug', 'info', 'warn', 'error', 'fatal']); +const nodeEnvSchema = z.enum(['development', 'production', 'test']); +const mailerProviderSchema = z.enum(['log', 'resend']); +const storageProviderSchema = z.enum(['minio', 's3']); + +const rawServerEnvSchema = z.object({ + NODE_ENV: z.string().optional(), + MONGODB_URI: z.string().min(1, 'MONGODB_URI is required'), + MONGODB_USE_TRANSACTIONS: z + .string() + .optional() + .transform((s) => s === 'true'), + BETTER_AUTH_SECRET: z.string().min(1, 'BETTER_AUTH_SECRET is required'), + BETTER_AUTH_URL: z.string().optional(), + BETTER_AUTH_TRUSTED_ORIGINS: z.string().optional(), + VERCEL_URL: z.string().optional(), + LOG_LEVEL: z.string().optional(), + MAILER_PROVIDER: z.string().optional(), + MAILER_FROM: z.string().optional(), + RESEND_API_KEY: z.string().optional(), + MAILER_RETRY_ENABLED: z + .string() + .optional() + .transform((s) => s === 'true'), + MAILER_RATE_LIMIT_PER_MINUTE: z + .string() + .optional() + .transform((s) => (s ? parseInt(s, 10) : undefined)), + STORAGE_PROVIDER: z.string().optional(), + S3_ENDPOINT: z.string().optional(), + S3_REGION: z.string().optional(), + S3_ACCESS_KEY: z.string().optional(), + S3_SECRET_KEY: z.string().optional(), + S3_BUCKET: z.string().optional(), + S3_PUBLIC_URL: z.string().optional(), + S3_FORCE_PATH_STYLE: z + .string() + .optional() + .transform((s) => s === 'true'), +}); + +const serverEnvSchema = rawServerEnvSchema + .refine( + (data) => { + if (data.MAILER_PROVIDER === 'resend') { + return !!data.RESEND_API_KEY?.trim(); + } + return true; + }, + { message: 'RESEND_API_KEY is required when MAILER_PROVIDER=resend', path: ['RESEND_API_KEY'] }, + ) + .transform((raw): ServerConfig => { + const nodeEnv = nodeEnvSchema.catch('development').parse(raw.NODE_ENV ?? 'development'); + const logLevel = logLevelSchema.catch('info').parse(raw.LOG_LEVEL ?? 'info'); + const mailerProvider = mailerProviderSchema.catch('log').parse(raw.MAILER_PROVIDER ?? 'log'); + const storageProvider = storageProviderSchema.catch('minio').parse(raw.STORAGE_PROVIDER ?? 'minio'); + + const betterAuthBaseUrl = + raw.BETTER_AUTH_URL?.trim() || (raw.VERCEL_URL ? `https://${raw.VERCEL_URL}` : 'http://localhost:3000'); + + const trustedOrigins: string[] = [betterAuthBaseUrl, 'http://localhost:3000', 'http://127.0.0.1:3000']; + if (raw.VERCEL_URL) { + trustedOrigins.push(`https://${raw.VERCEL_URL}`, `https://www.${raw.VERCEL_URL}`); + } + const extraOrigins = raw.BETTER_AUTH_TRUSTED_ORIGINS?.split(',') + .map((o) => o.trim()) + .filter(Boolean); + if (extraOrigins?.length) trustedOrigins.push(...extraOrigins); + + const mailerRateLimitPerMinute = + raw.MAILER_RATE_LIMIT_PER_MINUTE !== undefined && + !Number.isNaN(raw.MAILER_RATE_LIMIT_PER_MINUTE) && + raw.MAILER_RATE_LIMIT_PER_MINUTE > 0 + ? raw.MAILER_RATE_LIMIT_PER_MINUTE + : undefined; + + const storage = + storageProvider === 'minio' + ? { + provider: 'minio' as const, + endpoint: raw.S3_ENDPOINT?.trim() || 'http://localhost:9000', + region: raw.S3_REGION?.trim() || 'us-east-1', + accessKeyId: raw.S3_ACCESS_KEY?.trim() || 'minioadmin', + secretAccessKey: raw.S3_SECRET_KEY?.trim() || 'minioadmin', + bucket: raw.S3_BUCKET?.trim() || 'articlify-images', + publicUrl: raw.S3_PUBLIC_URL?.trim() || 'http://localhost:9000/articlify-images', + forcePathStyle: true, + } + : ({ + provider: 's3' as const, + endpoint: undefined, + region: raw.S3_REGION?.trim() || '', + accessKeyId: raw.S3_ACCESS_KEY?.trim() ?? '', + secretAccessKey: raw.S3_SECRET_KEY?.trim() ?? '', + bucket: raw.S3_BUCKET?.trim() ?? '', + publicUrl: raw.S3_PUBLIC_URL?.trim() ?? '', + forcePathStyle: raw.S3_FORCE_PATH_STYLE ?? false, + } as const); + + if (storageProvider === 's3') { + if ( + !storage.region || + !storage.accessKeyId || + !storage.secretAccessKey || + !storage.bucket || + !storage.publicUrl + ) { + throw new Error( + 'AWS S3 requires S3_REGION, S3_ACCESS_KEY, S3_SECRET_KEY, S3_BUCKET, and S3_PUBLIC_URL', + ); + } + } + + return { + nodeEnv, + mongodb: { + uri: raw.MONGODB_URI, + useTransactions: raw.MONGODB_USE_TRANSACTIONS ?? false, + }, + auth: { + secret: raw.BETTER_AUTH_SECRET, + baseUrl: betterAuthBaseUrl, + trustedOrigins, + }, + logLevel, + mailer: { + provider: mailerProvider, + from: raw.MAILER_FROM?.trim() || 'onboarding@resend.dev', + resendApiKey: raw.RESEND_API_KEY?.trim() || undefined, + retryEnabled: raw.MAILER_RETRY_ENABLED ?? false, + rateLimitPerMinute: mailerRateLimitPerMinute, + }, + storage, + }; + }); + +export type ServerConfig = { + nodeEnv: 'development' | 'production' | 'test'; + mongodb: { uri: string; useTransactions: boolean }; + auth: { secret: string; baseUrl: string; trustedOrigins: string[] }; + logLevel: 'debug' | 'info' | 'warn' | 'error' | 'fatal'; + mailer: { + provider: 'log' | 'resend'; + from: string; + resendApiKey: string | undefined; + retryEnabled: boolean; + rateLimitPerMinute: number | undefined; + }; + storage: + | { + provider: 'minio'; + endpoint: string; + region: string; + accessKeyId: string; + secretAccessKey: string; + bucket: string; + publicUrl: string; + forcePathStyle: boolean; + } + | { + provider: 's3'; + endpoint: undefined; + region: string; + accessKeyId: string; + secretAccessKey: string; + bucket: string; + publicUrl: string; + forcePathStyle: boolean; + }; +}; + +function getRawEnv(): Record { + return { + NODE_ENV: process.env.NODE_ENV, + MONGODB_URI: process.env.MONGODB_URI, + MONGODB_USE_TRANSACTIONS: process.env.MONGODB_USE_TRANSACTIONS, + BETTER_AUTH_SECRET: process.env.BETTER_AUTH_SECRET, + BETTER_AUTH_URL: process.env.BETTER_AUTH_URL, + BETTER_AUTH_TRUSTED_ORIGINS: process.env.BETTER_AUTH_TRUSTED_ORIGINS, + VERCEL_URL: process.env.VERCEL_URL, + LOG_LEVEL: process.env.LOG_LEVEL, + MAILER_PROVIDER: process.env.MAILER_PROVIDER, + MAILER_FROM: process.env.MAILER_FROM, + RESEND_API_KEY: process.env.RESEND_API_KEY, + MAILER_RETRY_ENABLED: process.env.MAILER_RETRY_ENABLED, + MAILER_RATE_LIMIT_PER_MINUTE: process.env.MAILER_RATE_LIMIT_PER_MINUTE, + STORAGE_PROVIDER: process.env.STORAGE_PROVIDER, + S3_ENDPOINT: process.env.S3_ENDPOINT, + S3_REGION: process.env.S3_REGION, + S3_ACCESS_KEY: process.env.S3_ACCESS_KEY, + S3_SECRET_KEY: process.env.S3_SECRET_KEY, + S3_BUCKET: process.env.S3_BUCKET, + S3_PUBLIC_URL: process.env.S3_PUBLIC_URL, + S3_FORCE_PATH_STYLE: process.env.S3_FORCE_PATH_STYLE, + }; +} + +let cached: ServerConfig | null = null; + +export function getServerConfig(): ServerConfig { + if (cached) return cached; + const raw = getRawEnv(); + const parsed = serverEnvSchema.safeParse(raw); + if (!parsed.success) { + const first = parsed.error.flatten(); + const fieldErrors = first.fieldErrors as Record; + const message = + first.formErrors?.[0] ?? + (typeof fieldErrors.RESEND_API_KEY === 'string' + ? fieldErrors.RESEND_API_KEY + : Array.isArray(fieldErrors.RESEND_API_KEY) + ? fieldErrors.RESEND_API_KEY[0] + : parsed.error.message); + throw new Error(`Server config validation failed: ${message}`); + } + cached = parsed.data; + return parsed.data; +} diff --git a/src/shared/emails/verify-email.tsx b/src/shared/emails/verify-email.tsx new file mode 100644 index 0000000..2759779 --- /dev/null +++ b/src/shared/emails/verify-email.tsx @@ -0,0 +1,161 @@ +import { + Body, + Button, + Container, + Head, + Heading, + Hr, + Html, + Link, + Preview, + Section, + Text, +} from '@react-email/components'; +import * as React from 'react'; +import { createTranslator } from 'next-intl'; + +/** + * Colors and styling aligned with app globals.css (primary green, background, muted). + * Auth domain: used by sendVerificationEmail in auth config. + */ +const theme = { + background: '#f4f4f5', + card: '#ffffff', + foreground: '#09090b', + mutedForeground: '#71717a', + primary: 'hsl(142 76% 36%)', + primaryForeground: '#fef2f2', + border: '#e4e4e7', + radiusMd: '6px', +}; + +type VerificationEmailBodyProps = { + url: string; + locale?: string; +}; + +export default async function VerificationEmailBody({ url, locale = 'en' }: VerificationEmailBodyProps) { + const messages = await import(`../lib/locales/${locale}.json`); + const t = createTranslator({ messages, namespace: 'email.verifyEmail', locale }); + + return ( + + + {t('preview')} + + +
+ Articlify +
+ {t('heading')} + {t('body')} +
+ +
+ + {t('fallback')}{' '} + + {url} + + +
+ {t('footer')} +
+ + + ); +} + +VerificationEmailBody.PreviewProps = { + url: 'https://articlify.basedest.tech/api/auth/verify-email?token=eyJhbGciOiJIUzI1NiJ9.eyJlbWFpbCI6Imkuc2hjaGVyYmFrb3ZAdmsudGVhbSIsImlhdCI6MTc3MTc0NDc2NSwiZXhwIjoxNzcxNzQ4MzY1fQ.m7GlIyJlOM6YSOtZxciLwhji2_gkbZW2dmGlyN6FGhU&callbackURL=%2F', + locale: 'en', +} as VerificationEmailBodyProps; + +const main = { + backgroundColor: theme.background, + color: theme.foreground, + fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Ubuntu, sans-serif', +}; + +const container = { + backgroundColor: theme.card, + border: `1px solid ${theme.border}`, + borderRadius: theme.radiusMd, + margin: '40px auto', + maxWidth: '465px', + padding: '32px 28px', +}; + +const logoSection = { + marginBottom: '28px', + textAlign: 'center' as const, +}; + +const logoText = { + fontSize: '20px', + fontWeight: '700', + color: theme.foreground, + letterSpacing: '-0.3px', + margin: '0', +}; + +const heading = { + fontSize: '22px', + fontWeight: '600', + color: theme.foreground, + margin: '0 0 12px', + padding: '0', + textAlign: 'center' as const, +}; + +const paragraph = { + fontSize: '14px', + lineHeight: '1.625', + color: theme.foreground, + margin: '0 0 24px', + textAlign: 'center' as const, +}; + +const buttonSection = { + textAlign: 'center' as const, + margin: '0 0 24px', +}; + +const button = { + backgroundColor: theme.primary, + color: theme.primaryForeground, + borderRadius: theme.radiusMd, + fontSize: '14px', + fontWeight: '500', + textDecoration: 'none', + textAlign: 'center' as const, + display: 'inline-block', + padding: '10px 24px', +}; + +const paragraphSmall = { + fontSize: '12px', + lineHeight: '1.5', + color: theme.mutedForeground, + margin: '0', + wordBreak: 'break-all' as const, +}; + +const link = { + color: theme.primary, + textDecoration: 'underline', +}; + +const hr = { + borderColor: theme.border, + margin: '24px 0', +}; + +const footer = { + fontSize: '12px', + lineHeight: '1.5', + color: theme.mutedForeground, + margin: '0', +}; diff --git a/src/shared/lib/locales/en.json b/src/shared/lib/locales/en.json new file mode 100644 index 0000000..cef9ab6 --- /dev/null +++ b/src/shared/lib/locales/en.json @@ -0,0 +1,197 @@ +{ + "nav": { + "home": "Home", + "about": "About", + "categories": "Categories", + "editor": "Editor", + "signIn": "Sign In", + "brand": "Articlify" + }, + "button": { + "save": "Save", + "cancel": "Cancel", + "login": "Login", + "register": "Register", + "signOut": "Sign Out", + "tryAgain": "Try again", + "goHome": "Go Home", + "selectTheme": "Select theme" + }, + "form": { + "email": "Email", + "password": "Password", + "username": "Username", + "confirmPassword": "Confirm Password", + "submit": "Submit", + "placeholderUsername": "Enter your username", + "placeholderPassword": "Enter your password", + "placeholderChooseUsername": "Choose a username", + "placeholderEmail": "your@email.com", + "placeholderCreatePassword": "Create a password", + "placeholderConfirmPassword": "Confirm your password" + }, + "error": { + "network": "Network error, please try again", + "unauthorized": "You are not authorized", + "invalidCredentials": "Invalid username or password", + "generic": "An error occurred. Please try again.", + "passwordsDoNotMatch": "Passwords do not match", + "registrationFailed": "Registration failed. Please try again.", + "emailAlreadyTaken": "This email is already in use. Sign in if it's yours, or use a different address.", + "registrationSuccessLoginFailed": "Registration successful, but login failed. Please try logging in.", + "unexpected": "An unexpected error occurred.", + "emailNotVerified": "Please verify your email address to sign in." + }, + "footer": { + "language": "Language", + "theme": "Theme", + "themeSystem": "System", + "themeDark": "Dark", + "themeLight": "Light", + "madeBy": "Made by", + "author": "Ivan Shcherbakov", + "version": "Version", + "copyright": "Copyright © 2022-2026 Articlify Inc. All rights reserved." + }, + "auth": { + "loginTitle": "Login", + "loginDescription": "Enter your credentials to access your account", + "loggingIn": "Logging in...", + "registerTitle": "Register", + "registerDescription": "Create a new account to start publishing articles", + "creatingAccount": "Creating account...", + "dontHaveAccount": "Don't have an account?", + "alreadyHaveAccount": "Already have an account?", + "accessDenied": "Access Denied", + "accessDeniedDescription": "You need to be signed in to view this page", + "myAccount": "My Account", + "profile": "Profile", + "checkEmailTitle": "Check your email", + "checkEmailDescription": "We sent a verification link to your email. Please verify to sign in.", + "checkEmailDescriptionWithMask": "We sent a verification link to {maskedEmail}. Check that inbox and verify, or resend below.", + "resendVerification": "Resend verification email", + "resendVerificationSessionRequired": "Session expired. Please sign in again to resend.", + "verifyEmailBeforeSignIn": "Please verify your email before signing in. Check your inbox or resend the verification email.", + "resendVerificationSuccess": "Verification email sent. Check your inbox.", + "resendVerificationFailed": "Failed to send verification email. Try again later." + }, + "common": { + "pageNotFound": "Page Not Found", + "pageNotFoundDescription": "The page you are looking for doesn't exist or has been moved.", + "somethingWentWrong": "Something went wrong!", + "english": "English", + "russian": "Русский" + }, + "home": { + "tagline": "A place with articles and without cancel-culture." + }, + "category": { + "art": "Art", + "it": "IT", + "games": "Games", + "music": "Music", + "science": "Science", + "sports": "Sports", + "travel": "Travel", + "movies": "Movies", + "other": "Other" + }, + "articles": { + "noArticles": "No articles", + "noArticlesMatchCriteria": "No articles match your search criteria.", + "articlesByTags": "Articles by Tags", + "articlesByTagsHeading": "{count, plural, =1 {Articles by tag: {tags}} other {Articles by tags: {tags}}}", + "allArticles": "All Articles", + "searchTitle": "Search: {title} | Articlify", + "allArticlesTitle": "All Articles | Articlify", + "browseAndSearch": "Browse and search articles", + "searchResults": "Search results", + "latestArticles": "Latest articles", + "searchByTitle": "Search by title...", + "filterAllCategories": "All categories", + "filterTags": "Tags...", + "resetFilters": "Reset filters", + "articlesCount": "{count} articles", + "browseCategory": "Browse {category} category", + "returnToAllArticles": "Return to all articles", + "lastUpdated": "Last updated:", + "categoryPageTitle": "{category} Articles | Articlify", + "categoryPageDescription": "Browse all articles in the {category} category", + "authorNoArticlesYet": "@{author} hasn't published any articles yet.", + "articlesByLabel": "Articles by", + "articlesByAuthor": "Articles by @{author}", + "userArticlesPageTitle": "Articles by @{author} | Articlify", + "userArticlesPageDescription": "Browse all articles written by {author}" + }, + "dashboard": { + "title": "Dashboard", + "profile": "Profile", + "profileDescription": "Your account information", + "memberSince": "Member since", + "viewYourArticles": "View Your Articles", + "quickActions": "Quick Actions", + "manageContent": "Manage your content", + "createNewArticle": "Create New Article", + "manageArticles": "Manage Articles", + "browseArticles": "Browse Articles" + }, + "editor": { + "placeholder": "Start writing...", + "loadingEditor": "Loading editor…", + "editArticle": "Edit Article", + "createNewArticle": "Create New Article", + "title": "Title", + "description": "Description", + "category": "Category", + "tags": "Tags", + "coverImage": "Cover Image", + "content": "Content", + "placeholderTitle": "Enter article title...", + "placeholderDescription": "Brief description of your article...", + "selectCategory": "Select a category...", + "selectOrCreateTags": "Select or create tags...", + "coverImageNote": "Image will be converted to 2:1 ratio", + "saving": "Saving...", + "updateArticle": "Update Article", + "publishArticle": "Publish Article", + "success": "Success", + "articleCreated": "Article created successfully", + "articleUpdated": "Article updated successfully", + "error": "Error", + "validationError": "Validation Error", + "titleCategoryDescriptionRequired": "Title, category, and description are required", + "failedToSave": "Failed to save article", + "noPermissionToEdit": "You do not have permission to edit this article", + "preview": "Preview" + }, + "email": { + "verifyEmail": { + "subject": "Verify your email address", + "preview": "Verify your email address for Articlify", + "heading": "Verify your email address", + "body": "Thanks for signing up! Please verify your email address to get started. This link will expire in 24 hours.", + "button": "Verify email address", + "fallback": "Or copy and paste this URL into your browser:", + "footer": "If you did not create an account with Articlify, you can safely ignore this email. Someone may have entered your email address by mistake." + } + }, + "pagination": { + "previous": "Previous", + "next": "Next", + "morePages": "More pages", + "goToPreviousPage": "Go to previous page", + "goToNextPage": "Go to next page", + "ariaLabel": "Pagination" + }, + "avatar": { + "administrator": "Administrator", + "user": "User", + "changeAvatar": "Change avatar", + "uploading": "Uploading…", + "avatarUpdated": "Avatar updated", + "avatarUpdatedDescription": "Your profile picture has been updated.", + "uploadFailed": "Upload failed", + "fileTooLarge": "File too large", + "imageTooLarge": "Image must be smaller than 2MB." + } +} diff --git a/src/shared/lib/locales/ru.json b/src/shared/lib/locales/ru.json new file mode 100644 index 0000000..67fd469 --- /dev/null +++ b/src/shared/lib/locales/ru.json @@ -0,0 +1,197 @@ +{ + "nav": { + "home": "Главная", + "about": "О сервисе", + "categories": "Категории", + "editor": "Редактор", + "signIn": "Войти", + "brand": "Articlify" + }, + "button": { + "save": "Сохранить", + "cancel": "Отмена", + "login": "Войти", + "register": "Регистрация", + "signOut": "Выйти", + "tryAgain": "Попробовать снова", + "goHome": "На главную", + "selectTheme": "Выберите тему" + }, + "form": { + "email": "Эл. почта", + "password": "Пароль", + "username": "Имя пользователя", + "confirmPassword": "Подтвердите пароль", + "submit": "Отправить", + "placeholderUsername": "Введите имя пользователя", + "placeholderPassword": "Введите пароль", + "placeholderChooseUsername": "Выберите имя пользователя", + "placeholderEmail": "your@email.com", + "placeholderCreatePassword": "Придумайте пароль", + "placeholderConfirmPassword": "Подтвердите пароль" + }, + "error": { + "network": "Ошибка сети, попробуйте снова", + "unauthorized": "У вас нет доступа", + "invalidCredentials": "Неверное имя пользователя или пароль", + "generic": "Произошла ошибка. Попробуйте снова.", + "passwordsDoNotMatch": "Пароли не совпадают", + "registrationFailed": "Ошибка регистрации. Попробуйте снова.", + "emailAlreadyTaken": "Этот адрес уже используется. Войдите, если это ваш аккаунт, или укажите другой email.", + "registrationSuccessLoginFailed": "Регистрация прошла успешно, но вход не удался. Попробуйте войти.", + "unexpected": "Произошла непредвиденная ошибка.", + "emailNotVerified": "Подтвердите адрес эл. почты для входа." + }, + "footer": { + "language": "Язык", + "theme": "Тема", + "themeSystem": "Системная", + "themeDark": "Тёмная", + "themeLight": "Светлая", + "madeBy": "Автор", + "author": "Иван Щербаков", + "version": "Версия", + "copyright": "© 2022-2026 Articlify Inc. Все права защищены." + }, + "auth": { + "loginTitle": "Вход", + "loginDescription": "Введите данные для доступа к аккаунту", + "loggingIn": "Вход...", + "registerTitle": "Регистрация", + "registerDescription": "Создайте аккаунт, чтобы публиковать статьи", + "creatingAccount": "Создание аккаунта...", + "dontHaveAccount": "Нет аккаунта?", + "alreadyHaveAccount": "Уже есть аккаунт?", + "accessDenied": "Доступ запрещён", + "accessDeniedDescription": "Для просмотра этой страницы необходимо войти", + "myAccount": "Мой аккаунт", + "profile": "Профиль", + "checkEmailTitle": "Проверьте почту", + "checkEmailDescription": "Мы отправили ссылку для подтверждения на вашу эл. почту. Подтвердите её, чтобы войти.", + "checkEmailDescriptionWithMask": "Мы отправили ссылку на {maskedEmail}. Проверьте эту почту и перейдите по ссылке или запросите повторную отправку ниже.", + "resendVerification": "Отправить письмо повторно", + "resendVerificationSessionRequired": "Сессия истекла. Войдите снова, чтобы отправить письмо повторно.", + "verifyEmailBeforeSignIn": "Подтвердите эл. почту перед входом. Проверьте входящие или запросите повторную отправку.", + "resendVerificationSuccess": "Письмо отправлено. Проверьте входящие.", + "resendVerificationFailed": "Не удалось отправить письмо. Попробуйте позже." + }, + "common": { + "pageNotFound": "Страница не найдена", + "pageNotFoundDescription": "Запрашиваемая страница не существует или была перемещена.", + "somethingWentWrong": "Что-то пошло не так!", + "english": "English", + "russian": "Русский" + }, + "home": { + "tagline": "Пространство для вдумчивых текстов — без культуры отмены." + }, + "category": { + "art": "Искусство", + "it": "IT", + "games": "Игры", + "music": "Музыка", + "science": "Наука", + "sports": "Спорт", + "travel": "Путешествия", + "movies": "Кино", + "other": "Другое" + }, + "articles": { + "noArticles": "Нет статей", + "noArticlesMatchCriteria": "Нет статей по вашему запросу.", + "articlesByTags": "Статьи по тегам", + "articlesByTagsHeading": "{count, plural, one {Статьи по тегу: {tags}} few {Статьи по тегам: {tags}} many {Статьи по тегам: {tags}} other {Статьи по тегам: {tags}}}", + "allArticles": "Все статьи", + "searchTitle": "Поиск: {title} | Articlify", + "allArticlesTitle": "Все статьи | Articlify", + "browseAndSearch": "Просмотр и поиск статей", + "searchResults": "Результаты поиска", + "latestArticles": "Последние статьи", + "searchByTitle": "Поиск по названию...", + "filterAllCategories": "Все категории", + "filterTags": "Теги...", + "resetFilters": "Сбросить фильтры", + "articlesCount": "{count} статей", + "browseCategory": "Статьи в разделе «{category}»", + "returnToAllArticles": "Все статьи", + "lastUpdated": "Обновлено:", + "categoryPageTitle": "Статьи — {category} | Articlify", + "categoryPageDescription": "Все статьи в разделе «{category}»", + "authorNoArticlesYet": "@{author} ещё не опубликовал ни одной статьи.", + "articlesByLabel": "Статьи", + "articlesByAuthor": "Статьи @{author}", + "userArticlesPageTitle": "Статьи @{author} | Articlify", + "userArticlesPageDescription": "Все статьи автора {author}" + }, + "dashboard": { + "title": "Панель управления", + "profile": "Профиль", + "profileDescription": "Информация об аккаунте", + "memberSince": "Участник с", + "viewYourArticles": "Мои статьи", + "quickActions": "Быстрые действия", + "manageContent": "Управление контентом", + "createNewArticle": "Создать статью", + "manageArticles": "Управление статьями", + "browseArticles": "Все статьи" + }, + "editor": { + "placeholder": "Начните писать...", + "loadingEditor": "Загрузка редактора…", + "editArticle": "Редактировать статью", + "createNewArticle": "Создать статью", + "title": "Заголовок", + "description": "Описание", + "category": "Категория", + "tags": "Теги", + "coverImage": "Обложка", + "content": "Содержание", + "placeholderTitle": "Введите заголовок статьи...", + "placeholderDescription": "Краткое описание статьи...", + "selectCategory": "Выберите категорию...", + "selectOrCreateTags": "Выберите или создайте теги...", + "coverImageNote": "Изображение будет приведено к формату 2:1", + "saving": "Сохранение...", + "updateArticle": "Обновить статью", + "publishArticle": "Опубликовать", + "success": "Успешно", + "articleCreated": "Статья успешно создана", + "articleUpdated": "Статья успешно обновлена", + "error": "Ошибка", + "validationError": "Ошибка проверки", + "titleCategoryDescriptionRequired": "Заполните заголовок, категорию и описание", + "failedToSave": "Не удалось сохранить статью", + "noPermissionToEdit": "У вас нет прав на редактирование этой статьи", + "preview": "Превью" + }, + "email": { + "verifyEmail": { + "subject": "Подтвердите адрес эл. почты", + "preview": "Подтвердите адрес электронной почты для Articlify", + "heading": "Подтвердите адрес эл. почты", + "body": "Спасибо за регистрацию! Пожалуйста, подтвердите свой адрес эл. почты, чтобы начать. Ссылка действительна 24 часа.", + "button": "Подтвердить адрес эл. почты", + "fallback": "Или скопируйте и вставьте этот URL в браузер:", + "footer": "Если вы не создавали аккаунт в Articlify, просто проигнорируйте это письмо. Возможно, кто-то ввёл ваш адрес по ошибке." + } + }, + "pagination": { + "previous": "Назад", + "next": "Вперёд", + "morePages": "Ещё страницы", + "goToPreviousPage": "Перейти на предыдущую страницу", + "goToNextPage": "Перейти на следующую страницу", + "ariaLabel": "Пагинация" + }, + "avatar": { + "administrator": "Администратор", + "user": "Пользователь", + "changeAvatar": "Изменить аватар", + "uploading": "Загрузка…", + "avatarUpdated": "Аватар обновлён", + "avatarUpdatedDescription": "Фото профиля обновлено.", + "uploadFailed": "Ошибка загрузки", + "fileTooLarge": "Файл слишком большой", + "imageTooLarge": "Размер изображения не должен превышать 2 МБ." + } +} diff --git a/src/shared/lib/mask-email.ts b/src/shared/lib/mask-email.ts new file mode 100644 index 0000000..313dfd5 --- /dev/null +++ b/src/shared/lib/mask-email.ts @@ -0,0 +1,12 @@ +/** + * Masks an email for display (e.g. "mail@example.com" → "m***@example.com"). + * Keeps the first character and the full domain. + */ +export function maskEmail(email: string): string { + const at = email.indexOf('@'); + if (at <= 0) return '***'; + const local = email.slice(0, at); + const domain = email.slice(at); + const first = local[0] ?? ''; + return `${first}***${domain}`; +} diff --git a/src/shared/lib/server/connection.ts b/src/shared/lib/server/connection.ts index 66f40fa..aa68a9b 100644 --- a/src/shared/lib/server/connection.ts +++ b/src/shared/lib/server/connection.ts @@ -1,9 +1,8 @@ import mongoose from 'mongoose'; -const { MONGODB_URI } = process.env; +import { getServerConfig } from '~/shared/config/env/server'; -// Подключение к базе данных MongoDB export const connectDB = async () => { - const conn = await mongoose.connect(MONGODB_URI as string).catch((err) => console.log(err)); + const conn = await mongoose.connect(getServerConfig().mongodb.uri).catch((err) => console.log(err)); return conn; }; diff --git a/src/shared/lib/server/get-mailer.ts b/src/shared/lib/server/get-mailer.ts new file mode 100644 index 0000000..9e12a45 --- /dev/null +++ b/src/shared/lib/server/get-mailer.ts @@ -0,0 +1,22 @@ +import 'server-only'; +import { getServerConfig } from '~/shared/config/env/server'; +import { log } from '~/shared/lib/server/logger'; +import { createMailer, type Mailer } from '@basedest/mailer'; + +let mailerInstance: Mailer | null = null; + +/** + * App-level singleton. Wires mailer with app config and logger. + */ +export function getMailer(): Mailer { + if (mailerInstance) { + return mailerInstance; + } + const { mailer: mailerConfig } = getServerConfig(); + mailerInstance = createMailer(mailerConfig, { + logger: { + log: (opts) => log(opts), + }, + }); + return mailerInstance; +} diff --git a/src/shared/lib/server/logger.ts b/src/shared/lib/server/logger.ts index 712d2b5..49e6afd 100644 --- a/src/shared/lib/server/logger.ts +++ b/src/shared/lib/server/logger.ts @@ -1,6 +1,7 @@ import pino from 'pino'; import { v4 as uuidv4 } from 'uuid'; import { context, trace } from '@opentelemetry/api'; +import { getServerConfig } from '~/shared/config/env/server'; export type LogLevel = 'debug' | 'info' | 'warn' | 'error' | 'fatal'; @@ -17,10 +18,11 @@ export interface LogOptions { durationMs?: number; } +const { logLevel, nodeEnv } = getServerConfig(); const logger = pino({ - level: process.env.LOG_LEVEL || 'info', - transport: process.env.NODE_ENV !== 'production' ? { target: 'pino-pretty' } : undefined, - base: { service: 'articlify', environment: process.env.NODE_ENV || 'dev' }, + level: logLevel, + transport: nodeEnv !== 'production' ? { target: 'pino-pretty' } : undefined, + base: { service: 'articlify', environment: nodeEnv }, timestamp: () => `,"timestamp":"${new Date().toISOString()}"`, }); diff --git a/src/shared/lib/server/mongodb-client.ts b/src/shared/lib/server/mongodb-client.ts index 49c8e89..c41fcf8 100644 --- a/src/shared/lib/server/mongodb-client.ts +++ b/src/shared/lib/server/mongodb-client.ts @@ -1,16 +1,14 @@ import { MongoClient } from 'mongodb'; +import { getServerConfig } from '~/shared/config/env/server'; -if (!process.env.MONGODB_URI) { - throw new Error('Please add your Mongo URI to .env.local'); -} - -const uri = process.env.MONGODB_URI; +const { mongodb, nodeEnv } = getServerConfig(); +const uri = mongodb.uri; const options = {}; let client: MongoClient; let clientPromise: Promise; -if (process.env.NODE_ENV === 'development') { +if (nodeEnv === 'development') { // In development mode, use a global variable so that the value // is preserved across module reloads caused by HMR (Hot Module Replacement). let globalWithMongo = global as typeof globalThis & { diff --git a/src/shared/lib/server/storage/factory.ts b/src/shared/lib/server/storage/factory.ts index c50dc60..995a2be 100644 --- a/src/shared/lib/server/storage/factory.ts +++ b/src/shared/lib/server/storage/factory.ts @@ -1,3 +1,4 @@ +import { getServerConfig } from '~/shared/config/env/server'; import type { StorageClient } from './index'; import { MinIOStorage } from './minio'; import { S3Storage } from './s3'; @@ -9,16 +10,12 @@ export function getStorageClient(): StorageClient { return storageInstance; } - const provider = process.env.STORAGE_PROVIDER || 'minio'; + const storage = getServerConfig().storage; - switch (provider) { - case 's3': - storageInstance = new S3Storage(); - break; - case 'minio': - default: - storageInstance = new MinIOStorage(); - break; + if (storage.provider === 's3') { + storageInstance = new S3Storage(storage); + } else { + storageInstance = new MinIOStorage(storage); } return storageInstance; diff --git a/src/shared/lib/server/storage/minio.ts b/src/shared/lib/server/storage/minio.ts index 1154936..ec73a38 100644 --- a/src/shared/lib/server/storage/minio.ts +++ b/src/shared/lib/server/storage/minio.ts @@ -8,8 +8,11 @@ import { PutBucketPolicyCommand, } from '@aws-sdk/client-s3'; import { getSignedUrl as awsGetSignedUrl } from '@aws-sdk/s3-request-presigner'; +import type { ServerConfig } from '~/shared/config/env/server'; import type { StorageClient } from './index'; +type MinIOStorageConfig = Extract; + /** MinIO/S3-compatible: ensure bucket exists before upload (fixes "bucket does not exist"). */ async function ensureBucketExists(client: S3Client, bucket: string): Promise { try { @@ -61,20 +64,16 @@ export class MinIOStorage implements StorageClient { private bucket: string; private publicUrl: string; - constructor() { - const endpoint = process.env.S3_ENDPOINT || 'http://localhost:9000'; - const region = process.env.S3_REGION || 'us-east-1'; - const accessKeyId = process.env.S3_ACCESS_KEY || 'minioadmin'; - const secretAccessKey = process.env.S3_SECRET_KEY || 'minioadmin'; - this.bucket = process.env.S3_BUCKET || 'articlify-images'; - this.publicUrl = process.env.S3_PUBLIC_URL || 'http://localhost:9000/articlify-images'; + constructor(config: MinIOStorageConfig) { + this.bucket = config.bucket; + this.publicUrl = config.publicUrl; this.client = new S3Client({ - endpoint, - region, + endpoint: config.endpoint, + region: config.region, credentials: { - accessKeyId, - secretAccessKey, + accessKeyId: config.accessKeyId, + secretAccessKey: config.secretAccessKey, }, forcePathStyle: true, // Required for MinIO }); diff --git a/src/shared/lib/server/storage/s3.ts b/src/shared/lib/server/storage/s3.ts index 9928302..db09db6 100644 --- a/src/shared/lib/server/storage/s3.ts +++ b/src/shared/lib/server/storage/s3.ts @@ -1,32 +1,27 @@ import { S3Client, PutObjectCommand, DeleteObjectCommand, GetObjectCommand } from '@aws-sdk/client-s3'; import { getSignedUrl as awsGetSignedUrl } from '@aws-sdk/s3-request-presigner'; +import type { ServerConfig } from '~/shared/config/env/server'; import type { StorageClient } from './index'; +type S3StorageConfig = Extract; + export class S3Storage implements StorageClient { private client: S3Client; private bucket: string; private publicUrl: string; - constructor() { - const region = process.env.S3_REGION || ''; - const accessKeyId = process.env.S3_ACCESS_KEY; - const secretAccessKey = process.env.S3_SECRET_KEY; - this.bucket = process.env.S3_BUCKET || ''; - - this.publicUrl = process.env.S3_PUBLIC_URL || ''; - - if (!accessKeyId || !secretAccessKey || !this.publicUrl || !this.bucket || !region) { - throw new Error('AWS credentials are required for S3 storage'); - } + constructor(config: S3StorageConfig) { + this.bucket = config.bucket; + this.publicUrl = config.publicUrl; this.client = new S3Client({ - region, + region: config.region, credentials: { - accessKeyId, - secretAccessKey, + accessKeyId: config.accessKeyId, + secretAccessKey: config.secretAccessKey, }, - endpoint: this.publicUrl, - forcePathStyle: process.env.S3_FORCE_PATH_STYLE === 'true', + endpoint: config.publicUrl, + forcePathStyle: config.forcePathStyle, }); } diff --git a/src/shared/types/session.ts b/src/shared/types/session.ts new file mode 100644 index 0000000..6dfc334 --- /dev/null +++ b/src/shared/types/session.ts @@ -0,0 +1,12 @@ +/** Session user shape (includes Better Auth additionalFields). Use for client-side typing. */ +export interface SessionUser { + id: string; + name: string; + email: string; + image?: string | null; + role?: string; // TODO: should be enum + regDate?: Date; + preferredLanguage?: string; + username?: string | null; + displayUsername?: string | null; +} diff --git a/src/shared/ui/badge.tsx b/src/shared/ui/badge.tsx index 717334c..6d4a80a 100644 --- a/src/shared/ui/badge.tsx +++ b/src/shared/ui/badge.tsx @@ -1,18 +1,20 @@ import * as React from 'react'; +import { Slot } from '@radix-ui/react-slot'; import { cva, type VariantProps } from 'class-variance-authority'; - import { cn } from '~/shared/lib/utils'; const badgeVariants = cva( - 'inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2', + 'inline-flex items-center justify-center rounded-full border border-transparent px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden', { variants: { variant: { - default: 'border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80', - secondary: 'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80', + default: 'bg-primary text-primary-foreground [a&]:hover:bg-primary/90', + secondary: 'bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90', destructive: - 'border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80', - outline: 'text-foreground', + 'bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60', + outline: 'border-border text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground', + ghost: '[a&]:hover:bg-accent [a&]:hover:text-accent-foreground', + link: 'text-primary underline-offset-4 [a&]:hover:underline', }, }, defaultVariants: { @@ -21,10 +23,22 @@ const badgeVariants = cva( }, ); -export interface BadgeProps extends React.HTMLAttributes, VariantProps {} +function Badge({ + className, + variant = 'default', + asChild = false, + ...props +}: React.ComponentProps<'span'> & VariantProps & { asChild?: boolean }) { + const Comp = asChild ? Slot : 'span'; -function Badge({ className, variant, ...props }: BadgeProps) { - return
; + return ( + + ); } export { Badge, badgeVariants }; diff --git a/src/shared/ui/multi-select.tsx b/src/shared/ui/multi-select.tsx index 1c2753e..b6d18ae 100644 --- a/src/shared/ui/multi-select.tsx +++ b/src/shared/ui/multi-select.tsx @@ -51,7 +51,7 @@ export function MultiSelect({ return (
-
+
{selected.map((item) => { const option = options.find((o) => o.value === item); return ( diff --git a/src/shared/ui/pagination.tsx b/src/shared/ui/pagination.tsx index a5c6aee..5f718d6 100644 --- a/src/shared/ui/pagination.tsx +++ b/src/shared/ui/pagination.tsx @@ -1,14 +1,18 @@ +'use client'; + import * as React from 'react'; import { ChevronLeftIcon, ChevronRightIcon, MoreHorizontalIcon } from 'lucide-react'; +import { useTranslations } from 'next-intl'; import { cn } from '~/shared/lib/utils'; import { buttonVariants, type Button } from '~/shared/ui/button'; function Pagination({ className, ...props }: React.ComponentProps<'nav'>) { + const t = useTranslations('pagination'); return (