Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4792df274e | ||
|
|
83f34ea679 | ||
|
|
0a5368577c | ||
|
|
82737ba188 | ||
|
|
9553d1e9be |
@@ -275,6 +275,14 @@
|
||||
|
||||
## Бэклог (после MVP)
|
||||
|
||||
- **⚠️ Смена и сброс пароля — три дефекта** (найдено 20261005, когда владелец менял пароль админа):
|
||||
1. `resetUserPassword` (`src/app/admin/users/[userId]/actions.ts`, ~стр. 82–99) заменяет пароль временным, но **не ставит `mustChangePassword: true`** — пользователя с временным паролем никто не заставляет его сменить.
|
||||
2. Ученическая зона при `mustChangePassword` делает `redirect("/change-password")` (`src/app/(student)/layout.tsx`, ~стр. 23), а **страницы `/change-password` в `src/app` нет** — ученик с флагом попадёт на 404. Нужна страница (форма уже есть: `src/app/(student)/profile/change-password-form.tsx` + `changePasswordAction`, она же снимает флаг).
|
||||
3. Временный пароль генерируется через `Math.random()` — не криптостойко; заменить на `crypto.randomInt` (node:crypto).
|
||||
- Заодно: у админа/куратора нет смены пароля в админке — сейчас только через ученический `/profile` (работает, проверено 20261005). Добавить ссылку или форму в админ-профиль; рассмотреть принудительную смену и для админки.
|
||||
- Приёмка: сброс → вход временным паролем → принудительная форма смены (и у ученика, и у админа) → флаг снят; тест на генерацию пароля.
|
||||
- Гигиена прода: остались служебные учётки `student@second-brain.ru` (`student-seed-001`, сид 20260407) и `loadtest@second-brain.ru` (`loadtest-user-001`, 20260506). Пароль из сида к ним не подходит (проверено bcrypt 20261005), но учётки не нужны — удалить или заблокировать по решению владельца.
|
||||
|
||||
- **Миграция email-шаблонов на React Email 6 + Resend CLI 2.0** (Resend Launch Week 6, 24.04.2026):
|
||||
- React Email 6: новые шаблоны для auth и ecommerce flows (welcome, password reset, purchase confirmation, course progress) — можно взять за основу вместо своих
|
||||
- Resend CLI 2.0: локальный preview и тестирование шаблонов (`resend send --local ...`), 50+ команд
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { accessWhere, isAuthorized, parseAccessCheck } from "@/lib/access-check";
|
||||
|
||||
/**
|
||||
* Internal: есть ли у почты действующий доступ к курсу.
|
||||
* Auth: тот же общий секрет, что у /api/internal/grant (`x-internal-secret`).
|
||||
*
|
||||
* Body: { email, course_slug } → { ok: true, has_access: boolean }
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
if (!isAuthorized(request.headers.get("x-internal-secret"), process.env.INTERNAL_GRANT_SECRET)) {
|
||||
return NextResponse.json({ ok: false, error: "unauthorized" }, { status: 401 });
|
||||
}
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return NextResponse.json({ ok: false, error: "bad_json" }, { status: 400 });
|
||||
}
|
||||
const input = parseAccessCheck(body);
|
||||
if (!input) {
|
||||
return NextResponse.json({ ok: false, error: "missing_fields", need: ["email", "course_slug"] }, { status: 400 });
|
||||
}
|
||||
const found = await prisma.courseEnrollment.findFirst({
|
||||
where: accessWhere(input, new Date()),
|
||||
select: { userId: true },
|
||||
});
|
||||
return NextResponse.json({ ok: true, has_access: Boolean(found) });
|
||||
}
|
||||
@@ -25,7 +25,7 @@ const ARCHETYPE_INFO: Record<
|
||||
emoji: "📚",
|
||||
name: "Библиотекарь",
|
||||
desc: "Ты собираешь и хранишь надёжно. Начни с готового хранилища и руководств.",
|
||||
cta: "Демо-хранилище",
|
||||
cta: "Second Brain Vault",
|
||||
href: "https://second-brain.ru/sb-vault",
|
||||
},
|
||||
pragmatist: {
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { accessWhere, isAuthorized, parseAccessCheck } from "../access-check";
|
||||
|
||||
describe("access-check", () => {
|
||||
it("нормализует почту и требует оба поля", () => {
|
||||
expect(parseAccessCheck({ email: " A@Example.COM ", course_slug: "second-brain-vault" }))
|
||||
.toEqual({ email: "a@example.com", courseSlug: "second-brain-vault" });
|
||||
expect(parseAccessCheck({ email: "a@example.com" })).toBeNull();
|
||||
expect(parseAccessCheck({ course_slug: "x" })).toBeNull();
|
||||
expect(parseAccessCheck({ email: "без-собаки", course_slug: "x" })).toBeNull();
|
||||
expect(parseAccessCheck(null)).toBeNull();
|
||||
expect(parseAccessCheck({ email: "a".repeat(250) + "@b.ru", course_slug: "x" })).toBeNull();
|
||||
});
|
||||
|
||||
it("условие: точная почта (без ILIKE-шаблонов), не заблокирован, курс по slug, доступ не истёк", () => {
|
||||
const now = new Date("2026-10-13T00:00:00Z");
|
||||
const w = accessWhere({ email: "a@example.com", courseSlug: "second-brain-vault" }, now);
|
||||
expect(w.user.email).toBe("a@example.com");
|
||||
expect(JSON.stringify(w)).not.toContain("insensitive");
|
||||
expect(w.user.NOT).toEqual({ banned: true, OR: [{ banExpires: null }, { banExpires: { gt: now } }] });
|
||||
expect(w.course).toEqual({ slug: "second-brain-vault" });
|
||||
expect(w.OR).toEqual([{ expiresAt: null }, { expiresAt: { gt: now } }]);
|
||||
});
|
||||
|
||||
it("без секрета или с чужим — отказ", () => {
|
||||
expect(isAuthorized("s", "s")).toBe(true);
|
||||
expect(isAuthorized("s", undefined)).toBe(false);
|
||||
expect(isAuthorized("s", "")).toBe(false);
|
||||
expect(isAuthorized(null, "s")).toBe(false);
|
||||
expect(isAuthorized("x", "s")).toBe(false);
|
||||
expect(isAuthorized("ss", "s")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* Проверка «у этой почты есть действующий доступ к курсу» для внутренних
|
||||
* вызовов (бот выдачи SB Access в payment-router, 20261013): старый покупатель
|
||||
* Second Brain Vault оставляет почту, бот получает ключ входа, только если
|
||||
* доступ к курсу действительно есть.
|
||||
*
|
||||
* Ответ — только да/нет: ни имени, ни дат наружу не отдаём.
|
||||
*/
|
||||
|
||||
import { timingSafeEqual } from "crypto";
|
||||
|
||||
export type AccessCheckInput = { email: string; courseSlug: string };
|
||||
|
||||
export function parseAccessCheck(body: unknown): AccessCheckInput | null {
|
||||
if (!body || typeof body !== "object") return null;
|
||||
const b = body as Record<string, unknown>;
|
||||
const email = typeof b.email === "string" ? b.email.trim().toLowerCase() : "";
|
||||
const courseSlug = typeof b.course_slug === "string" ? b.course_slug.trim() : "";
|
||||
if (!email || email.length > 254 || !email.includes("@") || !courseSlug || courseSlug.length > 100) {
|
||||
return null;
|
||||
}
|
||||
return { email, courseSlug };
|
||||
}
|
||||
|
||||
/**
|
||||
* Условие Prisma: не истёкшая запись о доступе к курсу у незаблокированного
|
||||
* пользователя с ЭТОЙ почтой.
|
||||
*
|
||||
* Почту сравниваем точно, без `mode: "insensitive"`: Prisma превращает такое
|
||||
* сравнение в ILIKE, и `%`/`_` в адресе становятся шаблоном — по адресу-маске
|
||||
* метод отвечал бы «доступ есть» за чужую почту (проверка безопасности
|
||||
* 20261013). Все почты в базе хранятся в нижнем регистре (проверено на проде:
|
||||
* 0 из 1426 с заглавными), вход нормализуется parseAccessCheck.
|
||||
*/
|
||||
export function accessWhere(input: AccessCheckInput, now: Date) {
|
||||
return {
|
||||
user: {
|
||||
email: input.email,
|
||||
NOT: { banned: true, OR: [{ banExpires: null }, { banExpires: { gt: now } }] },
|
||||
},
|
||||
course: { slug: input.courseSlug },
|
||||
OR: [{ expiresAt: null }, { expiresAt: { gt: now } }],
|
||||
};
|
||||
}
|
||||
|
||||
/** Секрет сравниваем за постоянное время: обычное === выдаёт длину совпавшего префикса по времени. */
|
||||
export function isAuthorized(header: string | null, secret: string | undefined): boolean {
|
||||
if (!secret || !header) return false;
|
||||
const a = Buffer.from(header);
|
||||
const b = Buffer.from(secret);
|
||||
return a.length === b.length && timingSafeEqual(a, b);
|
||||
}
|
||||
Reference in New Issue
Block a user