diff --git a/.env.example b/.env.example index f9a62b3..f605291 100644 --- a/.env.example +++ b/.env.example @@ -14,6 +14,9 @@ BETTER_AUTH_SECRET=replace-with-at-least-32-random-bytes # Separate trusted browser origins with commas for local development or proxies. BETTER_AUTH_TRUSTED_ORIGINS=http://localhost:3000 +# Resend sending key; verify sudloh.com before sending from no-reply@sudloh.com. +RESEND_API_KEY=replace-with-resend-sending-key + # Cloudflare Turnstile login and public registration protection TURNSTILE_SITE_KEY=replace-with-turnstile-site-key TURNSTILE_SECRET_KEY=replace-with-turnstile-secret-key diff --git a/README.md b/README.md index 150edab..f0a04ec 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,8 @@ not affiliated with or endorsed by HoYoverse. - Searchable character guide directory. - Structured editors for overview, weapons, artifacts, constellations, teams, and custom sections with autosave and conflict recovery. -- Better Auth email/password login with administrator-managed accounts. +- Better Auth email/password login, registration email OTP, profile email changes, + and administrator-managed accounts. - S3-compatible media uploads with reference-aware same-origin delivery for staged private objects. - PostgreSQL transactions and a retryable outbox. @@ -33,7 +34,6 @@ formula fixtures. Their guide text and media are not imported or published. | `/[character]/[page]` | Render a public guide page | | `/login` | Public account sign-in | | `/register` | Public account registration | -| `/admin/login` | Administrator email/password sign-in | | `/admin` | Character and page overview | | `/admin/[character]/[page]` | Visual page editor | | `/admin/create` | Create a structured guide from the synced character catalog | @@ -201,6 +201,7 @@ Before the first rollout, a cluster administrator must provision a ```text DATABASE_URL BETTER_AUTH_SECRET +RESEND_API_KEY REDIS_URL S3_ENDPOINT S3_PUBLIC_URL @@ -226,6 +227,14 @@ DISCORD_CHANNEL_ID across replicas and rolling deployments. Do not place secret values in the ConfigMap or commit them to this repository. +Before deploying required email verification, add `sudloh.com` to Resend and +publish the DNS records shown in its domain setup. Verify the domain, create a +sending-only API key restricted to it, and set `RESEND_API_KEY` in the external +Secret. Account verification and password reset emails are sent from +`Buzz Guide `; this address does not need an inbox. Existing +sessions continue until they expire or the user signs out, after which an +unverified address must be verified before signing in again. + The commission page accepts PromptPay mobile, national ID, or e-wallet identifiers. For `COMMISSION_PROMPTPAY_TYPE=mobile`, use a real 10-digit Thai mobile number registered with PromptPay for receiving transfers in `COMMISSION_PROMPTPAY_VALUE`. diff --git a/app/admin/[character]/[page]/page.tsx b/app/admin/[character]/[page]/page.tsx index 7e4c150..59e57c2 100644 --- a/app/admin/[character]/[page]/page.tsx +++ b/app/admin/[character]/[page]/page.tsx @@ -31,7 +31,7 @@ export default async function StructuredEditorPage({ params: Promise<{ character: string; page: string }>; }) { await connection(); - if (!(await getAdminSession())) redirect("/admin/login"); + if (!(await getAdminSession())) redirect("/login?next=%2Fadmin"); const { character, page } = await params; const guide = await getGuideEditorHeader(character); if (!guide) notFound(); diff --git a/app/admin/[character]/page.tsx b/app/admin/[character]/page.tsx index 61a77e5..1d2c1a5 100644 --- a/app/admin/[character]/page.tsx +++ b/app/admin/[character]/page.tsx @@ -27,7 +27,7 @@ export const instant = false; export default async function GuideOverviewPage({ params }: { params: Promise<{ character: string }> }) { await connection(); - if (!(await getAdminSession())) redirect("/admin/login"); + if (!(await getAdminSession())) redirect("/login?next=%2Fadmin"); const { character } = await params; const guide = await getGuideByCharacterKey(character); if (!guide) notFound(); diff --git a/app/admin/[character]/preview/[[...page]]/page.tsx b/app/admin/[character]/preview/[[...page]]/page.tsx index b5a8c04..eea10db 100644 --- a/app/admin/[character]/preview/[[...page]]/page.tsx +++ b/app/admin/[character]/preview/[[...page]]/page.tsx @@ -14,7 +14,7 @@ export default async function GuidePreviewPage({ params: Promise<{ character: string; page?: string[] }>; }) { await connection(); - if (!(await getAdminSession())) redirect("/admin/login"); + if (!(await getAdminSession())) redirect("/login?next=%2Fadmin"); const { character, page } = await params; if (page && page.length > 1) notFound(); const header = await getGuideEditorHeader(character); diff --git a/app/admin/activity/page.tsx b/app/admin/activity/page.tsx index 7798583..da7e7a3 100644 --- a/app/admin/activity/page.tsx +++ b/app/admin/activity/page.tsx @@ -21,7 +21,7 @@ export default async function AuditLogPage({ searchParams: Promise>; }) { await connection(); - if (!(await getAdminSession())) redirect("/admin/login"); + if (!(await getAdminSession())) redirect("/login?next=%2Fadmin"); const query = await searchParams; const rawPage = Array.isArray(query.page) ? query.page[0] : query.page; diff --git a/app/admin/commission/page.tsx b/app/admin/commission/page.tsx index 035bcfb..a345c5b 100644 --- a/app/admin/commission/page.tsx +++ b/app/admin/commission/page.tsx @@ -19,7 +19,7 @@ export const instant = false; export default async function AdminCommissionPage({ searchParams }: PageProps<"/admin/commission">) { await connection(); const session = await getAdminSession(); - if (!session) redirect("/admin/login"); + if (!session) redirect("/login?next=%2Fadmin"); const query = await searchParams; const rawPage = Number(query.page ?? 1); const page = Number.isInteger(rawPage) && rawPage > 0 ? Math.min(rawPage, 10000) : 1; diff --git a/app/admin/create/page.tsx b/app/admin/create/page.tsx index 724e78d..178c304 100644 --- a/app/admin/create/page.tsx +++ b/app/admin/create/page.tsx @@ -11,7 +11,7 @@ import { getActiveCatalog, listGuideCards } from "@/lib/guides/queries"; export default async function CreateGuidePage() { await connection(); - if (!(await getAdminSession())) redirect("/admin/login"); + if (!(await getAdminSession())) redirect("/login?next=%2Fadmin"); const [catalog, guides] = await Promise.all([getActiveCatalog(), listGuideCards()]); if (!catalog) redirect("/admin?sync=required"); return ( diff --git a/app/admin/glossary/page.tsx b/app/admin/glossary/page.tsx index 70ea7ef..c1c9e21 100644 --- a/app/admin/glossary/page.tsx +++ b/app/admin/glossary/page.tsx @@ -9,7 +9,7 @@ import { getCatalogOptions } from "@/lib/guides/queries"; export default async function GlossaryPage() { await connection(); - if (!(await getAdminSession())) redirect("/admin/login"); + if (!(await getAdminSession())) redirect("/login?next=%2Fadmin"); const [catalog, aliases] = await Promise.all([ getCatalogOptions(), listGlossaryAliases(), diff --git a/app/admin/image/layout.tsx b/app/admin/image/layout.tsx index e4b8462..b942657 100644 --- a/app/admin/image/layout.tsx +++ b/app/admin/image/layout.tsx @@ -9,7 +9,7 @@ export default async function ImageGeneratorLayout({ children, }: LayoutProps<"/admin/image">) { await connection(); - if (!(await getAdminSession())) redirect("/admin/login"); + if (!(await getAdminSession())) redirect("/login?next=%2Fadmin"); return (
diff --git a/app/admin/login/page.tsx b/app/admin/login/page.tsx deleted file mode 100644 index 99449ac..0000000 --- a/app/admin/login/page.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import { redirect } from "next/navigation"; -import { connection } from "next/server"; - -import { LoginCard } from "@/components/admin/login-card"; -import { SiteHeader } from "@/components/public/site-header"; -import { getAdminSession } from "@/lib/auth/server"; - -export default async function AdminLoginPage() { - await connection(); - if (await getAdminSession()) redirect("/admin"); - - const turnstileSiteKey = process.env.TURNSTILE_SITE_KEY; - if (process.env.NODE_ENV !== "development" && !turnstileSiteKey) { - throw new Error("TURNSTILE_SITE_KEY is required for administrator login."); - } - - return ( -
- -
- -
-
- ); -} diff --git a/app/admin/page.tsx b/app/admin/page.tsx index c927e1a..91671c1 100644 --- a/app/admin/page.tsx +++ b/app/admin/page.tsx @@ -12,7 +12,7 @@ export const instant = false; export default async function AdminPage() { await connection(); - if (!(await getAdminSession())) redirect("/admin/login"); + if (!(await getAdminSession())) redirect("/login?next=%2Fadmin"); const [guides, trash, catalogVersion] = await Promise.all([ listGuideCards(), listGuideCards(true), diff --git a/app/admin/stygian/page.tsx b/app/admin/stygian/page.tsx index 3a5185b..01b6161 100644 --- a/app/admin/stygian/page.tsx +++ b/app/admin/stygian/page.tsx @@ -11,7 +11,7 @@ import { getStygianSchedule, listStygianSchedules } from "@/lib/stygian/reposito export default async function AdminStygianPage({ searchParams }: { searchParams: Promise> }) { await connection(); - if (!(await getAdminSession())) redirect("/admin/login"); + if (!(await getAdminSession())) redirect("/login?next=%2Fadmin"); const query = await searchParams; const schedules = await listStygianSchedules(); const raw = Array.isArray(query.schedule) ? query.schedule[0] : query.schedule; diff --git a/app/check-email/page.tsx b/app/check-email/page.tsx new file mode 100644 index 0000000..ee88b15 --- /dev/null +++ b/app/check-email/page.tsx @@ -0,0 +1,15 @@ +import { connection } from "next/server"; +import { SiteHeader } from "@/components/public/site-header"; +import { RegistrationOtpForm } from "@/components/auth/registration-otp-form"; +import { safeAuthReturnPath } from "@/lib/auth/return-path"; + +export const instant = false; + +export default async function CheckEmailPage({ searchParams }: PageProps<"/check-email">) { + await connection(); + const { email, next, setup } = await searchParams; + return
+ +
; +} diff --git a/app/forgot-password/page.tsx b/app/forgot-password/page.tsx new file mode 100644 index 0000000..11b3702 --- /dev/null +++ b/app/forgot-password/page.tsx @@ -0,0 +1,8 @@ +import { SiteHeader } from "@/components/public/site-header"; +import { EmailActionForm } from "@/components/auth/email-action-form"; + +export default function ForgotPasswordPage() { + return
+ +
; +} diff --git a/app/login/page.tsx b/app/login/page.tsx index cbb73fb..272ab1d 100644 --- a/app/login/page.tsx +++ b/app/login/page.tsx @@ -5,6 +5,7 @@ export const instant = false; export default async function LoginPage({ searchParams }: PageProps<"/login">) { await connection(); - const { next } = await searchParams; - return ; + const { next, verified, error } = await searchParams; + return ; } diff --git a/app/profile/page.tsx b/app/profile/page.tsx index 5716ea8..67f3da7 100644 --- a/app/profile/page.tsx +++ b/app/profile/page.tsx @@ -6,6 +6,8 @@ import { users } from "@/db/schema"; import { SiteHeader } from "@/components/public/site-header"; import { AdminHeader } from "@/components/admin/admin-header"; import { ProfileForm } from "@/components/auth/profile-form"; +import { PasswordForm } from "@/components/auth/password-form"; +import { EmailSettings } from "@/components/auth/email-settings"; import { isAuthorizedAdmin } from "@/lib/auth/authorization"; import { getCustomerSession } from "@/lib/auth/server"; import { safeAuthReturnPath } from "@/lib/auth/return-path"; @@ -14,22 +16,28 @@ export const instant = false; export default async function ProfilePage({ searchParams }: PageProps<"/profile">) { await connection(); - const { setup, next, upload } = await searchParams; + const { setup, next, upload, emailAction, error } = await searchParams; const nextPath = safeAuthReturnPath(next); const setupNextPath = nextPath === "/profile" || nextPath.startsWith("/profile?") ? "/" : nextPath; const session = await getCustomerSession(); if (!session) redirect("/login?next=%2Fprofile"); - const [user] = await getDb().select({ name: users.name, email: users.email, image: users.image }) + const [user] = await getDb().select({ name: users.name, email: users.email, + emailVerified: users.emailVerified, image: users.image }) .from(users).where(eq(users.id, session.user.id)).limit(1); if (!user) redirect("/login?next=%2Fprofile"); return
{isAuthorizedAdmin(session.user) ? : } -
+

โปรไฟล์ของฉัน

{setup === "1" &&

{upload === "failed" ? "สร้างบัญชีแล้ว แต่บันทึกรูปโปรไฟล์ไม่สำเร็จ กรุณาลองอีกครั้งหรือข้ามไปก่อน" : "เพิ่มรูปโปรไฟล์หรือแก้ชื่อที่แสดงก่อนเริ่มใช้งาน"}

} - +
+ + + +
; } diff --git a/app/reset-password/page.tsx b/app/reset-password/page.tsx new file mode 100644 index 0000000..38411c7 --- /dev/null +++ b/app/reset-password/page.tsx @@ -0,0 +1,13 @@ +import { connection } from "next/server"; +import { SiteHeader } from "@/components/public/site-header"; +import { EmailActionForm } from "@/components/auth/email-action-form"; + +export const instant = false; + +export default async function ResetPasswordPage({ searchParams }: PageProps<"/reset-password">) { + await connection(); + const { token, error } = await searchParams; + return
+ +
; +} diff --git a/components/admin/login-card.tsx b/components/admin/login-card.tsx deleted file mode 100644 index 4775659..0000000 --- a/components/admin/login-card.tsx +++ /dev/null @@ -1,216 +0,0 @@ -"use client"; - -import { CircleAlertIcon, LogInIcon } from "lucide-react"; -import Link from "next/link"; -import { useRouter } from "next/navigation"; -import Script from "next/script"; -import { type FormEvent, useCallback, useRef, useState } from "react"; - -import { - Alert, - AlertDescription, - AlertTitle, -} from "@/components/ui/alert"; -import { Button, buttonVariants } from "@/components/ui/button"; -import { - Card, - CardContent, - CardDescription, - CardFooter, - CardHeader, - CardTitle, -} from "@/components/ui/card"; -import { Field, FieldGroup, FieldLabel } from "@/components/ui/field"; -import { Input } from "@/components/ui/input"; -import { Spinner } from "@/components/ui/spinner"; -import { authClient } from "@/lib/auth/client"; - -interface TurnstileApi { - render( - container: HTMLElement, - options: { - sitekey: string; - callback: (token: string) => void; - "expired-callback": () => void; - "error-callback": () => void; - }, - ): string; - reset(widgetId: string): void; -} - -declare global { - interface Window { - turnstile?: TurnstileApi; - } -} - -interface LoginCardProps { - turnstileSiteKey: string; -} - -export function LoginCard({ turnstileSiteKey }: LoginCardProps) { - const router = useRouter(); - const turnstileEnabled = process.env.NODE_ENV !== "development"; - const turnstileContainerRef = useRef(null); - const turnstileWidgetIdRef = useRef(null); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); - const [turnstileToken, setTurnstileToken] = useState(null); - - const renderTurnstile = useCallback(() => { - if ( - !turnstileEnabled || - !window.turnstile || - !turnstileContainerRef.current || - turnstileWidgetIdRef.current - ) { - return; - } - - turnstileWidgetIdRef.current = window.turnstile.render( - turnstileContainerRef.current, - { - sitekey: turnstileSiteKey, - callback: (token) => { - setTurnstileToken(token); - setError(null); - }, - "expired-callback": () => setTurnstileToken(null), - "error-callback": () => { - setTurnstileToken(null); - setError("ยืนยันตัวตนไม่สำเร็จ โปรดลองอีกครั้ง"); - }, - }, - ); - }, [turnstileEnabled, turnstileSiteKey]); - - function resetTurnstile() { - setTurnstileToken(null); - if (window.turnstile && turnstileWidgetIdRef.current) { - window.turnstile.reset(turnstileWidgetIdRef.current); - } - } - - async function signIn(event: FormEvent) { - event.preventDefault(); - if (turnstileEnabled && !turnstileToken) return; - setLoading(true); - setError(null); - - const formData = new FormData(event.currentTarget); - const email = String(formData.get("email") ?? "").trim(); - const password = String(formData.get("password") ?? ""); - - try { - const result = await authClient.signIn.email({ - email, - password, - rememberMe: true, - fetchOptions: { - headers: turnstileEnabled && turnstileToken - ? { "x-captcha-response": turnstileToken } - : undefined, - }, - }); - if (result.error) { - setError( - result.error.code === "INVALID_EMAIL_OR_PASSWORD" - ? "อีเมลหรือรหัสผ่านไม่ถูกต้อง" - : "เข้าสู่ระบบไม่สำเร็จ โปรดลองอีกครั้ง", - ); - return; - } - router.push("/admin"); - router.refresh(); - } catch { - setError("เชื่อมต่อระบบเข้าสู่ระบบไม่ได้ โปรดลองอีกครั้ง"); - } finally { - resetTurnstile(); - setLoading(false); - } - } - - return ( - - - เข้าสู่ระบบผู้ดูแล - - ใช้อีเมลและรหัสผ่านของบัญชีผู้ - - - -
- - - อีเมล - - - - รหัสผ่าน - - - {turnstileEnabled ? ( - -
-