From a7abf454d96c71202a79ecafcd8c4735d833b591 Mon Sep 17 00:00:00 2001 From: dmitriylaukhin Date: Sun, 14 Jun 2026 16:25:35 +0500 Subject: [PATCH] Add internal grant endpoint + temp-password / force-change flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - /api/internal/grant: secret-auth endpoint for payment-router to provision access on a paid order — find-or-create user (emailVerified, temp password, mustChangePassword), enroll by course slug list, AccessLog, delivery email. Idempotent by order_id. - User.mustChangePassword flag (+ migration); (student) layout redirects flagged users to /change-password (forced first-login change). - email.ts: sendCourseGrantEmail (temp password for new buyers). - middleware: /api/internal is public (own secret auth). Co-Authored-By: Claude Opus 4.8 --- .env.example | 3 + .../migration.sql | 2 + prisma/schema.prisma | 1 + src/app/(student)/layout.tsx | 8 ++ src/app/(student)/profile/actions.ts | 4 + src/app/api/internal/grant/route.ts | 118 ++++++++++++++++++ src/app/change-password/actions.ts | 34 +++++ src/app/change-password/force-change-form.tsx | 52 ++++++++ src/app/change-password/page.tsx | 26 ++++ src/lib/email.ts | 44 +++++++ src/middleware.ts | 2 +- 11 files changed, 293 insertions(+), 1 deletion(-) create mode 100644 prisma/migrations/20260614120000_add_must_change_password/migration.sql create mode 100644 src/app/api/internal/grant/route.ts create mode 100644 src/app/change-password/actions.ts create mode 100644 src/app/change-password/force-change-form.tsx create mode 100644 src/app/change-password/page.tsx diff --git a/.env.example b/.env.example index b9f4cc5..7a38b11 100644 --- a/.env.example +++ b/.env.example @@ -18,3 +18,6 @@ TURNSTILE_SECRET_KEY="" # Kinescope API KINESCOPE_API_KEY="" + +# Internal access-provisioning (payment-router → /api/internal/grant) +INTERNAL_GRANT_SECRET="" diff --git a/prisma/migrations/20260614120000_add_must_change_password/migration.sql b/prisma/migrations/20260614120000_add_must_change_password/migration.sql new file mode 100644 index 0000000..83f7465 --- /dev/null +++ b/prisma/migrations/20260614120000_add_must_change_password/migration.sql @@ -0,0 +1,2 @@ +-- Add force-password-change flag (set for auto-provisioned buyers with a temp password) +ALTER TABLE "User" ADD COLUMN "mustChangePassword" BOOLEAN NOT NULL DEFAULT false; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index a5befbb..b1e16b9 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -24,6 +24,7 @@ model User { banReason String? banExpires DateTime? comment String? + mustChangePassword Boolean @default(false) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt diff --git a/src/app/(student)/layout.tsx b/src/app/(student)/layout.tsx index 3d8557c..69e42d5 100644 --- a/src/app/(student)/layout.tsx +++ b/src/app/(student)/layout.tsx @@ -1,5 +1,6 @@ import { headers } from "next/headers"; import { auth } from "@/lib/auth"; +import { prisma } from "@/lib/prisma"; import { redirect } from "next/navigation"; import Link from "next/link"; import { LogoutButton } from "@/components/layout/logout-button"; @@ -10,6 +11,13 @@ export default async function StudentLayout({ children }: { children: React.Reac const session = await auth.api.getSession({ headers: await headers() }); if (!session) redirect("/login"); + // Force password change for auto-provisioned buyers (temp password) + const dbUser = await prisma.user.findUnique({ + where: { id: session.user.id }, + select: { mustChangePassword: true }, + }); + if (dbUser?.mustChangePassword) redirect("/change-password"); + // Maintenance mode: non-admin users see the maintenance page if (session.user.role !== "admin") { const maintenance = await getSetting("maintenanceMode"); diff --git a/src/app/(student)/profile/actions.ts b/src/app/(student)/profile/actions.ts index 7e5c937..aa74860 100644 --- a/src/app/(student)/profile/actions.ts +++ b/src/app/(student)/profile/actions.ts @@ -30,6 +30,10 @@ export async function changePasswordAction(_prevState: unknown, formData: FormDa where: { id: account.id }, data: { password: hash }, }); + await prisma.user.update({ + where: { id: session.user.id }, + data: { mustChangePassword: false }, + }); return { success: true }; } diff --git a/src/app/api/internal/grant/route.ts b/src/app/api/internal/grant/route.ts new file mode 100644 index 0000000..a5d87aa --- /dev/null +++ b/src/app/api/internal/grant/route.ts @@ -0,0 +1,118 @@ +import { NextRequest, NextResponse } from "next/server"; +import { prisma } from "@/lib/prisma"; +import { sendCourseGrantEmail } from "@/lib/email"; +import bcrypt from "bcryptjs"; +import crypto from "crypto"; + +/** + * Internal access-provisioning endpoint, called by payment-router on a paid order. + * Auth: shared secret in `x-internal-secret`. Idempotent by order_id (AccessLog note). + * + * Body: { email, name?, course_slugs: string[], order_id, payment_system? } + */ +export async function POST(request: NextRequest) { + const secret = process.env.INTERNAL_GRANT_SECRET; + if (!secret || request.headers.get("x-internal-secret") !== secret) { + return NextResponse.json({ ok: false, error: "unauthorized" }, { status: 401 }); + } + + let body: { + email?: string; + name?: string; + course_slugs?: string[]; + order_id?: string; + }; + try { + body = (await request.json()) as typeof body; + } catch { + return NextResponse.json({ ok: false, error: "bad_json" }, { status: 400 }); + } + + const email = (body.email ?? "").trim().toLowerCase(); + const name = (body.name ?? "").trim(); + const slugs = Array.isArray(body.course_slugs) + ? body.course_slugs.filter((s) => typeof s === "string" && s.trim()) + : []; + const orderId = (body.order_id ?? "").trim(); + + if (!email || slugs.length === 0 || !orderId) { + return NextResponse.json( + { ok: false, error: "missing_fields", need: ["email", "course_slugs", "order_id"] }, + { status: 400 }, + ); + } + + // Idempotency: this order already provisioned? + const marker = `order:${orderId}`; + const already = await prisma.accessLog.findFirst({ where: { note: { contains: marker } } }); + if (already) { + return NextResponse.json({ ok: true, idempotent: true }); + } + + // Resolve course slugs → ids + const courses = await prisma.course.findMany({ + where: { slug: { in: slugs } }, + select: { id: true, title: true, slug: true }, + }); + const resolvedSlugs = new Set(courses.map((c) => c.slug)); + const unresolved = slugs.filter((s) => !resolvedSlugs.has(s)); + if (courses.length === 0) { + return NextResponse.json( + { ok: false, error: "no_courses_resolved", slugs }, + { status: 422 }, + ); + } + + // Find or create user + let user = await prisma.user.findFirst({ + where: { email: { equals: email, mode: "insensitive" } }, + select: { id: true, name: true }, + }); + let tempPassword: string | null = null; + if (!user) { + tempPassword = "sb-" + crypto.randomBytes(3).toString("hex"); + const hash = await bcrypt.hash(tempPassword, 10); + user = await prisma.user.create({ + data: { + email, + name: name || email, + emailVerified: true, + mustChangePassword: true, + accounts: { + create: { accountId: email, providerId: "credential", password: hash }, + }, + }, + select: { id: true, name: true }, + }); + } + + // Enroll + audit log + await prisma.courseEnrollment.createMany({ + data: courses.map((c) => ({ userId: user!.id, courseId: c.id })), + skipDuplicates: true, + }); + await prisma.accessLog.createMany({ + data: courses.map((c) => ({ + courseId: c.id, + userId: user!.id, + action: "granted", + method: "auto-payment", + note: `auto-payment ${marker}`, + })), + }); + + // Delivery email (temp password for new buyers, plain "added" for existing) + await sendCourseGrantEmail( + email, + user.name ?? name, + courses.map((c) => c.title), + tempPassword, + ); + + return NextResponse.json({ + ok: true, + created: tempPassword !== null, + granted: courses.map((c) => c.slug), + unresolved, + }); +} diff --git a/src/app/change-password/actions.ts b/src/app/change-password/actions.ts new file mode 100644 index 0000000..96c4353 --- /dev/null +++ b/src/app/change-password/actions.ts @@ -0,0 +1,34 @@ +"use server"; + +import { headers } from "next/headers"; +import { auth } from "@/lib/auth"; +import { prisma } from "@/lib/prisma"; +import bcrypt from "bcryptjs"; + +// Принудительная смена пароля для покупателей с временным паролем. +// Текущий пароль не спрашиваем — пользователь уже аутентифицирован сессией. +export async function forceChangePasswordAction(_prevState: unknown, formData: FormData) { + const next = (formData.get("newPassword") as string) ?? ""; + const confirm = (formData.get("confirmPassword") as string) ?? ""; + + if (!next || !confirm) return { error: "Заполните оба поля" }; + if (next !== confirm) return { error: "Пароли не совпадают" }; + if (next.length < 8) return { error: "Пароль должен быть не короче 8 символов" }; + + const session = await auth.api.getSession({ headers: await headers() }); + if (!session) return { error: "Сессия истекла, войдите заново" }; + + const account = await prisma.account.findFirst({ + where: { userId: session.user.id, providerId: "credential" }, + }); + if (!account) return { error: "Аккаунт не найден" }; + + const hash = await bcrypt.hash(next, 10); + await prisma.account.update({ where: { id: account.id }, data: { password: hash } }); + await prisma.user.update({ + where: { id: session.user.id }, + data: { mustChangePassword: false }, + }); + + return { success: true }; +} diff --git a/src/app/change-password/force-change-form.tsx b/src/app/change-password/force-change-form.tsx new file mode 100644 index 0000000..2a31c90 --- /dev/null +++ b/src/app/change-password/force-change-form.tsx @@ -0,0 +1,52 @@ +"use client"; + +import { useActionState, useEffect } from "react"; +import { useRouter } from "next/navigation"; +import { forceChangePasswordAction } from "./actions"; + +export function ForceChangeForm() { + const router = useRouter(); + const [state, formAction, isPending] = useActionState(forceChangePasswordAction, null); + + useEffect(() => { + if (state && "success" in state && state.success) { + router.push("/dashboard"); + router.refresh(); + } + }, [state, router]); + + const inputStyle = { + width: "100%", + padding: "0.5rem 0.75rem", + fontSize: "16px", + border: "2px solid var(--border)", + background: "var(--background)", + outline: "none", + fontFamily: "inherit", + } as React.CSSProperties; + + return ( +
+
+ + +
+
+ + +
+ {state && "error" in state && state.error && ( +

+ {state.error} +

+ )} + +
+ ); +} diff --git a/src/app/change-password/page.tsx b/src/app/change-password/page.tsx new file mode 100644 index 0000000..86357bb --- /dev/null +++ b/src/app/change-password/page.tsx @@ -0,0 +1,26 @@ +import { headers } from "next/headers"; +import { auth } from "@/lib/auth"; +import { redirect } from "next/navigation"; +import { ForceChangeForm } from "./force-change-form"; + +export default async function ChangePasswordPage() { + const session = await auth.api.getSession({ headers: await headers() }); + if (!session) redirect("/login"); + + return ( +
+
+

+ Задайте свой пароль +

+

+ Вы вошли с временным паролем. Перед началом работы задайте постоянный — это займёт минуту. +

+ +
+
+ ); +} diff --git a/src/lib/email.ts b/src/lib/email.ts index 4d4d4b6..71e51b3 100644 --- a/src/lib/email.ts +++ b/src/lib/email.ts @@ -213,6 +213,50 @@ export async function sendAccessEmail( }).catch((e) => console.error("[email] sendAccessEmail:", e)); } +// Выдача доступа по факту оплаты (через payment-router /api/internal/grant). +// tempPassword != null — новый покупатель (аккаунт создан с временным паролем, +// force-change при первом входе). null — существующий (курсы добавлены). +export async function sendCourseGrantEmail( + to: string, + name: string, + courseTitles: string[], + tempPassword: string | null, +) { + const school = await getSchoolName(); + const list = courseTitles + .map((t) => `
  • ${t}
  • `) + .join(""); + const loginBlock = tempPassword + ? ` +

    Данные для входа:

    + ${quote(`Адрес: school.second-brain.ru\nEmail: ${to}\nПароль: ${tempPassword}`)} +

    Это временный пароль — при первом входе система попросит задать свой.

    ` + : ` +

    Войдите под своим обычным паролем — новые курсы уже на главной.

    `; + const subject = + courseTitles.length > 1 + ? `Доступ к курсам ${school} открыт` + : `Доступ к курсу «${courseTitles[0] ?? school}» открыт`; + await getResend() + .emails.send({ + from: FROM, + to, + subject, + html: base( + ` +

    Привет, ${name || ""} 👋🏽

    +

    Спасибо за покупку! Доступ уже открыт в вашем личном кабинете на school.second-brain.ru. Вам открыты курсы:

    + + ${loginBlock} + ${btn(`${BASE_URL}/login`, "Войти на платформу")} +

    Если что-то не получается — просто ответьте на это письмо.

    + `, + school, + ), + }) + .catch((e) => console.error("[email] sendCourseGrantEmail:", e)); +} + export async function sendPasswordResetEmail(to: string, name: string, resetUrl: string) { const school = await getSchoolName(); await getResend().emails.send({ diff --git a/src/middleware.ts b/src/middleware.ts index 57166d3..174621c 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -1,7 +1,7 @@ import { NextRequest, NextResponse } from "next/server"; import { getSessionCookie } from "better-auth/cookies"; -const PUBLIC_ROUTES = ["/login", "/register", "/verify-email", "/forgot-password", "/reset-password", "/api/auth", "/api/register", "/maintenance"]; +const PUBLIC_ROUTES = ["/login", "/register", "/verify-email", "/forgot-password", "/reset-password", "/api/auth", "/api/register", "/api/internal", "/maintenance"]; export function middleware(request: NextRequest) { const { pathname } = request.nextUrl;